mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
parent
92951532e3
commit
7edfa1bb86
18 changed files with 625 additions and 18 deletions
|
|
@ -1652,7 +1652,7 @@ func TestDockerReportPreservesMetadataAcrossObservedContainerRecreation(t *testi
|
|||
requiredMigrationSnippets := []string{
|
||||
"func (m *Monitor) migrateDockerContainerMetadataForRecreatedContainers(",
|
||||
"normalizeDockerContainerMetadataIdentity(container.Name)",
|
||||
`strings.TrimSpace(strings.TrimPrefix(name, "/"))`,
|
||||
`strings.TrimLeft(strings.TrimSpace(name), "/")`,
|
||||
"m.CopyDockerContainerMetadata(hostID, previousContainer.ID, container.ID)",
|
||||
}
|
||||
for _, snippet := range requiredMigrationSnippets {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
|
|
@ -30,8 +31,8 @@ func (m *Monitor) CopyDockerContainerMetadata(hostID, oldContainerID, newContain
|
|||
return nil
|
||||
}
|
||||
|
||||
oldKey := fmt.Sprintf("%s:container:%s", hostID, oldContainerID)
|
||||
newKey := fmt.Sprintf("%s:container:%s", hostID, newContainerID)
|
||||
oldKey := dockerContainerRuntimeMetadataKey(hostID, oldContainerID)
|
||||
newKey := dockerContainerRuntimeMetadataKey(hostID, newContainerID)
|
||||
|
||||
oldMeta := m.dockerMetadataStore.Get(oldKey)
|
||||
if oldMeta == nil {
|
||||
|
|
@ -73,6 +74,181 @@ func (m *Monitor) CopyDockerContainerMetadata(hostID, oldContainerID, newContain
|
|||
return m.dockerMetadataStore.Set(newKey, &merged)
|
||||
}
|
||||
|
||||
func dockerContainerRuntimeMetadataKey(hostID, containerID string) string {
|
||||
hostID = strings.TrimSpace(hostID)
|
||||
containerID = strings.TrimSpace(containerID)
|
||||
if hostID == "" || containerID == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s:container:%s", hostID, containerID)
|
||||
}
|
||||
|
||||
func dockerContainerNameMetadataKey(hostID, containerName string) string {
|
||||
hostID = strings.TrimSpace(hostID)
|
||||
containerName = normalizeDockerContainerMetadataIdentity(containerName)
|
||||
if hostID == "" || containerName == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s:container-name:%s", hostID, containerName)
|
||||
}
|
||||
|
||||
func dockerAppContainerMetadataKey(hostID, containerName string) string {
|
||||
hostID = strings.TrimSpace(hostID)
|
||||
containerName = normalizeDockerContainerMetadataIdentity(containerName)
|
||||
if hostID == "" || containerName == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("app-container:%s:name:%s", hostID, containerName)
|
||||
}
|
||||
|
||||
func dockerAppContainerLegacyResourceID(hostID, containerID string) string {
|
||||
hostID = strings.TrimSpace(hostID)
|
||||
containerID = strings.TrimSpace(containerID)
|
||||
if hostID == "" || containerID == "" {
|
||||
return ""
|
||||
}
|
||||
return unifiedresources.SourceSpecificID(
|
||||
unifiedresources.ResourceTypeAppContainer,
|
||||
unifiedresources.SourceDocker,
|
||||
fmt.Sprintf("%s/container/%s", hostID, containerID),
|
||||
)
|
||||
}
|
||||
|
||||
func dockerAppContainerLegacyMetadataKey(hostID, containerID string) string {
|
||||
hostID = strings.TrimSpace(hostID)
|
||||
containerID = strings.TrimSpace(containerID)
|
||||
if hostID == "" || containerID == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("app-container:%s:%s", hostID, containerID)
|
||||
}
|
||||
|
||||
func dockerAppContainerGuestMetadataLegacyKeys(hostID, containerID string) []string {
|
||||
candidates := []string{
|
||||
dockerAppContainerLegacyResourceID(hostID, containerID),
|
||||
dockerAppContainerLegacyMetadataKey(hostID, containerID),
|
||||
}
|
||||
out := make([]string, 0, len(candidates))
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[candidate]; ok {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = struct{}{}
|
||||
out = append(out, candidate)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func copyDockerMetadataAliasIfTargetMissing(store *config.DockerMetadataStore, sourceKey, targetKey string) error {
|
||||
if store == nil || strings.TrimSpace(sourceKey) == "" || strings.TrimSpace(targetKey) == "" {
|
||||
return nil
|
||||
}
|
||||
if store.Get(targetKey) != nil {
|
||||
return nil
|
||||
}
|
||||
source := store.Get(sourceKey)
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
clone := &config.DockerMetadata{
|
||||
CustomURL: source.CustomURL,
|
||||
Description: source.Description,
|
||||
}
|
||||
if len(source.Tags) > 0 {
|
||||
clone.Tags = append([]string(nil), source.Tags...)
|
||||
}
|
||||
if len(source.Notes) > 0 {
|
||||
clone.Notes = append([]string(nil), source.Notes...)
|
||||
}
|
||||
return store.Set(targetKey, clone)
|
||||
}
|
||||
|
||||
func copyGuestMetadataAliasIfTargetMissing(
|
||||
store *config.GuestMetadataStore,
|
||||
sourceKey,
|
||||
targetKey,
|
||||
containerName string,
|
||||
) error {
|
||||
if store == nil || strings.TrimSpace(sourceKey) == "" || strings.TrimSpace(targetKey) == "" {
|
||||
return nil
|
||||
}
|
||||
if store.Get(targetKey) != nil {
|
||||
return nil
|
||||
}
|
||||
source := store.Get(sourceKey)
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
clone := &config.GuestMetadata{
|
||||
CustomURL: source.CustomURL,
|
||||
Description: source.Description,
|
||||
LastKnownName: normalizeDockerContainerMetadataIdentity(containerName),
|
||||
LastKnownType: "app-container",
|
||||
}
|
||||
if len(source.Tags) > 0 {
|
||||
clone.Tags = append([]string(nil), source.Tags...)
|
||||
}
|
||||
if len(source.Notes) > 0 {
|
||||
clone.Notes = append([]string(nil), source.Notes...)
|
||||
}
|
||||
return store.Set(targetKey, clone)
|
||||
}
|
||||
|
||||
func (m *Monitor) migrateCurrentDockerContainerMetadataToStableIdentities(
|
||||
hostID string,
|
||||
containers []models.DockerContainer,
|
||||
) {
|
||||
if m == nil || len(containers) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
hostID = strings.TrimSpace(hostID)
|
||||
if hostID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
for _, container := range containers {
|
||||
containerID := strings.TrimSpace(container.ID)
|
||||
containerName := normalizeDockerContainerMetadataIdentity(container.Name)
|
||||
if containerID == "" || containerName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if stableKey := dockerContainerNameMetadataKey(hostID, containerName); stableKey != "" {
|
||||
sourceKey := dockerContainerRuntimeMetadataKey(hostID, containerID)
|
||||
if err := copyDockerMetadataAliasIfTargetMissing(m.dockerMetadataStore, sourceKey, stableKey); err != nil {
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Str("dockerHostID", hostID).
|
||||
Str("containerName", container.Name).
|
||||
Str("containerID", container.ID).
|
||||
Msg("Failed to migrate docker metadata to stable container name key")
|
||||
}
|
||||
}
|
||||
|
||||
if stableKey := dockerAppContainerMetadataKey(hostID, containerName); stableKey != "" {
|
||||
for _, sourceKey := range dockerAppContainerGuestMetadataLegacyKeys(hostID, containerID) {
|
||||
if err := copyGuestMetadataAliasIfTargetMissing(m.guestMetadataStore, sourceKey, stableKey, containerName); err != nil {
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Str("dockerHostID", hostID).
|
||||
Str("containerName", container.Name).
|
||||
Str("containerID", container.ID).
|
||||
Msg("Failed to migrate guest metadata to stable app-container name key")
|
||||
}
|
||||
if m.guestMetadataStore != nil && m.guestMetadataStore.Get(stableKey) != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Monitor) migrateDockerContainerMetadataForRecreatedContainers(
|
||||
hostID string,
|
||||
previousContainers []models.DockerContainer,
|
||||
|
|
@ -133,5 +309,5 @@ func (m *Monitor) migrateDockerContainerMetadataForRecreatedContainers(
|
|||
}
|
||||
|
||||
func normalizeDockerContainerMetadataIdentity(name string) string {
|
||||
return strings.TrimSpace(strings.TrimPrefix(name, "/"))
|
||||
return strings.TrimLeft(strings.TrimSpace(name), "/")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5176,7 +5176,7 @@ func (m *Monitor) getResourcesForBroadcast() []models.ResourceFrontend {
|
|||
}
|
||||
|
||||
func (m *Monitor) applyDockerMetadataToUnifiedResources(resources []unifiedresources.Resource) []unifiedresources.Resource {
|
||||
if len(resources) == 0 || m == nil || m.dockerMetadataStore == nil {
|
||||
if len(resources) == 0 || m == nil {
|
||||
return resources
|
||||
}
|
||||
|
||||
|
|
@ -5189,16 +5189,58 @@ func (m *Monitor) applyDockerMetadataToUnifiedResources(resources []unifiedresou
|
|||
}
|
||||
hostID := strings.TrimSpace(resource.Docker.HostSourceID)
|
||||
containerID := strings.TrimSpace(resource.Docker.ContainerID)
|
||||
if hostID == "" || containerID == "" {
|
||||
if hostID == "" {
|
||||
continue
|
||||
}
|
||||
if meta := m.dockerMetadataStore.Get(fmt.Sprintf("%s:container:%s", hostID, containerID)); meta != nil {
|
||||
resource.CustomURL = strings.TrimSpace(meta.CustomURL)
|
||||
if customURL, ok := m.dockerAppContainerCustomURL(*resource, hostID, containerID); ok {
|
||||
resource.CustomURL = customURL
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Monitor) dockerAppContainerCustomURL(
|
||||
resource unifiedresources.Resource,
|
||||
hostID,
|
||||
containerID string,
|
||||
) (string, bool) {
|
||||
if m == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if m.guestMetadataStore != nil {
|
||||
if stableKey := dockerAppContainerMetadataKey(hostID, resource.Name); stableKey != "" {
|
||||
if meta := m.guestMetadataStore.Get(stableKey); meta != nil {
|
||||
return strings.TrimSpace(meta.CustomURL), true
|
||||
}
|
||||
}
|
||||
for _, key := range append(
|
||||
[]string{strings.TrimSpace(resource.ID)},
|
||||
dockerAppContainerGuestMetadataLegacyKeys(hostID, containerID)...,
|
||||
) {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if meta := m.guestMetadataStore.Get(key); meta != nil {
|
||||
return strings.TrimSpace(meta.CustomURL), true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if m.dockerMetadataStore != nil {
|
||||
if stableKey := dockerContainerNameMetadataKey(hostID, resource.Name); stableKey != "" {
|
||||
if meta := m.dockerMetadataStore.Get(stableKey); meta != nil {
|
||||
return strings.TrimSpace(meta.CustomURL), true
|
||||
}
|
||||
}
|
||||
if meta := m.dockerMetadataStore.Get(dockerContainerRuntimeMetadataKey(hostID, containerID)); meta != nil {
|
||||
return strings.TrimSpace(meta.CustomURL), true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// convertResourcesForBroadcast converts unified resources into the frontend payload shape.
|
||||
func convertResourcesForBroadcast(
|
||||
allResources []unifiedresources.Resource,
|
||||
|
|
|
|||
|
|
@ -1446,6 +1446,7 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con
|
|||
if hasPrevious {
|
||||
m.migrateDockerContainerMetadataForRecreatedContainers(identifier, previous.Containers(), host.Containers)
|
||||
}
|
||||
m.migrateCurrentDockerContainerMetadataToStableIdentities(identifier, host.Containers)
|
||||
|
||||
if tokenRecord != nil {
|
||||
host.TokenID = tokenRecord.ID
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ func newTestMonitor(t *testing.T) *Monitor {
|
|||
rateTracker: NewRateTracker(),
|
||||
metricsHistory: NewMetricsHistory(1000, 24*time.Hour),
|
||||
dockerTokenBindings: make(map[string]string),
|
||||
guestMetadataStore: config.NewGuestMetadataStore(t.TempDir(), nil),
|
||||
dockerMetadataStore: config.NewDockerMetadataStore(t.TempDir(), nil),
|
||||
}
|
||||
t.Cleanup(func() { m.alertManager.Stop() })
|
||||
|
|
@ -428,6 +429,85 @@ func TestApplyDockerReportMigratesMetadataWhenContainerRuntimeIDChanges(t *testi
|
|||
}
|
||||
}
|
||||
|
||||
func TestApplyDockerReportMigratesGuestMetadataToStableContainerName(t *testing.T) {
|
||||
monitor := newTestMonitor(t)
|
||||
|
||||
baseTimestamp := time.Now().UTC()
|
||||
report := agentsdocker.Report{
|
||||
Agent: agentsdocker.AgentInfo{
|
||||
ID: "agent-stable-guest",
|
||||
Version: "1.0.0",
|
||||
IntervalSeconds: 30,
|
||||
},
|
||||
Host: agentsdocker.HostInfo{
|
||||
Hostname: "docker-host-stable-guest",
|
||||
MachineID: "machine-stable-guest",
|
||||
},
|
||||
Containers: []agentsdocker.Container{
|
||||
{ID: "container-old", Name: "/app"},
|
||||
},
|
||||
Timestamp: baseTimestamp,
|
||||
}
|
||||
|
||||
host, err := monitor.ApplyDockerReport(report, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("first ApplyDockerReport failed: %v", err)
|
||||
}
|
||||
|
||||
legacyKey := dockerAppContainerLegacyResourceID(host.ID, "container-old")
|
||||
if err := monitor.guestMetadataStore.Set(legacyKey, &config.GuestMetadata{
|
||||
CustomURL: "https://app.internal",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed guest metadata: %v", err)
|
||||
}
|
||||
|
||||
report.Timestamp = baseTimestamp.Add(30 * time.Second)
|
||||
if _, err := monitor.ApplyDockerReport(report, nil); err != nil {
|
||||
t.Fatalf("metadata seeding ApplyDockerReport failed: %v", err)
|
||||
}
|
||||
|
||||
stableKey := dockerAppContainerMetadataKey(host.ID, "app")
|
||||
stableMeta := monitor.guestMetadataStore.Get(stableKey)
|
||||
if stableMeta == nil {
|
||||
t.Fatalf("expected stable guest metadata key %q", stableKey)
|
||||
}
|
||||
if stableMeta.CustomURL != "https://app.internal" {
|
||||
t.Fatalf("stable guest metadata URL = %q, want https://app.internal", stableMeta.CustomURL)
|
||||
}
|
||||
|
||||
report.Timestamp = baseTimestamp.Add(60 * time.Second)
|
||||
report.Containers = nil
|
||||
if _, err := monitor.ApplyDockerReport(report, nil); err != nil {
|
||||
t.Fatalf("empty-container ApplyDockerReport failed: %v", err)
|
||||
}
|
||||
|
||||
report.Timestamp = baseTimestamp.Add(90 * time.Second)
|
||||
report.Containers = []agentsdocker.Container{
|
||||
{ID: "container-new", Name: "app"},
|
||||
}
|
||||
host, err = monitor.ApplyDockerReport(report, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("recreated-container ApplyDockerReport failed: %v", err)
|
||||
}
|
||||
|
||||
resources := []unifiedresources.Resource{
|
||||
{
|
||||
ID: dockerAppContainerLegacyResourceID(host.ID, "container-new"),
|
||||
Type: unifiedresources.ResourceTypeAppContainer,
|
||||
Name: "app",
|
||||
Docker: &unifiedresources.DockerData{
|
||||
HostSourceID: host.ID,
|
||||
ContainerID: "container-new",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := monitor.applyDockerMetadataToUnifiedResources(resources)
|
||||
if got[0].CustomURL != "https://app.internal" {
|
||||
t.Fatalf("CustomURL after recreate gap = %q, want https://app.internal", got[0].CustomURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDockerReportSkipsMetadataMigrationForAmbiguousContainerNames(t *testing.T) {
|
||||
monitor := newTestMonitor(t)
|
||||
|
||||
|
|
@ -530,6 +610,63 @@ func TestApplyDockerMetadataToUnifiedResourcesKeepsResourceCustomURL(t *testing.
|
|||
}
|
||||
}
|
||||
|
||||
func TestApplyDockerMetadataToUnifiedResourcesUsesStableDockerMetadata(t *testing.T) {
|
||||
monitor := newTestMonitor(t)
|
||||
if err := monitor.dockerMetadataStore.Set("docker-host-1:container-name:app", &config.DockerMetadata{
|
||||
CustomURL: "https://stable-docker.internal",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed stable docker metadata: %v", err)
|
||||
}
|
||||
|
||||
resources := []unifiedresources.Resource{
|
||||
{
|
||||
ID: "resource:app-container:app",
|
||||
Type: unifiedresources.ResourceTypeAppContainer,
|
||||
Name: "app",
|
||||
Docker: &unifiedresources.DockerData{
|
||||
HostSourceID: "docker-host-1",
|
||||
ContainerID: "container-new",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := monitor.applyDockerMetadataToUnifiedResources(resources)
|
||||
if got[0].CustomURL != "https://stable-docker.internal" {
|
||||
t.Fatalf("CustomURL = %q, want stable Docker metadata URL", got[0].CustomURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDockerMetadataToUnifiedResourcesStableGuestMetadataBlocksLegacyFallback(t *testing.T) {
|
||||
monitor := newTestMonitor(t)
|
||||
if err := monitor.guestMetadataStore.Set(dockerAppContainerMetadataKey("docker-host-1", "app"), &config.GuestMetadata{
|
||||
CustomURL: "",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed stable guest metadata: %v", err)
|
||||
}
|
||||
if err := monitor.dockerMetadataStore.Set("docker-host-1:container:container-new", &config.DockerMetadata{
|
||||
CustomURL: "https://legacy.internal",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed legacy docker metadata: %v", err)
|
||||
}
|
||||
|
||||
resources := []unifiedresources.Resource{
|
||||
{
|
||||
ID: "resource:app-container:app",
|
||||
Type: unifiedresources.ResourceTypeAppContainer,
|
||||
Name: "app",
|
||||
Docker: &unifiedresources.DockerData{
|
||||
HostSourceID: "docker-host-1",
|
||||
ContainerID: "container-new",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := monitor.applyDockerMetadataToUnifiedResources(resources)
|
||||
if got[0].CustomURL != "" {
|
||||
t.Fatalf("CustomURL = %q, want stable empty guest metadata to block legacy fallback", got[0].CustomURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDockerReportComputesContainerNetworkAndDiskRates(t *testing.T) {
|
||||
monitor := newTestMonitor(t)
|
||||
baseTime := time.Now().UTC()
|
||||
|
|
|
|||
|
|
@ -253,6 +253,52 @@ func TestApplyDockerReportNormalizesContainerCPUCapacityAcceptedIngestProof(t *t
|
|||
}
|
||||
}
|
||||
|
||||
func TestApplyDockerReportMigratesAppContainerURLToStableNameAcceptedIngestProof(t *testing.T) {
|
||||
monitor := newTestMonitor(t)
|
||||
report := agentsdocker.Report{
|
||||
Agent: agentsdocker.AgentInfo{
|
||||
ID: "docker-url-agent",
|
||||
Version: "1.0.0",
|
||||
IntervalSeconds: 30,
|
||||
},
|
||||
Host: agentsdocker.HostInfo{
|
||||
Hostname: "docker-url-host",
|
||||
MachineID: "docker-url-machine",
|
||||
},
|
||||
Containers: []agentsdocker.Container{{
|
||||
ID: "container-runtime-old",
|
||||
Name: "/homepage",
|
||||
}},
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
|
||||
host, err := monitor.ApplyDockerReport(report, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("initial ApplyDockerReport: %v", err)
|
||||
}
|
||||
|
||||
legacyKey := dockerAppContainerLegacyResourceID(host.ID, "container-runtime-old")
|
||||
if err := monitor.guestMetadataStore.Set(legacyKey, &config.GuestMetadata{
|
||||
CustomURL: "https://homepage.internal",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed legacy guest metadata: %v", err)
|
||||
}
|
||||
|
||||
report.Timestamp = report.Timestamp.Add(30 * time.Second)
|
||||
if _, err := monitor.ApplyDockerReport(report, nil); err != nil {
|
||||
t.Fatalf("metadata migration ApplyDockerReport: %v", err)
|
||||
}
|
||||
|
||||
stableKey := dockerAppContainerMetadataKey(host.ID, "homepage")
|
||||
stableMeta := monitor.guestMetadataStore.Get(stableKey)
|
||||
if stableMeta == nil {
|
||||
t.Fatalf("expected stable metadata at %q", stableKey)
|
||||
}
|
||||
if stableMeta.CustomURL != "https://homepage.internal" {
|
||||
t.Fatalf("stable CustomURL = %q, want legacy URL", stableMeta.CustomURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitor_HostAgentConfigUpdatePreservesReportedCommandStateInHostState(t *testing.T) {
|
||||
monitor := &Monitor{
|
||||
state: models.NewState(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue