package server import ( "bytes" "context" "crypto/rand" "encoding/json" "fmt" "html/template" "io" "net/http" "os" "path/filepath" "regexp" "sort" "strings" "time" "git.mchus.pro/mchus/logpile/internal/collector" "git.mchus.pro/mchus/logpile/internal/exporter" "git.mchus.pro/mchus/logpile/internal/models" "git.mchus.pro/mchus/logpile/internal/parser" ) func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } tmplContent, err := WebFS.ReadFile("templates/index.html") if err != nil { http.Error(w, "Template not found", http.StatusInternalServerError) return } tmpl, err := template.New("index").Parse(string(tmplContent)) if err != nil { http.Error(w, "Template parse error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl.Execute(w, nil) } func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { // Max 100MB file if err := r.ParseMultipartForm(100 << 20); err != nil { jsonError(w, "File too large", http.StatusBadRequest) return } file, header, err := r.FormFile("archive") if err != nil { jsonError(w, "Failed to read file", http.StatusBadRequest) return } defer file.Close() payload, err := io.ReadAll(file) if err != nil { jsonError(w, "Failed to read file", http.StatusBadRequest) return } var ( result *models.AnalysisResult vendor string ) if looksLikeJSONSnapshot(header.Filename, payload) { snapshotResult, snapshotErr := parseUploadedSnapshot(payload) if snapshotErr != nil { jsonError(w, "Failed to parse snapshot: "+snapshotErr.Error(), http.StatusBadRequest) return } result = snapshotResult vendor = strings.TrimSpace(snapshotResult.Protocol) if vendor == "" { vendor = "snapshot" } } else { // Parse archive p := parser.NewBMCParser() if err := p.ParseFromReader(bytes.NewReader(payload), header.Filename); err != nil { jsonError(w, "Failed to parse archive: "+err.Error(), http.StatusBadRequest) return } result = p.Result() applyArchiveSourceMetadata(result) vendor = p.DetectedVendor() } s.SetResult(result) s.SetDetectedVendor(vendor) jsonResponse(w, map[string]interface{}{ "status": "ok", "message": "File uploaded and parsed successfully", "filename": header.Filename, "vendor": vendor, "stats": map[string]int{ "events": len(result.Events), "sensors": len(result.Sensors), "fru": len(result.FRU), }, }) } func (s *Server) handleGetParsers(w http.ResponseWriter, r *http.Request) { jsonResponse(w, map[string]interface{}{ "parsers": parser.ListParsersInfo(), }) } func (s *Server) handleGetEvents(w http.ResponseWriter, r *http.Request) { result := s.GetResult() if result == nil { jsonResponse(w, []interface{}{}) return } // Sort events by timestamp (newest first) events := make([]models.Event, len(result.Events)) copy(events, result.Events) // Sort in descending order using sort.Slice (newest first) sort.Slice(events, func(i, j int) bool { return events[i].Timestamp.After(events[j].Timestamp) }) jsonResponse(w, events) } func (s *Server) handleGetSensors(w http.ResponseWriter, r *http.Request) { result := s.GetResult() if result == nil { jsonResponse(w, []interface{}{}) return } jsonResponse(w, result.Sensors) } func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) { result := s.GetResult() if result == nil { jsonResponse(w, map[string]interface{}{}) return } response := map[string]interface{}{ "source_type": result.SourceType, "protocol": result.Protocol, "target_host": result.TargetHost, "collected_at": result.CollectedAt, } if result.Hardware == nil { response["hardware"] = map[string]interface{}{} response["specification"] = []SpecLine{} jsonResponse(w, response) return } // Build specification summary spec := buildSpecification(result) response["hardware"] = result.Hardware response["specification"] = spec jsonResponse(w, response) } // SpecLine represents a single line in specification type SpecLine struct { Category string `json:"category"` Name string `json:"name"` Quantity int `json:"quantity"` } func buildSpecification(result *models.AnalysisResult) []SpecLine { var spec []SpecLine hw := result.Hardware if hw == nil { return spec } // CPUs - group by model cpuGroups := make(map[string]int) cpuDetails := make(map[string]models.CPU) for _, cpu := range hw.CPUs { cpuGroups[cpu.Model]++ cpuDetails[cpu.Model] = cpu } for model, count := range cpuGroups { cpu := cpuDetails[model] name := fmt.Sprintf("Intel %s (%.1fGHz %dC %dW)", model, float64(cpu.FrequencyMHz)/1000, cpu.Cores, cpu.TDP) spec = append(spec, SpecLine{Category: "Процессор", Name: name, Quantity: count}) } // Memory - group by size, type and frequency (only installed modules) memGroups := make(map[string]int) for _, mem := range hw.Memory { // Skip empty slots (not present or 0 size) if !mem.Present || mem.SizeMB == 0 { continue } // Include frequency if available key := "" if mem.CurrentSpeedMHz > 0 { key = fmt.Sprintf("%s %dGB %dMHz", mem.Type, mem.SizeMB/1024, mem.CurrentSpeedMHz) } else { key = fmt.Sprintf("%s %dGB", mem.Type, mem.SizeMB/1024) } memGroups[key]++ } for key, count := range memGroups { spec = append(spec, SpecLine{Category: "Память", Name: key, Quantity: count}) } // Storage - group by type and capacity storGroups := make(map[string]int) for _, stor := range hw.Storage { var key string if stor.SizeGB >= 1000 { key = fmt.Sprintf("%s %s %.2fTB", stor.Type, stor.Interface, float64(stor.SizeGB)/1000) } else { key = fmt.Sprintf("%s %s %dGB", stor.Type, stor.Interface, stor.SizeGB) } storGroups[key]++ } for key, count := range storGroups { spec = append(spec, SpecLine{Category: "Накопитель", Name: key, Quantity: count}) } // PCIe devices - group by device class/name and manufacturer pcieGroups := make(map[string]int) pcieDetails := make(map[string]models.PCIeDevice) for _, pcie := range hw.PCIeDevices { // Create unique key from manufacturer + device class/name key := pcie.DeviceClass if pcie.Manufacturer != "" { key = pcie.Manufacturer + " " + pcie.DeviceClass } if pcie.PartNumber != "" && pcie.PartNumber != pcie.DeviceClass { key = key + " (" + pcie.PartNumber + ")" } pcieGroups[key]++ pcieDetails[key] = pcie } for key, count := range pcieGroups { pcie := pcieDetails[key] category := "PCIe устройство" name := key // Determine category based on device class or known GPU names deviceClass := pcie.DeviceClass isGPU := isGPUDevice(deviceClass) isNetwork := deviceClass == "Network" || strings.Contains(deviceClass, "ConnectX") if isGPU { category = "Графический процессор" } else if isNetwork { category = "Сетевой адаптер" } else if deviceClass == "NVMe" || deviceClass == "RAID" || deviceClass == "SAS" || deviceClass == "SATA" || deviceClass == "Storage" { category = "Контроллер" } spec = append(spec, SpecLine{Category: category, Name: name, Quantity: count}) } // Power supplies - group by model/wattage psuGroups := make(map[string]int) for _, psu := range hw.PowerSupply { key := psu.Model if key == "" && psu.WattageW > 0 { key = fmt.Sprintf("%dW", psu.WattageW) } if key != "" { psuGroups[key]++ } } for key, count := range psuGroups { spec = append(spec, SpecLine{Category: "Блок питания", Name: key, Quantity: count}) } return spec } func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) { result := s.GetResult() if result == nil { jsonResponse(w, []interface{}{}) return } // Collect all serial numbers from various sources type SerialEntry struct { Component string `json:"component"` Location string `json:"location,omitempty"` SerialNumber string `json:"serial_number"` Manufacturer string `json:"manufacturer,omitempty"` PartNumber string `json:"part_number,omitempty"` Category string `json:"category"` } var serials []SerialEntry // From FRU for _, fru := range result.FRU { if fru.SerialNumber == "" { continue } name := fru.ProductName if name == "" { name = fru.Description } serials = append(serials, SerialEntry{ Component: name, SerialNumber: fru.SerialNumber, Manufacturer: fru.Manufacturer, PartNumber: fru.PartNumber, Category: "FRU", }) } // From Hardware if result.Hardware != nil { // Board if result.Hardware.BoardInfo.SerialNumber != "" { serials = append(serials, SerialEntry{ Component: result.Hardware.BoardInfo.ProductName, SerialNumber: result.Hardware.BoardInfo.SerialNumber, Manufacturer: result.Hardware.BoardInfo.Manufacturer, PartNumber: result.Hardware.BoardInfo.PartNumber, Category: "Board", }) } // CPUs for _, cpu := range result.Hardware.CPUs { sn := cpu.SerialNumber if sn == "" { sn = cpu.PPIN // Use PPIN as fallback identifier } if sn == "" { continue } serials = append(serials, SerialEntry{ Component: cpu.Model, Location: fmt.Sprintf("CPU%d", cpu.Socket), SerialNumber: sn, Category: "CPU", }) } // Memory DIMMs for _, mem := range result.Hardware.Memory { if mem.SerialNumber == "" { continue } location := mem.Location if location == "" { location = mem.Slot } serials = append(serials, SerialEntry{ Component: mem.PartNumber, Location: location, SerialNumber: mem.SerialNumber, Manufacturer: mem.Manufacturer, PartNumber: mem.PartNumber, Category: "Memory", }) } // Storage for _, stor := range result.Hardware.Storage { if stor.SerialNumber == "" { continue } serials = append(serials, SerialEntry{ Component: stor.Model, Location: stor.Slot, SerialNumber: stor.SerialNumber, Manufacturer: stor.Manufacturer, Category: "Storage", }) } // PCIe devices for _, pcie := range result.Hardware.PCIeDevices { if pcie.SerialNumber == "" { continue } serials = append(serials, SerialEntry{ Component: pcie.DeviceClass, Location: pcie.Slot, SerialNumber: pcie.SerialNumber, Manufacturer: pcie.Manufacturer, PartNumber: pcie.PartNumber, Category: "PCIe", }) } // Network cards for _, nic := range result.Hardware.NetworkCards { if nic.SerialNumber == "" { continue } serials = append(serials, SerialEntry{ Component: nic.Model, SerialNumber: nic.SerialNumber, Category: "Network", }) } // Power supplies for _, psu := range result.Hardware.PowerSupply { if psu.SerialNumber == "" { continue } serials = append(serials, SerialEntry{ Component: psu.Model, Location: psu.Slot, SerialNumber: psu.SerialNumber, Manufacturer: psu.Vendor, Category: "PSU", }) } // Firmware (using version as "serial number" for display) for _, fw := range result.Hardware.Firmware { serials = append(serials, SerialEntry{ Component: fw.DeviceName, SerialNumber: fw.Version, Category: "Firmware", }) } } jsonResponse(w, serials) } func (s *Server) handleGetFirmware(w http.ResponseWriter, r *http.Request) { result := s.GetResult() if result == nil || result.Hardware == nil { jsonResponse(w, []interface{}{}) return } // Deduplicate firmware by extracting model name and version // E.g., "PSU0 (AP-CR3000F12BY)" and "PSU1 (AP-CR3000F12BY)" with same version -> one entry type FirmwareEntry struct { Component string `json:"component"` Model string `json:"model"` Version string `json:"version"` } seen := make(map[string]bool) var deduplicated []FirmwareEntry for _, fw := range result.Hardware.Firmware { // Extract component type and model from device name component, model := extractFirmwareComponentAndModel(fw.DeviceName) key := component + "|" + model + "|" + fw.Version if !seen[key] { seen[key] = true deduplicated = append(deduplicated, FirmwareEntry{ Component: component, Model: model, Version: fw.Version, }) } } jsonResponse(w, deduplicated) } // extractFirmwareComponentAndModel extracts the component type and model from firmware device name func extractFirmwareComponentAndModel(deviceName string) (component, model string) { // Parse different firmware name formats and extract component + model // For "PSU0 (AP-CR3000F12BY)" -> component: "PSU", model: "AP-CR3000F12BY" if strings.HasPrefix(deviceName, "PSU") { if idx := strings.Index(deviceName, "("); idx != -1 { model = strings.Trim(deviceName[idx:], "()") return "PSU", model } return "PSU", "-" } // For "CPU0 Microcode" -> component: "CPU Microcode", model: "-" if strings.HasPrefix(deviceName, "CPU") && strings.Contains(deviceName, "Microcode") { return "CPU Microcode", "-" } // For "NIC #CPU1_PCIE9 (MCX512A-ACAT)" -> component: "NIC", model: "MCX512A-ACAT" if strings.HasPrefix(deviceName, "NIC ") { if idx := strings.Index(deviceName, "("); idx != -1 { model = strings.Trim(deviceName[idx:], "()") return "NIC", model } return "NIC", "-" } // For "HDD Samsung MZ7L33T8HBNA-00A07" -> component: "HDD", model: "Samsung MZ7L33T8HBNA-00A07" if strings.HasPrefix(deviceName, "HDD ") { return "HDD", strings.TrimPrefix(deviceName, "HDD ") } // For "SSD Samsung MZ7..." -> component: "SSD", model: "Samsung MZ7..." if strings.HasPrefix(deviceName, "SSD ") { return "SSD", strings.TrimPrefix(deviceName, "SSD ") } // For "NVMe KIOXIA..." -> component: "NVMe", model: "KIOXIA..." if strings.HasPrefix(deviceName, "NVMe ") { return "NVMe", strings.TrimPrefix(deviceName, "NVMe ") } // For simple names like "BIOS", "ME", "BKC", "Virtual MicroCo" // component = name, model = "-" return deviceName, "-" } func (s *Server) handleGetStatus(w http.ResponseWriter, r *http.Request) { result := s.GetResult() if result == nil { jsonResponse(w, map[string]interface{}{ "loaded": false, }) return } jsonResponse(w, map[string]interface{}{ "loaded": true, "filename": result.Filename, "vendor": s.GetDetectedVendor(), "source_type": result.SourceType, "protocol": result.Protocol, "target_host": result.TargetHost, "collected_at": result.CollectedAt, "stats": map[string]int{ "events": len(result.Events), "sensors": len(result.Sensors), "fru": len(result.FRU), }, }) } func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) { result := s.GetResult() w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", exportFilename(result, "csv"))) exp := exporter.New(result) exp.ExportCSV(w) } func (s *Server) handleExportJSON(w http.ResponseWriter, r *http.Request) { result := s.GetResult() w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", exportFilename(result, "json"))) exp := exporter.New(result) exp.ExportJSON(w) } func (s *Server) handleExportTXT(w http.ResponseWriter, r *http.Request) { result := s.GetResult() w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", exportFilename(result, "txt"))) exp := exporter.New(result) exp.ExportTXT(w) } func (s *Server) handleClear(w http.ResponseWriter, r *http.Request) { s.SetResult(nil) s.SetDetectedVendor("") jsonResponse(w, map[string]string{ "status": "ok", "message": "Data cleared", }) } func (s *Server) handleShutdown(w http.ResponseWriter, r *http.Request) { jsonResponse(w, map[string]string{ "status": "ok", "message": "Server shutting down", }) // Shutdown in a goroutine so the response can be sent go func() { time.Sleep(100 * time.Millisecond) s.Shutdown() os.Exit(0) }() } func (s *Server) handleCollectStart(w http.ResponseWriter, r *http.Request) { var req CollectRequest decoder := json.NewDecoder(r.Body) decoder.DisallowUnknownFields() if err := decoder.Decode(&req); err != nil { jsonError(w, "Invalid JSON body", http.StatusBadRequest) return } if err := validateCollectRequest(req); err != nil { jsonError(w, err.Error(), http.StatusUnprocessableEntity) return } job := s.jobManager.CreateJob(req) s.startCollectionJob(job.ID, req) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusAccepted) _ = json.NewEncoder(w).Encode(job.toJobResponse("Collection job accepted")) } func (s *Server) handleCollectStatus(w http.ResponseWriter, r *http.Request) { jobID := strings.TrimSpace(r.PathValue("id")) if !isValidCollectJobID(jobID) { jsonError(w, "Invalid collect job id", http.StatusBadRequest) return } job, ok := s.jobManager.GetJob(jobID) if !ok { jsonError(w, "Collect job not found", http.StatusNotFound) return } jsonResponse(w, job.toStatusResponse()) } func (s *Server) handleCollectCancel(w http.ResponseWriter, r *http.Request) { jobID := strings.TrimSpace(r.PathValue("id")) if !isValidCollectJobID(jobID) { jsonError(w, "Invalid collect job id", http.StatusBadRequest) return } job, ok := s.jobManager.CancelJob(jobID) if !ok { jsonError(w, "Collect job not found", http.StatusNotFound) return } jsonResponse(w, job.toStatusResponse()) } func (s *Server) startCollectionJob(jobID string, req CollectRequest) { ctx, cancel := context.WithCancel(context.Background()) if attached := s.jobManager.AttachJobCancel(jobID, cancel); !attached { cancel() return } go func() { connector, ok := s.getCollector(req.Protocol) if !ok { s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Коннектор для протокола не зарегистрирован") s.jobManager.AppendJobLog(jobID, "Сбор завершен с ошибкой") return } emitProgress := func(update collector.Progress) { if job, ok := s.jobManager.GetJob(jobID); !ok || isTerminalCollectStatus(job.Status) { return } status := update.Status if status == "" { status = CollectStatusRunning } s.jobManager.UpdateJobStatus(jobID, status, update.Progress, "") if update.Message != "" { s.jobManager.AppendJobLog(jobID, update.Message) } } result, err := connector.Collect(ctx, toCollectorRequest(req), emitProgress) if err != nil { if ctx.Err() != nil { return } if job, ok := s.jobManager.GetJob(jobID); !ok || isTerminalCollectStatus(job.Status) { return } s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, err.Error()) s.jobManager.AppendJobLog(jobID, "Сбор завершен с ошибкой") return } if job, ok := s.jobManager.GetJob(jobID); !ok || isTerminalCollectStatus(job.Status) { return } applyCollectSourceMetadata(result, req) s.jobManager.UpdateJobStatus(jobID, CollectStatusSuccess, 100, "") s.jobManager.AppendJobLog(jobID, "Сбор завершен") s.SetResult(result) s.SetDetectedVendor(req.Protocol) }() } func validateCollectRequest(req CollectRequest) error { if strings.TrimSpace(req.Host) == "" { return fmt.Errorf("field 'host' is required") } switch req.Protocol { case "redfish", "ipmi": default: return fmt.Errorf("field 'protocol' must be one of: redfish, ipmi") } if req.Port < 1 || req.Port > 65535 { return fmt.Errorf("field 'port' must be in range 1..65535") } if strings.TrimSpace(req.Username) == "" { return fmt.Errorf("field 'username' is required") } switch req.AuthType { case "password": if strings.TrimSpace(req.Password) == "" { return fmt.Errorf("field 'password' is required when auth_type=password") } case "token": if strings.TrimSpace(req.Token) == "" { return fmt.Errorf("field 'token' is required when auth_type=token") } default: return fmt.Errorf("field 'auth_type' must be one of: password, token") } switch req.TLSMode { case "strict", "insecure": default: return fmt.Errorf("field 'tls_mode' must be one of: strict, insecure") } return nil } var collectJobIDPattern = regexp.MustCompile(`^job_[a-zA-Z0-9_-]{8,}$`) func isValidCollectJobID(id string) bool { return collectJobIDPattern.MatchString(id) } func generateJobID() string { buf := make([]byte, 8) if _, err := rand.Read(buf); err != nil { return fmt.Sprintf("job_%d", time.Now().UnixNano()) } return fmt.Sprintf("job_%x", buf) } func applyArchiveSourceMetadata(result *models.AnalysisResult) { if result == nil { return } result.SourceType = models.SourceTypeArchive result.Protocol = "" result.TargetHost = "" result.CollectedAt = time.Now().UTC() } func applyCollectSourceMetadata(result *models.AnalysisResult, req CollectRequest) { if result == nil { return } result.SourceType = models.SourceTypeAPI result.Protocol = req.Protocol result.TargetHost = req.Host result.CollectedAt = time.Now().UTC() if strings.TrimSpace(result.Filename) == "" { result.Filename = fmt.Sprintf("%s://%s", req.Protocol, req.Host) } } func toCollectorRequest(req CollectRequest) collector.Request { return collector.Request{ Host: req.Host, Protocol: req.Protocol, Port: req.Port, Username: req.Username, AuthType: req.AuthType, Password: req.Password, Token: req.Token, TLSMode: req.TLSMode, } } func looksLikeJSONSnapshot(filename string, payload []byte) bool { ext := strings.ToLower(filepath.Ext(filename)) if ext == ".json" { return true } trimmed := bytes.TrimSpace(payload) return len(trimmed) > 0 && (trimmed[0] == '{' || trimmed[0] == '[') } func parseUploadedSnapshot(payload []byte) (*models.AnalysisResult, error) { var result models.AnalysisResult if err := json.Unmarshal(payload, &result); err != nil { return nil, err } if result.Hardware == nil && len(result.Events) == 0 && len(result.Sensors) == 0 && len(result.FRU) == 0 { return nil, fmt.Errorf("unsupported snapshot format") } if strings.TrimSpace(result.SourceType) == "" { if result.Protocol != "" { result.SourceType = models.SourceTypeAPI } else { result.SourceType = models.SourceTypeArchive } } if result.CollectedAt.IsZero() { result.CollectedAt = time.Now().UTC() } if strings.TrimSpace(result.Filename) == "" { result.Filename = "uploaded_snapshot.json" } return &result, nil } func (s *Server) getCollector(protocol string) (collector.Connector, bool) { if s.collectors == nil { s.collectors = collector.NewDefaultRegistry() } return s.collectors.Get(protocol) } func jsonResponse(w http.ResponseWriter, data interface{}) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(data) } func jsonError(w http.ResponseWriter, message string, code int) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(map[string]string{"error": message}) } // isGPUDevice checks if device class indicates a GPU func isGPUDevice(deviceClass string) bool { // Standard PCI class names if deviceClass == "VGA" || deviceClass == "3D Controller" || deviceClass == "Display" { return true } // Known GPU model patterns gpuPatterns := []string{ "L40", "A100", "A10", "A16", "A30", "H100", "H200", "V100", "RTX", "GTX", "Quadro", "Tesla", "Instinct", "Radeon", "AST2500", "AST2600", // ASPEED BMC VGA } upperClass := strings.ToUpper(deviceClass) for _, pattern := range gpuPatterns { if strings.Contains(upperClass, strings.ToUpper(pattern)) { return true } } return false } func exportFilename(result *models.AnalysisResult, ext string) string { date := time.Now().UTC().Format("2006-01-02") model := "SERVER MODEL" sn := "SERVER SN" if result != nil { if !result.CollectedAt.IsZero() { date = result.CollectedAt.UTC().Format("2006-01-02") } if result.Hardware != nil { if m := strings.TrimSpace(result.Hardware.BoardInfo.ProductName); m != "" { model = m } if serial := strings.TrimSpace(result.Hardware.BoardInfo.SerialNumber); serial != "" { sn = serial } } } model = sanitizeFilenamePart(model) sn = sanitizeFilenamePart(sn) ext = strings.TrimPrefix(strings.TrimSpace(ext), ".") if ext == "" { ext = "txt" } return fmt.Sprintf("%s (%s) - %s.%s", date, model, sn, ext) } func sanitizeFilenamePart(v string) string { v = strings.TrimSpace(v) if v == "" { return "-" } replacer := strings.NewReplacer( "/", "_", "\\", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_", "\n", " ", "\r", " ", "\t", " ", ) v = replacer.Replace(v) v = strings.Join(strings.Fields(v), " ") if v == "" { return "-" } return v }