服务端媒体源导入/保存/客户端输出链路修复:支持 snake/camel、subcategories/sources,默认客户端可见,保存后发布兼容 media-types.json。
build-winui / winui (push) Waiting to run
新增数据库同步 Job API、持久化状态、实时输出、最新任务恢复,以及系统日志聚合接口。 管理端优化:日志中心、运维实时状态框、同步输出自动滚动、仪表盘“输出”列、真实延迟空态、本地 favicon/avatar。 新增 server/unified-management/assets/favicon.ico 和 developer-avatar.png,并接好 /favicon.ico、/admin/favicon.ico、/setup/favicon.ico、/assets/*。 WinUI 随机放映室卡片优先显示子接口原始 Description。 Inno 安装器输出框改为选区末尾 + SendMessage 滚动到底部。
@@ -226,6 +226,9 @@ const
|
|||||||
InstallOutputFlushLines = 16;
|
InstallOutputFlushLines = 16;
|
||||||
InstallOutputTrimCharacters = 65000;
|
InstallOutputTrimCharacters = 65000;
|
||||||
InstallOutputKeepCharacters = 48000;
|
InstallOutputKeepCharacters = 48000;
|
||||||
|
EM_SCROLLCARET = $00B7;
|
||||||
|
WM_VSCROLL = $0115;
|
||||||
|
SB_BOTTOM = 7;
|
||||||
BundledVCRedistIncluded = {#BundleVCRedist};
|
BundledVCRedistIncluded = {#BundleVCRedist};
|
||||||
BundledWebView2Included = {#BundleWebView2};
|
BundledWebView2Included = {#BundleWebView2};
|
||||||
WebView2ClientKey = 'SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}';
|
WebView2ClientKey = 'SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}';
|
||||||
@@ -245,6 +248,9 @@ var
|
|||||||
InstallOutputPendingLineCount: Integer;
|
InstallOutputPendingLineCount: Integer;
|
||||||
InstallOutputBuffer: string;
|
InstallOutputBuffer: string;
|
||||||
|
|
||||||
|
function SendMessage(hWnd: Longint; Msg: Longint; wParam: Longint; lParam: Longint): Longint;
|
||||||
|
external 'SendMessageW@user32.dll stdcall';
|
||||||
|
|
||||||
function NormalizePathPrefix(const Value: string): string;
|
function NormalizePathPrefix(const Value: string): string;
|
||||||
begin
|
begin
|
||||||
Result := Lowercase(AddBackslash(ExpandConstant(Value)));
|
Result := Lowercase(AddBackslash(ExpandConstant(Value)));
|
||||||
@@ -449,6 +455,9 @@ begin
|
|||||||
Exit;
|
Exit;
|
||||||
|
|
||||||
InstallOutputMemo.SelStart := Length(InstallOutputMemo.Text);
|
InstallOutputMemo.SelStart := Length(InstallOutputMemo.Text);
|
||||||
|
InstallOutputMemo.SelLength := 0;
|
||||||
|
SendMessage(InstallOutputMemo.Handle, EM_SCROLLCARET, 0, 0);
|
||||||
|
SendMessage(InstallOutputMemo.Handle, WM_VSCROLL, SB_BOTTOM, 0);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
procedure FlushInstallOutput();
|
procedure FlushInstallOutput();
|
||||||
|
|||||||
|
After Width: | Height: | Size: 5.5 MiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -33,10 +33,10 @@ func NormalizeBranding(current BrandingConfig, incoming BrandingConfig) Branding
|
|||||||
next.FeedbackEmail = value
|
next.FeedbackEmail = value
|
||||||
}
|
}
|
||||||
if next.SiteIconURL == "" {
|
if next.SiteIconURL == "" {
|
||||||
next.SiteIconURL = "https://img.ymhut.cn/file/1782108850041_icon.webp"
|
next.SiteIconURL = "/assets/favicon.ico"
|
||||||
}
|
}
|
||||||
if next.DeveloperAvatarURL == "" {
|
if next.DeveloperAvatarURL == "" {
|
||||||
next.DeveloperAvatarURL = "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp"
|
next.DeveloperAvatarURL = "/assets/developer-avatar.png"
|
||||||
}
|
}
|
||||||
if next.DeveloperName == "" {
|
if next.DeveloperName == "" {
|
||||||
next.DeveloperName = "YMhut"
|
next.DeveloperName = "YMhut"
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ func defaults(root string) *Config {
|
|||||||
TimestampWindowSeconds: 600,
|
TimestampWindowSeconds: 600,
|
||||||
MaxRequestBytes: 12 * 1024 * 1024,
|
MaxRequestBytes: 12 * 1024 * 1024,
|
||||||
MaxPackageBytes: 10 * 1024 * 1024,
|
MaxPackageBytes: 10 * 1024 * 1024,
|
||||||
SourceCheckSeconds: 300,
|
SourceCheckSeconds: 60,
|
||||||
Database: DatabaseConfig{
|
Database: DatabaseConfig{
|
||||||
Provider: "sqlite",
|
Provider: "sqlite",
|
||||||
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
|
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
|
||||||
@@ -150,7 +150,7 @@ func defaults(root string) *Config {
|
|||||||
MySQLPort: 3306,
|
MySQLPort: 3306,
|
||||||
FailoverEnabled: true,
|
FailoverEnabled: true,
|
||||||
HotSyncEnabled: true,
|
HotSyncEnabled: true,
|
||||||
HealthIntervalSec: 30,
|
HealthIntervalSec: 60,
|
||||||
MaxOpenConns: 10,
|
MaxOpenConns: 10,
|
||||||
MaxIdleConns: 4,
|
MaxIdleConns: 4,
|
||||||
ConnMaxLifetimeSeconds: 300,
|
ConnMaxLifetimeSeconds: 300,
|
||||||
@@ -163,8 +163,8 @@ func defaults(root string) *Config {
|
|||||||
TimeoutSeconds: 20,
|
TimeoutSeconds: 20,
|
||||||
},
|
},
|
||||||
Branding: BrandingConfig{
|
Branding: BrandingConfig{
|
||||||
SiteIconURL: "https://img.ymhut.cn/file/1782108850041_icon.webp",
|
SiteIconURL: "/assets/favicon.ico",
|
||||||
DeveloperAvatarURL: "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp",
|
DeveloperAvatarURL: "/assets/developer-avatar.png",
|
||||||
DeveloperName: "YMhut",
|
DeveloperName: "YMhut",
|
||||||
FeedbackEmail: "support@ymhut.cn",
|
FeedbackEmail: "support@ymhut.cn",
|
||||||
},
|
},
|
||||||
@@ -393,7 +393,7 @@ func normalize(root string, cfg *Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cfg.Database.HealthIntervalSec <= 0 {
|
if cfg.Database.HealthIntervalSec <= 0 {
|
||||||
cfg.Database.HealthIntervalSec = 30
|
cfg.Database.HealthIntervalSec = 60
|
||||||
}
|
}
|
||||||
if cfg.Database.MaxOpenConns <= 0 {
|
if cfg.Database.MaxOpenConns <= 0 {
|
||||||
cfg.Database.MaxOpenConns = 10
|
cfg.Database.MaxOpenConns = 10
|
||||||
@@ -435,7 +435,7 @@ func normalize(root string, cfg *Config) {
|
|||||||
cfg.UploadGuard.MaxReadableTextBytes = 256 * 1024
|
cfg.UploadGuard.MaxReadableTextBytes = 256 * 1024
|
||||||
}
|
}
|
||||||
if cfg.SourceCheckSeconds <= 0 {
|
if cfg.SourceCheckSeconds <= 0 {
|
||||||
cfg.SourceCheckSeconds = 300
|
cfg.SourceCheckSeconds = 60
|
||||||
}
|
}
|
||||||
if cfg.Mail.Port <= 0 {
|
if cfg.Mail.Port <= 0 {
|
||||||
cfg.Mail.Port = 465
|
cfg.Mail.Port = 465
|
||||||
@@ -454,10 +454,10 @@ func normalize(root string, cfg *Config) {
|
|||||||
cfg.Mail.TimeoutSeconds = 20
|
cfg.Mail.TimeoutSeconds = 20
|
||||||
}
|
}
|
||||||
if cfg.Branding.SiteIconURL == "" {
|
if cfg.Branding.SiteIconURL == "" {
|
||||||
cfg.Branding.SiteIconURL = "https://img.ymhut.cn/file/1782108850041_icon.webp"
|
cfg.Branding.SiteIconURL = "/assets/favicon.ico"
|
||||||
}
|
}
|
||||||
if cfg.Branding.DeveloperAvatarURL == "" {
|
if cfg.Branding.DeveloperAvatarURL == "" {
|
||||||
cfg.Branding.DeveloperAvatarURL = "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp"
|
cfg.Branding.DeveloperAvatarURL = "/assets/developer-avatar.png"
|
||||||
}
|
}
|
||||||
if cfg.Branding.DeveloperName == "" {
|
if cfg.Branding.DeveloperName == "" {
|
||||||
cfg.Branding.DeveloperName = "YMhut"
|
cfg.Branding.DeveloperName = "YMhut"
|
||||||
|
|||||||
@@ -21,31 +21,80 @@ func (s *Store) CopyRemoteToSQLite() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ImportSQLiteToRemote() (SyncResult, error) {
|
func (s *Store) ImportSQLiteToRemote() (SyncResult, error) {
|
||||||
if !s.trySyncLock() {
|
return s.RunDatabaseSync("sqlite_to_remote")
|
||||||
return SyncResult{Direction: "sqlite_to_remote", Status: "running", Skipped: true, Tables: map[string]int{}, FinishedAt: Now(), Warnings: []string{"database sync is already running"}}, errors.New("database sync is already running")
|
|
||||||
}
|
|
||||||
defer s.syncMu.Unlock()
|
|
||||||
s.mu.RLock()
|
|
||||||
remote := s.remoteDB
|
|
||||||
remoteDialect := s.remoteDialect
|
|
||||||
local := s.localDB
|
|
||||||
localDialect := s.localDialect
|
|
||||||
s.mu.RUnlock()
|
|
||||||
if remote == nil {
|
|
||||||
result := SyncResult{Direction: "sqlite_to_remote", Status: "skipped", Skipped: true, Tables: map[string]int{}, FinishedAt: Now(), Warnings: []string{"remote database is not configured"}}
|
|
||||||
s.setSyncStatus(result, nil)
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
result, err := copyAllTables(local, localDialect, remote, remoteDialect, "sqlite_to_remote")
|
|
||||||
s.setSyncStatus(result, err)
|
|
||||||
return result, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) SyncNow() (SyncResult, error) {
|
func (s *Store) SyncNow() (SyncResult, error) {
|
||||||
|
return s.RunDatabaseSync("remote_to_sqlite")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) QueueDatabaseSync(direction string) (DatabaseSyncJob, error) {
|
||||||
|
normalized, err := normalizeSyncDirection(direction)
|
||||||
|
if err != nil {
|
||||||
|
return DatabaseSyncJob{}, err
|
||||||
|
}
|
||||||
if !s.trySyncLock() {
|
if !s.trySyncLock() {
|
||||||
return SyncResult{Direction: "remote_to_sqlite", Status: "running", Skipped: true, Tables: map[string]int{}, FinishedAt: Now(), Warnings: []string{"database sync is already running"}}, errors.New("database sync is already running")
|
job, insertErr := s.insertDatabaseSyncJob(normalized, "skipped", []string{"同步任务未启动:已有数据库同步正在执行。"}, []string{"database sync is already running"}, nil, nil, Now())
|
||||||
|
if insertErr != nil {
|
||||||
|
return DatabaseSyncJob{}, insertErr
|
||||||
|
}
|
||||||
|
return job, errors.New("database sync is already running")
|
||||||
|
}
|
||||||
|
job, err := s.insertDatabaseSyncJob(normalized, "running", []string{"同步任务已创建,正在准备数据库连接。"}, nil, nil, map[string]int{}, "")
|
||||||
|
if err != nil {
|
||||||
|
s.syncMu.Unlock()
|
||||||
|
return DatabaseSyncJob{}, err
|
||||||
|
}
|
||||||
|
s.setCurrentSyncJob(&job)
|
||||||
|
go func(jobID int64, jobDirection string) {
|
||||||
|
defer s.syncMu.Unlock()
|
||||||
|
_, _ = s.runDatabaseSyncLocked(jobID, jobDirection)
|
||||||
|
}(job.ID, normalized)
|
||||||
|
return job, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RunDatabaseSync(direction string) (SyncResult, error) {
|
||||||
|
normalized, err := normalizeSyncDirection(direction)
|
||||||
|
if err != nil {
|
||||||
|
return SyncResult{}, err
|
||||||
|
}
|
||||||
|
if !s.trySyncLock() {
|
||||||
|
result := SyncResult{
|
||||||
|
Direction: normalized,
|
||||||
|
Status: "skipped",
|
||||||
|
Skipped: true,
|
||||||
|
StartedAt: Now(),
|
||||||
|
FinishedAt: Now(),
|
||||||
|
Tables: map[string]int{},
|
||||||
|
Warnings: []string{"database sync is already running"},
|
||||||
|
Output: []string{"同步任务未启动:已有数据库同步正在执行。"},
|
||||||
|
}
|
||||||
|
_, _ = s.insertDatabaseSyncJob(normalized, result.Status, result.Output, result.Warnings, result.Errors, result.Tables, result.FinishedAt)
|
||||||
|
s.setSyncStatus(result, errors.New("database sync is already running"))
|
||||||
|
return result, errors.New("database sync is already running")
|
||||||
}
|
}
|
||||||
defer s.syncMu.Unlock()
|
defer s.syncMu.Unlock()
|
||||||
|
job, err := s.insertDatabaseSyncJob(normalized, "running", []string{"同步任务已创建,正在准备数据库连接。"}, nil, nil, map[string]int{}, "")
|
||||||
|
if err != nil {
|
||||||
|
return SyncResult{}, err
|
||||||
|
}
|
||||||
|
s.setCurrentSyncJob(&job)
|
||||||
|
return s.runDatabaseSyncLocked(job.ID, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) runDatabaseSyncLocked(jobID int64, direction string) (SyncResult, error) {
|
||||||
|
started := Now()
|
||||||
|
if job, err := s.GetDatabaseSyncJob(jobID); err == nil && job.StartedAt != "" {
|
||||||
|
started = job.StartedAt
|
||||||
|
}
|
||||||
|
result := SyncResult{
|
||||||
|
Direction: direction,
|
||||||
|
Status: "completed",
|
||||||
|
StartedAt: started,
|
||||||
|
Tables: map[string]int{},
|
||||||
|
Output: []string{"同步开始:" + directionLabel(direction)},
|
||||||
|
}
|
||||||
|
_ = s.updateDatabaseSyncJob(jobID, "running", result.Output, result.Warnings, result.Errors, result.Tables, "")
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
remote := s.remoteDB
|
remote := s.remoteDB
|
||||||
remoteDialect := s.remoteDialect
|
remoteDialect := s.remoteDialect
|
||||||
@@ -53,18 +102,43 @@ func (s *Store) SyncNow() (SyncResult, error) {
|
|||||||
localDialect := s.localDialect
|
localDialect := s.localDialect
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
if remote == nil {
|
if remote == nil {
|
||||||
result := SyncResult{Direction: "remote_to_sqlite", Status: "skipped", Skipped: true, Tables: map[string]int{}, FinishedAt: Now(), Warnings: []string{"remote database is not configured"}}
|
result.Status = "skipped"
|
||||||
|
result.Skipped = true
|
||||||
|
result.Warnings = append(result.Warnings, "remote database is not configured")
|
||||||
|
result.Output = append(result.Output, "远程 MySQL 未配置或不可用,未执行数据复制。")
|
||||||
|
result.FinishedAt = Now()
|
||||||
|
_ = s.updateDatabaseSyncJob(jobID, result.Status, result.Output, result.Warnings, result.Errors, result.Tables, result.FinishedAt)
|
||||||
s.setSyncStatus(result, nil)
|
s.setSyncStatus(result, nil)
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
result, err := copyAllTables(remote, remoteDialect, local, localDialect, "remote_to_sqlite")
|
src, srcDialect, dst, dstDialect := local, localDialect, remote, remoteDialect
|
||||||
|
if direction == "remote_to_sqlite" {
|
||||||
|
src, srcDialect, dst, dstDialect = remote, remoteDialect, local, localDialect
|
||||||
|
}
|
||||||
|
err := copyAllTablesWithProgress(src, srcDialect, dst, dstDialect, direction, func(table string, count int) {
|
||||||
|
result.Tables[table] = count
|
||||||
|
result.Output = append(result.Output, fmt.Sprintf("%s:%d 条", table, count))
|
||||||
|
_ = s.updateDatabaseSyncJob(jobID, "running", result.Output, result.Warnings, result.Errors, result.Tables, "")
|
||||||
|
})
|
||||||
|
result.FinishedAt = Now()
|
||||||
|
if err != nil {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.Errors = append(result.Errors, err.Error())
|
||||||
|
result.Output = append(result.Output, "同步失败:"+err.Error())
|
||||||
|
_ = s.updateDatabaseSyncJob(jobID, result.Status, result.Output, result.Warnings, result.Errors, result.Tables, result.FinishedAt)
|
||||||
s.setSyncStatus(result, err)
|
s.setSyncStatus(result, err)
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
result.Output = append(result.Output, "同步完成:"+directionLabel(direction))
|
||||||
|
_ = s.updateDatabaseSyncJob(jobID, result.Status, result.Output, result.Warnings, result.Errors, result.Tables, result.FinishedAt)
|
||||||
|
s.setSyncStatus(result, nil)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) setSyncStatus(result SyncResult, err error) {
|
func (s *Store) setSyncStatus(result SyncResult, err error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
s.status.CurrentSyncJob = nil
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.status.LastSyncAt = result.FinishedAt
|
s.status.LastSyncAt = result.FinishedAt
|
||||||
s.status.LastSyncError = err.Error()
|
s.status.LastSyncError = err.Error()
|
||||||
@@ -78,6 +152,33 @@ func (s *Store) trySyncLock() bool {
|
|||||||
return s.syncMu.TryLock()
|
return s.syncMu.TryLock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) setCurrentSyncJob(job *DatabaseSyncJob) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.status.CurrentSyncJob = job
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSyncDirection(value string) (string, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "sqlite_to_remote", "import", "sqlite_to_mysql":
|
||||||
|
return "sqlite_to_remote", nil
|
||||||
|
case "remote_to_sqlite", "sync", "mysql_to_sqlite":
|
||||||
|
return "remote_to_sqlite", nil
|
||||||
|
default:
|
||||||
|
return "", errors.New("unsupported database sync direction")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func directionLabel(direction string) string {
|
||||||
|
if direction == "sqlite_to_remote" {
|
||||||
|
return "SQLite -> MySQL"
|
||||||
|
}
|
||||||
|
if direction == "remote_to_sqlite" {
|
||||||
|
return "MySQL -> SQLite"
|
||||||
|
}
|
||||||
|
return direction
|
||||||
|
}
|
||||||
|
|
||||||
type tableSpec struct {
|
type tableSpec struct {
|
||||||
Name string
|
Name string
|
||||||
Columns []string
|
Columns []string
|
||||||
@@ -100,6 +201,7 @@ var syncTables = []tableSpec{
|
|||||||
{"source_endpoints", []string{"id", "category_id", "category_name", "source_id", "name", "description", "method", "api_url", "url_template", "thumbnail_url", "proxy_mode", "timeout_ms", "retry_count", "cache_seconds", "check_interval_sec", "enabled", "client_visible", "supported_formats", "last_status", "last_latency_ms", "last_checked_at", "last_error", "consecutive_failure", "created_at", "updated_at"}, []string{"id"}},
|
{"source_endpoints", []string{"id", "category_id", "category_name", "source_id", "name", "description", "method", "api_url", "url_template", "thumbnail_url", "proxy_mode", "timeout_ms", "retry_count", "cache_seconds", "check_interval_sec", "enabled", "client_visible", "supported_formats", "last_status", "last_latency_ms", "last_checked_at", "last_error", "consecutive_failure", "created_at", "updated_at"}, []string{"id"}},
|
||||||
{"endpoint_health_checks", []string{"id", "source_db_id", "status", "latency_ms", "error", "checked_at"}, []string{"id"}},
|
{"endpoint_health_checks", []string{"id", "source_db_id", "status", "latency_ms", "error", "checked_at"}, []string{"id"}},
|
||||||
{"endpoint_call_logs", []string{"id", "source_id", "status", "latency_ms", "error", "client", "created_at"}, []string{"id"}},
|
{"endpoint_call_logs", []string{"id", "source_id", "status", "latency_ms", "error", "client", "created_at"}, []string{"id"}},
|
||||||
|
{"database_sync_jobs", []string{"id", "direction", "status", "message", "tables_json", "started_at", "finished_at"}, []string{"id"}},
|
||||||
{"system_settings", []string{"key", "value", "updated_at"}, []string{"key"}},
|
{"system_settings", []string{"key", "value", "updated_at"}, []string{"key"}},
|
||||||
{"audit_logs", []string{"id", "actor", "type", "target", "message", "ip", "user_agent", "created_at"}, []string{"id"}},
|
{"audit_logs", []string{"id", "actor", "type", "target", "message", "ip", "user_agent", "created_at"}, []string{"id"}},
|
||||||
{"legacy_json_revisions", []string{"id", "name", "raw", "note", "created_by", "created_at"}, []string{"id"}},
|
{"legacy_json_revisions", []string{"id", "name", "raw", "note", "created_by", "created_at"}, []string{"id"}},
|
||||||
@@ -109,19 +211,31 @@ var syncTables = []tableSpec{
|
|||||||
|
|
||||||
func copyAllTables(src *sql.DB, srcDialect dialect, dst *sql.DB, dstDialect dialect, direction string) (SyncResult, error) {
|
func copyAllTables(src *sql.DB, srcDialect dialect, dst *sql.DB, dstDialect dialect, direction string) (SyncResult, error) {
|
||||||
result := SyncResult{Direction: direction, Status: "completed", Tables: map[string]int{}, FinishedAt: Now()}
|
result := SyncResult{Direction: direction, Status: "completed", Tables: map[string]int{}, FinishedAt: Now()}
|
||||||
for _, table := range syncTables {
|
err := copyAllTablesWithProgress(src, srcDialect, dst, dstDialect, direction, func(table string, count int) {
|
||||||
count, err := copyTable(src, srcDialect, dst, dstDialect, table)
|
result.Tables[table] = count
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Status = "failed"
|
result.Status = "failed"
|
||||||
result.FinishedAt = Now()
|
result.FinishedAt = Now()
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
result.Tables[table.Name] = count
|
|
||||||
}
|
|
||||||
result.FinishedAt = Now()
|
result.FinishedAt = Now()
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func copyAllTablesWithProgress(src *sql.DB, srcDialect dialect, dst *sql.DB, dstDialect dialect, direction string, progress func(table string, count int)) error {
|
||||||
|
for _, table := range syncTables {
|
||||||
|
count, err := copyTable(src, srcDialect, dst, dstDialect, table)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if progress != nil {
|
||||||
|
progress(table.Name, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func copyTable(src *sql.DB, srcDialect dialect, dst *sql.DB, dstDialect dialect, spec tableSpec) (int, error) {
|
func copyTable(src *sql.DB, srcDialect dialect, dst *sql.DB, dstDialect dialect, spec tableSpec) (int, error) {
|
||||||
rows, err := src.Query(srcDialect.rebind("SELECT " + srcDialect.columnList(spec.Columns) + " FROM " + spec.Name))
|
rows, err := src.Query(srcDialect.rebind("SELECT " + srcDialect.columnList(spec.Columns) + " FROM " + spec.Name))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type syncJobMessage struct {
|
||||||
|
Output []string `json:"output"`
|
||||||
|
Warnings []string `json:"warnings"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type syncJobScanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) insertDatabaseSyncJob(direction, status string, output, warnings, jobErrors []string, tables map[string]int, finishedAt string) (DatabaseSyncJob, error) {
|
||||||
|
startedAt := Now()
|
||||||
|
message, tablesJSON := encodeDatabaseSyncJob(output, warnings, jobErrors, tables)
|
||||||
|
id, err := s.insertID(`INSERT INTO database_sync_jobs (direction, status, message, tables_json, started_at, finished_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
direction, status, message, tablesJSON, startedAt, finishedAt)
|
||||||
|
if err != nil {
|
||||||
|
return DatabaseSyncJob{}, err
|
||||||
|
}
|
||||||
|
return s.GetDatabaseSyncJob(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) updateDatabaseSyncJob(id int64, status string, output, warnings, jobErrors []string, tables map[string]int, finishedAt string) error {
|
||||||
|
message, tablesJSON := encodeDatabaseSyncJob(output, warnings, jobErrors, tables)
|
||||||
|
if _, err := s.exec(`UPDATE database_sync_jobs SET status = ?, message = ?, tables_json = ?, finished_at = ? WHERE id = ?`,
|
||||||
|
status, message, tablesJSON, finishedAt, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if job, err := s.GetDatabaseSyncJob(id); err == nil {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.status.CurrentSyncJob != nil && s.status.CurrentSyncJob.ID == id {
|
||||||
|
s.status.CurrentSyncJob = &job
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetDatabaseSyncJob(id int64) (DatabaseSyncJob, error) {
|
||||||
|
job, err := scanDatabaseSyncJob(s.queryRow(`SELECT id, direction, status, message, tables_json, started_at, finished_at FROM database_sync_jobs WHERE id = ?`, id))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return DatabaseSyncJob{}, errors.New("database sync job not found")
|
||||||
|
}
|
||||||
|
return job, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) LatestDatabaseSyncJob() (DatabaseSyncJob, error) {
|
||||||
|
job, err := scanDatabaseSyncJob(s.queryRow(`SELECT id, direction, status, message, tables_json, started_at, finished_at FROM database_sync_jobs ORDER BY id DESC LIMIT 1`))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return DatabaseSyncJob{}, errors.New("database sync job not found")
|
||||||
|
}
|
||||||
|
return job, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) restoreDatabaseSyncStatus() {
|
||||||
|
job, err := s.LatestDatabaseSyncJob()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if job.Status == "running" {
|
||||||
|
s.status.CurrentSyncJob = &job
|
||||||
|
} else {
|
||||||
|
s.status.CurrentSyncJob = nil
|
||||||
|
}
|
||||||
|
if job.FinishedAt != "" {
|
||||||
|
s.status.LastSyncAt = job.FinishedAt
|
||||||
|
} else {
|
||||||
|
s.status.LastSyncAt = job.StartedAt
|
||||||
|
}
|
||||||
|
if job.Status == "failed" {
|
||||||
|
s.status.LastSyncError = strings.Join(job.Errors, "; ")
|
||||||
|
} else {
|
||||||
|
s.status.LastSyncError = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanDatabaseSyncJob(scanner syncJobScanner) (DatabaseSyncJob, error) {
|
||||||
|
var job DatabaseSyncJob
|
||||||
|
var message, tablesJSON string
|
||||||
|
if err := scanner.Scan(&job.ID, &job.Direction, &job.Status, &message, &tablesJSON, &job.StartedAt, &job.FinishedAt); err != nil {
|
||||||
|
return DatabaseSyncJob{}, err
|
||||||
|
}
|
||||||
|
decodeDatabaseSyncJob(message, tablesJSON, &job)
|
||||||
|
return job, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeDatabaseSyncJob(output, warnings, jobErrors []string, tables map[string]int) (string, string) {
|
||||||
|
if output == nil {
|
||||||
|
output = []string{}
|
||||||
|
}
|
||||||
|
if warnings == nil {
|
||||||
|
warnings = []string{}
|
||||||
|
}
|
||||||
|
if jobErrors == nil {
|
||||||
|
jobErrors = []string{}
|
||||||
|
}
|
||||||
|
if tables == nil {
|
||||||
|
tables = map[string]int{}
|
||||||
|
}
|
||||||
|
message, _ := json.Marshal(syncJobMessage{Output: output, Warnings: warnings, Errors: jobErrors})
|
||||||
|
tableData, _ := json.Marshal(tables)
|
||||||
|
return string(message), string(tableData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeDatabaseSyncJob(message, tablesJSON string, job *DatabaseSyncJob) {
|
||||||
|
job.Output = []string{}
|
||||||
|
job.Warnings = []string{}
|
||||||
|
job.Errors = []string{}
|
||||||
|
job.Tables = map[string]int{}
|
||||||
|
var payload syncJobMessage
|
||||||
|
if strings.TrimSpace(message) != "" && json.Unmarshal([]byte(message), &payload) == nil {
|
||||||
|
job.Output = payload.Output
|
||||||
|
job.Warnings = payload.Warnings
|
||||||
|
job.Errors = payload.Errors
|
||||||
|
} else if strings.TrimSpace(message) != "" {
|
||||||
|
job.Output = []string{message}
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(tablesJSON), &job.Tables)
|
||||||
|
if job.Output == nil {
|
||||||
|
job.Output = []string{}
|
||||||
|
}
|
||||||
|
if job.Warnings == nil {
|
||||||
|
job.Warnings = []string{}
|
||||||
|
}
|
||||||
|
if job.Errors == nil {
|
||||||
|
job.Errors = []string{}
|
||||||
|
}
|
||||||
|
if job.Tables == nil {
|
||||||
|
job.Tables = map[string]int{}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,9 @@ type DatabaseStatus struct {
|
|||||||
LastRecoveredAt string `json:"lastRecoveredAt"`
|
LastRecoveredAt string `json:"lastRecoveredAt"`
|
||||||
LastSyncAt string `json:"lastSyncAt"`
|
LastSyncAt string `json:"lastSyncAt"`
|
||||||
LastSyncError string `json:"lastSyncError"`
|
LastSyncError string `json:"lastSyncError"`
|
||||||
|
LastHealthCheckedAt string `json:"lastHealthCheckedAt"`
|
||||||
|
LastHealthStatus string `json:"lastHealthStatus"`
|
||||||
|
CurrentSyncJob *DatabaseSyncJob `json:"currentSyncJob,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SyncResult struct {
|
type SyncResult struct {
|
||||||
@@ -38,10 +41,25 @@ type SyncResult struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Skipped bool `json:"skipped"`
|
Skipped bool `json:"skipped"`
|
||||||
Warnings []string `json:"warnings,omitempty"`
|
Warnings []string `json:"warnings,omitempty"`
|
||||||
|
Errors []string `json:"errors,omitempty"`
|
||||||
|
Output []string `json:"output,omitempty"`
|
||||||
|
StartedAt string `json:"startedAt,omitempty"`
|
||||||
Tables map[string]int `json:"tables"`
|
Tables map[string]int `json:"tables"`
|
||||||
FinishedAt string `json:"finishedAt"`
|
FinishedAt string `json:"finishedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DatabaseSyncJob struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Direction string `json:"direction"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Output []string `json:"output"`
|
||||||
|
Warnings []string `json:"warnings"`
|
||||||
|
Errors []string `json:"errors"`
|
||||||
|
Tables map[string]int `json:"tables"`
|
||||||
|
StartedAt string `json:"startedAt"`
|
||||||
|
FinishedAt string `json:"finishedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
type AdminUser struct {
|
type AdminUser struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
@@ -246,6 +264,8 @@ type Source struct {
|
|||||||
ConsecutiveFailure int `json:"consecutiveFailure"`
|
ConsecutiveFailure int `json:"consecutiveFailure"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
EnabledSet bool `json:"-"`
|
||||||
|
ClientVisibleSet bool `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SourceCheck struct {
|
type SourceCheck struct {
|
||||||
@@ -301,3 +321,28 @@ type LegacyJsonRevision struct {
|
|||||||
CreatedBy string `json:"createdBy"`
|
CreatedBy string `json:"createdBy"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SystemLogItem struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SystemLogPage struct {
|
||||||
|
Items []SystemLogItem `json:"items"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PerPage int `json:"perPage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SystemLogFilters struct {
|
||||||
|
Page int
|
||||||
|
PerPage int
|
||||||
|
Category string
|
||||||
|
Query string
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ func (s *Store) UpsertSource(item Source) (Source, error) {
|
|||||||
if item.SourceID == "" {
|
if item.SourceID == "" {
|
||||||
item.SourceID = item.CategoryID + "-" + item.Name
|
item.SourceID = item.CategoryID + "-" + item.Name
|
||||||
}
|
}
|
||||||
|
existing, existingErr := s.GetSourceBySourceID(item.SourceID)
|
||||||
|
hasExisting := existingErr == nil
|
||||||
if item.Method == "" {
|
if item.Method == "" {
|
||||||
item.Method = "GET"
|
item.Method = "GET"
|
||||||
}
|
}
|
||||||
@@ -33,17 +35,52 @@ func (s *Store) UpsertSource(item Source) (Source, error) {
|
|||||||
item.CheckIntervalSec = item.CacheSeconds
|
item.CheckIntervalSec = item.CacheSeconds
|
||||||
}
|
}
|
||||||
if item.SupportedFormats == "" {
|
if item.SupportedFormats == "" {
|
||||||
|
if hasExisting && existing.SupportedFormats != "" {
|
||||||
|
item.SupportedFormats = existing.SupportedFormats
|
||||||
|
} else {
|
||||||
item.SupportedFormats = "[]"
|
item.SupportedFormats = "[]"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if item.LastStatus == "" {
|
if item.LastStatus == "" {
|
||||||
|
if hasExisting && existing.LastStatus != "" {
|
||||||
|
item.LastStatus = existing.LastStatus
|
||||||
|
} else {
|
||||||
item.LastStatus = "unknown"
|
item.LastStatus = "unknown"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if item.LastLatencyMS == 0 && hasExisting {
|
||||||
|
item.LastLatencyMS = existing.LastLatencyMS
|
||||||
|
}
|
||||||
|
if item.LastCheckedAt == "" && hasExisting {
|
||||||
|
item.LastCheckedAt = existing.LastCheckedAt
|
||||||
|
}
|
||||||
|
if item.LastError == "" && hasExisting {
|
||||||
|
item.LastError = existing.LastError
|
||||||
|
}
|
||||||
|
if item.ConsecutiveFailure == 0 && hasExisting {
|
||||||
|
item.ConsecutiveFailure = existing.ConsecutiveFailure
|
||||||
|
}
|
||||||
if item.CategoryID == "" {
|
if item.CategoryID == "" {
|
||||||
item.CategoryID = "custom"
|
item.CategoryID = "custom"
|
||||||
}
|
}
|
||||||
if item.CategoryName == "" {
|
if item.CategoryName == "" {
|
||||||
item.CategoryName = item.CategoryID
|
item.CategoryName = item.CategoryID
|
||||||
}
|
}
|
||||||
|
if !item.EnabledSet {
|
||||||
|
item.Enabled = true
|
||||||
|
if hasExisting {
|
||||||
|
item.Enabled = existing.Enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !item.ClientVisibleSet {
|
||||||
|
item.ClientVisible = true
|
||||||
|
if hasExisting {
|
||||||
|
item.ClientVisible = existing.ClientVisible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if item.CreatedAt == "" && hasExisting {
|
||||||
|
item.CreatedAt = existing.CreatedAt
|
||||||
|
}
|
||||||
_, _ = s.exec(`INSERT INTO source_categories (category_id, name, enabled, ui_config, created_at, updated_at)
|
_, _ = s.exec(`INSERT INTO source_categories (category_id, name, enabled, ui_config, created_at, updated_at)
|
||||||
VALUES (?, ?, 1, '{}', ?, ?)
|
VALUES (?, ?, 1, '{}', ?, ?)
|
||||||
ON CONFLICT (category_id) DO UPDATE SET name = excluded.name, updated_at = excluded.updated_at`,
|
ON CONFLICT (category_id) DO UPDATE SET name = excluded.name, updated_at = excluded.updated_at`,
|
||||||
@@ -97,6 +134,12 @@ func (s *Store) CountSources() (int, error) {
|
|||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) CountClientVisibleSources() (int, error) {
|
||||||
|
var count int
|
||||||
|
err := s.queryRow(`SELECT COUNT(*) FROM source_endpoints WHERE enabled = 1 AND client_visible = 1`).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteSource(sourceID string) error {
|
func (s *Store) DeleteSource(sourceID string) error {
|
||||||
_, err := s.exec(`DELETE FROM source_endpoints WHERE source_id = ?`, sourceID)
|
_, err := s.exec(`DELETE FROM source_endpoints WHERE source_id = ?`, sourceID)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ func Open(cfg *config.Config) (*Store, error) {
|
|||||||
SchemaVersion: CurrentSchemaVersion,
|
SchemaVersion: CurrentSchemaVersion,
|
||||||
SQLiteReady: true,
|
SQLiteReady: true,
|
||||||
LastRecoveredAt: Now(),
|
LastRecoveredAt: Now(),
|
||||||
|
LastHealthStatus: "not_configured",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := store.migrate(local, localDialect); err != nil {
|
if err := store.migrate(local, localDialect); err != nil {
|
||||||
@@ -86,6 +87,7 @@ func Open(cfg *config.Config) (*Store, error) {
|
|||||||
store.markFailover(err)
|
store.markFailover(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
store.restoreDatabaseSyncStatus()
|
||||||
go store.maintain()
|
go store.maintain()
|
||||||
return store, nil
|
return store, nil
|
||||||
}
|
}
|
||||||
@@ -171,14 +173,17 @@ func (s *Store) ReconfigureDatabase(cfg *config.Config) error {
|
|||||||
s.status.RemoteReady = remote != nil
|
s.status.RemoteReady = remote != nil
|
||||||
s.status.LastError = ""
|
s.status.LastError = ""
|
||||||
s.status.FailoverActive = false
|
s.status.FailoverActive = false
|
||||||
|
s.status.LastHealthCheckedAt = Now()
|
||||||
if remote != nil {
|
if remote != nil {
|
||||||
s.db = remote
|
s.db = remote
|
||||||
s.dialect = remoteDialect
|
s.dialect = remoteDialect
|
||||||
s.status.ActiveProvider = "mysql"
|
s.status.ActiveProvider = "mysql"
|
||||||
|
s.status.LastHealthStatus = "ok"
|
||||||
} else {
|
} else {
|
||||||
s.db = local
|
s.db = local
|
||||||
s.dialect = localDialect
|
s.dialect = localDialect
|
||||||
s.status.ActiveProvider = "sqlite"
|
s.status.ActiveProvider = "sqlite"
|
||||||
|
s.status.LastHealthStatus = "not_configured"
|
||||||
}
|
}
|
||||||
s.status.LastRecoveredAt = Now()
|
s.status.LastRecoveredAt = Now()
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
@@ -188,6 +193,7 @@ func (s *Store) ReconfigureDatabase(cfg *config.Config) error {
|
|||||||
if oldLocal != nil && oldLocal != local {
|
if oldLocal != nil && oldLocal != local {
|
||||||
_ = oldLocal.Close()
|
_ = oldLocal.Close()
|
||||||
}
|
}
|
||||||
|
s.restoreDatabaseSyncStatus()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +243,11 @@ func (s *Store) insertID(query string, args ...any) (int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) maintain() {
|
func (s *Store) maintain() {
|
||||||
ticker := time.NewTicker(time.Duration(s.cfg.Database.HealthIntervalSec) * time.Second)
|
interval := s.cfg.Database.HealthIntervalSec
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 60
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(time.Duration(interval) * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -276,12 +286,18 @@ func (s *Store) openRemote() error {
|
|||||||
s.status.FailoverActive = false
|
s.status.FailoverActive = false
|
||||||
s.status.LastError = ""
|
s.status.LastError = ""
|
||||||
s.status.LastRecoveredAt = Now()
|
s.status.LastRecoveredAt = Now()
|
||||||
|
s.status.LastHealthCheckedAt = Now()
|
||||||
|
s.status.LastHealthStatus = "ok"
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) checkRemote() {
|
func (s *Store) checkRemote() {
|
||||||
if !strings.EqualFold(s.cfg.Database.Provider, "mysql") {
|
if !strings.EqualFold(s.cfg.Database.Provider, "mysql") {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.status.LastHealthCheckedAt = Now()
|
||||||
|
s.status.LastHealthStatus = "not_configured"
|
||||||
|
s.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
@@ -311,6 +327,8 @@ func (s *Store) checkRemote() {
|
|||||||
s.status.FailoverActive = false
|
s.status.FailoverActive = false
|
||||||
s.status.LastError = ""
|
s.status.LastError = ""
|
||||||
s.status.LastRecoveredAt = Now()
|
s.status.LastRecoveredAt = Now()
|
||||||
|
s.status.LastHealthCheckedAt = Now()
|
||||||
|
s.status.LastHealthStatus = "ok"
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,4 +347,6 @@ func (s *Store) markFailover(err error) {
|
|||||||
s.status.FailoverActive = !strings.EqualFold(s.cfg.Database.Provider, "sqlite")
|
s.status.FailoverActive = !strings.EqualFold(s.cfg.Database.Provider, "sqlite")
|
||||||
s.status.LastError = err.Error()
|
s.status.LastError = err.Error()
|
||||||
s.status.LastFailoverAt = Now()
|
s.status.LastFailoverAt = Now()
|
||||||
|
s.status.LastHealthCheckedAt = Now()
|
||||||
|
s.status.LastHealthStatus = "error"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -391,3 +391,47 @@ func TestDashboardOverviewKeepsChecksForDeletedSources(t *testing.T) {
|
|||||||
t.Fatalf("deleted source check should have fallback sourceId/name: %#v", checks[0])
|
t.Fatalf("deleted source check should have fallback sourceId/name: %#v", checks[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDatabaseSyncJobPersistsLatestStatusAndOutput(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
path := filepath.Join(root, "unified.sqlite")
|
||||||
|
store, err := Open(&config.Config{
|
||||||
|
StorageDir: root,
|
||||||
|
Database: config.DatabaseConfig{
|
||||||
|
Provider: "sqlite",
|
||||||
|
SQLitePath: path,
|
||||||
|
FailoverEnabled: true,
|
||||||
|
HealthIntervalSec: 3600,
|
||||||
|
MaxOpenConns: 1,
|
||||||
|
MaxIdleConns: 1,
|
||||||
|
ConnMaxLifetimeSeconds: 60,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
result, err := store.ImportSQLiteToRemote()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !result.Skipped || result.Status != "skipped" {
|
||||||
|
t.Fatalf("expected missing remote sync to be skipped, got %#v", result)
|
||||||
|
}
|
||||||
|
latest, err := store.LatestDatabaseSyncJob()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if latest.Direction != "sqlite_to_remote" || latest.Status != "skipped" {
|
||||||
|
t.Fatalf("unexpected latest sync job: %#v", latest)
|
||||||
|
}
|
||||||
|
if len(latest.Output) == 0 || latest.Tables == nil {
|
||||||
|
t.Fatalf("latest job should include output and tables: %#v", latest)
|
||||||
|
}
|
||||||
|
store.restoreDatabaseSyncStatus()
|
||||||
|
status := store.Status()
|
||||||
|
if status.LastSyncAt == "" || status.LastSyncError != "" {
|
||||||
|
t.Fatalf("sync status was not restored from latest job: %#v", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Store) ListSystemLogsPage(filters SystemLogFilters) (SystemLogPage, error) {
|
||||||
|
page := filters.Page
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
perPage := filters.PerPage
|
||||||
|
if perPage <= 0 {
|
||||||
|
perPage = 35
|
||||||
|
}
|
||||||
|
if perPage > 100 {
|
||||||
|
perPage = 100
|
||||||
|
}
|
||||||
|
items, err := s.collectSystemLogs(filters)
|
||||||
|
if err != nil {
|
||||||
|
return SystemLogPage{}, err
|
||||||
|
}
|
||||||
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
|
if items[i].CreatedAt == items[j].CreatedAt {
|
||||||
|
return items[i].ID > items[j].ID
|
||||||
|
}
|
||||||
|
return items[i].CreatedAt > items[j].CreatedAt
|
||||||
|
})
|
||||||
|
total := len(items)
|
||||||
|
start := (page - 1) * perPage
|
||||||
|
if start > total {
|
||||||
|
start = total
|
||||||
|
}
|
||||||
|
end := start + perPage
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
return SystemLogPage{Items: items[start:end], Total: total, Page: page, PerPage: perPage}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) collectSystemLogs(filters SystemLogFilters) ([]SystemLogItem, error) {
|
||||||
|
items := []SystemLogItem{}
|
||||||
|
appendItems := func(category string, next []SystemLogItem, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, item := range next {
|
||||||
|
if matchesSystemLog(filters, item, category) {
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
logs, err := s.operationLogs()
|
||||||
|
if err := appendItems("operation", logs, err); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
logs, err = s.healthLogs()
|
||||||
|
if err := appendItems("health", logs, err); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
logs, err = s.clientCallLogs()
|
||||||
|
if err := appendItems("client", logs, err); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
logs, err = s.databaseSyncLogs()
|
||||||
|
if err := appendItems("database_sync", logs, err); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
logs, err = s.legacySyncLogs()
|
||||||
|
if err := appendItems("legacy_sync", logs, err); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) operationLogs() ([]SystemLogItem, error) {
|
||||||
|
rows, err := s.query(`SELECT id, actor, type, target, message, ip, user_agent, created_at FROM audit_logs ORDER BY id DESC LIMIT 500`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []SystemLogItem{}
|
||||||
|
for rows.Next() {
|
||||||
|
var item AuditLog
|
||||||
|
if err := rows.Scan(&item.ID, &item.Actor, &item.Type, &item.Target, &item.Message, &item.IP, &item.UserAgent, &item.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, SystemLogItem{
|
||||||
|
ID: item.ID,
|
||||||
|
Category: "operation",
|
||||||
|
Type: item.Type,
|
||||||
|
Target: item.Target,
|
||||||
|
Status: firstNonEmpty(item.Actor, "system"),
|
||||||
|
Message: item.Message,
|
||||||
|
Detail: strings.TrimSpace(item.IP + " " + item.UserAgent),
|
||||||
|
CreatedAt: item.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) healthLogs() ([]SystemLogItem, error) {
|
||||||
|
rows, err := s.query(`SELECT h.id, h.source_db_id, COALESCE(e.source_id, ''), COALESCE(e.name, ''), h.status, h.latency_ms, h.error, h.checked_at
|
||||||
|
FROM endpoint_health_checks h LEFT JOIN source_endpoints e ON e.id = h.source_db_id
|
||||||
|
ORDER BY h.id DESC LIMIT 500`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []SystemLogItem{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, sourceDBID int64
|
||||||
|
var sourceID, name, status, message, checkedAt string
|
||||||
|
var latency int
|
||||||
|
if err := rows.Scan(&id, &sourceDBID, &sourceID, &name, &status, &latency, &message, &checkedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
target := firstNonEmpty(sourceID, fmt.Sprintf("source#%d", sourceDBID))
|
||||||
|
items = append(items, SystemLogItem{
|
||||||
|
ID: id,
|
||||||
|
Category: "health",
|
||||||
|
Type: "endpoint.health",
|
||||||
|
Target: target,
|
||||||
|
Status: status,
|
||||||
|
Message: firstNonEmpty(name, target),
|
||||||
|
Detail: fmt.Sprintf("%dms %s", latency, message),
|
||||||
|
CreatedAt: checkedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) clientCallLogs() ([]SystemLogItem, error) {
|
||||||
|
rows, err := s.query(`SELECT id, source_id, status, latency_ms, error, client, created_at FROM endpoint_call_logs ORDER BY id DESC LIMIT 500`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []SystemLogItem{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var sourceID, status, message, client, createdAt string
|
||||||
|
var latency int
|
||||||
|
if err := rows.Scan(&id, &sourceID, &status, &latency, &message, &client, &createdAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, SystemLogItem{
|
||||||
|
ID: id,
|
||||||
|
Category: "client",
|
||||||
|
Type: "endpoint.call",
|
||||||
|
Target: sourceID,
|
||||||
|
Status: status,
|
||||||
|
Message: message,
|
||||||
|
Detail: fmt.Sprintf("%dms %s", latency, client),
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) databaseSyncLogs() ([]SystemLogItem, error) {
|
||||||
|
rows, err := s.query(`SELECT id, direction, status, message, tables_json, started_at, finished_at FROM database_sync_jobs ORDER BY id DESC LIMIT 500`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []SystemLogItem{}
|
||||||
|
for rows.Next() {
|
||||||
|
job, err := scanDatabaseSyncJob(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
detail, _ := json.Marshal(map[string]any{"tables": job.Tables, "warnings": job.Warnings, "errors": job.Errors})
|
||||||
|
items = append(items, SystemLogItem{
|
||||||
|
ID: job.ID,
|
||||||
|
Category: "database_sync",
|
||||||
|
Type: job.Direction,
|
||||||
|
Target: directionLabel(job.Direction),
|
||||||
|
Status: job.Status,
|
||||||
|
Message: strings.Join(job.Output, "\n"),
|
||||||
|
Detail: string(detail),
|
||||||
|
CreatedAt: firstNonEmpty(job.FinishedAt, job.StartedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) legacySyncLogs() ([]SystemLogItem, error) {
|
||||||
|
rows, err := s.query(`SELECT id, status, summary, stats_json, started_at, finished_at FROM legacy_sync_jobs ORDER BY id DESC LIMIT 500`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []SystemLogItem{}
|
||||||
|
for rows.Next() {
|
||||||
|
var item LegacySyncJob
|
||||||
|
if err := rows.Scan(&item.ID, &item.Status, &item.Summary, &item.StatsJSON, &item.StartedAt, &item.FinishedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, SystemLogItem{
|
||||||
|
ID: item.ID,
|
||||||
|
Category: "legacy_sync",
|
||||||
|
Type: "legacy.sync",
|
||||||
|
Target: "legacy",
|
||||||
|
Status: item.Status,
|
||||||
|
Message: item.Summary,
|
||||||
|
Detail: item.StatsJSON,
|
||||||
|
CreatedAt: firstNonEmpty(item.FinishedAt, item.StartedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesSystemLog(filters SystemLogFilters, item SystemLogItem, category string) bool {
|
||||||
|
if value := strings.TrimSpace(filters.Category); value != "" && value != category && value != item.Category {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if value := strings.ToLower(strings.TrimSpace(filters.Query)); value != "" {
|
||||||
|
haystack := strings.ToLower(strings.Join([]string{item.Category, item.Type, item.Target, item.Status, item.Message, item.Detail, item.CreatedAt}, " "))
|
||||||
|
return strings.Contains(haystack, value)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ type mediaCandidate struct {
|
|||||||
|
|
||||||
type legacyMedia struct {
|
type legacyMedia struct {
|
||||||
Categories []legacyCategory `json:"categories"`
|
Categories []legacyCategory `json:"categories"`
|
||||||
|
Sources []legacySubcategory `json:"sources"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxSourceProbeBytes int64 = 2 * 1024 * 1024
|
const maxSourceProbeBytes int64 = 2 * 1024 * 1024
|
||||||
@@ -70,19 +71,44 @@ var absoluteURLPattern = regexp.MustCompile(`https?://[^\s"'<>\\]+`)
|
|||||||
|
|
||||||
type legacyCategory struct {
|
type legacyCategory struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
CategoryID string `json:"categoryId"`
|
||||||
|
CategoryIDAlt string `json:"category_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
Subcategories []legacySubcategory `json:"subcategories"`
|
Subcategories []legacySubcategory `json:"subcategories"`
|
||||||
|
Sources []legacySubcategory `json:"sources"`
|
||||||
|
Endpoints []legacySubcategory `json:"endpoints"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type legacySubcategory struct {
|
type legacySubcategory struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
SourceID string `json:"sourceId"`
|
||||||
|
SourceIDAlt string `json:"source_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
Method string `json:"method"`
|
||||||
APIURL string `json:"api_url"`
|
APIURL string `json:"api_url"`
|
||||||
|
APIURLAlt string `json:"apiUrl"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
URLTemplate string `json:"urlTemplate"`
|
||||||
|
URLTemplateAlt string `json:"url_template"`
|
||||||
ThumbnailURL string `json:"thumbnail_url"`
|
ThumbnailURL string `json:"thumbnail_url"`
|
||||||
|
ThumbnailURLAlt string `json:"thumbnailUrl"`
|
||||||
|
ProxyMode string `json:"proxy_mode"`
|
||||||
|
ProxyModeAlt string `json:"proxyMode"`
|
||||||
RefreshInterval int `json:"refresh_interval"`
|
RefreshInterval int `json:"refresh_interval"`
|
||||||
|
RefreshIntervalAlt int `json:"refreshInterval"`
|
||||||
|
CacheSeconds int `json:"cacheSeconds"`
|
||||||
|
CheckIntervalSec int `json:"checkIntervalSec"`
|
||||||
|
TimeoutMS int `json:"timeoutMs"`
|
||||||
|
RetryCount int `json:"retryCount"`
|
||||||
SupportedFormats []string `json:"supported_formats"`
|
SupportedFormats []string `json:"supported_formats"`
|
||||||
|
SupportedFormatsAlt []string `json:"supportedFormats"`
|
||||||
|
MediaType string `json:"mediaType"`
|
||||||
|
MediaTypeAlt string `json:"media_type"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
ClientVisible *bool `json:"clientVisible"`
|
||||||
|
ClientVisibleAlt *bool `json:"client_visible"`
|
||||||
Downloadable bool `json:"downloadable"`
|
Downloadable bool `json:"downloadable"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +152,11 @@ func (s *Service) ImportLegacyMediaTypesIfEmpty(ctx context.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if count > 0 {
|
visible, err := s.store.CountClientVisibleSources()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 && visible > 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return s.ImportLegacyMediaTypes(ctx)
|
return s.ImportLegacyMediaTypes(ctx)
|
||||||
@@ -141,28 +171,54 @@ func (s *Service) ImportLegacyMediaTypes(ctx context.Context) error {
|
|||||||
if err := json.Unmarshal(data, &legacy); err != nil {
|
if err := json.Unmarshal(data, &legacy); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if len(legacy.Sources) > 0 && len(legacy.Categories) == 0 {
|
||||||
|
legacy.Categories = []legacyCategory{{ID: "media", Name: "Media", Sources: legacy.Sources}}
|
||||||
|
}
|
||||||
for _, category := range legacy.Categories {
|
for _, category := range legacy.Categories {
|
||||||
for _, sub := range category.Subcategories {
|
categoryID := firstNonEmpty(category.ID, category.CategoryID, category.CategoryIDAlt, "media")
|
||||||
if strings.TrimSpace(sub.APIURL) == "" {
|
categoryName := defaultString(category.Name, categoryID)
|
||||||
|
for _, sub := range category.entries() {
|
||||||
|
apiURL := firstNonEmpty(sub.APIURL, sub.APIURLAlt, sub.URL, sub.URLTemplate, sub.URLTemplateAlt)
|
||||||
|
if strings.TrimSpace(apiURL) == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
formats, _ := json.Marshal(sub.SupportedFormats)
|
formatsList := firstNonEmptySlice(sub.SupportedFormats, sub.SupportedFormatsAlt)
|
||||||
|
if len(formatsList) == 0 && firstNonEmpty(sub.MediaType, sub.MediaTypeAlt) != "" {
|
||||||
|
formatsList = []string{firstNonEmpty(sub.MediaType, sub.MediaTypeAlt)}
|
||||||
|
}
|
||||||
|
formats, _ := json.Marshal(formatsList)
|
||||||
|
enabled := legacyEnabled(category.Enabled)
|
||||||
|
if sub.Enabled != nil {
|
||||||
|
enabled = *sub.Enabled
|
||||||
|
}
|
||||||
|
visible := true
|
||||||
|
if sub.ClientVisible != nil {
|
||||||
|
visible = *sub.ClientVisible
|
||||||
|
} else if sub.ClientVisibleAlt != nil {
|
||||||
|
visible = *sub.ClientVisibleAlt
|
||||||
|
}
|
||||||
|
interval := firstPositive(sub.CheckIntervalSec, sub.RefreshInterval, sub.RefreshIntervalAlt, sub.CacheSeconds, 60)
|
||||||
|
cacheSeconds := firstPositive(sub.CacheSeconds, interval, 60)
|
||||||
_, err := s.store.UpsertSource(db.Source{
|
_, err := s.store.UpsertSource(db.Source{
|
||||||
CategoryID: defaultString(category.ID, "media"),
|
CategoryID: categoryID,
|
||||||
CategoryName: defaultString(category.Name, category.ID),
|
CategoryName: categoryName,
|
||||||
SourceID: defaultString(sub.ID, category.ID+"-"+sub.Name),
|
SourceID: defaultString(firstNonEmpty(sub.ID, sub.SourceID, sub.SourceIDAlt), categoryID+"-"+sub.Name),
|
||||||
Name: defaultString(sub.Name, sub.ID),
|
Name: defaultString(sub.Name, firstNonEmpty(sub.ID, sub.SourceID, sub.SourceIDAlt)),
|
||||||
Description: sub.Description,
|
Description: sub.Description,
|
||||||
Method: "GET",
|
Method: firstNonEmpty(sub.Method, "GET"),
|
||||||
APIURL: sub.APIURL,
|
APIURL: apiURL,
|
||||||
ThumbnailURL: sub.ThumbnailURL,
|
URLTemplate: firstNonEmpty(sub.URLTemplate, sub.URLTemplateAlt, apiURL),
|
||||||
ProxyMode: "client_direct",
|
ThumbnailURL: firstNonEmpty(sub.ThumbnailURL, sub.ThumbnailURLAlt),
|
||||||
TimeoutMS: 8000,
|
ProxyMode: firstNonEmpty(sub.ProxyMode, sub.ProxyModeAlt, "client_direct"),
|
||||||
|
TimeoutMS: firstPositive(sub.TimeoutMS, 8000),
|
||||||
RetryCount: 1,
|
RetryCount: 1,
|
||||||
CheckIntervalSec: maxInt(sub.RefreshInterval, 300),
|
CacheSeconds: cacheSeconds,
|
||||||
Enabled: legacyEnabled(category.Enabled),
|
CheckIntervalSec: interval,
|
||||||
ClientVisible: true,
|
Enabled: enabled,
|
||||||
|
ClientVisible: visible,
|
||||||
SupportedFormats: string(formats),
|
SupportedFormats: string(formats),
|
||||||
|
EnabledSet: true,
|
||||||
|
ClientVisibleSet: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -229,23 +285,38 @@ func (s *Service) Catalog(includeHidden bool) (map[string]any, error) {
|
|||||||
_ = json.Unmarshal([]byte(item.SupportedFormats), &formats)
|
_ = json.Unmarshal([]byte(item.SupportedFormats), &formats)
|
||||||
sub := map[string]any{
|
sub := map[string]any{
|
||||||
"id": item.SourceID,
|
"id": item.SourceID,
|
||||||
|
"sourceId": item.SourceID,
|
||||||
|
"source_id": item.SourceID,
|
||||||
"name": item.Name,
|
"name": item.Name,
|
||||||
"description": item.Description,
|
"description": item.Description,
|
||||||
"api_url": item.APIURL,
|
"api_url": item.APIURL,
|
||||||
|
"apiUrl": item.APIURL,
|
||||||
"urlTemplate": firstNonEmpty(item.URLTemplate, item.APIURL),
|
"urlTemplate": firstNonEmpty(item.URLTemplate, item.APIURL),
|
||||||
|
"url_template": firstNonEmpty(item.URLTemplate, item.APIURL),
|
||||||
"thumbnail_url": item.ThumbnailURL,
|
"thumbnail_url": item.ThumbnailURL,
|
||||||
|
"thumbnailUrl": item.ThumbnailURL,
|
||||||
"method": item.Method,
|
"method": item.Method,
|
||||||
"proxy_mode": item.ProxyMode,
|
"proxy_mode": item.ProxyMode,
|
||||||
"proxyMode": item.ProxyMode,
|
"proxyMode": item.ProxyMode,
|
||||||
"refresh_interval": item.CheckIntervalSec,
|
"refresh_interval": item.CheckIntervalSec,
|
||||||
|
"refreshInterval": item.CheckIntervalSec,
|
||||||
"cacheSeconds": item.CacheSeconds,
|
"cacheSeconds": item.CacheSeconds,
|
||||||
|
"enabled": item.Enabled,
|
||||||
|
"clientVisible": item.ClientVisible,
|
||||||
"supported_formats": formats,
|
"supported_formats": formats,
|
||||||
|
"supportedFormats": formats,
|
||||||
"downloadable": true,
|
"downloadable": true,
|
||||||
|
"kind": inferMediaType(formats, item),
|
||||||
|
"mediaType": inferMediaType(formats, item),
|
||||||
|
"media_type": inferMediaType(formats, item),
|
||||||
"health": map[string]any{
|
"health": map[string]any{
|
||||||
"status": item.LastStatus,
|
"status": item.LastStatus,
|
||||||
"latency_ms": item.LastLatencyMS,
|
"latency_ms": item.LastLatencyMS,
|
||||||
|
"latencyMs": item.LastLatencyMS,
|
||||||
"last_checked_at": item.LastCheckedAt,
|
"last_checked_at": item.LastCheckedAt,
|
||||||
|
"lastCheckedAt": item.LastCheckedAt,
|
||||||
"last_error": item.LastError,
|
"last_error": item.LastError,
|
||||||
|
"lastError": item.LastError,
|
||||||
"consecutiveFailure": item.ConsecutiveFailure,
|
"consecutiveFailure": item.ConsecutiveFailure,
|
||||||
"meta": parseHealthMeta(item.LastError),
|
"meta": parseHealthMeta(item.LastError),
|
||||||
},
|
},
|
||||||
@@ -275,20 +346,35 @@ func (s *Service) Endpoints(includeHidden bool) ([]map[string]any, error) {
|
|||||||
_ = json.Unmarshal([]byte(item.SupportedFormats), &formats)
|
_ = json.Unmarshal([]byte(item.SupportedFormats), &formats)
|
||||||
endpoint := map[string]any{
|
endpoint := map[string]any{
|
||||||
"id": item.SourceID,
|
"id": item.SourceID,
|
||||||
|
"sourceId": item.SourceID,
|
||||||
"category": item.CategoryID,
|
"category": item.CategoryID,
|
||||||
|
"categoryId": item.CategoryID,
|
||||||
|
"categoryName": item.CategoryName,
|
||||||
"name": item.Name,
|
"name": item.Name,
|
||||||
|
"description": item.Description,
|
||||||
"method": item.Method,
|
"method": item.Method,
|
||||||
|
"apiUrl": item.APIURL,
|
||||||
|
"api_url": item.APIURL,
|
||||||
"urlTemplate": firstNonEmpty(item.URLTemplate, item.APIURL),
|
"urlTemplate": firstNonEmpty(item.URLTemplate, item.APIURL),
|
||||||
|
"url_template": firstNonEmpty(item.URLTemplate, item.APIURL),
|
||||||
"proxyMode": item.ProxyMode,
|
"proxyMode": item.ProxyMode,
|
||||||
|
"proxy_mode": item.ProxyMode,
|
||||||
"clientVisible": item.ClientVisible,
|
"clientVisible": item.ClientVisible,
|
||||||
"enabled": item.Enabled,
|
"enabled": item.Enabled,
|
||||||
"cacheSeconds": item.CacheSeconds,
|
"cacheSeconds": item.CacheSeconds,
|
||||||
"supportedFormats": formats,
|
"supportedFormats": formats,
|
||||||
|
"supported_formats": formats,
|
||||||
|
"kind": inferMediaType(formats, item),
|
||||||
|
"mediaType": inferMediaType(formats, item),
|
||||||
|
"media_type": inferMediaType(formats, item),
|
||||||
"health": map[string]any{
|
"health": map[string]any{
|
||||||
"status": item.LastStatus,
|
"status": item.LastStatus,
|
||||||
"latencyMs": item.LastLatencyMS,
|
"latencyMs": item.LastLatencyMS,
|
||||||
|
"latency_ms": item.LastLatencyMS,
|
||||||
"lastCheckedAt": item.LastCheckedAt,
|
"lastCheckedAt": item.LastCheckedAt,
|
||||||
|
"last_checked_at": item.LastCheckedAt,
|
||||||
"lastError": item.LastError,
|
"lastError": item.LastError,
|
||||||
|
"last_error": item.LastError,
|
||||||
"consecutiveFailure": item.ConsecutiveFailure,
|
"consecutiveFailure": item.ConsecutiveFailure,
|
||||||
"meta": parseHealthMeta(item.LastError),
|
"meta": parseHealthMeta(item.LastError),
|
||||||
},
|
},
|
||||||
@@ -826,6 +912,79 @@ func parseHealthMeta(message string) map[string]any {
|
|||||||
return meta
|
return meta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c legacyCategory) entries() []legacySubcategory {
|
||||||
|
out := make([]legacySubcategory, 0, len(c.Subcategories)+len(c.Sources)+len(c.Endpoints))
|
||||||
|
out = append(out, c.Subcategories...)
|
||||||
|
out = append(out, c.Sources...)
|
||||||
|
out = append(out, c.Endpoints...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptySlice(values ...[]string) []string {
|
||||||
|
for _, value := range values {
|
||||||
|
if len(value) > 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstPositive(values ...int) int {
|
||||||
|
for _, value := range values {
|
||||||
|
if value > 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferMediaType(formats []string, item db.Source) string {
|
||||||
|
for _, format := range formats {
|
||||||
|
if value := strings.ToLower(strings.TrimSpace(format)); value != "" {
|
||||||
|
switch value {
|
||||||
|
case "image", "video", "audio":
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value := mediaTypeFromURL(mustParseURL(firstNonEmpty(item.URLTemplate, item.APIURL, item.ThumbnailURL))); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
for _, format := range formats {
|
||||||
|
if value := mediaTypeFromFormat(format); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
category := strings.ToLower(strings.TrimSpace(item.CategoryID + " " + item.CategoryName))
|
||||||
|
for _, candidate := range []string{"image", "video", "audio"} {
|
||||||
|
if strings.Contains(category, candidate) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "media"
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaTypeFromFormat(value string) string {
|
||||||
|
switch strings.ToLower(strings.Trim(strings.TrimSpace(value), ".")) {
|
||||||
|
case "jpg", "jpeg", "png", "webp", "gif", "bmp", "tif", "tiff":
|
||||||
|
return "image"
|
||||||
|
case "mp4", "webm", "m3u8", "mkv", "mov", "m4v", "avi", "wmv":
|
||||||
|
return "video"
|
||||||
|
case "mp3", "wav", "flac", "aac", "m4a", "ogg", "wma":
|
||||||
|
return "audio"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParseURL(value string) *url.URL {
|
||||||
|
parsed, err := url.Parse(strings.TrimSpace(value))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
func defaultString(value, fallback string) string {
|
func defaultString(value, fallback string) string {
|
||||||
if strings.TrimSpace(value) == "" {
|
if strings.TrimSpace(value) == "" {
|
||||||
return fallback
|
return fallback
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -229,6 +230,63 @@ func TestCheckOneResolvesTextMediaURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestImportLegacyMediaTypesRestoresClientVisibleSources(t *testing.T) {
|
||||||
|
cfg, store := testStore(t)
|
||||||
|
if err := os.MkdirAll(cfg.UpdatePublicDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mediaTypes := `{
|
||||||
|
"categories": [{
|
||||||
|
"categoryId": "image",
|
||||||
|
"name": "Images",
|
||||||
|
"sources": [{
|
||||||
|
"sourceId": "random-card",
|
||||||
|
"name": "Random Card",
|
||||||
|
"description": "Card description from source",
|
||||||
|
"apiUrl": "https://example.test/random-card",
|
||||||
|
"thumbnailUrl": "https://example.test/thumb.webp",
|
||||||
|
"supportedFormats": ["webp"],
|
||||||
|
"mediaType": "image"
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}`
|
||||||
|
if err := os.WriteFile(filepath.Join(cfg.UpdatePublicDir, "media-types.json"), []byte(mediaTypes), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := store.UpsertSource(db.Source{
|
||||||
|
CategoryID: "hidden",
|
||||||
|
CategoryName: "Hidden",
|
||||||
|
SourceID: "hidden-source",
|
||||||
|
Name: "Hidden",
|
||||||
|
APIURL: "https://example.test/hidden",
|
||||||
|
Enabled: false,
|
||||||
|
ClientVisible: false,
|
||||||
|
EnabledSet: true,
|
||||||
|
ClientVisibleSet: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
service := NewService(cfg, store)
|
||||||
|
if err := service.ImportLegacyMediaTypesIfEmpty(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
visible, err := store.CountClientVisibleSources()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if visible != 1 {
|
||||||
|
t.Fatalf("visible source count = %d, want 1", visible)
|
||||||
|
}
|
||||||
|
catalog, err := service.Catalog(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sub := catalog["categories"].([]map[string]any)[0]["subcategories"].([]map[string]any)[0]
|
||||||
|
if sub["description"] != "Card description from source" || sub["apiUrl"] != "https://example.test/random-card" || sub["mediaType"] != "image" {
|
||||||
|
t.Fatalf("catalog did not expose stable source fields: %#v", sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testStore(t *testing.T) (*config.Config, *db.Store) {
|
func testStore(t *testing.T) (*config.Config, *db.Store) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ func (r *router) handleAdminSources(w http.ResponseWriter, req *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, "IMPORT_FAILED", err)
|
writeError(w, http.StatusInternalServerError, "IMPORT_FAILED", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
_ = r.sources.PublishLegacyMediaTypes(req.Context(), "admin")
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
case req.Method == http.MethodPost && path == "/api/admin/sources/check":
|
case req.Method == http.MethodPost && path == "/api/admin/sources/check":
|
||||||
job := r.sources.QueueCheckAll()
|
job := r.sources.QueueCheckAll()
|
||||||
@@ -46,8 +47,8 @@ func (r *router) handleAdminSources(w http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "source": item})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "source": item})
|
||||||
case (req.Method == http.MethodPost || req.Method == http.MethodPut) && path == "/api/admin/sources":
|
case (req.Method == http.MethodPost || req.Method == http.MethodPut) && path == "/api/admin/sources":
|
||||||
var item db.Source
|
item, err := r.decodeAdminSource(req)
|
||||||
if err := json.NewDecoder(req.Body).Decode(&item); err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -72,3 +73,114 @@ func (r *router) handleAdminSources(w http.ResponseWriter, req *http.Request) {
|
|||||||
http.NotFound(w, req)
|
http.NotFound(w, req)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type adminSourceRequest struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
CategoryID string `json:"categoryId"`
|
||||||
|
CategoryIDAlt string `json:"category_id"`
|
||||||
|
CategoryName string `json:"categoryName"`
|
||||||
|
CategoryNameAlt string `json:"category_name"`
|
||||||
|
SourceID string `json:"sourceId"`
|
||||||
|
SourceIDAlt string `json:"source_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
APIURL string `json:"apiUrl"`
|
||||||
|
APIURLAlt string `json:"api_url"`
|
||||||
|
URLTemplate string `json:"urlTemplate"`
|
||||||
|
URLTemplateAlt string `json:"url_template"`
|
||||||
|
ThumbnailURL string `json:"thumbnailUrl"`
|
||||||
|
ThumbnailURLAlt string `json:"thumbnail_url"`
|
||||||
|
ProxyMode string `json:"proxyMode"`
|
||||||
|
ProxyModeAlt string `json:"proxy_mode"`
|
||||||
|
TimeoutMS int `json:"timeoutMs"`
|
||||||
|
TimeoutMSAlt int `json:"timeout_ms"`
|
||||||
|
RetryCount int `json:"retryCount"`
|
||||||
|
RetryCountAlt int `json:"retry_count"`
|
||||||
|
CacheSeconds int `json:"cacheSeconds"`
|
||||||
|
CacheSecondsAlt int `json:"cache_seconds"`
|
||||||
|
CheckIntervalSec int `json:"checkIntervalSec"`
|
||||||
|
CheckIntervalSecAlt int `json:"check_interval_sec"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
ClientVisible *bool `json:"clientVisible"`
|
||||||
|
ClientVisibleAlt *bool `json:"client_visible"`
|
||||||
|
SupportedFormats []string `json:"supportedFormats"`
|
||||||
|
SupportedFormatsAlt []string `json:"supported_formats"`
|
||||||
|
LastStatus string `json:"lastStatus"`
|
||||||
|
LastStatusAlt string `json:"last_status"`
|
||||||
|
LastLatencyMS int `json:"lastLatencyMs"`
|
||||||
|
LastLatencyMSAlt int `json:"last_latency_ms"`
|
||||||
|
LastCheckedAt string `json:"lastCheckedAt"`
|
||||||
|
LastCheckedAtAlt string `json:"last_checked_at"`
|
||||||
|
LastError string `json:"lastError"`
|
||||||
|
LastErrorAlt string `json:"last_error"`
|
||||||
|
ConsecutiveFailure int `json:"consecutiveFailure"`
|
||||||
|
ConsecutiveFailAlt int `json:"consecutive_failure"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *router) decodeAdminSource(req *http.Request) (db.Source, error) {
|
||||||
|
var body adminSourceRequest
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
return db.Source{}, err
|
||||||
|
}
|
||||||
|
sourceID := firstNonEmpty(body.SourceID, body.SourceIDAlt)
|
||||||
|
var existing db.Source
|
||||||
|
hasExisting := false
|
||||||
|
if sourceID != "" {
|
||||||
|
if item, err := r.store.GetSourceBySourceID(sourceID); err == nil {
|
||||||
|
existing = item
|
||||||
|
hasExisting = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item := existing
|
||||||
|
item.ID = body.ID
|
||||||
|
item.CategoryID = firstNonEmpty(body.CategoryID, body.CategoryIDAlt, item.CategoryID)
|
||||||
|
item.CategoryName = firstNonEmpty(body.CategoryName, body.CategoryNameAlt, item.CategoryName)
|
||||||
|
item.SourceID = firstNonEmpty(sourceID, item.SourceID)
|
||||||
|
item.Name = firstNonEmpty(body.Name, item.Name)
|
||||||
|
item.Description = body.Description
|
||||||
|
item.Method = firstNonEmpty(body.Method, item.Method)
|
||||||
|
item.APIURL = firstNonEmpty(body.APIURL, body.APIURLAlt, item.APIURL)
|
||||||
|
item.URLTemplate = firstNonEmpty(body.URLTemplate, body.URLTemplateAlt, item.URLTemplate, item.APIURL)
|
||||||
|
item.ThumbnailURL = firstNonEmpty(body.ThumbnailURL, body.ThumbnailURLAlt)
|
||||||
|
item.ProxyMode = firstNonEmpty(body.ProxyMode, body.ProxyModeAlt, item.ProxyMode)
|
||||||
|
item.TimeoutMS = firstPositive(body.TimeoutMS, body.TimeoutMSAlt, item.TimeoutMS)
|
||||||
|
item.RetryCount = firstPositive(body.RetryCount, body.RetryCountAlt, item.RetryCount)
|
||||||
|
item.CacheSeconds = firstPositive(body.CacheSeconds, body.CacheSecondsAlt, item.CacheSeconds)
|
||||||
|
item.CheckIntervalSec = firstPositive(body.CheckIntervalSec, body.CheckIntervalSecAlt, item.CheckIntervalSec)
|
||||||
|
item.LastStatus = firstNonEmpty(body.LastStatus, body.LastStatusAlt, item.LastStatus)
|
||||||
|
item.LastLatencyMS = firstPositive(body.LastLatencyMS, body.LastLatencyMSAlt, item.LastLatencyMS)
|
||||||
|
item.LastCheckedAt = firstNonEmpty(body.LastCheckedAt, body.LastCheckedAtAlt, item.LastCheckedAt)
|
||||||
|
item.LastError = firstNonEmpty(body.LastError, body.LastErrorAlt, item.LastError)
|
||||||
|
item.ConsecutiveFailure = firstPositive(body.ConsecutiveFailure, body.ConsecutiveFailAlt, item.ConsecutiveFailure)
|
||||||
|
if formats := firstNonEmptyStringSlice(body.SupportedFormats, body.SupportedFormatsAlt); len(formats) > 0 {
|
||||||
|
data, _ := json.Marshal(formats)
|
||||||
|
item.SupportedFormats = string(data)
|
||||||
|
}
|
||||||
|
if body.Enabled != nil {
|
||||||
|
item.Enabled = *body.Enabled
|
||||||
|
item.EnabledSet = true
|
||||||
|
} else if hasExisting {
|
||||||
|
item.Enabled = existing.Enabled
|
||||||
|
}
|
||||||
|
visible := body.ClientVisible
|
||||||
|
if visible == nil {
|
||||||
|
visible = body.ClientVisibleAlt
|
||||||
|
}
|
||||||
|
if visible != nil {
|
||||||
|
item.ClientVisible = *visible
|
||||||
|
item.ClientVisibleSet = true
|
||||||
|
} else if hasExisting {
|
||||||
|
item.ClientVisible = existing.ClientVisible
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyStringSlice(values ...[]string) []string {
|
||||||
|
for _, value := range values {
|
||||||
|
if len(value) > 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ymhut-box/server/unified-management/internal/config"
|
"ymhut-box/server/unified-management/internal/config"
|
||||||
@@ -55,6 +56,40 @@ func (r *router) handleAdminDatabase(w http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.database.saved", Target: body.Provider, Message: "数据库配置已保存并热切换", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.database.saved", Target: body.Provider, Message: "数据库配置已保存并热切换", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "database": r.store.Status(), "config": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database)})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "database": r.store.Status(), "config": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database)})
|
||||||
|
case req.Method == http.MethodPost && path == "/api/admin/database/sync/jobs":
|
||||||
|
var body struct {
|
||||||
|
Direction string `json:"direction"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
job, err := r.store.QueueDatabaseSync(body.Direction)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusConflict, "DATABASE_SYNC_RUNNING", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "database.sync.queued", Target: job.Direction, Message: "数据库同步任务已启动", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "jobId": job.ID, "job": job})
|
||||||
|
case req.Method == http.MethodGet && path == "/api/admin/database/sync/jobs/latest":
|
||||||
|
job, err := r.store.LatestDatabaseSyncJob()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "job": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "job": job})
|
||||||
|
case req.Method == http.MethodGet && strings.HasPrefix(path, "/api/admin/database/sync/jobs/"):
|
||||||
|
id, err := strconv.ParseInt(strings.TrimPrefix(path, "/api/admin/database/sync/jobs/"), 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "INVALID_JOB_ID", errors.New("invalid sync job id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
job, err := r.store.GetDatabaseSyncJob(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "SYNC_JOB_NOT_FOUND", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "job": job})
|
||||||
case req.Method == http.MethodPost && path == "/api/admin/database/import-sqlite":
|
case req.Method == http.MethodPost && path == "/api/admin/database/import-sqlite":
|
||||||
result, err := r.store.ImportSQLiteToRemote()
|
result, err := r.store.ImportSQLiteToRemote()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -218,6 +253,22 @@ func (r *router) handleAdminSystem(w http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": page.Items, "page": page})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": page.Items, "page": page})
|
||||||
|
case "/api/admin/system/logs":
|
||||||
|
if req.Method != http.MethodGet {
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page, err := r.store.ListSystemLogsPage(db.SystemLogFilters{
|
||||||
|
Page: queryInt(req, "page", 1),
|
||||||
|
PerPage: queryInt(req, "perPage", 35),
|
||||||
|
Category: req.URL.Query().Get("category"),
|
||||||
|
Query: req.URL.Query().Get("q"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "SYSTEM_LOGS_FAILED", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": page.Items, "page": page})
|
||||||
case "/api/admin/system/mail/config":
|
case "/api/admin/system/mail/config":
|
||||||
r.handleMailConfig(w, req)
|
r.handleMailConfig(w, req)
|
||||||
case "/api/admin/system/mail/test":
|
case "/api/admin/system/mail/test":
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||||||
r.auth.Require(http.HandlerFunc(r.handleChangePassword)).ServeHTTP(w, req)
|
r.auth.Require(http.HandlerFunc(r.handleChangePassword)).ServeHTTP(w, req)
|
||||||
case path == "/api/client/bootstrap":
|
case path == "/api/client/bootstrap":
|
||||||
r.handleClientBootstrap(w, req)
|
r.handleClientBootstrap(w, req)
|
||||||
|
case path == "/favicon.ico" || path == "/admin/favicon.ico":
|
||||||
|
r.serveServerAsset(w, req, "favicon.ico")
|
||||||
|
case path == "/assets/favicon.ico":
|
||||||
|
r.serveServerAsset(w, req, "favicon.ico")
|
||||||
|
case path == "/assets/developer-avatar.png":
|
||||||
|
r.serveServerAsset(w, req, "developer-avatar.png")
|
||||||
case path == "/api/client/releases" || path == "/api/releases" || path == "/api/update-info":
|
case path == "/api/client/releases" || path == "/api/releases" || path == "/api/update-info":
|
||||||
writeJSON(w, http.StatusOK, r.releases.Manifest(req))
|
writeJSON(w, http.StatusOK, r.releases.Manifest(req))
|
||||||
case path == "/api/client/sources":
|
case path == "/api/client/sources":
|
||||||
|
|||||||
@@ -447,6 +447,31 @@ func TestBuiltFrontendAssetsAreServed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServerBrandAssetsAreServed(t *testing.T) {
|
||||||
|
handler, cleanup := testRouter(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
for _, item := range []struct {
|
||||||
|
Path string
|
||||||
|
ContentTypes []string
|
||||||
|
}{
|
||||||
|
{Path: "/favicon.ico", ContentTypes: []string{"image/x-icon", "image/vnd.microsoft.icon"}},
|
||||||
|
{Path: "/admin/favicon.ico", ContentTypes: []string{"image/x-icon", "image/vnd.microsoft.icon"}},
|
||||||
|
{Path: "/assets/favicon.ico", ContentTypes: []string{"image/x-icon", "image/vnd.microsoft.icon"}},
|
||||||
|
{Path: "/assets/developer-avatar.png", ContentTypes: []string{"image/png"}},
|
||||||
|
} {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, item.Path, nil)
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(res, req)
|
||||||
|
if res.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s returned %d: %s", item.Path, res.Code, res.Body.String())
|
||||||
|
}
|
||||||
|
if got := res.Header().Get("Content-Type"); !containsAny(got, item.ContentTypes) {
|
||||||
|
t.Fatalf("%s content type = %q, want one of %v", item.Path, got, item.ContentTypes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAdminSystemAndLegacyAdminPagesServeSPA(t *testing.T) {
|
func TestAdminSystemAndLegacyAdminPagesServeSPA(t *testing.T) {
|
||||||
handler, cleanup := testRouter(t)
|
handler, cleanup := testRouter(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -720,9 +745,19 @@ func testRouter(t *testing.T) (http.Handler, func()) {
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
public := filepath.Join(root, "public")
|
public := filepath.Join(root, "public")
|
||||||
noticeDir := filepath.Join(root, "update-notice")
|
noticeDir := filepath.Join(root, "update-notice")
|
||||||
|
assetDir := filepath.Join(root, "assets")
|
||||||
if err := os.MkdirAll(filepath.Join(public, "downloads"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(public, "downloads"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if err := os.MkdirAll(assetDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(assetDir, "favicon.ico"), []byte("ico"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(assetDir, "developer-avatar.png"), []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
adminDist := filepath.Join(root, "admin")
|
adminDist := filepath.Join(root, "admin")
|
||||||
portalDist := filepath.Join(root, "portal")
|
portalDist := filepath.Join(root, "portal")
|
||||||
for _, dir := range []string{filepath.Join(adminDist, "assets"), filepath.Join(portalDist, "assets")} {
|
for _, dir := range []string{filepath.Join(adminDist, "assets"), filepath.Join(portalDist, "assets")} {
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ func (r *setupRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||||||
switch {
|
switch {
|
||||||
case path == "/" || path == "/setup":
|
case path == "/" || path == "/setup":
|
||||||
r.serveSetup(w, req)
|
r.serveSetup(w, req)
|
||||||
|
case path == "/favicon.ico" || path == "/setup/favicon.ico":
|
||||||
|
serveSetupServerAsset(w, req, r.cfg.BaseDir, "favicon.ico")
|
||||||
|
case path == "/assets/favicon.ico":
|
||||||
|
serveSetupServerAsset(w, req, r.cfg.BaseDir, "favicon.ico")
|
||||||
|
case path == "/assets/developer-avatar.png":
|
||||||
|
serveSetupServerAsset(w, req, r.cfg.BaseDir, "developer-avatar.png")
|
||||||
case strings.HasPrefix(path, "/setup/assets/"):
|
case strings.HasPrefix(path, "/setup/assets/"):
|
||||||
serveStaticAsset(w, req, r.cfg.SetupWebDir, "setup/dist", strings.TrimPrefix(path, "/setup/"))
|
serveStaticAsset(w, req, r.cfg.SetupWebDir, "setup/dist", strings.TrimPrefix(path, "/setup/"))
|
||||||
case path == "/api/setup/status":
|
case path == "/api/setup/status":
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ func serveStaticAsset(w http.ResponseWriter, req *http.Request, root, embedRoot,
|
|||||||
http.NotFound(w, req)
|
http.NotFound(w, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *router) serveServerAsset(w http.ResponseWriter, req *http.Request, assetPath string) {
|
||||||
|
serveStaticAsset(w, req, filepath.Join(r.cfg.BaseDir, "assets"), "", assetPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveSetupServerAsset(w http.ResponseWriter, req *http.Request, cfgRoot, assetPath string) {
|
||||||
|
serveStaticAsset(w, req, filepath.Join(cfgRoot, "assets"), "", assetPath)
|
||||||
|
}
|
||||||
|
|
||||||
func tryServeDiskFile(w http.ResponseWriter, req *http.Request, root, assetPath string) bool {
|
func tryServeDiskFile(w http.ResponseWriter, req *http.Request, root, assetPath string) bool {
|
||||||
path := filepath.Join(root, filepath.FromSlash(assetPath))
|
path := filepath.Join(root, filepath.FromSlash(assetPath))
|
||||||
resolved, err := filepath.Abs(path)
|
resolved, err := filepath.Abs(path)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import { createSystemStore } from "./stores/system";
|
|||||||
|
|
||||||
const DashboardView = defineAsyncComponent(() => import("./views/DashboardView.vue"));
|
const DashboardView = defineAsyncComponent(() => import("./views/DashboardView.vue"));
|
||||||
|
|
||||||
type SystemTab = "database" | "migration" | "sync" | "security" | "health" | "audit";
|
type SystemTab = "database" | "migration" | "sync" | "security" | "health" | "logs" | "audit";
|
||||||
type ToastState = { message: string; type: "success" | "warn" | "error" };
|
type ToastState = { message: string; type: "success" | "warn" | "error" };
|
||||||
type LoadSystemOptions = { preserveForms?: boolean };
|
type LoadSystemOptions = { preserveForms?: boolean };
|
||||||
type LoadDatabaseOptions = { previewLegacy?: boolean; preserveForm?: boolean };
|
type LoadDatabaseOptions = { previewLegacy?: boolean; preserveForm?: boolean };
|
||||||
@@ -64,6 +64,7 @@ const autoRefreshPaused = ref(false);
|
|||||||
const databaseFormEditing = ref(false);
|
const databaseFormEditing = ref(false);
|
||||||
const mailConfigEditing = ref(false);
|
const mailConfigEditing = ref(false);
|
||||||
let refreshTimer: number | undefined;
|
let refreshTimer: number | undefined;
|
||||||
|
let systemRefreshTimer: number | undefined;
|
||||||
let toastTimer: number | undefined;
|
let toastTimer: number | undefined;
|
||||||
let events: EventSource | null = null;
|
let events: EventSource | null = null;
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ const { page: feedbackPage, selected: selectedFeedback, filters: feedbackFilters
|
|||||||
const { releases, notices: releaseNotices, selectedNotice, noticeDraft, uploadDraft } = releaseStore;
|
const { releases, notices: releaseNotices, selectedNotice, noticeDraft, uploadDraft } = releaseStore;
|
||||||
const { sync: legacySync, documents: legacyDocuments, drafts: legacyDrafts, modal: legacyModal, activeMediaCategoryIndex } = legacyStore;
|
const { sync: legacySync, documents: legacyDocuments, drafts: legacyDrafts, modal: legacyModal, activeMediaCategoryIndex } = legacyStore;
|
||||||
const { sources, endpoints, draft: sourceDraft } = sourceStore;
|
const { sources, endpoints, draft: sourceDraft } = sourceStore;
|
||||||
const { database, databaseConfig, databaseLastSync, healthSnapshot, auditLogs, auditPage, migrationStatus, branding, databaseForm, databaseConfigCollapsed, mailConfig, legacySyncMode } = systemStore;
|
const { database, databaseConfig, databaseLastSync, databaseSyncJob, databaseSyncOutput, healthSnapshot, auditLogs, auditPage, systemLogPage, migrationStatus, branding, databaseForm, databaseConfigCollapsed, mailConfig, legacySyncMode } = systemStore;
|
||||||
|
|
||||||
const routes: RouteItem[] = [
|
const routes: RouteItem[] = [
|
||||||
{ path: "/admin/dashboard", label: "仪表盘", description: "服务状态、接口心跳与运营指标", icon: LayoutDashboard },
|
{ path: "/admin/dashboard", label: "仪表盘", description: "服务状态、接口心跳与运营指标", icon: LayoutDashboard },
|
||||||
@@ -136,7 +137,7 @@ const heartbeatChartRows = computed(() => {
|
|||||||
status: labelStatus(item.status),
|
status: labelStatus(item.status),
|
||||||
}))
|
}))
|
||||||
.filter((item: any) => Number.isFinite(item.latency));
|
.filter((item: any) => Number.isFinite(item.latency));
|
||||||
return rows.length ? rows : [{ time: Date.now(), label: "暂无", latency: 0, name: "暂无检测记录", status: "未检测" }];
|
return rows;
|
||||||
});
|
});
|
||||||
const isHeartbeatChartEmpty = computed(() => heartbeats.value.length === 0);
|
const isHeartbeatChartEmpty = computed(() => heartbeats.value.length === 0);
|
||||||
|
|
||||||
@@ -255,6 +256,8 @@ const viewContext = computed(() => ({
|
|||||||
databaseFormEditing: databaseFormEditing.value,
|
databaseFormEditing: databaseFormEditing.value,
|
||||||
databaseForm,
|
databaseForm,
|
||||||
databaseLastSync: databaseLastSync.value,
|
databaseLastSync: databaseLastSync.value,
|
||||||
|
databaseSyncJob: databaseSyncJob.value,
|
||||||
|
databaseSyncOutput: databaseSyncOutput.value,
|
||||||
databaseSyncStatusLabel,
|
databaseSyncStatusLabel,
|
||||||
databaseSyncDirectionLabel,
|
databaseSyncDirectionLabel,
|
||||||
databaseSyncTableCount,
|
databaseSyncTableCount,
|
||||||
@@ -268,6 +271,7 @@ const viewContext = computed(() => ({
|
|||||||
feedbackPage: feedbackPage.value,
|
feedbackPage: feedbackPage.value,
|
||||||
feedbackUpdate,
|
feedbackUpdate,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
|
formatHealthOutput,
|
||||||
healthOption: healthOption.value,
|
healthOption: healthOption.value,
|
||||||
healthSnapshot: healthSnapshot.value,
|
healthSnapshot: healthSnapshot.value,
|
||||||
healthyEndpointCount: healthyEndpointCount.value,
|
healthyEndpointCount: healthyEndpointCount.value,
|
||||||
@@ -290,6 +294,7 @@ const viewContext = computed(() => ({
|
|||||||
loadBranding,
|
loadBranding,
|
||||||
loadFeedbacks,
|
loadFeedbacks,
|
||||||
loadMigrationStatus,
|
loadMigrationStatus,
|
||||||
|
loadSystemLogs,
|
||||||
mailConfig,
|
mailConfig,
|
||||||
mailConfigEditing: mailConfigEditing.value,
|
mailConfigEditing: mailConfigEditing.value,
|
||||||
markDatabaseFormEditing,
|
markDatabaseFormEditing,
|
||||||
@@ -329,9 +334,12 @@ const viewContext = computed(() => ({
|
|||||||
sourceDraft,
|
sourceDraft,
|
||||||
statusTone,
|
statusTone,
|
||||||
syncDatabase,
|
syncDatabase,
|
||||||
|
systemLogPage,
|
||||||
systemTab: systemTab.value,
|
systemTab: systemTab.value,
|
||||||
setSystemTab,
|
setSystemTab,
|
||||||
setAuditPage,
|
setAuditPage,
|
||||||
|
setSystemLogPage,
|
||||||
|
selectSystemLog,
|
||||||
selectAuditLog,
|
selectAuditLog,
|
||||||
testDatabase,
|
testDatabase,
|
||||||
toggleAutoRefresh,
|
toggleAutoRefresh,
|
||||||
@@ -367,7 +375,7 @@ function normalizeAdminPath(value: string) {
|
|||||||
|
|
||||||
function normalizeSystemTab(value: unknown): SystemTab {
|
function normalizeSystemTab(value: unknown): SystemTab {
|
||||||
const tab = Array.isArray(value) ? value[0] : value;
|
const tab = Array.isArray(value) ? value[0] : value;
|
||||||
if (tab === "migration" || tab === "sync" || tab === "security" || tab === "health" || tab === "audit") return tab;
|
if (tab === "migration" || tab === "sync" || tab === "security" || tab === "health" || tab === "logs" || tab === "audit") return tab;
|
||||||
return "database";
|
return "database";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,6 +493,8 @@ async function loadSystem(options: LoadSystemOptions = {}) {
|
|||||||
loadMailConfig({ preserveForm: options.preserveForms }),
|
loadMailConfig({ preserveForm: options.preserveForms }),
|
||||||
loadHealth(),
|
loadHealth(),
|
||||||
loadAudit(),
|
loadAudit(),
|
||||||
|
loadSystemLogs(),
|
||||||
|
loadDatabaseSyncLatest(),
|
||||||
loadMigrationStatus(),
|
loadMigrationStatus(),
|
||||||
loadBranding(),
|
loadBranding(),
|
||||||
]);
|
]);
|
||||||
@@ -977,6 +987,10 @@ async function loadDatabase(options: LoadDatabaseOptions = {}) {
|
|||||||
const data = await api<{ database: any; config?: any }>("/api/admin/database/status");
|
const data = await api<{ database: any; config?: any }>("/api/admin/database/status");
|
||||||
database.value = data.database;
|
database.value = data.database;
|
||||||
databaseConfig.value = data.config || null;
|
databaseConfig.value = data.config || null;
|
||||||
|
if (data.database?.currentSyncJob) {
|
||||||
|
databaseSyncJob.value = data.database.currentSyncJob;
|
||||||
|
databaseSyncOutput.value = data.database.currentSyncJob.output || [];
|
||||||
|
}
|
||||||
if (!options.preserveForm || !databaseFormEditing.value) {
|
if (!options.preserveForm || !databaseFormEditing.value) {
|
||||||
applyDatabaseConfig(data.config || {}, data.database || {});
|
applyDatabaseConfig(data.config || {}, data.database || {});
|
||||||
databaseFormEditing.value = false;
|
databaseFormEditing.value = false;
|
||||||
@@ -1036,18 +1050,44 @@ async function saveDatabase() {
|
|||||||
|
|
||||||
async function syncDatabase(direction: "import" | "sync") {
|
async function syncDatabase(direction: "import" | "sync") {
|
||||||
await guarded(async () => {
|
await guarded(async () => {
|
||||||
const data = await api<{ result?: any; finishedAt?: string }>(direction === "import" ? "/api/admin/database/import-sqlite" : "/api/admin/database/sync", { method: "POST", body: "{}" });
|
const payload = { direction: direction === "import" ? "sqlite_to_remote" : "remote_to_sqlite" };
|
||||||
databaseLastSync.value = data.result || { direction: direction === "import" ? "sqlite_to_remote" : "remote_to_sqlite", finishedAt: data.finishedAt };
|
const data = await api<{ jobId: number; job: any }>("/api/admin/database/sync/jobs", { method: "POST", body: JSON.stringify(payload) });
|
||||||
const result = databaseLastSync.value || {};
|
databaseSyncJob.value = data.job;
|
||||||
if (result.skipped) {
|
databaseLastSync.value = data.job;
|
||||||
setToast(result.warnings?.[0] || "远端 MySQL 未配置,同步已跳过", "warn");
|
databaseSyncOutput.value = data.job?.output || [];
|
||||||
} else {
|
setToast("数据库同步任务已启动");
|
||||||
setToast(direction === "import" ? "SQLite 已导入远端库" : "远端库已同步回本地");
|
await pollDatabaseSyncJob(data.jobId || data.job?.id);
|
||||||
}
|
await Promise.all([loadDatabase({ previewLegacy: false, preserveForm: true }), loadSystemLogs()]);
|
||||||
await loadDatabase({ previewLegacy: false });
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadDatabaseSyncLatest() {
|
||||||
|
const data = await api<{ job: any | null }>("/api/admin/database/sync/jobs/latest");
|
||||||
|
if (data.job) {
|
||||||
|
databaseSyncJob.value = data.job;
|
||||||
|
databaseLastSync.value = data.job;
|
||||||
|
databaseSyncOutput.value = data.job.output || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollDatabaseSyncJob(id: number) {
|
||||||
|
if (!id) return;
|
||||||
|
for (let index = 0; index < 120; index += 1) {
|
||||||
|
const data = await api<{ job: any }>(`/api/admin/database/sync/jobs/${id}`);
|
||||||
|
databaseSyncJob.value = data.job;
|
||||||
|
databaseLastSync.value = data.job;
|
||||||
|
databaseSyncOutput.value = data.job?.output || [];
|
||||||
|
if (!data.job || data.job.status !== "running") {
|
||||||
|
if (data.job?.status === "failed") setToast(data.job.errors?.[0] || "数据库同步失败", "error");
|
||||||
|
else if (data.job?.status === "skipped") setToast(data.job.warnings?.[0] || "数据库同步已跳过", "warn");
|
||||||
|
else setToast("数据库同步已完成");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await delay(1000);
|
||||||
|
}
|
||||||
|
setToast("数据库同步仍在执行,输出会继续随状态刷新", "warn");
|
||||||
|
}
|
||||||
|
|
||||||
function editDatabaseConfig() {
|
function editDatabaseConfig() {
|
||||||
databaseFormEditing.value = true;
|
databaseFormEditing.value = true;
|
||||||
databaseConfigCollapsed.value = false;
|
databaseConfigCollapsed.value = false;
|
||||||
@@ -1228,6 +1268,32 @@ function selectAuditLog(item: any) {
|
|||||||
auditPage.selected = item;
|
auditPage.selected = item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadSystemLogs() {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(systemLogPage.page || 1),
|
||||||
|
perPage: String(systemLogPage.perPage || 35),
|
||||||
|
});
|
||||||
|
if (systemLogPage.q) params.set("q", systemLogPage.q);
|
||||||
|
if (systemLogPage.category) params.set("category", systemLogPage.category);
|
||||||
|
const data = await api<{ items: any[]; page?: any }>(`/api/admin/system/logs?${params}`);
|
||||||
|
const page = data.page || { items: data.items || [], total: data.items?.length || 0, page: systemLogPage.page, perPage: systemLogPage.perPage };
|
||||||
|
Object.assign(systemLogPage, {
|
||||||
|
items: page.items || [],
|
||||||
|
total: Number(page.total || 0),
|
||||||
|
page: Number(page.page || systemLogPage.page || 1),
|
||||||
|
perPage: Number(page.perPage || systemLogPage.perPage || 35),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSystemLogPage(page: number) {
|
||||||
|
systemLogPage.page = Math.max(1, page);
|
||||||
|
void loadSystemLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSystemLog(item: any) {
|
||||||
|
systemLogPage.selected = item;
|
||||||
|
}
|
||||||
|
|
||||||
async function changePassword() {
|
async function changePassword() {
|
||||||
await guarded(async () => {
|
await guarded(async () => {
|
||||||
const data = await api<{ isDefaultPassword: boolean; warning?: string }>("/api/admin/auth/password", { method: "POST", body: JSON.stringify(passwordForm) });
|
const data = await api<{ isDefaultPassword: boolean; warning?: string }>("/api/admin/auth/password", { method: "POST", body: JSON.stringify(passwordForm) });
|
||||||
@@ -1338,6 +1404,24 @@ function databaseSyncTableCount(result: any) {
|
|||||||
return Object.values(tables).reduce((total: number, value: any) => total + Number(value || 0), 0);
|
return Object.values(tables).reduce((total: number, value: any) => total + Number(value || 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatHealthOutput(item: any) {
|
||||||
|
const raw = String(item?.error || item?.lastError || "").trim();
|
||||||
|
if (!raw) return "-";
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(raw);
|
||||||
|
const parts = [
|
||||||
|
meta.finalStatus ? `状态 ${meta.finalStatus}` : "",
|
||||||
|
meta.finalUrl ? `最终 URL ${meta.finalUrl}` : "",
|
||||||
|
meta.resolvedUrl ? `媒体 ${meta.resolvedUrl}` : "",
|
||||||
|
meta.mediaType ? `类型 ${meta.mediaType}` : "",
|
||||||
|
meta.error ? `错误 ${meta.error}` : "",
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.length ? parts.join(" / ") : raw;
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatBytes(value: number) {
|
function formatBytes(value: number) {
|
||||||
if (!Number.isFinite(value) || value <= 0) return "0 B";
|
if (!Number.isFinite(value) || value <= 0) return "0 B";
|
||||||
const units = ["B", "KB", "MB", "GB"];
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
@@ -1389,12 +1473,19 @@ function splitList(value: string) {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function delay(ms: number) {
|
||||||
|
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
localStorage.removeItem("ymhut.csrf");
|
localStorage.removeItem("ymhut.csrf");
|
||||||
void load();
|
void load();
|
||||||
refreshTimer = window.setInterval(() => {
|
refreshTimer = window.setInterval(() => {
|
||||||
if (!autoRefreshPaused.value && currentPath.value === "/admin/dashboard" && csrf.value) void loadDashboard();
|
if (!autoRefreshPaused.value && currentPath.value === "/admin/dashboard" && csrf.value) void loadDashboard();
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
systemRefreshTimer = window.setInterval(() => {
|
||||||
|
if (!autoRefreshPaused.value && currentPath.value === "/admin/system" && csrf.value) void loadSystem({ preserveForms: true });
|
||||||
|
}, 60000);
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(currentPath, () => {
|
watch(currentPath, () => {
|
||||||
@@ -1403,6 +1494,7 @@ watch(currentPath, () => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (refreshTimer) window.clearInterval(refreshTimer);
|
if (refreshTimer) window.clearInterval(refreshTimer);
|
||||||
|
if (systemRefreshTimer) window.clearInterval(systemRefreshTimer);
|
||||||
events?.close();
|
events?.close();
|
||||||
events = null;
|
events = null;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export function createSystemStore() {
|
|||||||
const database = ref<any>(null);
|
const database = ref<any>(null);
|
||||||
const databaseConfig = ref<any>(null);
|
const databaseConfig = ref<any>(null);
|
||||||
const databaseLastSync = ref<any>(null);
|
const databaseLastSync = ref<any>(null);
|
||||||
|
const databaseSyncJob = ref<any>(null);
|
||||||
|
const databaseSyncOutput = ref<string[]>([]);
|
||||||
const healthSnapshot = ref<any>(null);
|
const healthSnapshot = ref<any>(null);
|
||||||
const auditLogs = ref<any[]>([]);
|
const auditLogs = ref<any[]>([]);
|
||||||
const auditPage = reactive({
|
const auditPage = reactive({
|
||||||
@@ -16,10 +18,19 @@ export function createSystemStore() {
|
|||||||
target: "",
|
target: "",
|
||||||
selected: null as any | null,
|
selected: null as any | null,
|
||||||
});
|
});
|
||||||
|
const systemLogPage = reactive({
|
||||||
|
items: [] as any[],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
perPage: 35,
|
||||||
|
q: "",
|
||||||
|
category: "",
|
||||||
|
selected: null as any | null,
|
||||||
|
});
|
||||||
const migrationStatus = ref<any>(null);
|
const migrationStatus = ref<any>(null);
|
||||||
const branding = reactive({
|
const branding = reactive({
|
||||||
siteIconUrl: "https://img.ymhut.cn/file/1782108850041_icon.webp",
|
siteIconUrl: "/assets/favicon.ico",
|
||||||
developerAvatarUrl: "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp",
|
developerAvatarUrl: "/assets/developer-avatar.png",
|
||||||
developerName: "YMhut",
|
developerName: "YMhut",
|
||||||
feedbackEmail: "support@ymhut.cn",
|
feedbackEmail: "support@ymhut.cn",
|
||||||
});
|
});
|
||||||
@@ -49,5 +60,5 @@ export function createSystemStore() {
|
|||||||
});
|
});
|
||||||
const legacySyncMode = ref<"preview" | "run">("preview");
|
const legacySyncMode = ref<"preview" | "run">("preview");
|
||||||
|
|
||||||
return { database, databaseConfig, databaseLastSync, healthSnapshot, auditLogs, auditPage, migrationStatus, branding, databaseForm, databaseConfigCollapsed, mailConfig, legacySyncMode };
|
return { database, databaseConfig, databaseLastSync, databaseSyncJob, databaseSyncOutput, healthSnapshot, auditLogs, auditPage, systemLogPage, migrationStatus, branding, databaseForm, databaseConfigCollapsed, mailConfig, legacySyncMode };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -433,6 +433,48 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
.runtime-status {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.runtime-status div {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.runtime-status span {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.runtime-status strong {
|
||||||
|
display: block;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.sync-output-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.sync-output {
|
||||||
|
min-height: 180px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
.ops-note {
|
.ops-note {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -64,13 +64,13 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
|||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="section-head"><h2>最近服务端检测</h2><span class="badge">{{ ctx.heartbeats.length }} 条</span></div>
|
<div class="section-head"><h2>最近服务端检测</h2><span class="badge">{{ ctx.heartbeats.length }} 条</span></div>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>接口</th><th>状态</th><th>延迟</th><th>错误</th><th>时间</th></tr></thead>
|
<thead><tr><th>接口</th><th>状态</th><th>延迟</th><th>输出</th><th>时间</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="item in ctx.heartbeats.slice(0, 10)" :key="item.id">
|
<tr v-for="item in ctx.heartbeats.slice(0, 10)" :key="item.id">
|
||||||
<td>{{ item.name || item.sourceId }}</td>
|
<td>{{ item.name || item.sourceId }}</td>
|
||||||
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
|
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
|
||||||
<td>{{ item.latencyMs || 0 }}ms</td>
|
<td>{{ item.latencyMs || 0 }}ms</td>
|
||||||
<td class="hash">{{ item.error || "-" }}</td>
|
<td class="hash">{{ ctx.formatHealthOutput(item) }}</td>
|
||||||
<td>{{ item.checkedAt || "-" }}</td>
|
<td>{{ item.checkedAt || "-" }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="ctx.heartbeats.length === 0"><td colspan="5">暂无检测记录,点击“立即服务端检测”后会刷新。</td></tr>
|
<tr v-if="ctx.heartbeats.length === 0"><td colspan="5">暂无检测记录,点击“立即服务端检测”后会刷新。</td></tr>
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ defineProps<{ ctx: any }>();
|
|||||||
<div v-for="cat in ctx.sourceCategories" :key="cat.id || cat.name" class="source-group">
|
<div v-for="cat in ctx.sourceCategories" :key="cat.id || cat.name" class="source-group">
|
||||||
<h3>{{ cat.name || cat.id }} <span class="badge">{{ cat.subcategories?.length || 0 }}</span></h3>
|
<h3>{{ cat.name || cat.id }} <span class="badge">{{ cat.subcategories?.length || 0 }}</span></h3>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>名称</th><th>模式</th><th>状态</th><th>延迟</th><th>URL</th></tr></thead>
|
<thead><tr><th>名称</th><th>描述</th><th>模式</th><th>状态</th><th>延迟</th><th>URL</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="src in cat.subcategories || []" :key="src.id || src.sourceId">
|
<tr v-for="src in cat.subcategories || []" :key="src.id || src.sourceId">
|
||||||
<td>{{ src.name }}</td>
|
<td>{{ src.name }}</td>
|
||||||
|
<td class="hash">{{ src.description || "-" }}</td>
|
||||||
<td>{{ src.proxyMode || src.proxy_mode || "client_direct" }}</td>
|
<td>{{ src.proxyMode || src.proxy_mode || "client_direct" }}</td>
|
||||||
<td><span :class="['badge', ctx.statusTone(src.health?.status || src.lastStatus)]">{{ src.health?.status || src.lastStatus || "unknown" }}</span></td>
|
<td><span :class="['badge', ctx.statusTone(src.health?.status || src.lastStatus)]">{{ src.health?.status || src.lastStatus || "unknown" }}</span></td>
|
||||||
<td>{{ src.health?.latency_ms || src.lastLatencyMs || 0 }}ms</td>
|
<td>{{ src.health?.latency_ms ?? src.lastLatencyMs ?? 0 }}ms</td>
|
||||||
<td class="hash">{{ src.api_url || src.urlTemplate || src.apiUrl }}</td>
|
<td class="hash">{{ src.api_url || src.urlTemplate || src.apiUrl }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { nextTick, ref, watch } from "vue";
|
||||||
import { Activity, AlertTriangle, ArrowDownUp, Clock3, Database, HardDrive, KeyRound, ListChecks, Mail, RefreshCw, Save, ShieldCheck, UserRound } from "lucide-vue-next";
|
import { Activity, AlertTriangle, ArrowDownUp, Clock3, Database, HardDrive, KeyRound, ListChecks, Mail, RefreshCw, Save, ShieldCheck, UserRound } from "lucide-vue-next";
|
||||||
|
|
||||||
defineProps<{ ctx: any }>();
|
const props = defineProps<{ ctx: any }>();
|
||||||
|
const syncOutputRef = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.ctx.databaseSyncOutput?.join("\n"),
|
||||||
|
async () => {
|
||||||
|
await nextTick();
|
||||||
|
if (syncOutputRef.value) syncOutputRef.value.scrollTop = syncOutputRef.value.scrollHeight;
|
||||||
|
},
|
||||||
|
{ flush: "post" },
|
||||||
|
);
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: "database", label: "数据库", icon: Database },
|
{ id: "database", label: "数据库", icon: Database },
|
||||||
@@ -11,6 +22,7 @@ const tabs = [
|
|||||||
{ id: "health", label: "健康快照", icon: Activity },
|
{ id: "health", label: "健康快照", icon: Activity },
|
||||||
{ id: "audit", label: "审计日志", icon: ListChecks },
|
{ id: "audit", label: "审计日志", icon: ListChecks },
|
||||||
];
|
];
|
||||||
|
tabs.splice(tabs.length - 1, 0, { id: "logs", label: "日志中心", icon: ListChecks });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -40,6 +52,16 @@ const tabs = [
|
|||||||
<span>最近错误</span><strong>{{ ctx.database?.lastSyncError || ctx.database?.lastError || "-" }}</strong>
|
<span>最近错误</span><strong>{{ ctx.database?.lastSyncError || ctx.database?.lastError || "-" }}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="runtime-status">
|
||||||
|
<div><span>Active provider</span><strong>{{ ctx.database?.activeProvider || "-" }}</strong></div>
|
||||||
|
<div><span>SQLite</span><strong>{{ ctx.database?.sqliteReady ? "ready" : "missing" }}</strong></div>
|
||||||
|
<div><span>MySQL</span><strong>{{ ctx.database?.remoteReady ? "ready" : "offline" }}</strong></div>
|
||||||
|
<div><span>Failover</span><strong>{{ ctx.database?.failoverActive ? "active" : "standby" }}</strong></div>
|
||||||
|
<div><span>最近检测</span><strong>{{ ctx.database?.lastHealthCheckedAt || "-" }}</strong></div>
|
||||||
|
<div><span>检测状态</span><strong>{{ ctx.database?.lastHealthStatus || "-" }}</strong></div>
|
||||||
|
<div><span>当前同步</span><strong>{{ ctx.databaseSyncJob?.status === "running" ? ctx.databaseSyncDirectionLabel(ctx.databaseSyncJob.direction) : "idle" }}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="sync-summary">
|
<div class="sync-summary">
|
||||||
<div><span><ArrowDownUp :size="15" />最近方向</span><strong>{{ ctx.databaseSyncDirectionLabel(ctx.databaseLastSync?.direction) }}</strong></div>
|
<div><span><ArrowDownUp :size="15" />最近方向</span><strong>{{ ctx.databaseSyncDirectionLabel(ctx.databaseLastSync?.direction) }}</strong></div>
|
||||||
<div><span><ListChecks :size="15" />最近状态</span><strong>{{ ctx.databaseSyncStatusLabel(ctx.databaseLastSync?.status) }}</strong></div>
|
<div><span><ListChecks :size="15" />最近状态</span><strong>{{ ctx.databaseSyncStatusLabel(ctx.databaseLastSync?.status) }}</strong></div>
|
||||||
@@ -89,8 +111,15 @@ const tabs = [
|
|||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button class="btn ghost" @click="ctx.testDatabase">测试连接</button>
|
<button class="btn ghost" @click="ctx.testDatabase">测试连接</button>
|
||||||
<button class="btn primary" @click="ctx.saveDatabase"><Save :size="16" />测试并保存</button>
|
<button class="btn primary" @click="ctx.saveDatabase"><Save :size="16" />测试并保存</button>
|
||||||
<button class="btn ghost" @click="ctx.syncDatabase('import')">SQLite -> MySQL</button>
|
<button class="btn ghost" :disabled="ctx.databaseSyncJob?.status === 'running'" @click="ctx.syncDatabase('import')">SQLite -> MySQL</button>
|
||||||
<button class="btn ghost" @click="ctx.syncDatabase('sync')">MySQL -> SQLite</button>
|
<button class="btn ghost" :disabled="ctx.databaseSyncJob?.status === 'running'" @click="ctx.syncDatabase('sync')">MySQL -> SQLite</button>
|
||||||
|
</div>
|
||||||
|
<div class="sync-output-panel">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>操作输出</h2>
|
||||||
|
<span :class="['badge', ctx.statusTone(ctx.databaseSyncJob?.status)]">{{ ctx.databaseSyncStatusLabel(ctx.databaseSyncJob?.status) }}</span>
|
||||||
|
</div>
|
||||||
|
<pre ref="syncOutputRef" class="sync-output">{{ (ctx.databaseSyncOutput || []).join("\n") || "等待同步任务输出。" }}</pre>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
@@ -214,6 +243,56 @@ const tabs = [
|
|||||||
<pre class="json-preview tall">{{ ctx.pretty(ctx.healthSnapshot) }}</pre>
|
<pre class="json-preview tall">{{ ctx.pretty(ctx.healthSnapshot) }}</pre>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="ctx.systemTab === 'logs'" class="panel page-stack">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>日志中心</h2>
|
||||||
|
<button class="btn ghost" @click="ctx.loadSystemLogs">刷新</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<select v-model="ctx.systemLogPage.category" @change="ctx.systemLogPage.page = 1; ctx.loadSystemLogs()">
|
||||||
|
<option value="">全部分类</option>
|
||||||
|
<option value="operation">操作审计</option>
|
||||||
|
<option value="health">接口健康</option>
|
||||||
|
<option value="client">客户端调用</option>
|
||||||
|
<option value="database_sync">数据库同步</option>
|
||||||
|
<option value="legacy_sync">旧项目同步</option>
|
||||||
|
</select>
|
||||||
|
<input v-model="ctx.systemLogPage.q" placeholder="搜索类型、目标、状态或内容" @keyup.enter="ctx.systemLogPage.page = 1; ctx.loadSystemLogs()" />
|
||||||
|
<button class="btn ghost" @click="ctx.systemLogPage.page = 1; ctx.loadSystemLogs()">筛选</button>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>分类</th><th>类型</th><th>状态</th><th>目标</th><th>内容</th><th>时间</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in ctx.systemLogPage.items" :key="`${item.category}-${item.id}`" class="clickable" @click="ctx.selectSystemLog(item)">
|
||||||
|
<td><span class="badge neutral">{{ item.category }}</span></td>
|
||||||
|
<td>{{ item.type || "-" }}</td>
|
||||||
|
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
|
||||||
|
<td>{{ item.target || "-" }}</td>
|
||||||
|
<td class="hash">{{ item.message || item.detail || "-" }}</td>
|
||||||
|
<td>{{ item.createdAt || "-" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="ctx.systemLogPage.items.length === 0"><td colspan="6">暂无系统日志。</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="pager">
|
||||||
|
<button class="btn ghost compact" :disabled="ctx.systemLogPage.page <= 1" @click="ctx.setSystemLogPage(ctx.systemLogPage.page - 1)">上一页</button>
|
||||||
|
<span>第 {{ ctx.systemLogPage.page }} 页 / 共 {{ Math.max(1, Math.ceil(ctx.systemLogPage.total / ctx.systemLogPage.perPage)) }} 页,{{ ctx.systemLogPage.total }} 条</span>
|
||||||
|
<button class="btn ghost compact" :disabled="ctx.systemLogPage.page >= Math.ceil(ctx.systemLogPage.total / ctx.systemLogPage.perPage)" @click="ctx.setSystemLogPage(ctx.systemLogPage.page + 1)">下一页</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="ctx.systemLogPage.selected" class="modal-backdrop" @click.self="ctx.systemLogPage.selected = null">
|
||||||
|
<section class="modal-panel">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>日志详情</h2>
|
||||||
|
<button class="btn ghost compact" @click="ctx.systemLogPage.selected = null">关闭</button>
|
||||||
|
</div>
|
||||||
|
<pre class="json-preview tall">{{ ctx.pretty(ctx.systemLogPage.selected) }}</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section v-else class="panel page-stack">
|
<section v-else class="panel page-stack">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>审计日志</h2>
|
<h2>审计日志</h2>
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ export function usePortalState() {
|
|||||||
const appVersion = computed(() => releases.value?.app_version || bootstrap.value?.release?.app_version || latestNotice.value?.version || "未发布");
|
const appVersion = computed(() => releases.value?.app_version || bootstrap.value?.release?.app_version || latestNotice.value?.version || "未发布");
|
||||||
const serviceVersion = computed(() => bootstrap.value?.serviceVersion || "-");
|
const serviceVersion = computed(() => bootstrap.value?.serviceVersion || "-");
|
||||||
const branding = computed(() => ({
|
const branding = computed(() => ({
|
||||||
siteIconUrl: bootstrap.value?.branding?.siteIconUrl || "https://img.ymhut.cn/file/1782108850041_icon.webp",
|
siteIconUrl: bootstrap.value?.branding?.siteIconUrl || "/assets/favicon.ico",
|
||||||
developerAvatarUrl: bootstrap.value?.branding?.developerAvatarUrl || "https://img.ymhut.cn/file/1782108780690_b_3db45f3787f19192c8de8e06bc0987ef.webp",
|
developerAvatarUrl: bootstrap.value?.branding?.developerAvatarUrl || "/assets/developer-avatar.png",
|
||||||
developerName: bootstrap.value?.branding?.developerName || "YMhut",
|
developerName: bootstrap.value?.branding?.developerName || "YMhut",
|
||||||
feedbackEmail: bootstrap.value?.branding?.feedbackEmail || "support@ymhut.cn",
|
feedbackEmail: bootstrap.value?.branding?.feedbackEmail || "support@ymhut.cn",
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,54 +1,54 @@
|
|||||||
{
|
{
|
||||||
"manifestVersion": 5,
|
"manifestVersion": 5,
|
||||||
"latestVersion": "2.0.7.5",
|
"latestVersion": "2.0.7.6",
|
||||||
"appVersion": "2.0.7.5",
|
"appVersion": "2.0.7.6",
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"build": "05",
|
"build": "06",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"latest": {
|
"latest": {
|
||||||
"version": "2.0.7.5",
|
"version": "2.0.7.6",
|
||||||
"fullInstaller": {
|
"fullInstaller": {
|
||||||
"fileName": "YMhut_Box_WinUI_Setup_2.0.7.5.exe",
|
"fileName": "YMhut_Box_WinUI_Setup_2.0.7.6.exe",
|
||||||
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.5.exe",
|
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.6.exe",
|
||||||
"sha256": "52283178f0dce0f34eb0da81c745e7eddae761491e776b828a61dc74d1310228",
|
"sha256": "972478ab77d016165e2f35171571b1f59ba7dfa97d7c86ed9fb44b0b82ad4d93",
|
||||||
"size": 113484192,
|
"size": 113486448,
|
||||||
"version": "2.0.7.5"
|
"version": "2.0.7.6"
|
||||||
},
|
},
|
||||||
"msix": {
|
"msix": {
|
||||||
"fileName": "YMhutBox_2.0.7.5_x64.msix",
|
"fileName": "YMhutBox_2.0.7.6_x64.msix",
|
||||||
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.5_x64.msix",
|
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.6_x64.msix",
|
||||||
"sha256": "388bc5374023bb7d71e42e4c88915b0d59935a06b0be40d7eab6244832220228",
|
"sha256": "a05b02ebf5f2d1b71e051e906bf9bdd71bd018893badf6f462e3bd61e3f232c0",
|
||||||
"size": 259968386,
|
"size": 259970957,
|
||||||
"version": "2.0.7.5"
|
"version": "2.0.7.6"
|
||||||
},
|
},
|
||||||
"appInstaller": {
|
"appInstaller": {
|
||||||
"fileName": "winui.appinstaller",
|
"fileName": "winui.appinstaller",
|
||||||
"url": "https://update.ymhut.cn/downloads/winui.appinstaller",
|
"url": "https://update.ymhut.cn/downloads/winui.appinstaller",
|
||||||
"sha256": "12897720203ed1b41f418f6daf097c8f2e21d0b2c609d54d561219b2f353f17e",
|
"sha256": "db2eb12a69366c6ae77fea859b17755b9a4d16e449531976337e0c9eb60d510d",
|
||||||
"size": 558,
|
"size": 558,
|
||||||
"version": "2.0.7.5"
|
"version": "2.0.7.6"
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"fullInstaller": {
|
"fullInstaller": {
|
||||||
"fileName": "YMhut_Box_WinUI_Setup_2.0.7.5.exe",
|
"fileName": "YMhut_Box_WinUI_Setup_2.0.7.6.exe",
|
||||||
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.5.exe",
|
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.6.exe",
|
||||||
"sha256": "52283178f0dce0f34eb0da81c745e7eddae761491e776b828a61dc74d1310228",
|
"sha256": "972478ab77d016165e2f35171571b1f59ba7dfa97d7c86ed9fb44b0b82ad4d93",
|
||||||
"size": 113484192,
|
"size": 113486448,
|
||||||
"version": "2.0.7.5"
|
"version": "2.0.7.6"
|
||||||
},
|
},
|
||||||
"msix": {
|
"msix": {
|
||||||
"fileName": "YMhutBox_2.0.7.5_x64.msix",
|
"fileName": "YMhutBox_2.0.7.6_x64.msix",
|
||||||
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.5_x64.msix",
|
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.6_x64.msix",
|
||||||
"sha256": "388bc5374023bb7d71e42e4c88915b0d59935a06b0be40d7eab6244832220228",
|
"sha256": "a05b02ebf5f2d1b71e051e906bf9bdd71bd018893badf6f462e3bd61e3f232c0",
|
||||||
"size": 259968386,
|
"size": 259970957,
|
||||||
"version": "2.0.7.5"
|
"version": "2.0.7.6"
|
||||||
},
|
},
|
||||||
"appInstaller": {
|
"appInstaller": {
|
||||||
"fileName": "winui.appinstaller",
|
"fileName": "winui.appinstaller",
|
||||||
"url": "https://update.ymhut.cn/downloads/winui.appinstaller",
|
"url": "https://update.ymhut.cn/downloads/winui.appinstaller",
|
||||||
"sha256": "12897720203ed1b41f418f6daf097c8f2e21d0b2c609d54d561219b2f353f17e",
|
"sha256": "db2eb12a69366c6ae77fea859b17755b9a4d16e449531976337e0c9eb60d510d",
|
||||||
"size": 558,
|
"size": 558,
|
||||||
"version": "2.0.7.5"
|
"version": "2.0.7.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -56,5 +56,5 @@
|
|||||||
"updateInfo": "The official update-info catalog only describes the full offline installer, MSIX, and appinstaller artifacts.",
|
"updateInfo": "The official update-info catalog only describes the full offline installer, MSIX, and appinstaller artifacts.",
|
||||||
"distribution": "The update channel publishes the full offline installer, MSIX, and appinstaller artifacts."
|
"distribution": "The update channel publishes the full offline installer, MSIX, and appinstaller artifacts."
|
||||||
},
|
},
|
||||||
"createdAt": "2026-06-27T08:54:56.8073504Z"
|
"createdAt": "2026-06-29T06:32:36.4668331Z"
|
||||||
}
|
}
|
||||||
@@ -113,6 +113,32 @@ public sealed class RemoteMediaCatalogTests
|
|||||||
Assert.IsTrue(source.IsAvailable);
|
Assert.IsTrue(source.IsAvailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void ParserPreservesSourceDescriptionForCards()
|
||||||
|
{
|
||||||
|
const string content = """
|
||||||
|
{
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"id": "image",
|
||||||
|
"subcategories": [
|
||||||
|
{
|
||||||
|
"id": "demo",
|
||||||
|
"name": "Demo",
|
||||||
|
"description": "后台配置的子接口描述",
|
||||||
|
"api_url": "https://api.example.test/random"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var source = RemoteMediaCatalogParser.Parse(content).Categories.Single().Sources.Single();
|
||||||
|
|
||||||
|
Assert.AreEqual("后台配置的子接口描述", source.Description);
|
||||||
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public void ExplicitMediaTypeWinsOverCategoryAndFormats()
|
public void ExplicitMediaTypeWinsOverCategoryAndFormats()
|
||||||
{
|
{
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
@@ -1422,9 +1422,9 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
|
|
||||||
private static string DisplayDescription(RemoteMediaSource source)
|
private static string DisplayDescription(RemoteMediaSource source)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(source.DisplayDescription))
|
if (!string.IsNullOrWhiteSpace(source.Description))
|
||||||
{
|
{
|
||||||
return source.DisplayDescription;
|
return source.Description;
|
||||||
}
|
}
|
||||||
|
|
||||||
return LooksBroken(source.Description)
|
return LooksBroken(source.Description)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"build": "05",
|
"build": "06",
|
||||||
"channel": "stable"
|
"channel": "stable"
|
||||||
}
|
}
|
||||||
|
|||||||