diff --git a/README.md b/README.md index c49b65b..f951350 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# omc +# omq -Oracle Multi Querier (omc) - программа для выгрузки результатов SQL-запроса с нескольких серверов Oracle. +Oracle Multi Querier (omq) - программа для выгрузки результатов SQL-запроса с нескольких серверов Oracle. ![Скриншот главного окна](doc/main-window.png) diff --git a/go.mod b/go.mod index 3709324..7f4d649 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.21.3 require ( github.com/dimchansky/utfbom v1.1.1 + github.com/gdamore/tcell/v2 v2.6.0 github.com/rivo/tview v0.0.0-20231115183240-7c9e464bac02 github.com/sijms/go-ora/v2 v2.7.21 github.com/xuri/excelize/v2 v2.8.0 @@ -13,7 +14,6 @@ require ( require ( github.com/gdamore/encoding v1.0.0 // indirect - github.com/gdamore/tcell/v2 v2.6.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect diff --git a/kernel.go b/kernel.go index 93baa2f..41e9aa5 100644 --- a/kernel.go +++ b/kernel.go @@ -14,7 +14,6 @@ import ( "github.com/dimchansky/utfbom" go_ora "github.com/sijms/go-ora/v2" - "gopkg.in/ini.v1" ) type Row struct { @@ -22,13 +21,34 @@ type Row struct { data []any } -func launch(configFilePath, scriptFilePath string, exportFileFormat ExportFormat, encoding Encoding) error { - sqlBytes, err := readFileIgnoreBOM(scriptFilePath) +type Job struct { + configFilePath string + scriptFilePath string + exportFileFormat ExportFormat + encoding Encoding + + servers []Server + status string + + isFinished bool +} + +func (j *Job) init() error { + j.status = "Чтение списка серверов..." + + servers, err := loadConfig(j.configFilePath) if err != nil { return err } - servers, err := loadConfig(configFilePath) + j.servers = servers + + return nil +} + +func (j *Job) launch() error { + j.status = "Чтение файла SQL-скрипта..." + sqlBytes, err := readFileIgnoreBOM(j.scriptFilePath) if err != nil { return err } @@ -38,9 +58,9 @@ func launch(configFilePath, scriptFilePath string, exportFileFormat ExportFormat return fmt.Errorf("Некорректное значение номера поля филиала: %v", branchFieldNum) } - rowsChan := iterateServers(servers, string(sqlBytes), branchFieldNum) + rowsChan := j.iterateServers(string(sqlBytes), branchFieldNum) - err = export(scriptFilePath, exportFileFormat, encoding, rowsChan) + err = j.export(rowsChan) if err != nil { return err } @@ -48,13 +68,13 @@ func launch(configFilePath, scriptFilePath string, exportFileFormat ExportFormat return nil } -func export(scriptFilePath string, exportFileFormat ExportFormat, encoding Encoding, inputRows chan Row) error { - converter, err := exportFileFormat.GetExporter(encoding) +func (j *Job) export(inputRows chan Row) error { + converter, err := j.exportFileFormat.GetExporter(j.encoding) if err != nil { return err } - fileName := filepath.Base(scriptFilePath) + fileName := filepath.Base(j.scriptFilePath) fileName = strings.TrimSuffix(fileName, filepath.Ext(fileName)) outputRows := make(chan []any) @@ -81,50 +101,60 @@ func export(scriptFilePath string, exportFileFormat ExportFormat, encoding Encod outputRows <- cachedRow } } else { - rowsCache = append(rowsCache, row.data) + rowsCache = append(rowsCache, row.data) // store data row in cache until got a header row } } }() - return converter.Convert(fileName, outputRows) + err = converter.Convert(fileName, outputRows) + j.status = "ЗАВЕРШЕНО." + j.isFinished = true + if err != nil { + return err + } + + return nil } -func iterateServers(servers []Server, sqlStr string, branchFieldNum int) chan Row { +func (j *Job) iterateServers(sqlStr string, branchFieldNum int) chan Row { rowsChan := make(chan Row) - slog.Info("Выгрузка начата...") - go func() { defer func() { close(rowsChan) }() wg := new(sync.WaitGroup) - wg.Add(len(servers)) + wg.Add(len(j.servers)) - for i, server := range servers { + for i, server := range j.servers { i := i server := server go func() { defer wg.Done() - db, err := sql.Open("oracle", server.Url) + j.servers[i].Status = "Подключение к серверу" + db := sql.OpenDB(go_ora.NewConnector(server.Url)) + defer db.Close() + + err := db.Ping() if err != nil { - slog.Error("Ошибка подключения к серверу", slog.String("server", server.Url), slog.Any("err", err)) + j.servers[i].Error = err return } + j.servers[i].Status = "Выполнение SQL-запроса" rows, err := db.Query(sqlStr) if err != nil { - slog.Error("Ошибка выполнения запроса", slog.String("server", server.Url), slog.Any("err", err)) + j.servers[i].Error = err return } defer rows.Close() cols, err := rows.Columns() if err != nil { - slog.Error("Ошибка получения списка колонок", slog.String("server", server.Url), slog.Any("err", err)) + j.servers[i].Error = err return } @@ -134,6 +164,8 @@ func iterateServers(servers []Server, sqlStr string, branchFieldNum int) chan Ro rowNum := 0 for rows.Next() { + j.servers[i].Status = fmt.Sprintf("Выгружено %d строк", rowNum) + pointers := make([]any, len(cols)) container := make([]any, len(cols)) for i := range pointers { @@ -142,66 +174,22 @@ func iterateServers(servers []Server, sqlStr string, branchFieldNum int) chan Ro err = rows.Scan(pointers...) if err != nil { - slog.Error("Ошибка получения строки", slog.String("server", server.Url), slog.Any("err", err)) + j.servers[i].Error = err break } rowsChan <- Row{isHeader: false, data: append(append(container[:branchFieldNum-1], server.Name), container[branchFieldNum:]...)} // Добавление имени сервера rowNum += 1 } - slog.Info("Получение строк завершено", slog.String("server", server.Name), slog.Int("rowCount", rowNum)) + j.servers[i].Status = fmt.Sprintf("ЗАВЕРШЕНО: Выгружено %d строк.", rowNum) }() } + j.status = "Ожидание завершения работы с серверами" wg.Wait() }() return rowsChan } -// loadConfig считывает конфиг и возвращает список параметров серверов -func loadConfig(filePath string) ([]Server, error) { - servers := make([]Server, 0) - - iniBytes, err := readFileIgnoreBOM(filePath) - if err != nil { - return nil, err - } - - cfg, err := ini.Load(iniBytes) - if err != nil { - return nil, err - } - - cfg.DeleteSection("DEFAULT") - - for _, server := range cfg.SectionStrings() { - loginKey, err := cfg.Section(server).GetKey("Login") - if err != nil { - return nil, err - } - passwordKey, err := cfg.Section(server).GetKey("Password") - if err != nil { - return nil, err - } - - nameKey, err := cfg.Section(server).GetKey("Name") - if err != nil { - return nil, err - } - - serv := strings.Split(server, "/")[0] - service := strings.Split(server, "/")[1] - dbUrl := go_ora.BuildUrl(serv, 1521, service, loginKey.String(), passwordKey.String(), nil) - - server := Server{ - Url: dbUrl, - Name: nameKey.String()} - - servers = append(servers, server) - } - - return servers, nil -} - func sliceToAnySlice[T string | any](slice []T) []any { result := make([]any, len(slice)) for i := range result { diff --git a/main.go b/main.go index f6144dc..a7d1c11 100644 --- a/main.go +++ b/main.go @@ -1,12 +1,13 @@ package main import ( - "fmt" - "log" "log/slog" "os" "path/filepath" + "strings" + "time" + "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) @@ -16,28 +17,17 @@ func init() { } func main() { - configFilePath, scriptFilePath, exportFileFormat, encoding, err := getReportParams() - if err != nil { - log.Fatalln(err) - } - - err = launch(configFilePath, scriptFilePath, exportFileFormat, encoding) - if err != nil { - slog.Error("Ошибка при выполнении", slog.Any("err", err)) - fmt.Scanln() - return - } + renderUI() } -func getReportParams() (configFilePath, scriptFilePath string, exportFileFormat ExportFormat, encoding Encoding, err error) { - exportFileFormatStr := "" - - // Список файлов с SQL-скриптами +func renderUI() { + // SQL-files list files, _ := filepath.Glob(filepath.Join(SQL_FILES_DIR, "*.sql")) for i := range files { files[i] = filepath.Base(files[i]) } - // Список файлов с настройками подключения к БД + + // Servers file list configs, _ := filepath.Glob("*.ini") configFileDropDown := tview.NewDropDown(). @@ -76,19 +66,136 @@ func getReportParams() (configFilePath, scriptFilePath string, exportFileFormat grid := tview.NewGrid().SetRows(-1, 1). AddItem(form, 0, 0, 1, 1, 0, 0, true). AddItem(tview.NewButton("Запуск").SetSelectedFunc(func() { - _, scriptFilePath = sqlFileDropDown.GetCurrentOption() - _, exportFileFormatStr = formatDropDown.GetCurrentOption() - _, configFilePath = configFileDropDown.GetCurrentOption() + _, scriptFilePath := sqlFileDropDown.GetCurrentOption() + _, exportFileFormatStr := formatDropDown.GetCurrentOption() + _, configFilePath := configFileDropDown.GetCurrentOption() _, encodingStr := encodingDropDown.GetCurrentOption() - encoding = Encoding(encodingStr) - app.Stop() + + job := &Job{ + configFilePath: configFilePath, + scriptFilePath: filepath.Join(SQL_FILES_DIR, scriptFilePath), + exportFileFormat: ExportFormat(exportFileFormatStr), + encoding: Encoding(encodingStr)} + + err := job.init() + if err != nil { + showError(app, err) + return + } + + renderProgressUI(app, job) }), 1, 0, 1, 1, 0, 0, false) grid.SetBorderPadding(0, 1, 1, 1) form.SetTitle("Параметры выгрузки").SetTitleAlign(tview.AlignLeft) if err := app.SetRoot(grid, true).EnableMouse(true).Run(); err != nil { - return "", "", "", "", err + panic(err) + } +} + +func renderProgressUI(app *tview.Application, job *Job) { + statusTextViews := make([]*tview.TextView, 0) + maxServerNameLen := 0 + for i := range job.servers { + if len(job.servers[i].Name) > maxServerNameLen { + maxServerNameLen = len([]rune(job.servers[i].Name)) + 2 + } + } + rows := make([]int, len(job.servers)+3) + for i := range rows { + rows[i] = 1 } - return configFilePath, filepath.Join(SQL_FILES_DIR, scriptFilePath), ExportFormat(exportFileFormatStr), encoding, nil + grid := tview.NewGrid().SetRows(rows...).SetColumns(1, maxServerNameLen+2) + for i, server := range job.servers { + grid.AddItem(tview.NewTextView().SetTextAlign(tview.AlignLeft).SetText(server.Name), i+1, 1, 1, 1, 0, 0, false) + + p := tview.NewTextView().SetTextAlign(tview.AlignLeft).SetText("...") + statusTextViews = append(statusTextViews, p) + grid.AddItem(p, i+1, 2, 1, 1, 0, 0, false) + } + + grid.AddItem(tview.NewTextView().SetTextAlign(tview.AlignLeft).SetText(strings.Repeat("-", maxServerNameLen)), len(job.servers)+1, 1, 1, 1, 0, 0, false) + grid.AddItem(tview.NewTextView().SetTextAlign(tview.AlignLeft), len(job.servers)+1, 2, 1, 1, 0, 0, false) + + statusTextView := tview.NewTextView() + grid.AddItem(statusTextView, len(job.servers)+2, 2, 1, 1, 0, 0, false) + + // Last empty line + grid.AddItem(tview.NewTextView(), len(job.servers)+3, 1, 1, 1, 0, 0, false) + grid.AddItem(tview.NewTextView(), len(job.servers)+3, 2, 1, 1, 0, 0, false) + + go func() { + oneMoreUpdate := false + for !job.isFinished || oneMoreUpdate { + if !oneMoreUpdate { + time.Sleep(time.Second) + } + + for i, server := range job.servers { + s := []string{server.Status} + if server.Error != nil { + s = append(s, server.Error.Error()) + } + statusTextViews[i].SetText(strings.Join(s, ": ")) + + if server.Error != nil { + statusTextViews[i].SetTextColor(tcell.ColorRed) + } else if strings.HasPrefix(server.Status, "ЗАВЕРШЕНО: ") { + statusTextViews[i].SetTextColor(tcell.ColorGreen) + } + } + + statusTextView.SetText(job.status) + if strings.HasPrefix(job.status, "ЗАВЕРШЕНО") { + statusTextView.SetTextColor(tcell.ColorGreen) + } + + app.ForceDraw() + if job.isFinished { + if !oneMoreUpdate { + oneMoreUpdate = true + } else { + break + } + } + } + // TODO: + /*finishButton := tview.NewButton("OK").SetSelectedFunc(func() { app.Stop() }).SetBackgroundColor(tcell.ColorDarkGreen) + grid.AddItem(finishButton, len(job.servers)+2, 1, 1, 1, 0, 0, false) + app.ForceDraw().SetFocus(finishButton)*/ + showMessage(app, "Выгрузка завершена.") + }() + + go func() { + err := job.launch() + if err != nil { + showError(app, err) + } + }() + + if err := app.SetRoot(grid, true).SetFocus(grid).Run(); err != nil { + panic(err) + } +} + +func showError(app *tview.Application, err error) { + modal := tview.NewModal(). + SetText(err.Error()). + AddButtons([]string{"OK"}). + SetBackgroundColor(tcell.ColorRed). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + os.Exit(1) + }) + app.SetRoot(modal, true).SetFocus(modal) +} + +func showMessage(app *tview.Application, text string) { + modal := tview.NewModal(). + SetText(text). + AddButtons([]string{"OK"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + os.Exit(0) + }) + app.SetRoot(modal, true).SetFocus(modal).ForceDraw() } diff --git a/servers.go b/servers.go index 38b9c42..2e80ec0 100644 --- a/servers.go +++ b/servers.go @@ -1,9 +1,68 @@ package main +import ( + "strings" + + go_ora "github.com/sijms/go-ora/v2" + "gopkg.in/ini.v1" +) + // Server - экземпляр сервера type Server struct { // Полная ссылка на БД, вкючая логин/пароль Url string + // Наименование филиала Name string + + // Статус работы с сервером + Status string + + // Ошибка работы с сервером + Error error +} + +// loadConfig считывает конфиг и возвращает список параметров серверов +func loadConfig(filePath string) ([]Server, error) { + servers := make([]Server, 0) + + iniBytes, err := readFileIgnoreBOM(filePath) + if err != nil { + return nil, err + } + + cfg, err := ini.Load(iniBytes) + if err != nil { + return nil, err + } + + cfg.DeleteSection("DEFAULT") + + for _, server := range cfg.SectionStrings() { + loginKey, err := cfg.Section(server).GetKey("Login") + if err != nil { + return nil, err + } + passwordKey, err := cfg.Section(server).GetKey("Password") + if err != nil { + return nil, err + } + + nameKey, err := cfg.Section(server).GetKey("Name") + if err != nil { + return nil, err + } + + serv := strings.Split(server, "/")[0] + service := strings.Split(server, "/")[1] + dbUrl := go_ora.BuildUrl(serv, 1521, service, loginKey.String(), passwordKey.String(), map[string]string{"TIMEOUT": "0", "PREFETCH_ROWS": "1000"}) + + server := Server{ + Url: dbUrl, + Name: nameKey.String()} + + servers = append(servers, server) + } + + return servers, nil }