diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 18bdc59..c80c8d5 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -1,6 +1,7 @@ package server import ( + "archive/zip" "bytes" "context" "crypto/rand" @@ -47,7 +48,8 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { - if err := r.ParseMultipartForm(uploadMultipartMaxBytes()); err != nil { + r.Body = http.MaxBytesReader(w, r.Body, uploadMultipartMaxBytes()) + if err := r.ParseMultipartForm(uploadMultipartFormMemoryBytes()); err != nil { jsonError(w, "File too large", http.StatusBadRequest) return } @@ -70,61 +72,16 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { vendor string ) - if rawPkg, ok, err := parseRawExportBundle(payload); err != nil { - jsonError(w, "Failed to parse raw export bundle: "+err.Error(), http.StatusBadRequest) + result, vendor, rawPkg, err := s.analyzeUploadedFile(header.Filename, header.Header.Get("Content-Type"), payload) + if err != nil { + jsonError(w, "Failed to parse uploaded file: "+err.Error(), http.StatusBadRequest) return - } else if ok { - replayed, replayVendor, replayErr := s.reanalyzeRawExportPackage(rawPkg) - if replayErr != nil { - jsonError(w, "Failed to reanalyze raw export package: "+replayErr.Error(), http.StatusBadRequest) - return - } - result = replayed - vendor = replayVendor - if strings.TrimSpace(vendor) == "" { - vendor = "snapshot" - } + } + if strings.TrimSpace(vendor) == "" { + vendor = "snapshot" + } + if rawPkg != nil { s.SetRawExport(rawPkg) - } else if looksLikeJSONSnapshot(header.Filename, payload) { - if rawPkg, ok, err := parseRawExportPackage(payload); err != nil { - jsonError(w, "Failed to parse raw export package: "+err.Error(), http.StatusBadRequest) - return - } else if ok { - replayed, replayVendor, replayErr := s.reanalyzeRawExportPackage(rawPkg) - if replayErr != nil { - jsonError(w, "Failed to reanalyze raw export package: "+replayErr.Error(), http.StatusBadRequest) - return - } - result = replayed - vendor = replayVendor - if strings.TrimSpace(vendor) == "" { - vendor = "snapshot" - } - s.SetRawExport(rawPkg) - } else { - 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" - } - s.SetRawExport(newRawExportFromUploadedFile(header.Filename, header.Header.Get("Content-Type"), payload, result)) - } - } 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.SetRawExport(newRawExportFromUploadedFile(header.Filename, header.Header.Get("Content-Type"), payload, result)) } s.SetResult(result) @@ -143,13 +100,60 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) analyzeUploadedFile(filename, mimeType string, payload []byte) (*models.AnalysisResult, string, *RawExportPackage, error) { + if rawPkg, ok, err := parseRawExportBundle(payload); err != nil { + return nil, "", nil, err + } else if ok { + result, vendor, err := s.reanalyzeRawExportPackage(rawPkg) + if err != nil { + return nil, "", nil, err + } + if strings.TrimSpace(vendor) == "" { + vendor = "snapshot" + } + return result, vendor, rawPkg, nil + } + + if looksLikeJSONSnapshot(filename, payload) { + if rawPkg, ok, err := parseRawExportPackage(payload); err != nil { + return nil, "", nil, err + } else if ok { + result, vendor, err := s.reanalyzeRawExportPackage(rawPkg) + if err != nil { + return nil, "", nil, err + } + if strings.TrimSpace(vendor) == "" { + vendor = "snapshot" + } + return result, vendor, rawPkg, nil + } + + snapshotResult, err := parseUploadedSnapshot(payload) + if err != nil { + return nil, "", nil, err + } + vendor := strings.TrimSpace(snapshotResult.Protocol) + if vendor == "" { + vendor = "snapshot" + } + return snapshotResult, vendor, newRawExportFromUploadedFile(filename, mimeType, payload, snapshotResult), nil + } + + p := parser.NewBMCParser() + if err := p.ParseFromReader(bytes.NewReader(payload), filename); err != nil { + return nil, "", nil, err + } + result := p.Result() + applyArchiveSourceMetadata(result) + return result, p.DetectedVendor(), newRawExportFromUploadedFile(filename, mimeType, payload, result), nil +} + func uploadMultipartMaxBytes() int64 { - // Large Redfish raw bundles can easily exceed 100 MiB once raw trees and logs - // are embedded. Keep the default high but bounded for a normal workstation. + // Limit for incoming multipart request body. const ( - defMB = 512 + defMB = 2048 minMB = 100 - maxMB = 2048 + maxMB = 8192 ) mb := defMB if v := strings.TrimSpace(os.Getenv("LOGPILE_UPLOAD_MAX_MB")); v != "" { @@ -166,6 +170,12 @@ func uploadMultipartMaxBytes() int64 { return int64(mb) << 20 } +func uploadMultipartFormMemoryBytes() int64 { + // Keep a small in-memory threshold; file parts spill to temp files. + const formMemoryMB = 32 + return int64(formMemoryMB) << 20 +} + func (s *Server) reanalyzeRawExportPackage(pkg *RawExportPackage) (*models.AnalysisResult, string, error) { if pkg == nil { return nil, "", fmt.Errorf("empty package") @@ -1076,10 +1086,320 @@ func (s *Server) handleExportReanimator(w http.ResponseWriter, r *http.Request) } } +func (s *Server) handleConvertReanimatorBatch(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, uploadMultipartMaxBytes()) + if err := r.ParseMultipartForm(uploadMultipartFormMemoryBytes()); err != nil { + jsonError(w, "File too large", http.StatusBadRequest) + return + } + + form := r.MultipartForm + if form == nil { + jsonError(w, "No files provided", http.StatusBadRequest) + return + } + + files := form.File["files[]"] + if len(files) == 0 { + files = form.File["files"] + } + if len(files) == 0 { + jsonError(w, "No files provided", http.StatusBadRequest) + return + } + + tempDir, err := os.MkdirTemp("", "logpile-convert-input-*") + if err != nil { + jsonError(w, "Не удалось создать временную директорию", http.StatusInternalServerError) + return + } + + inputFiles := make([]convertInputFile, 0, len(files)) + var skipped int + for _, fh := range files { + if fh == nil { + continue + } + if !isSupportedConvertFileName(fh.Filename) { + skipped++ + continue + } + + tmpFile, err := os.CreateTemp(tempDir, "input-*") + if err != nil { + continue + } + src, err := fh.Open() + if err != nil { + _ = tmpFile.Close() + _ = os.Remove(tmpFile.Name()) + continue + } + _, err = io.Copy(tmpFile, src) + _ = src.Close() + _ = tmpFile.Close() + if err != nil { + _ = os.Remove(tmpFile.Name()) + continue + } + + mimeType := "" + if fh.Header != nil { + mimeType = fh.Header.Get("Content-Type") + } + + inputFiles = append(inputFiles, convertInputFile{ + Name: fh.Filename, + Path: tmpFile.Name(), + MIMEType: mimeType, + }) + } + + if len(inputFiles) == 0 { + _ = os.RemoveAll(tempDir) + jsonError(w, "Нет файлов поддерживаемого типа для конвертации", http.StatusBadRequest) + return + } + + job := s.jobManager.CreateJob(CollectRequest{ + Host: "convert.local", + Protocol: "convert", + Port: 0, + Username: "convert", + AuthType: "password", + TLSMode: "insecure", + }) + s.markConvertJob(job.ID) + s.jobManager.AppendJobLog(job.ID, fmt.Sprintf("Запущена пакетная конвертация: %d файлов", len(inputFiles))) + if skipped > 0 { + s.jobManager.AppendJobLog(job.ID, fmt.Sprintf("Пропущено неподдерживаемых файлов: %d", skipped)) + } + s.jobManager.UpdateJobStatus(job.ID, CollectStatusRunning, 0, "") + + go s.runConvertJob(job.ID, tempDir, inputFiles, skipped, len(files)) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]any{ + "job_id": job.ID, + "status": CollectStatusRunning, + "accepted": len(inputFiles), + "skipped": skipped, + "total_files": len(files), + }) +} + +type convertInputFile struct { + Name string + Path string + MIMEType string +} + +func (s *Server) runConvertJob(jobID, tempDir string, inputFiles []convertInputFile, skipped, total int) { + defer os.RemoveAll(tempDir) + + resultFile, err := os.CreateTemp("", "logpile-convert-result-*.zip") + if err != nil { + s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "не удалось создать zip") + return + } + resultPath := resultFile.Name() + defer resultFile.Close() + + zw := zip.NewWriter(resultFile) + failures := make([]string, 0) + success := 0 + totalProcess := len(inputFiles) + + for i, in := range inputFiles { + s.jobManager.AppendJobLog(jobID, fmt.Sprintf("Обработка %s", in.Name)) + payload, err := os.ReadFile(in.Path) + if err != nil { + failures = append(failures, fmt.Sprintf("%s: %v", in.Name, err)) + continue + } + + result, _, _, err := s.analyzeUploadedFile(in.Name, in.MIMEType, payload) + if err != nil { + failures = append(failures, fmt.Sprintf("%s: %v", in.Name, err)) + progress := ((i + 1) * 100) / totalProcess + s.jobManager.UpdateJobStatus(jobID, CollectStatusRunning, progress, "") + continue + } + if result == nil || result.Hardware == nil { + failures = append(failures, fmt.Sprintf("%s: no hardware data", in.Name)) + progress := ((i + 1) * 100) / totalProcess + s.jobManager.UpdateJobStatus(jobID, CollectStatusRunning, progress, "") + continue + } + + reanimatorData, err := exporter.ConvertToReanimator(result) + if err != nil { + failures = append(failures, fmt.Sprintf("%s: %v", in.Name, err)) + progress := ((i + 1) * 100) / totalProcess + s.jobManager.UpdateJobStatus(jobID, CollectStatusRunning, progress, "") + continue + } + + entryPath := sanitizeZipPath(in.Name) + entry, err := zw.Create(entryPath + ".reanimator.json") + if err != nil { + failures = append(failures, fmt.Sprintf("%s: %v", in.Name, err)) + progress := ((i + 1) * 100) / totalProcess + s.jobManager.UpdateJobStatus(jobID, CollectStatusRunning, progress, "") + continue + } + + encoder := json.NewEncoder(entry) + encoder.SetIndent("", " ") + if err := encoder.Encode(reanimatorData); err != nil { + failures = append(failures, fmt.Sprintf("%s: %v", in.Name, err)) + } else { + success++ + } + + progress := ((i + 1) * 100) / totalProcess + s.jobManager.UpdateJobStatus(jobID, CollectStatusRunning, progress, "") + } + + if success == 0 { + _ = zw.Close() + _ = os.Remove(resultPath) + s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Не удалось конвертировать ни один файл") + return + } + + summaryLines := []string{fmt.Sprintf("Конвертировано %d из %d файлов", success, total)} + if skipped > 0 { + summaryLines = append(summaryLines, fmt.Sprintf("Пропущено неподдерживаемых: %d", skipped)) + } + summaryLines = append(summaryLines, failures...) + if entry, err := zw.Create("convert-summary.txt"); err == nil { + _, _ = io.WriteString(entry, strings.Join(summaryLines, "\n")) + } + if err := zw.Close(); err != nil { + _ = os.Remove(resultPath) + s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Не удалось упаковать результаты") + return + } + + s.setConvertArtifact(jobID, ConvertArtifact{ + Path: resultPath, + Summary: summaryLines[0], + }) + s.jobManager.UpdateJobStatus(jobID, CollectStatusSuccess, 100, "") +} + +func (s *Server) handleConvertStatus(w http.ResponseWriter, r *http.Request) { + jobID := strings.TrimSpace(r.PathValue("id")) + if !isValidCollectJobID(jobID) { + jsonError(w, "Invalid convert job id", http.StatusBadRequest) + return + } + if !s.isConvertJob(jobID) { + jsonError(w, "Convert job not found", http.StatusNotFound) + return + } + + job, ok := s.jobManager.GetJob(jobID) + if !ok { + jsonError(w, "Convert job not found", http.StatusNotFound) + return + } + jsonResponse(w, job.toStatusResponse()) +} + +func (s *Server) handleConvertDownload(w http.ResponseWriter, r *http.Request) { + jobID := strings.TrimSpace(r.PathValue("id")) + if !isValidCollectJobID(jobID) { + jsonError(w, "Invalid convert job id", http.StatusBadRequest) + return + } + if !s.isConvertJob(jobID) { + jsonError(w, "Convert job not found", http.StatusNotFound) + return + } + + job, ok := s.jobManager.GetJob(jobID) + if !ok { + jsonError(w, "Convert job not found", http.StatusNotFound) + return + } + if job.Status != CollectStatusSuccess { + jsonError(w, "Convert job is not finished yet", http.StatusConflict) + return + } + + artifact, ok := s.getConvertArtifact(jobID) + if !ok || strings.TrimSpace(artifact.Path) == "" { + jsonError(w, "Convert result not found", http.StatusNotFound) + return + } + + file, err := os.Open(artifact.Path) + if err != nil { + jsonError(w, "Convert result not found", http.StatusNotFound) + return + } + defer file.Close() + defer func() { + _ = os.Remove(artifact.Path) + s.clearConvertArtifact(jobID) + }() + + stat, err := file.Stat() + if err != nil { + jsonError(w, "Convert result not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", "logpile-convert.zip")) + if artifact.Summary != "" { + w.Header().Set("X-Convert-Summary", artifact.Summary) + } + http.ServeContent(w, r, "logpile-convert.zip", stat.ModTime(), file) +} + +func isSupportedConvertFileName(filename string) bool { + name := strings.ToLower(strings.TrimSpace(filename)) + if name == "" { + return false + } + return strings.HasSuffix(name, ".zip") || + strings.HasSuffix(name, ".tar") || + strings.HasSuffix(name, ".tar.gz") || + strings.HasSuffix(name, ".tgz") || + strings.HasSuffix(name, ".json") || + strings.HasSuffix(name, ".txt") || + strings.HasSuffix(name, ".log") +} + +func sanitizeZipPath(filename string) string { + path := filepath.Clean(filename) + if path == "." || path == "/" { + path = filepath.Base(filename) + } + path = strings.TrimPrefix(path, string(filepath.Separator)) + if strings.HasPrefix(path, "..") { + path = filepath.Base(path) + } + path = filepath.ToSlash(path) + if path == "" { + path = filepath.Base(filename) + } + return path +} + func (s *Server) handleClear(w http.ResponseWriter, r *http.Request) { s.SetResult(nil) s.SetDetectedVendor("") s.SetRawExport(nil) + for _, artifact := range s.clearAllConvertArtifacts() { + if strings.TrimSpace(artifact.Path) != "" { + _ = os.Remove(artifact.Path) + } + } jsonResponse(w, map[string]string{ "status": "ok", "message": "Data cleared", diff --git a/internal/server/server.go b/internal/server/server.go index e7d8ade..8c5eb64 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -32,17 +32,26 @@ type Server struct { result *models.AnalysisResult detectedVendor string rawExport *RawExportPackage + convertJobs map[string]struct{} + convertOutput map[string]ConvertArtifact jobManager *JobManager collectors *collector.Registry } +type ConvertArtifact struct { + Path string + Summary string +} + func New(cfg Config) *Server { s := &Server{ - config: cfg, - mux: http.NewServeMux(), - jobManager: NewJobManager(), - collectors: collector.NewDefaultRegistry(), + config: cfg, + mux: http.NewServeMux(), + jobManager: NewJobManager(), + collectors: collector.NewDefaultRegistry(), + convertJobs: make(map[string]struct{}), + convertOutput: make(map[string]ConvertArtifact), } s.setupRoutes() return s @@ -72,6 +81,9 @@ func (s *Server) setupRoutes() { s.mux.HandleFunc("GET /api/export/csv", s.handleExportCSV) s.mux.HandleFunc("GET /api/export/json", s.handleExportJSON) s.mux.HandleFunc("GET /api/export/reanimator", s.handleExportReanimator) + s.mux.HandleFunc("POST /api/convert", s.handleConvertReanimatorBatch) + s.mux.HandleFunc("GET /api/convert/{id}", s.handleConvertStatus) + s.mux.HandleFunc("GET /api/convert/{id}/download", s.handleConvertDownload) s.mux.HandleFunc("DELETE /api/clear", s.handleClear) s.mux.HandleFunc("POST /api/shutdown", s.handleShutdown) s.mux.HandleFunc("POST /api/collect", s.handleCollectStart) @@ -154,3 +166,47 @@ func (s *Server) GetDetectedVendor() string { defer s.mu.RUnlock() return s.detectedVendor } + +func (s *Server) markConvertJob(id string) { + s.mu.Lock() + defer s.mu.Unlock() + s.convertJobs[id] = struct{}{} +} + +func (s *Server) isConvertJob(id string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + _, ok := s.convertJobs[id] + return ok +} + +func (s *Server) setConvertArtifact(id string, artifact ConvertArtifact) { + s.mu.Lock() + defer s.mu.Unlock() + s.convertOutput[id] = artifact +} + +func (s *Server) getConvertArtifact(id string) (ConvertArtifact, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + artifact, ok := s.convertOutput[id] + return artifact, ok +} + +func (s *Server) clearConvertArtifact(id string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.convertOutput, id) +} + +func (s *Server) clearAllConvertArtifacts() []ConvertArtifact { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]ConvertArtifact, 0, len(s.convertOutput)) + for _, artifact := range s.convertOutput { + out = append(out, artifact) + } + s.convertOutput = make(map[string]ConvertArtifact) + s.convertJobs = make(map[string]struct{}) + return out +} diff --git a/web/static/css/style.css b/web/static/css/style.css index 7bbdfb2..d4776c6 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -84,13 +84,6 @@ main { } .upload-area button { - background: #3498db; - color: white; - border: none; - padding: 0.75rem 1.5rem; - border-radius: 4px; - cursor: pointer; - font-size: 1rem; margin: 1rem 0; } @@ -166,6 +159,9 @@ main { .api-form-actions { margin-top: 0.9rem; + display: flex; + flex-wrap: wrap; + gap: 0.6rem; } #api-connect-form.is-disabled { @@ -173,19 +169,39 @@ main { pointer-events: none; } -#api-connect-btn { +#api-connect-btn, +#convert-folder-btn, +#convert-run-btn, +#cancel-job-btn, +.upload-area button { background: #3498db; - color: white; + color: #fff; border: none; - padding: 0.6rem 1.2rem; - border-radius: 4px; + border-radius: 6px; + padding: 0.6rem 1rem; + font-size: 0.95rem; + font-weight: 600; cursor: pointer; + transition: background-color 0.2s ease, opacity 0.2s ease; } -#api-connect-btn:hover { +#api-connect-btn:hover, +#convert-folder-btn:hover, +#convert-run-btn:hover, +#cancel-job-btn:hover, +.upload-area button:hover { background: #2980b9; } +#convert-run-btn:disabled, +#convert-folder-btn:disabled, +#api-connect-btn:disabled, +#cancel-job-btn:disabled, +.upload-area button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + .api-connect-status { margin-top: 0.75rem; font-size: 0.85rem; @@ -199,6 +215,38 @@ main { color: #dc3545; } +.api-connect-status.info { + color: #0f4dba; +} + +.convert-progress { + margin-top: 0.9rem; +} + +.convert-progress-meta { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.35rem; + font-size: 0.82rem; + color: #475569; +} + +.convert-progress-track { + height: 12px; + border-radius: 999px; + border: 1px solid #cbd5e1; + background: #e2e8f0; + overflow: hidden; +} + +.convert-progress-bar { + height: 100%; + width: 0%; + background: linear-gradient(90deg, #2563eb, #0ea5e9); + transition: width 0.2s ease; +} + .job-status { margin-top: 1rem; border: 1px solid #d0d7de; @@ -220,15 +268,6 @@ main { color: #2c3e50; } -#cancel-job-btn { - background: #dc3545; - color: #fff; - border: none; - border-radius: 4px; - padding: 0.45rem 0.75rem; - cursor: pointer; -} - #cancel-job-btn:disabled { background: #9ca3af; cursor: default; diff --git a/web/static/js/app.js b/web/static/js/app.js index 9e7015d..c88d58a 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -4,12 +4,15 @@ document.addEventListener('DOMContentLoaded', () => { initSourceType(); initApiSource(); initUpload(); + initConvertMode(); initTabs(); initFilters(); loadParsersInfo(); }); let sourceType = 'archive'; +let convertFiles = []; +let isConvertRunning = false; let apiConnectPayload = null; let collectionJob = null; let collectionJobPollTimer = null; @@ -29,7 +32,13 @@ function initSourceType() { } function setSourceType(nextType) { - sourceType = nextType === 'api' ? 'api' : 'archive'; + if (nextType === 'api') { + sourceType = 'api'; + } else if (nextType === 'convert') { + sourceType = 'convert'; + } else { + sourceType = 'archive'; + } document.querySelectorAll('.source-switch-btn').forEach(button => { button.classList.toggle('active', button.dataset.sourceType === sourceType); @@ -37,8 +46,12 @@ function setSourceType(nextType) { const archiveContent = document.getElementById('archive-source-content'); const apiSourceContent = document.getElementById('api-source-content'); + const convertSourceContent = document.getElementById('convert-source-content'); archiveContent.classList.toggle('hidden', sourceType !== 'archive'); apiSourceContent.classList.toggle('hidden', sourceType !== 'api'); + if (convertSourceContent) { + convertSourceContent.classList.toggle('hidden', sourceType !== 'convert'); + } } function initApiSource() { @@ -599,6 +612,295 @@ async function uploadFile(file) { } } +function initConvertMode() { + const folderInput = document.getElementById('convert-folder-input'); + const runButton = document.getElementById('convert-run-btn'); + if (!folderInput || !runButton) { + return; + } + + folderInput.addEventListener('change', () => { + convertFiles = Array.from(folderInput.files || []).filter(file => file && file.name); + renderConvertSummary(); + }); + + runButton.addEventListener('click', async () => { + await runConvertBatch(); + }); + renderConvertSummary(); +} + +function renderConvertSummary() { + const summary = document.getElementById('convert-folder-summary'); + if (!summary) { + return; + } + + if (convertFiles.length === 0) { + summary.textContent = 'Выберите папку с файлами, включая вложенные каталоги.'; + summary.className = 'api-connect-status'; + return; + } + + const supportedFiles = convertFiles.filter(file => isSupportedConvertFileName(file.webkitRelativePath || file.name)); + const skippedCount = convertFiles.length - supportedFiles.length; + const previewCount = 5; + const previewFiles = supportedFiles.slice(0, previewCount).map(file => escapeHtml(file.webkitRelativePath || file.name)); + const remaining = supportedFiles.length - previewFiles.length; + const previewText = previewFiles.length > 0 ? `Примеры: ${previewFiles.join(', ')}` : ''; + const skippedText = skippedCount > 0 ? ` Пропущено неподдерживаемых: ${skippedCount}.` : ''; + + summary.innerHTML = `${supportedFiles.length} файлов готовы к конвертации.${previewText ? ` ${previewText}` : ''}${remaining > 0 ? ` и ещё ${remaining}` : ''}.${skippedText}`; + summary.className = 'api-connect-status'; +} + +async function runConvertBatch() { + const runButton = document.getElementById('convert-run-btn'); + if (!runButton || isConvertRunning) { + return; + } + if (convertFiles.length === 0) { + renderConvertStatus('Нет файлов для конвертации', 'error'); + return; + } + + const supportedFiles = convertFiles.filter(file => isSupportedConvertFileName(file.webkitRelativePath || file.name)); + if (supportedFiles.length === 0) { + renderConvertStatus('В выбранной папке нет файлов поддерживаемого типа', 'error'); + return; + } + + isConvertRunning = true; + runButton.disabled = true; + renderConvertProgress(0, 'Подготовка загрузки...'); + renderConvertStatus('Выполняю пакетную конвертацию...', 'info'); + + const formData = new FormData(); + supportedFiles.forEach(file => { + const relativePath = file.webkitRelativePath || file.name || 'file'; + formData.append('files[]', file, relativePath); + }); + + try { + const startResponse = await uploadConvertBatch(formData, (percent) => { + const uploadPercent = Math.round(percent * 0.3); + renderConvertProgress(uploadPercent, `Загрузка файлов: ${percent}%`); + }); + + if (!startResponse.ok) { + const errorPayload = parseConvertErrorPayload(startResponse.bodyText); + hideConvertProgress(); + renderConvertStatus(errorPayload.error || 'Пакетная конвертация завершилась с ошибкой', 'error'); + return; + } + + if (!startResponse.jobId) { + hideConvertProgress(); + renderConvertStatus('Сервер не вернул идентификатор задачи', 'error'); + return; + } + + await waitForConvertJob(startResponse.jobId, (statusPayload) => { + const serverProgress = Number(statusPayload.progress || 0); + const combined = 30 + Math.round(Math.max(0, Math.min(100, serverProgress)) * 0.7); + renderConvertProgress(combined, `Конвертация: ${serverProgress}%`); + }); + + renderConvertProgress(100, 'Подготовка выгрузки...'); + const downloadResponse = await downloadConvertArchive(startResponse.jobId); + if (!downloadResponse.ok) { + const errorPayload = parseConvertErrorPayload(downloadResponse.bodyText); + hideConvertProgress(); + renderConvertStatus(errorPayload.error || 'Не удалось скачать результат', 'error'); + return; + } + + const blob = downloadResponse.blob; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + downloadBlob(blob, `logpile-convert-${timestamp}.zip`); + const summary = downloadResponse.summaryHeader || 'Конвертация завершена'; + hideConvertProgress(); + renderConvertStatus(summary, 'success'); + } catch (err) { + hideConvertProgress(); + renderConvertStatus('Ошибка соединения при конвертации', 'error'); + } finally { + isConvertRunning = false; + runButton.disabled = false; + } +} + +function uploadConvertBatch(formData, onUploadPercent) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/convert'); + xhr.responseType = 'text'; + + xhr.upload.addEventListener('progress', (event) => { + if (!event.lengthComputable) { + return; + } + const percent = Math.max(0, Math.min(100, Math.round((event.loaded / event.total) * 100))); + onUploadPercent(percent); + }); + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + let body = {}; + try { + body = JSON.parse(xhr.responseText || '{}'); + } catch (err) { + body = {}; + } + resolve({ + ok: true, + status: xhr.status, + jobId: body.job_id || '' + }); + return; + } + resolve({ + ok: false, + status: xhr.status, + bodyText: xhr.responseText || '' + }); + }); + + xhr.addEventListener('error', () => { + reject(new Error('network')); + }); + + xhr.send(formData); + }); +} + +async function waitForConvertJob(jobId, onProgress) { + while (true) { + const response = await fetch(`/api/convert/${encodeURIComponent(jobId)}`); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload.error || 'Не удалось получить статус конвертации'); + } + + if (onProgress) { + onProgress(payload); + } + + const status = String(payload.status || '').toLowerCase(); + if (status === 'success') { + return payload; + } + if (status === 'failed' || status === 'canceled') { + throw new Error(payload.error || 'Конвертация завершилась ошибкой'); + } + + await delay(900); + } +} + +async function downloadConvertArchive(jobId) { + const response = await fetch(`/api/convert/${encodeURIComponent(jobId)}/download`); + if (!response.ok) { + return { + ok: false, + bodyText: await response.text().catch(() => '') + }; + } + return { + ok: true, + blob: await response.blob(), + summaryHeader: response.headers.get('X-Convert-Summary') || '' + }; +} + +function delay(ms) { + return new Promise((resolve) => { + window.setTimeout(resolve, ms); + }); +} + +function parseConvertErrorPayload(bodyText) { + if (!bodyText) { + return {}; + } + try { + return JSON.parse(bodyText); + } catch (err) { + return {}; + } +} + +function isSupportedConvertFileName(filename) { + const name = String(filename || '').trim().toLowerCase(); + if (!name) { + return false; + } + return ( + name.endsWith('.zip') || + name.endsWith('.tar') || + name.endsWith('.tar.gz') || + name.endsWith('.tgz') || + name.endsWith('.json') || + name.endsWith('.txt') || + name.endsWith('.log') + ); +} + +function renderConvertStatus(message, status) { + const statusNode = document.getElementById('convert-status'); + if (!statusNode) { + return; + } + + statusNode.textContent = message || ''; + statusNode.className = 'api-connect-status'; + if (status === 'success') { + statusNode.classList.add('success'); + } else if (status === 'error') { + statusNode.classList.add('error'); + } else if (status === 'info') { + statusNode.classList.add('info'); + } +} + +function renderConvertProgress(percent, label) { + const wrap = document.getElementById('convert-progress'); + const bar = document.getElementById('convert-progress-bar'); + const value = document.getElementById('convert-progress-value'); + const text = document.getElementById('convert-progress-label'); + if (!wrap || !bar || !value || !text) { + return; + } + + const safePercent = Math.max(0, Math.min(100, Number(percent) || 0)); + wrap.classList.remove('hidden'); + bar.style.width = `${safePercent}%`; + value.textContent = `${safePercent}%`; + text.textContent = label || 'Выполняется...'; +} + +function hideConvertProgress() { + const wrap = document.getElementById('convert-progress'); + if (!wrap) { + return; + } + wrap.classList.add('hidden'); +} + +function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.style.display = 'none'; + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.setTimeout(() => { + URL.revokeObjectURL(url); + }, 3000); +} + // Tab navigation function initTabs() { const tabs = document.querySelectorAll('.tab'); diff --git a/web/templates/index.html b/web/templates/index.html index 64df95e..d0f1383 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -17,6 +17,7 @@
+
@@ -90,6 +91,27 @@
+ +