mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-30 22:26:38 +08:00
feat: enhance apply logs and config versions features
- Add detailed diff reporting for OpenResty options in TestPreviewAndDiffConfigVersion. - Update ApplyLogsPage to display additional log details including checksums and support file counts. - Introduce ConfigVersionSnapshotModal for viewing configuration snapshots. - Refactor config versions page to utilize new snapshot modal and improve option diff display. - Extend ApplyLogItem type to include checksums and support file count. - Enhance NodeDetailPage to show target version details and checksums.
This commit is contained in:
@@ -49,10 +49,14 @@ type RegisterNodeResponse struct {
|
||||
}
|
||||
|
||||
type ApplyLogPayload struct {
|
||||
NodeID string `json:"node_id"`
|
||||
Version string `json:"version"`
|
||||
Result string `json:"result"`
|
||||
Message string `json:"message"`
|
||||
NodeID string `json:"node_id"`
|
||||
Version string `json:"version"`
|
||||
Result string `json:"result"`
|
||||
Message string `json:"message"`
|
||||
Checksum string `json:"checksum"`
|
||||
MainConfigChecksum string `json:"main_config_checksum"`
|
||||
RouteConfigChecksum string `json:"route_config_checksum"`
|
||||
SupportFileCount int `json:"support_file_count"`
|
||||
}
|
||||
|
||||
type ActiveConfigResponse struct {
|
||||
|
||||
@@ -2,6 +2,8 @@ package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log"
|
||||
|
||||
"atsflare-agent/internal/protocol"
|
||||
@@ -92,6 +94,8 @@ func (s *Service) sync(ctx context.Context, startup bool) error {
|
||||
if routeConfig == "" {
|
||||
routeConfig = config.RenderedConfig
|
||||
}
|
||||
mainConfigChecksum := checksumString(config.MainConfig)
|
||||
routeConfigChecksum := checksumString(routeConfig)
|
||||
log.Printf("applying new openresty config: mode=%s from_version=%s to_version=%s old_checksum=%s new_checksum=%s", mode, snapshot.CurrentVersion, config.Version, currentChecksum, config.Checksum)
|
||||
if err = s.nginxManager.Apply(ctx, config.MainConfig, routeConfig, config.SupportFiles); err != nil {
|
||||
log.Printf("apply openresty config failed: mode=%s version=%s error=%v", mode, config.Version, err)
|
||||
@@ -100,10 +104,14 @@ func (s *Service) sync(ctx context.Context, startup bool) error {
|
||||
snapshot.OpenrestyMessage = err.Error()
|
||||
_ = s.stateStore.Save(snapshot)
|
||||
reportErr := s.client.ReportApplyLog(ctx, protocol.ApplyLogPayload{
|
||||
NodeID: snapshot.NodeID,
|
||||
Version: config.Version,
|
||||
Result: ApplyResultFailed,
|
||||
Message: err.Error(),
|
||||
NodeID: snapshot.NodeID,
|
||||
Version: config.Version,
|
||||
Result: ApplyResultFailed,
|
||||
Message: err.Error(),
|
||||
Checksum: config.Checksum,
|
||||
MainConfigChecksum: mainConfigChecksum,
|
||||
RouteConfigChecksum: routeConfigChecksum,
|
||||
SupportFileCount: len(config.SupportFiles),
|
||||
})
|
||||
if reportErr != nil {
|
||||
log.Printf("report failed apply log failed: version=%s error=%v", config.Version, reportErr)
|
||||
@@ -122,10 +130,14 @@ func (s *Service) sync(ctx context.Context, startup bool) error {
|
||||
return err
|
||||
}
|
||||
if err = s.client.ReportApplyLog(ctx, protocol.ApplyLogPayload{
|
||||
NodeID: snapshot.NodeID,
|
||||
Version: config.Version,
|
||||
Result: ApplyResultSuccess,
|
||||
Message: "apply success",
|
||||
NodeID: snapshot.NodeID,
|
||||
Version: config.Version,
|
||||
Result: ApplyResultSuccess,
|
||||
Message: "apply success",
|
||||
Checksum: config.Checksum,
|
||||
MainConfigChecksum: mainConfigChecksum,
|
||||
RouteConfigChecksum: routeConfigChecksum,
|
||||
SupportFileCount: len(config.SupportFiles),
|
||||
}); err != nil {
|
||||
log.Printf("report successful apply log failed: version=%s error=%v", config.Version, err)
|
||||
return err
|
||||
@@ -133,3 +145,8 @@ func (s *Service) sync(ctx context.Context, startup bool) error {
|
||||
log.Printf("successful apply log reported: version=%s", config.Version)
|
||||
return nil
|
||||
}
|
||||
|
||||
func checksumString(content string) string {
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
@@ -3,12 +3,16 @@ package model
|
||||
import "time"
|
||||
|
||||
type ApplyLog struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
NodeID string `json:"node_id" gorm:"index;size:64;not null"`
|
||||
Version string `json:"version" gorm:"size:32;not null"`
|
||||
Result string `json:"result" gorm:"size:32;not null"`
|
||||
Message string `json:"message" gorm:"size:1024"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
NodeID string `json:"node_id" gorm:"index;size:64;not null"`
|
||||
Version string `json:"version" gorm:"size:32;not null"`
|
||||
Result string `json:"result" gorm:"size:32;not null"`
|
||||
Message string `json:"message" gorm:"size:1024"`
|
||||
Checksum string `json:"checksum" gorm:"size:64;not null;default:''"`
|
||||
MainConfigChecksum string `json:"main_config_checksum" gorm:"size:64;not null;default:''"`
|
||||
RouteConfigChecksum string `json:"route_config_checksum" gorm:"size:64;not null;default:''"`
|
||||
SupportFileCount int `json:"support_file_count" gorm:"not null;default:0"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func ListApplyLogs(nodeID string) (logs []*ApplyLog, err error) {
|
||||
|
||||
@@ -35,10 +35,14 @@ type AgentNodePayload struct {
|
||||
}
|
||||
|
||||
type ApplyLogPayload struct {
|
||||
NodeID string `json:"node_id"`
|
||||
Version string `json:"version"`
|
||||
Result string `json:"result"`
|
||||
Message string `json:"message"`
|
||||
NodeID string `json:"node_id"`
|
||||
Version string `json:"version"`
|
||||
Result string `json:"result"`
|
||||
Message string `json:"message"`
|
||||
Checksum string `json:"checksum"`
|
||||
MainConfigChecksum string `json:"main_config_checksum"`
|
||||
RouteConfigChecksum string `json:"route_config_checksum"`
|
||||
SupportFileCount int `json:"support_file_count"`
|
||||
}
|
||||
|
||||
type AgentConfigResponse struct {
|
||||
@@ -88,6 +92,10 @@ type NodeView struct {
|
||||
LastError string `json:"last_error"`
|
||||
LatestApplyResult string `json:"latest_apply_result"`
|
||||
LatestApplyMessage string `json:"latest_apply_message"`
|
||||
LatestApplyChecksum string `json:"latest_apply_checksum"`
|
||||
LatestMainConfigChecksum string `json:"latest_main_config_checksum"`
|
||||
LatestRouteConfigChecksum string `json:"latest_route_config_checksum"`
|
||||
LatestSupportFileCount int `json:"latest_support_file_count"`
|
||||
LatestApplyAt *time.Time `json:"latest_apply_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -161,6 +169,9 @@ func ReportApplyLog(payload ApplyLogPayload) (*model.ApplyLog, error) {
|
||||
payload.Version = strings.TrimSpace(payload.Version)
|
||||
payload.Result = strings.TrimSpace(strings.ToLower(payload.Result))
|
||||
payload.Message = strings.TrimSpace(payload.Message)
|
||||
payload.Checksum = strings.TrimSpace(payload.Checksum)
|
||||
payload.MainConfigChecksum = strings.TrimSpace(payload.MainConfigChecksum)
|
||||
payload.RouteConfigChecksum = strings.TrimSpace(payload.RouteConfigChecksum)
|
||||
if payload.NodeID == "" {
|
||||
return nil, errors.New("node_id 不能为空")
|
||||
}
|
||||
@@ -173,11 +184,15 @@ func ReportApplyLog(payload ApplyLogPayload) (*model.ApplyLog, error) {
|
||||
common.SysLog("agent apply log received: node_id=" + payload.NodeID + " version=" + payload.Version + " result=" + payload.Result)
|
||||
|
||||
log := &model.ApplyLog{
|
||||
NodeID: payload.NodeID,
|
||||
Version: payload.Version,
|
||||
Result: payload.Result,
|
||||
Message: payload.Message,
|
||||
CreatedAt: now,
|
||||
NodeID: payload.NodeID,
|
||||
Version: payload.Version,
|
||||
Result: payload.Result,
|
||||
Message: payload.Message,
|
||||
Checksum: payload.Checksum,
|
||||
MainConfigChecksum: payload.MainConfigChecksum,
|
||||
RouteConfigChecksum: payload.RouteConfigChecksum,
|
||||
SupportFileCount: payload.SupportFileCount,
|
||||
CreatedAt: now,
|
||||
}
|
||||
err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
node := &model.Node{}
|
||||
@@ -230,6 +245,10 @@ func ListNodeViews() ([]*NodeView, error) {
|
||||
if log, err := model.GetLatestApplyLog(node.NodeID); err == nil {
|
||||
view.LatestApplyResult = log.Result
|
||||
view.LatestApplyMessage = log.Message
|
||||
view.LatestApplyChecksum = log.Checksum
|
||||
view.LatestMainConfigChecksum = log.MainConfigChecksum
|
||||
view.LatestRouteConfigChecksum = log.RouteConfigChecksum
|
||||
view.LatestSupportFileCount = log.SupportFileCount
|
||||
view.LatestApplyAt = &log.CreatedAt
|
||||
}
|
||||
views = append(views, view)
|
||||
|
||||
@@ -36,12 +36,19 @@ type ConfigPreviewResult struct {
|
||||
}
|
||||
|
||||
type ConfigDiffResult struct {
|
||||
ActiveVersion string `json:"active_version,omitempty"`
|
||||
AddedDomains []string `json:"added_domains"`
|
||||
RemovedDomains []string `json:"removed_domains"`
|
||||
ModifiedDomains []string `json:"modified_domains"`
|
||||
MainConfigChanged bool `json:"main_config_changed"`
|
||||
ChangedOptionKeys []string `json:"changed_option_keys"`
|
||||
ActiveVersion string `json:"active_version,omitempty"`
|
||||
AddedDomains []string `json:"added_domains"`
|
||||
RemovedDomains []string `json:"removed_domains"`
|
||||
ModifiedDomains []string `json:"modified_domains"`
|
||||
MainConfigChanged bool `json:"main_config_changed"`
|
||||
ChangedOptionKeys []string `json:"changed_option_keys"`
|
||||
ChangedOptionDetails []ConfigOptionDiffItem `json:"changed_option_details"`
|
||||
}
|
||||
|
||||
type ConfigOptionDiffItem struct {
|
||||
Key string `json:"key"`
|
||||
PreviousValue string `json:"previous_value"`
|
||||
CurrentValue string `json:"current_value"`
|
||||
}
|
||||
|
||||
type snapshotRoute struct {
|
||||
@@ -139,10 +146,11 @@ func DiffConfigVersion() (*ConfigDiffResult, error) {
|
||||
return nil, err
|
||||
}
|
||||
result := &ConfigDiffResult{
|
||||
AddedDomains: []string{},
|
||||
RemovedDomains: []string{},
|
||||
ModifiedDomains: []string{},
|
||||
ChangedOptionKeys: []string{},
|
||||
AddedDomains: []string{},
|
||||
RemovedDomains: []string{},
|
||||
ModifiedDomains: []string{},
|
||||
ChangedOptionKeys: []string{},
|
||||
ChangedOptionDetails: []ConfigOptionDiffItem{},
|
||||
}
|
||||
activeVersion, err := model.GetActiveConfigVersion()
|
||||
if err != nil {
|
||||
@@ -152,6 +160,7 @@ func DiffConfigVersion() (*ConfigDiffResult, error) {
|
||||
}
|
||||
result.MainConfigChanged = true
|
||||
result.ChangedOptionKeys = openRestyOptionKeys()
|
||||
result.ChangedOptionDetails = buildInitialOpenRestyOptionDiffs(bundle.OpenRestyConfig)
|
||||
return result, nil
|
||||
}
|
||||
return nil, err
|
||||
@@ -185,7 +194,8 @@ func DiffConfigVersion() (*ConfigDiffResult, error) {
|
||||
}
|
||||
}
|
||||
result.MainConfigChanged = activeVersion.MainConfig != bundle.MainConfig
|
||||
result.ChangedOptionKeys = diffOpenRestyOptionKeys(activeSnapshot.OpenRestyConfig, bundle.OpenRestyConfig)
|
||||
result.ChangedOptionDetails = diffOpenRestyOptionDetails(activeSnapshot.OpenRestyConfig, bundle.OpenRestyConfig)
|
||||
result.ChangedOptionKeys = extractOptionDiffKeys(result.ChangedOptionDetails)
|
||||
sort.Strings(result.AddedDomains)
|
||||
sort.Strings(result.RemovedDomains)
|
||||
sort.Strings(result.ModifiedDomains)
|
||||
@@ -426,44 +436,70 @@ func buildOpenRestyConfigSnapshot() openRestyConfigSnapshot {
|
||||
}
|
||||
|
||||
func diffOpenRestyOptionKeys(left openRestyConfigSnapshot, right openRestyConfigSnapshot) []string {
|
||||
changes := make([]string, 0)
|
||||
appendIfChanged := func(key string, changed bool) {
|
||||
if changed {
|
||||
changes = append(changes, key)
|
||||
}
|
||||
details := diffOpenRestyOptionDetails(left, right)
|
||||
return extractOptionDiffKeys(details)
|
||||
}
|
||||
|
||||
func buildInitialOpenRestyOptionDiffs(current openRestyConfigSnapshot) []ConfigOptionDiffItem {
|
||||
details := diffOpenRestyOptionDetails(openRestyConfigSnapshot{}, current)
|
||||
for index := range details {
|
||||
details[index].PreviousValue = ""
|
||||
}
|
||||
appendIfChanged("OpenRestyWorkerProcesses", left.WorkerProcesses != right.WorkerProcesses)
|
||||
appendIfChanged("OpenRestyWorkerConnections", left.WorkerConnections != right.WorkerConnections)
|
||||
appendIfChanged("OpenRestyWorkerRlimitNofile", left.WorkerRlimitNofile != right.WorkerRlimitNofile)
|
||||
appendIfChanged("OpenRestyEventsUse", left.EventsUse != right.EventsUse)
|
||||
appendIfChanged("OpenRestyEventsMultiAcceptEnabled", left.EventsMultiAcceptEnabled != right.EventsMultiAcceptEnabled)
|
||||
appendIfChanged("OpenRestyKeepaliveTimeout", left.KeepaliveTimeout != right.KeepaliveTimeout)
|
||||
appendIfChanged("OpenRestyKeepaliveRequests", left.KeepaliveRequests != right.KeepaliveRequests)
|
||||
appendIfChanged("OpenRestyClientHeaderTimeout", left.ClientHeaderTimeout != right.ClientHeaderTimeout)
|
||||
appendIfChanged("OpenRestyClientBodyTimeout", left.ClientBodyTimeout != right.ClientBodyTimeout)
|
||||
appendIfChanged("OpenRestySendTimeout", left.SendTimeout != right.SendTimeout)
|
||||
appendIfChanged("OpenRestyProxyConnectTimeout", left.ProxyConnectTimeout != right.ProxyConnectTimeout)
|
||||
appendIfChanged("OpenRestyProxySendTimeout", left.ProxySendTimeout != right.ProxySendTimeout)
|
||||
appendIfChanged("OpenRestyProxyReadTimeout", left.ProxyReadTimeout != right.ProxyReadTimeout)
|
||||
appendIfChanged("OpenRestyProxyBufferingEnabled", left.ProxyBufferingEnabled != right.ProxyBufferingEnabled)
|
||||
appendIfChanged("OpenRestyProxyBuffers", left.ProxyBuffers != right.ProxyBuffers)
|
||||
appendIfChanged("OpenRestyProxyBufferSize", left.ProxyBufferSize != right.ProxyBufferSize)
|
||||
appendIfChanged("OpenRestyProxyBusyBuffersSize", left.ProxyBusyBuffersSize != right.ProxyBusyBuffersSize)
|
||||
appendIfChanged("OpenRestyGzipEnabled", left.GzipEnabled != right.GzipEnabled)
|
||||
appendIfChanged("OpenRestyGzipMinLength", left.GzipMinLength != right.GzipMinLength)
|
||||
appendIfChanged("OpenRestyGzipCompLevel", left.GzipCompLevel != right.GzipCompLevel)
|
||||
appendIfChanged("OpenRestyCacheEnabled", left.CacheEnabled != right.CacheEnabled)
|
||||
appendIfChanged("OpenRestyCachePath", left.CachePath != right.CachePath)
|
||||
appendIfChanged("OpenRestyCacheLevels", left.CacheLevels != right.CacheLevels)
|
||||
appendIfChanged("OpenRestyCacheInactive", left.CacheInactive != right.CacheInactive)
|
||||
appendIfChanged("OpenRestyCacheMaxSize", left.CacheMaxSize != right.CacheMaxSize)
|
||||
appendIfChanged("OpenRestyCacheKeyTemplate", left.CacheKeyTemplate != right.CacheKeyTemplate)
|
||||
appendIfChanged("OpenRestyCacheLockEnabled", left.CacheLockEnabled != right.CacheLockEnabled)
|
||||
appendIfChanged("OpenRestyCacheLockTimeout", left.CacheLockTimeout != right.CacheLockTimeout)
|
||||
appendIfChanged("OpenRestyCacheUseStale", left.CacheUseStale != right.CacheUseStale)
|
||||
return details
|
||||
}
|
||||
|
||||
func diffOpenRestyOptionDetails(left openRestyConfigSnapshot, right openRestyConfigSnapshot) []ConfigOptionDiffItem {
|
||||
changes := make([]ConfigOptionDiffItem, 0)
|
||||
appendIfChanged := func(key string, previous string, current string) {
|
||||
if previous == current {
|
||||
return
|
||||
}
|
||||
changes = append(changes, ConfigOptionDiffItem{
|
||||
Key: key,
|
||||
PreviousValue: previous,
|
||||
CurrentValue: current,
|
||||
})
|
||||
}
|
||||
appendIfChanged("OpenRestyWorkerProcesses", left.WorkerProcesses, right.WorkerProcesses)
|
||||
appendIfChanged("OpenRestyWorkerConnections", fmt.Sprintf("%d", left.WorkerConnections), fmt.Sprintf("%d", right.WorkerConnections))
|
||||
appendIfChanged("OpenRestyWorkerRlimitNofile", fmt.Sprintf("%d", left.WorkerRlimitNofile), fmt.Sprintf("%d", right.WorkerRlimitNofile))
|
||||
appendIfChanged("OpenRestyEventsUse", left.EventsUse, right.EventsUse)
|
||||
appendIfChanged("OpenRestyEventsMultiAcceptEnabled", fmt.Sprintf("%t", left.EventsMultiAcceptEnabled), fmt.Sprintf("%t", right.EventsMultiAcceptEnabled))
|
||||
appendIfChanged("OpenRestyKeepaliveTimeout", fmt.Sprintf("%d", left.KeepaliveTimeout), fmt.Sprintf("%d", right.KeepaliveTimeout))
|
||||
appendIfChanged("OpenRestyKeepaliveRequests", fmt.Sprintf("%d", left.KeepaliveRequests), fmt.Sprintf("%d", right.KeepaliveRequests))
|
||||
appendIfChanged("OpenRestyClientHeaderTimeout", fmt.Sprintf("%d", left.ClientHeaderTimeout), fmt.Sprintf("%d", right.ClientHeaderTimeout))
|
||||
appendIfChanged("OpenRestyClientBodyTimeout", fmt.Sprintf("%d", left.ClientBodyTimeout), fmt.Sprintf("%d", right.ClientBodyTimeout))
|
||||
appendIfChanged("OpenRestySendTimeout", fmt.Sprintf("%d", left.SendTimeout), fmt.Sprintf("%d", right.SendTimeout))
|
||||
appendIfChanged("OpenRestyProxyConnectTimeout", fmt.Sprintf("%d", left.ProxyConnectTimeout), fmt.Sprintf("%d", right.ProxyConnectTimeout))
|
||||
appendIfChanged("OpenRestyProxySendTimeout", fmt.Sprintf("%d", left.ProxySendTimeout), fmt.Sprintf("%d", right.ProxySendTimeout))
|
||||
appendIfChanged("OpenRestyProxyReadTimeout", fmt.Sprintf("%d", left.ProxyReadTimeout), fmt.Sprintf("%d", right.ProxyReadTimeout))
|
||||
appendIfChanged("OpenRestyProxyBufferingEnabled", fmt.Sprintf("%t", left.ProxyBufferingEnabled), fmt.Sprintf("%t", right.ProxyBufferingEnabled))
|
||||
appendIfChanged("OpenRestyProxyBuffers", left.ProxyBuffers, right.ProxyBuffers)
|
||||
appendIfChanged("OpenRestyProxyBufferSize", left.ProxyBufferSize, right.ProxyBufferSize)
|
||||
appendIfChanged("OpenRestyProxyBusyBuffersSize", left.ProxyBusyBuffersSize, right.ProxyBusyBuffersSize)
|
||||
appendIfChanged("OpenRestyGzipEnabled", fmt.Sprintf("%t", left.GzipEnabled), fmt.Sprintf("%t", right.GzipEnabled))
|
||||
appendIfChanged("OpenRestyGzipMinLength", fmt.Sprintf("%d", left.GzipMinLength), fmt.Sprintf("%d", right.GzipMinLength))
|
||||
appendIfChanged("OpenRestyGzipCompLevel", fmt.Sprintf("%d", left.GzipCompLevel), fmt.Sprintf("%d", right.GzipCompLevel))
|
||||
appendIfChanged("OpenRestyCacheEnabled", fmt.Sprintf("%t", left.CacheEnabled), fmt.Sprintf("%t", right.CacheEnabled))
|
||||
appendIfChanged("OpenRestyCachePath", left.CachePath, right.CachePath)
|
||||
appendIfChanged("OpenRestyCacheLevels", left.CacheLevels, right.CacheLevels)
|
||||
appendIfChanged("OpenRestyCacheInactive", left.CacheInactive, right.CacheInactive)
|
||||
appendIfChanged("OpenRestyCacheMaxSize", left.CacheMaxSize, right.CacheMaxSize)
|
||||
appendIfChanged("OpenRestyCacheKeyTemplate", left.CacheKeyTemplate, right.CacheKeyTemplate)
|
||||
appendIfChanged("OpenRestyCacheLockEnabled", fmt.Sprintf("%t", left.CacheLockEnabled), fmt.Sprintf("%t", right.CacheLockEnabled))
|
||||
appendIfChanged("OpenRestyCacheLockTimeout", left.CacheLockTimeout, right.CacheLockTimeout)
|
||||
appendIfChanged("OpenRestyCacheUseStale", left.CacheUseStale, right.CacheUseStale)
|
||||
return changes
|
||||
}
|
||||
|
||||
func extractOptionDiffKeys(details []ConfigOptionDiffItem) []string {
|
||||
keys := make([]string, 0, len(details))
|
||||
for _, item := range details {
|
||||
keys = append(keys, item.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func openRestyOptionKeys() []string {
|
||||
return []string{
|
||||
"OpenRestyWorkerProcesses",
|
||||
|
||||
@@ -221,6 +221,21 @@ func TestPreviewAndDiffConfigVersion(t *testing.T) {
|
||||
if len(diff.ChangedOptionKeys) == 0 || diff.ChangedOptionKeys[0] == "" {
|
||||
t.Fatal("expected changed OpenResty option keys to be reported")
|
||||
}
|
||||
if len(diff.ChangedOptionDetails) == 0 {
|
||||
t.Fatal("expected changed OpenResty option details to be reported")
|
||||
}
|
||||
found := false
|
||||
for _, item := range diff.ChangedOptionDetails {
|
||||
if item.Key == "OpenRestyProxyReadTimeout" {
|
||||
found = true
|
||||
if item.PreviousValue != "60" || item.CurrentValue != "120" {
|
||||
t.Fatalf("unexpected option diff values: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected OpenRestyProxyReadTimeout diff detail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTLSCertificateRejectsInvalidPEM(t *testing.T) {
|
||||
|
||||
@@ -39,12 +39,26 @@ function buildSummary(logs: ApplyLogItem[]) {
|
||||
|
||||
return [
|
||||
{ label: '记录总数', value: logs.length },
|
||||
{ label: '成功', value: logs.filter((item) => item.result === 'success').length },
|
||||
{ label: '失败', value: logs.filter((item) => item.result !== 'success').length },
|
||||
{
|
||||
label: '成功',
|
||||
value: logs.filter((item) => item.result === 'success').length,
|
||||
},
|
||||
{
|
||||
label: '失败',
|
||||
value: logs.filter((item) => item.result !== 'success').length,
|
||||
},
|
||||
{ label: '节点数', value: nodeIds.size },
|
||||
];
|
||||
}
|
||||
|
||||
function truncateHash(value: string) {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return value.length > 12 ? `${value.slice(0, 12)}...` : value;
|
||||
}
|
||||
|
||||
export function ApplyLogsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [nodeFilterInput, setNodeFilterInput] = useState('');
|
||||
@@ -85,62 +99,73 @@ export function ApplyLogsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title='应用记录'
|
||||
description='查看节点应用版本的成功或失败记录,支持按 node_id 过滤并查看单条详情。'
|
||||
title="应用记录"
|
||||
description="查看节点应用版本的成功或失败记录,支持按 node_id 过滤并查看单条详情。"
|
||||
action={
|
||||
<Link
|
||||
href='/node'
|
||||
className='inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]'
|
||||
href="/node"
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
返回节点页
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? <InlineMessage tone='info' message={feedback} /> : null}
|
||||
{feedback ? <InlineMessage tone="info" message={feedback} /> : null}
|
||||
|
||||
<AppCard title='记录摘要' description='帮助快速识别失败趋势和受影响节点范围。'>
|
||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<AppCard
|
||||
title="记录摘要"
|
||||
description="帮助快速识别失败趋势和受影响节点范围。"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summary.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'
|
||||
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
|
||||
>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>{item.label}</p>
|
||||
<p className='mt-2 text-lg font-semibold text-[var(--foreground-primary)]'>{item.value}</p>
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
{item.label}
|
||||
</p>
|
||||
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
{item.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-[1.2fr_0.8fr]'>
|
||||
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<AppCard
|
||||
title='过滤与列表'
|
||||
description='默认展示全部记录,可按 node_id 快速筛选单节点应用结果。'
|
||||
title="过滤与列表"
|
||||
description="默认展示全部记录,可按 node_id 快速筛选单节点应用结果。"
|
||||
action={
|
||||
<SecondaryButton
|
||||
type='button'
|
||||
onClick={() => void queryClient.invalidateQueries({ queryKey: applyLogsQueryKey(nodeFilter) })}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: applyLogsQueryKey(nodeFilter),
|
||||
})
|
||||
}
|
||||
>
|
||||
刷新
|
||||
</SecondaryButton>
|
||||
}
|
||||
>
|
||||
<div className='space-y-5'>
|
||||
<div className='flex flex-col gap-3 lg:flex-row lg:items-center'>
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<ResourceInput
|
||||
value={nodeFilterInput}
|
||||
onChange={(event) => setNodeFilterInput(event.target.value)}
|
||||
placeholder='输入 node_id 过滤应用记录'
|
||||
className='lg:max-w-md'
|
||||
placeholder="输入 node_id 过滤应用记录"
|
||||
className="lg:max-w-md"
|
||||
/>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<PrimaryButton type='button' onClick={handleSearch}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<PrimaryButton type="button" onClick={handleSearch}>
|
||||
筛选
|
||||
</PrimaryButton>
|
||||
<SecondaryButton type='button' onClick={handleReset}>
|
||||
<SecondaryButton type="button" onClick={handleReset}>
|
||||
清空
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
@@ -149,45 +174,69 @@ export function ApplyLogsPage() {
|
||||
{logsQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : logsQuery.isError ? (
|
||||
<ErrorState title='应用记录加载失败' description={getErrorMessage(logsQuery.error)} />
|
||||
<ErrorState
|
||||
title="应用记录加载失败"
|
||||
description={getErrorMessage(logsQuery.error)}
|
||||
/>
|
||||
) : logs.length === 0 ? (
|
||||
<EmptyState title='暂无应用记录' description='当前筛选条件下没有可展示的应用结果。' />
|
||||
<EmptyState
|
||||
title="暂无应用记录"
|
||||
description="当前筛选条件下没有可展示的应用结果。"
|
||||
/>
|
||||
) : (
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='min-w-full divide-y divide-[var(--border-default)] text-left text-sm'>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
|
||||
<thead>
|
||||
<tr className='text-[var(--foreground-secondary)]'>
|
||||
<th className='px-3 py-3 font-medium'>Node ID</th>
|
||||
<th className='px-3 py-3 font-medium'>版本</th>
|
||||
<th className='px-3 py-3 font-medium'>结果</th>
|
||||
<th className='px-3 py-3 font-medium'>时间</th>
|
||||
<th className='px-3 py-3 font-medium'>详情</th>
|
||||
<tr className="text-[var(--foreground-secondary)]">
|
||||
<th className="px-3 py-3 font-medium">Node ID</th>
|
||||
<th className="px-3 py-3 font-medium">版本</th>
|
||||
<th className="px-3 py-3 font-medium">结果</th>
|
||||
<th className="px-3 py-3 font-medium">Checksum</th>
|
||||
<th className="px-3 py-3 font-medium">时间</th>
|
||||
<th className="px-3 py-3 font-medium">详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='divide-y divide-[var(--border-default)]'>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{logs.map((log) => {
|
||||
const resultMeta = getResultMeta(log.result);
|
||||
return (
|
||||
<tr key={log.id} className='align-top'>
|
||||
<td className='px-3 py-4 font-medium text-[var(--foreground-primary)]'>{log.node_id}</td>
|
||||
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>{log.version}</td>
|
||||
<td className='px-3 py-4'>
|
||||
<StatusBadge label={resultMeta.label} variant={resultMeta.variant} />
|
||||
<tr key={log.id} className="align-top">
|
||||
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
|
||||
{log.node_id}
|
||||
</td>
|
||||
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>
|
||||
<div className='space-y-1'>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{log.version}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<StatusBadge
|
||||
label={resultMeta.label}
|
||||
variant={resultMeta.variant}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
className="px-3 py-4 text-[var(--foreground-secondary)]"
|
||||
title={log.checksum}
|
||||
>
|
||||
{truncateHash(log.checksum)}
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
<div className="space-y-1">
|
||||
<p>{formatDateTime(log.created_at)}</p>
|
||||
<p className='text-xs'>{formatRelativeTime(log.created_at)}</p>
|
||||
<p className="text-xs">
|
||||
{formatRelativeTime(log.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className='px-3 py-4'>
|
||||
<td className="px-3 py-4">
|
||||
<SecondaryButton
|
||||
type='button'
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedLogId(log.id);
|
||||
setFeedback(`已选中节点 ${log.node_id} 的版本 ${log.version} 记录。`);
|
||||
setFeedback(
|
||||
`已选中节点 ${log.node_id} 的版本 ${log.version} 记录。`,
|
||||
);
|
||||
}}
|
||||
className='px-3 py-2 text-xs'
|
||||
className="px-3 py-2 text-xs"
|
||||
>
|
||||
查看详情
|
||||
</SecondaryButton>
|
||||
@@ -202,39 +251,92 @@ export function ApplyLogsPage() {
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard title='记录详情' description='展示所选应用记录的完整结果与错误信息。'>
|
||||
<AppCard
|
||||
title="记录详情"
|
||||
description="展示所选应用记录的完整结果与错误信息。"
|
||||
>
|
||||
{selectedLog ? (
|
||||
<div className='space-y-4'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<StatusBadge {...getResultMeta(selectedLog.result)} />
|
||||
<StatusBadge label={`Node:${selectedLog.node_id}`} variant='info' />
|
||||
<StatusBadge label={`版本:${selectedLog.version}`} variant='warning' />
|
||||
<StatusBadge
|
||||
label={`Node:${selectedLog.node_id}`}
|
||||
variant="info"
|
||||
/>
|
||||
<StatusBadge
|
||||
label={`版本:${selectedLog.version}`}
|
||||
variant="warning"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>创建时间</p>
|
||||
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
创建时间
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{formatDateTime(selectedLog.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>相对时间</p>
|
||||
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
相对时间
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{formatRelativeTime(selectedLog.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
目标 Checksum
|
||||
</p>
|
||||
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
|
||||
{selectedLog.checksum || '无'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
支持文件数
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{selectedLog.support_file_count}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>应用信息</p>
|
||||
<p className='mt-3 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--foreground-primary)]'>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
主配置摘要
|
||||
</p>
|
||||
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
|
||||
{selectedLog.main_config_checksum || '无'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
路由配置摘要
|
||||
</p>
|
||||
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
|
||||
{selectedLog.route_config_checksum || '无'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
应用信息
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-6 break-words whitespace-pre-wrap text-[var(--foreground-primary)]">
|
||||
{selectedLog.message || '无附加信息'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title='未选择记录' description='请先从左侧列表中选择一条应用记录查看详情。' />
|
||||
<EmptyState
|
||||
title="未选择记录"
|
||||
description="请先从左侧列表中选择一条应用记录查看详情。"
|
||||
/>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
@@ -4,5 +4,9 @@ export interface ApplyLogItem {
|
||||
version: string;
|
||||
result: 'success' | 'failed' | string;
|
||||
message: string;
|
||||
checksum: string;
|
||||
main_config_checksum: string;
|
||||
route_config_checksum: string;
|
||||
support_file_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import type { ConfigVersionItem } from '@/features/config-versions/types';
|
||||
import {
|
||||
CodeBlock,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
export function ConfigVersionSnapshotModal({
|
||||
version,
|
||||
onClose,
|
||||
}: {
|
||||
version: ConfigVersionItem | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={Boolean(version)}
|
||||
onClose={onClose}
|
||||
title={version ? `版本 ${version.version}` : '查看快照'}
|
||||
description="在弹窗中查看快照 JSON 与渲染结果,避免把页面主视图撑长。"
|
||||
size="xl"
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
关闭
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{version ? (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
Checksum
|
||||
</p>
|
||||
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
|
||||
{version.checksum}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
创建人
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{version.created_by || '系统'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
创建时间
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{formatDateTime(version.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
快照 JSON
|
||||
</p>
|
||||
<CodeBlock className="max-h-96 whitespace-pre-wrap">
|
||||
{version.snapshot_json}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
主配置
|
||||
</p>
|
||||
<CodeBlock className="max-h-96 whitespace-pre-wrap">
|
||||
{version.main_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
路由配置
|
||||
</p>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{version.rendered_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import {
|
||||
@@ -18,7 +17,9 @@ import {
|
||||
getConfigVersions,
|
||||
publishConfigVersion,
|
||||
} from '@/features/config-versions/api/config-versions';
|
||||
import { ConfigVersionSnapshotModal } from '@/features/config-versions/components/config-version-snapshot-modal';
|
||||
import type {
|
||||
ConfigOptionDiffItem,
|
||||
ConfigDiffResult,
|
||||
ConfigPreviewResult,
|
||||
ConfigVersionItem,
|
||||
@@ -121,84 +122,46 @@ function SupportFilesList({ files }: { files: SupportFile[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SnapshotModal({
|
||||
version,
|
||||
onClose,
|
||||
}: {
|
||||
version: ConfigVersionItem | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={Boolean(version)}
|
||||
onClose={onClose}
|
||||
title={version ? `版本 ${version.version}` : '查看快照'}
|
||||
description="在弹窗中查看快照 JSON 与渲染结果,避免把版本页主视图撑长。"
|
||||
size="xl"
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
关闭
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{version ? (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
Checksum
|
||||
</p>
|
||||
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
|
||||
{version.checksum}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
创建人
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{version.created_by || '系统'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
创建时间
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{formatDateTime(version.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
function renderOptionValue(value: string) {
|
||||
return value === '' ? '空' : value;
|
||||
}
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
快照 JSON
|
||||
</p>
|
||||
<CodeBlock className="max-h-96 whitespace-pre-wrap">
|
||||
{version.snapshot_json}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
主配置
|
||||
</p>
|
||||
<CodeBlock className="max-h-96 whitespace-pre-wrap">
|
||||
{version.main_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
路由配置
|
||||
</p>
|
||||
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
|
||||
{version.rendered_config}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AppModal>
|
||||
function OptionDiffTable({ items }: { items: ConfigOptionDiffItem[] }) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
当前无 OpenResty 性能参数变化。
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-2xl border border-[var(--border-default)]">
|
||||
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[var(--surface-elevated)] text-[var(--foreground-secondary)]">
|
||||
<th className="px-3 py-3 font-medium">参数</th>
|
||||
<th className="px-3 py-3 font-medium">当前激活值</th>
|
||||
<th className="px-3 py-3 font-medium">待发布值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{items.map((item) => (
|
||||
<tr key={item.key} className="align-top">
|
||||
<td className="px-3 py-3 font-medium text-[var(--foreground-primary)]">
|
||||
{item.key}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-[var(--foreground-secondary)]">
|
||||
<code>{renderOptionValue(item.previous_value)}</code>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-[var(--foreground-secondary)]">
|
||||
<code>{renderOptionValue(item.current_value)}</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -295,22 +258,7 @@ function PublishPreviewCard({
|
||||
variant={diff.changed_option_keys.length > 0 ? 'info' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
{diff.changed_option_keys.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{diff.changed_option_keys.map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className="rounded-full border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-1 text-xs text-[var(--foreground-secondary)]"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
当前无 OpenResty 性能参数变化。
|
||||
</p>
|
||||
)}
|
||||
<OptionDiffTable items={diff.changed_option_details} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -579,7 +527,7 @@ export function ConfigVersionsPage() {
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<SnapshotModal
|
||||
<ConfigVersionSnapshotModal
|
||||
version={selectedVersion}
|
||||
onClose={() => setSelectedVersionId(null)}
|
||||
/>
|
||||
|
||||
@@ -33,4 +33,11 @@ export interface ConfigDiffResult {
|
||||
modified_domains: string[];
|
||||
main_config_changed: boolean;
|
||||
changed_option_keys: string[];
|
||||
changed_option_details: ConfigOptionDiffItem[];
|
||||
}
|
||||
|
||||
export interface ConfigOptionDiffItem {
|
||||
key: string;
|
||||
previous_value: string;
|
||||
current_value: string;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ import { PageHeader } from '@/components/layout/page-header';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import { getConfigVersions } from '@/features/config-versions/api/config-versions';
|
||||
import { ConfigVersionSnapshotModal } from '@/features/config-versions/components/config-version-snapshot-modal';
|
||||
import type { ConfigVersionItem } from '@/features/config-versions/types';
|
||||
import { getApplyLogs } from '@/features/apply-logs/api/apply-logs';
|
||||
import {
|
||||
deleteNode,
|
||||
@@ -97,6 +100,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [isAgentUpdateModalOpen, setIsAgentUpdateModalOpen] = useState(false);
|
||||
const [isTargetSnapshotOpen, setIsTargetSnapshotOpen] = useState(false);
|
||||
const [selectedReleaseChannel, setSelectedReleaseChannel] =
|
||||
useState<ReleaseChannel>('stable');
|
||||
const [agentUpdateFeedback, setAgentUpdateFeedback] =
|
||||
@@ -144,6 +148,12 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const configVersionsQuery = useQuery({
|
||||
queryKey: ['config-versions'],
|
||||
queryFn: getConfigVersions,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && !serverUrl) {
|
||||
setServerUrl(window.location.origin);
|
||||
@@ -279,6 +289,12 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
const activeConfigVersion = useMemo<ConfigVersionItem | null>(() => {
|
||||
return (
|
||||
(configVersionsQuery.data ?? []).find((item) => item.is_active) ?? null
|
||||
);
|
||||
}, [configVersionsQuery.data]);
|
||||
|
||||
if (nodesQuery.isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
@@ -320,6 +336,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
? previewAgentReleaseQuery.isFetching
|
||||
: stableAgentReleaseQuery.isFetching;
|
||||
const applyLogs = applyLogsQuery.data ?? [];
|
||||
const isTargetVersionApplied =
|
||||
activeConfigVersion !== null &&
|
||||
activeConfigVersion.version === node.current_version;
|
||||
|
||||
const handleOpenAgentUpdateModal = () => {
|
||||
setAgentUpdateFeedback(null);
|
||||
@@ -353,6 +372,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['apply-logs', node.node_id],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['config-versions'],
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -460,10 +482,95 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
)} · ${formatDateTime(node.latest_apply_at)}`
|
||||
: '暂无应用记录'}
|
||||
</p>
|
||||
{node.latest_apply_checksum ? (
|
||||
<div className="space-y-1 text-sm text-[var(--foreground-secondary)]">
|
||||
<p>目标 Checksum:{node.latest_apply_checksum}</p>
|
||||
<p>支持文件:{node.latest_support_file_count}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<AppCard
|
||||
title="当前目标版本"
|
||||
description="展示当前全局激活配置版本,便于直接核对节点应追上的主配置与路由配置。"
|
||||
action={
|
||||
activeConfigVersion ? (
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setIsTargetSnapshotOpen(true)}
|
||||
>
|
||||
查看目标快照
|
||||
</SecondaryButton>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{configVersionsQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : configVersionsQuery.isError ? (
|
||||
<InlineMessage
|
||||
tone="danger"
|
||||
message={getErrorMessage(configVersionsQuery.error)}
|
||||
/>
|
||||
) : activeConfigVersion ? (
|
||||
<div className="grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
追平状态
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<StatusBadge
|
||||
label={
|
||||
isTargetVersionApplied
|
||||
? '已追平目标版本'
|
||||
: '待追平目标版本'
|
||||
}
|
||||
variant={isTargetVersionApplied ? 'success' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-[var(--foreground-secondary)]">
|
||||
{isTargetVersionApplied
|
||||
? '当前节点已应用全局激活配置。'
|
||||
: '当前节点版本落后于全局激活配置,可结合应用记录定位原因。'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
目标版本
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{activeConfigVersion.version}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
Target Checksum
|
||||
</p>
|
||||
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
|
||||
{activeConfigVersion.checksum}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
激活时间
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
|
||||
{formatDateTime(activeConfigVersion.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<InlineMessage
|
||||
tone="info"
|
||||
message="当前还没有全局激活配置版本,无法展示目标快照。"
|
||||
/>
|
||||
)}
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="OpenResty 健康与控制"
|
||||
description="OpenResty 当前健康状态。"
|
||||
@@ -643,6 +750,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
<tr className="text-[var(--foreground-secondary)]">
|
||||
<th className="px-3 py-3 font-medium">版本</th>
|
||||
<th className="px-3 py-3 font-medium">结果</th>
|
||||
<th className="px-3 py-3 font-medium">Checksum</th>
|
||||
<th className="px-3 py-3 font-medium">时间</th>
|
||||
<th className="px-3 py-3 font-medium">消息</th>
|
||||
</tr>
|
||||
@@ -661,14 +769,35 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
className="px-3 py-4 text-[var(--foreground-secondary)]"
|
||||
title={log.checksum}
|
||||
>
|
||||
{log.checksum ? `${log.checksum.slice(0, 12)}...` : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{formatRelativeTime(log.created_at)} ·{' '}
|
||||
{formatDateTime(log.created_at)}
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
<p className="max-w-80 break-words whitespace-pre-wrap">
|
||||
{log.message || '—'}
|
||||
</p>
|
||||
<div className="max-w-80 space-y-2 break-words whitespace-pre-wrap">
|
||||
<p>
|
||||
{log.main_config_checksum
|
||||
? `主配置:${log.main_config_checksum}`
|
||||
: ''}
|
||||
</p>
|
||||
<p>
|
||||
{log.route_config_checksum
|
||||
? `路由配置:${log.route_config_checksum}`
|
||||
: ''}
|
||||
</p>
|
||||
<p>
|
||||
{log.support_file_count
|
||||
? `支持文件:${log.support_file_count}`
|
||||
: ''}
|
||||
</p>
|
||||
<p>{log.message || '—'}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -679,6 +808,11 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<ConfigVersionSnapshotModal
|
||||
version={isTargetSnapshotOpen ? activeConfigVersion : null}
|
||||
onClose={() => setIsTargetSnapshotOpen(false)}
|
||||
/>
|
||||
|
||||
<AppModal
|
||||
isOpen={isEditorOpen}
|
||||
onClose={() => setIsEditorOpen(false)}
|
||||
|
||||
@@ -21,6 +21,10 @@ export interface NodeItem {
|
||||
last_error: string;
|
||||
latest_apply_result: 'success' | 'failed' | '';
|
||||
latest_apply_message: string;
|
||||
latest_apply_checksum: string;
|
||||
latest_main_config_checksum: string;
|
||||
latest_route_config_checksum: string;
|
||||
latest_support_file_count: number;
|
||||
latest_apply_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
Reference in New Issue
Block a user