From 0fa3de757cd684dc8ce1673563e99d31a0178a78 Mon Sep 17 00:00:00 2001 From: iRedTea Date: Thu, 15 Oct 2026 16:50:56 +0300 Subject: [PATCH] feat: edit SQLite databases in the file manager - the agent opens them through SQLite: values by a click, rows added and deleted, an SQL tab; changes go to the audit log --- README.md | 2 +- internal/agent/sqlitedb.go | 512 +++++++++++++++++++++++ internal/agent/users.go | 2 + internal/proto/proto.go | 6 + internal/server/handlers_panel_fs.go | 51 ++- internal/server/web/static/dbedit.js | 326 +++++++++++++++ internal/server/web/templates/files.html | 61 +++ 7 files changed, 947 insertions(+), 13 deletions(-) create mode 100644 internal/agent/sqlitedb.go create mode 100644 internal/server/web/static/dbedit.js diff --git a/README.md b/README.md index cfc1979..b33e1d9 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ а шаги подключения видны в статусе. Ключ хоста запоминается при первом входе (`ssh_known_hosts` рядом с `state.json` агента); если он сменится, вход откажет. - **Файлы** - просмотр, редактор с подсветкой, hex-редактор, предпросмотр картинок, видео, PDF, - таблиц SQLite; загрузка и скачивание до 512 МБ кусками по 4 МБ (проходит через Cloudflare); + баз SQLite с правкой (значение по клику, добавление и удаление строк, вкладка SQL; агент меняет базу через сам SQLite, так что работающая база не ломается); загрузка и скачивание до 512 МБ кусками по 4 МБ (проходит через Cloudflare); свойства (chmod/chown), zip/unzip. - **Перемещение и копирование** - внутри машины или, кнопкой «сеть» в том же окне, **на другую машину или на SSH-хост**: папка уходит потоком tar без промежуточных файлов, с прогрессом, diff --git a/internal/agent/sqlitedb.go b/internal/agent/sqlitedb.go new file mode 100644 index 0000000..765a887 --- /dev/null +++ b/internal/agent/sqlitedb.go @@ -0,0 +1,512 @@ +package agent + +import ( + "context" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "strconv" + "strings" + "time" + + _ "modernc.org/sqlite" + + "github.com/x-grk/longpolling-remote-access/internal/proto" +) + +// SQLite databases are read and changed here, through SQLite itself, so a +// database that is in use keeps its locks and journal: a value is set with an +// UPDATE, not by writing the file over. + +// dbCell is one value as the panel shows and sends it: t is "i" (integer), +// "r" (real), "s" (text), "b" (blob, v its size, not editable) or "n" (NULL). +type dbCell struct { + T string `json:"t"` + V string `json:"v,omitempty"` +} + +func cellOf(v any) dbCell { + switch x := v.(type) { + case nil: + return dbCell{T: "n"} + case int64: + return dbCell{T: "i", V: strconv.FormatInt(x, 10)} + case float64: + return dbCell{T: "r", V: strconv.FormatFloat(x, 'g', -1, 64)} + case string: + return dbCell{T: "s", V: x} + case []byte: + return dbCell{T: "b", V: strconv.Itoa(len(x))} + case bool: + if x { + return dbCell{T: "i", V: "1"} + } + return dbCell{T: "i", V: "0"} + case time.Time: + return dbCell{T: "s", V: x.Format(time.RFC3339Nano)} + } + return dbCell{T: "s", V: fmt.Sprint(v)} +} + +// value is what a cell becomes for SQLite. +func (c dbCell) value() (any, error) { + switch c.T { + case "n": + return nil, nil + case "i": + n, err := strconv.ParseInt(strings.TrimSpace(c.V), 10, 64) + if err != nil { + return nil, fmt.Errorf("%q - не целое число", c.V) + } + return n, nil + case "r": + f, err := strconv.ParseFloat(strings.TrimSpace(c.V), 64) + if err != nil { + return nil, fmt.Errorf("%q - не число", c.V) + } + return f, nil + case "x": + b, err := hex.DecodeString(strings.ReplaceAll(c.V, " ", "")) + if err != nil { + return nil, errors.New("blob - нужны шестнадцатеричные байты") + } + return b, nil + } + return c.V, nil +} + +type dbArgs struct { + Path string `json:"path"` + Table string `json:"table,omitempty"` + Offset int `json:"offset,omitempty"` + Limit int `json:"limit,omitempty"` + Key json.RawMessage `json:"key,omitempty"` + Keys []json.RawMessage `json:"keys,omitempty"` + Column string `json:"column,omitempty"` + Value *dbCell `json:"value,omitempty"` + Values map[string]dbCell `json:"values,omitempty"` + SQL string `json:"sql,omitempty"` +} + +func dbOpen(path string) (*sql.DB, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if info.IsDir() { + return nil, errors.New("это каталог") + } + u := url.URL{Scheme: "file", Path: path} + db, err := sql.Open("sqlite", u.String()+"?mode=rw&_pragma=busy_timeout(5000)") + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + return db, nil +} + +func qid(name string) string { return `"` + strings.ReplaceAll(name, `"`, `""`) + `"` } + +// dbTable is a table's shape: its columns, and how a row is found again - +// by rowid, by its primary key (a WITHOUT ROWID table), or not at all (a +// view: shown, not changed). +type dbTable struct { + Name string `json:"name"` + Type string `json:"type"` + Columns []dbColumn `json:"columns"` + Key string `json:"key"` // "rowid", "pk" or "" + // Alias is the INTEGER PRIMARY KEY column: another name for the rowid. + Alias string `json:"alias,omitempty"` + pk []string +} + +type dbColumn struct { + Name string `json:"name"` + Type string `json:"type"` + NotNull bool `json:"notnull,omitempty"` + PK bool `json:"pk,omitempty"` + Default string `json:"default,omitempty"` +} + +func dbTableInfo(ctx context.Context, db *sql.DB, name string) (*dbTable, error) { + t := &dbTable{Name: name} + if err := db.QueryRowContext(ctx, `SELECT type FROM sqlite_master WHERE name = ? AND type IN ('table','view')`, name).Scan(&t.Type); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("нет таблицы %s", name) + } + return nil, err + } + rows, err := db.QueryContext(ctx, `SELECT name, type, "notnull", dflt_value, pk FROM pragma_table_info(?)`, name) + if err != nil { + return nil, err + } + defer rows.Close() + type pkc struct { + n int + col string + } + var pks []pkc + for rows.Next() { + var c dbColumn + var notnull, pk int + var dflt sql.NullString + if err := rows.Scan(&c.Name, &c.Type, ¬null, &dflt, &pk); err != nil { + return nil, err + } + c.NotNull, c.PK, c.Default = notnull == 1, pk > 0, dflt.String + if pk > 0 { + pks = append(pks, pkc{pk, c.Name}) + } + t.Columns = append(t.Columns, c) + } + if err := rows.Err(); err != nil { + return nil, err + } + for i := 1; i <= len(pks); i++ { + for _, p := range pks { + if p.n == i { + t.pk = append(t.pk, p.col) + } + } + } + if t.Type == "table" { + if r, err := db.QueryContext(ctx, `SELECT rowid FROM `+qid(name)+` LIMIT 0`); err == nil { + r.Close() + t.Key = "rowid" + if len(t.pk) == 1 { + for _, c := range t.Columns { + if c.Name == t.pk[0] && strings.EqualFold(strings.TrimSpace(c.Type), "INTEGER") { + t.Alias = c.Name + } + } + } + } else if len(t.pk) > 0 { + t.Key = "pk" + } + } + return t, nil +} + +// where is the condition that finds the row by its key: a rowid, or the +// primary key's cells in order. +func (t *dbTable) where(key json.RawMessage) (string, []any, error) { + switch t.Key { + case "rowid": + var id int64 + if err := json.Unmarshal(key, &id); err != nil { + var s string + if json.Unmarshal(key, &s) != nil { + return "", nil, errors.New("плохой ключ строки") + } + if id, err = strconv.ParseInt(s, 10, 64); err != nil { + return "", nil, errors.New("плохой ключ строки") + } + } + return "rowid = ?", []any{id}, nil + case "pk": + var cells []dbCell + if err := json.Unmarshal(key, &cells); err != nil || len(cells) != len(t.pk) { + return "", nil, errors.New("плохой ключ строки") + } + parts, args := make([]string, len(t.pk)), make([]any, len(t.pk)) + for i, col := range t.pk { + v, err := cells[i].value() + if err != nil { + return "", nil, err + } + parts[i], args[i] = qid(col)+" IS ?", v + } + return strings.Join(parts, " AND "), args, nil + } + return "", nil, errors.New("строки этой таблицы нельзя менять: нет ни rowid, ни первичного ключа") +} + +func (t *dbTable) column(name string) bool { + for _, c := range t.Columns { + if c.Name == name { + return true + } + } + return false +} + +// dbTask runs one database task. +func dbTask(env proto.TaskEnvelope) (any, error) { + var a dbArgs + if err := json.Unmarshal(env.Args, &a); err != nil { + return nil, err + } + db, err := dbOpen(a.Path) + if err != nil { + return nil, err + } + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second) + defer cancel() + + if env.Type == proto.TaskDBTables { + rows, err := db.QueryContext(ctx, `SELECT name, type FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY type, name`) + if err != nil { + return nil, dbErr(err) + } + defer rows.Close() + type item struct { + Name string `json:"name"` + Type string `json:"type"` + } + out := []item{} + for rows.Next() { + var it item + if err := rows.Scan(&it.Name, &it.Type); err != nil { + return nil, err + } + out = append(out, it) + } + return map[string]any{"tables": out}, rows.Err() + } + if env.Type == proto.TaskDBSQL { + return dbSQL(ctx, db, a.SQL) + } + + t, err := dbTableInfo(ctx, db, a.Table) + if err != nil { + return nil, dbErr(err) + } + switch env.Type { + case proto.TaskDBRows: + return dbRows(ctx, db, t, a.Offset, a.Limit) + case proto.TaskDBSet: + if !t.column(a.Column) || a.Value == nil { + return nil, errors.New("нет такого столбца") + } + cond, args, err := t.where(a.Key) + if err != nil { + return nil, err + } + v, err := a.Value.value() + if err != nil { + return nil, err + } + res, err := db.ExecContext(ctx, `UPDATE `+qid(t.Name)+` SET `+qid(a.Column)+` = ? WHERE `+cond, append([]any{v}, args...)...) + if err != nil { + return nil, dbErr(err) + } + if n, _ := res.RowsAffected(); n != 1 { + return nil, fmt.Errorf("изменено строк: %d - строку могли удалить или поменять", n) + } + // A key column changed: the row is found by its new key now. + out := map[string]any{} + switch { + case t.Key == "rowid" && a.Column == t.Alias: + args = []any{v} + out["key"] = a.Value.V + case t.Key == "pk": + var cells []dbCell + _ = json.Unmarshal(a.Key, &cells) + for i, col := range t.pk { + if col == a.Column { + args[i], cells[i] = v, *a.Value + out["key"] = cells + } + } + } + // What SQLite stored, after its type affinity. + var got any + _ = db.QueryRowContext(ctx, `SELECT `+qid(a.Column)+` FROM `+qid(t.Name)+` WHERE `+cond, args...).Scan(&got) + out["value"] = cellOf(got) + if k, ok := out["key"].([]dbCell); ok { + for i, col := range t.pk { + if col == a.Column { + k[i] = cellOf(got) + } + } + } + return out, nil + case proto.TaskDBAdd: + if t.Type != "table" { + return nil, errors.New("в представление строки не добавляются") + } + var cols, marks []string + var vals []any + for name, c := range a.Values { + if !t.column(name) { + return nil, fmt.Errorf("нет столбца %s", name) + } + v, err := c.value() + if err != nil { + return nil, err + } + cols, marks, vals = append(cols, qid(name)), append(marks, "?"), append(vals, v) + } + q := `INSERT INTO ` + qid(t.Name) + ` DEFAULT VALUES` + if len(cols) > 0 { + q = `INSERT INTO ` + qid(t.Name) + ` (` + strings.Join(cols, ", ") + `) VALUES (` + strings.Join(marks, ", ") + `)` + } + res, err := db.ExecContext(ctx, q, vals...) + if err != nil { + return nil, dbErr(err) + } + id, _ := res.LastInsertId() + return map[string]any{"rowid": id}, nil + case proto.TaskDBDel: + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return nil, dbErr(err) + } + defer tx.Rollback() + n := int64(0) + for _, k := range a.Keys { + cond, args, err := t.where(k) + if err != nil { + return nil, err + } + res, err := tx.ExecContext(ctx, `DELETE FROM `+qid(t.Name)+` WHERE `+cond, args...) + if err != nil { + return nil, dbErr(err) + } + c, _ := res.RowsAffected() + n += c + } + if err := tx.Commit(); err != nil { + return nil, dbErr(err) + } + return map[string]any{"deleted": n}, nil + } + return nil, errors.New("неизвестная операция") +} + +// dbRows is a page of a table's rows, with each row's key. +func dbRows(ctx context.Context, db *sql.DB, t *dbTable, offset, limit int) (any, error) { + if limit <= 0 || limit > 1000 { + limit = 200 + } + var total int64 + _ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+qid(t.Name)).Scan(&total) + sel := make([]string, 0, len(t.Columns)+1) + if t.Key == "rowid" { + sel = append(sel, "rowid") + } + for _, c := range t.Columns { + sel = append(sel, qid(c.Name)) + } + order := "" + switch t.Key { + case "rowid": + order = " ORDER BY rowid" + case "pk": + parts := make([]string, len(t.pk)) + for i, c := range t.pk { + parts[i] = qid(c) + } + order = " ORDER BY " + strings.Join(parts, ", ") + } + rows, err := db.QueryContext(ctx, `SELECT `+strings.Join(sel, ", ")+` FROM `+qid(t.Name)+order+` LIMIT ? OFFSET ?`, limit, offset) + if err != nil { + return nil, dbErr(err) + } + defer rows.Close() + type row struct { + K any `json:"k,omitempty"` + V []dbCell `json:"v"` + } + out := []row{} + n := len(sel) + for rows.Next() { + vals := make([]any, n) + ptrs := make([]any, n) + for i := range vals { + ptrs[i] = &vals[i] + } + if err := rows.Scan(ptrs...); err != nil { + return nil, err + } + r := row{} + if t.Key == "rowid" { + r.K = cellOf(vals[0]).V + vals = vals[1:] + } + for _, v := range vals { + r.V = append(r.V, cellOf(v)) + } + if t.Key == "pk" { + key := make([]dbCell, len(t.pk)) + for i, pk := range t.pk { + for j, c := range t.Columns { + if c.Name == pk { + key[i] = r.V[j] + } + } + } + r.K = key + } + out = append(out, r) + } + return map[string]any{"table": t, "total": total, "offset": offset, "rows": out}, rows.Err() +} + +// dbSQL runs what was typed: a query gives up to 500 rows, anything else +// how many rows it changed. +func dbSQL(ctx context.Context, db *sql.DB, q string) (any, error) { + q = strings.TrimSpace(q) + if q == "" { + return nil, errors.New("пустой запрос") + } + head := strings.ToLower(strings.Fields(q)[0]) + if head == "select" || head == "pragma" || head == "with" || head == "explain" || head == "values" { + rows, err := db.QueryContext(ctx, q) + if err != nil { + return nil, dbErr(err) + } + defer rows.Close() + cols, _ := rows.Columns() + out := [][]dbCell{} + more := false + for rows.Next() { + if len(out) == 500 { + more = true + break + } + vals := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + for i := range vals { + ptrs[i] = &vals[i] + } + if err := rows.Scan(ptrs...); err != nil { + return nil, err + } + r := make([]dbCell, len(cols)) + for i, v := range vals { + r[i] = cellOf(v) + } + out = append(out, r) + } + return map[string]any{"columns": cols, "rows": out, "more": more}, dbErr(rows.Err()) + } + res, err := db.ExecContext(ctx, q) + if err != nil { + return nil, dbErr(err) + } + n, _ := res.RowsAffected() + return map[string]any{"changed": n}, nil +} + +// dbErr says the common SQLite troubles in words. +func dbErr(err error) error { + if err == nil { + return nil + } + m := err.Error() + switch { + case strings.Contains(m, "file is not a database"): + return errors.New("это не база SQLite") + case strings.Contains(m, "database is locked"), strings.Contains(m, "SQLITE_BUSY"): + return errors.New("база занята другой программой - попробуйте ещё раз") + case strings.Contains(m, "readonly"): + return errors.New("база только для чтения (нет прав на файл или его папку)") + } + return err +} diff --git a/internal/agent/users.go b/internal/agent/users.go index a634d17..4887a5b 100644 --- a/internal/agent/users.go +++ b/internal/agent/users.go @@ -39,6 +39,8 @@ func dispatchTask(env proto.TaskEnvelope) (any, error) { var a proto.SysUserAddArgs _ = json.Unmarshal(env.Args, &a) return nil, addUser(a) + case proto.TaskDBTables, proto.TaskDBRows, proto.TaskDBSet, proto.TaskDBAdd, proto.TaskDBDel, proto.TaskDBSQL: + return dbTask(env) } return dispatchFS(env) } diff --git a/internal/proto/proto.go b/internal/proto/proto.go index df2bd06..901e5dc 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -161,6 +161,12 @@ const ( TaskSysUserAdd = "sys.useradd" // create a new OS user TaskSysStat = "sys.stat" // CPU load, RAM and disk usage right now TaskSysSpeed = "sys.speed" // measure the link to the server (ping, down, up) + TaskDBTables = "db.tables" // an SQLite database's tables and views + TaskDBRows = "db.rows" // a page of a table's rows, with each row's key + TaskDBSet = "db.set" // set one value of one row + TaskDBAdd = "db.add" // add a row + TaskDBDel = "db.del" // delete rows by their keys + TaskDBSQL = "db.sql" // run a statement typed in the panel TaskFSShipOut = "fs.ship.out" // pack a file or folder and send it to another machine TaskFSShipIn = "fs.ship.in" // unpack what another machine sends ) diff --git a/internal/server/handlers_panel_fs.go b/internal/server/handlers_panel_fs.go index 084c286..f3975da 100644 --- a/internal/server/handlers_panel_fs.go +++ b/internal/server/handlers_panel_fs.go @@ -1,6 +1,7 @@ package server import ( + "encoding/json" "io" "net/http" "time" @@ -10,18 +11,24 @@ import ( // fsOps maps the URL op segment to a proto task type. var fsOps = map[string]string{ - "list": proto.TaskFSList, - "read": proto.TaskFSRead, - "write": proto.TaskFSWrite, - "mkdir": proto.TaskFSMkdir, - "delete": proto.TaskFSDelete, - "deltree": proto.TaskFSDelTree, - "unzip": proto.TaskFSUnzip, - "zip": proto.TaskFSZip, - "move": proto.TaskFSMove, - "stat": proto.TaskFSStat, - "chmod": proto.TaskFSChmod, - "copy": proto.TaskFSCopy, + "list": proto.TaskFSList, + "read": proto.TaskFSRead, + "write": proto.TaskFSWrite, + "mkdir": proto.TaskFSMkdir, + "delete": proto.TaskFSDelete, + "deltree": proto.TaskFSDelTree, + "unzip": proto.TaskFSUnzip, + "zip": proto.TaskFSZip, + "move": proto.TaskFSMove, + "stat": proto.TaskFSStat, + "chmod": proto.TaskFSChmod, + "copy": proto.TaskFSCopy, + "db.tables": proto.TaskDBTables, + "db.rows": proto.TaskDBRows, + "db.set": proto.TaskDBSet, + "db.add": proto.TaskDBAdd, + "db.del": proto.TaskDBDel, + "db.sql": proto.TaskDBSQL, } // fsTaskTimeout bounds how long we wait for an agent to answer a file op. @@ -63,6 +70,26 @@ func (s *Server) handleFSOp(w http.ResponseWriter, r *http.Request) { return } + // Changes to a database go to the audit log, with what was done. + if op == "db.set" || op == "db.add" || op == "db.del" || op == "db.sql" { + var d struct { + Path, Table, Column, SQL string + } + _ = json.Unmarshal(args, &d) + what := d.Path + " " + d.Table + if d.Column != "" { + what += "." + d.Column + } + if d.SQL != "" { + what += " " + d.SQL + } + if len(what) > 300 { + what = what[:300] + } + if admin := s.currentAdmin(r); admin != nil { + s.db.Audit(admin.ID, agentID, op+":"+what) + } + } taskID, resultCh := s.hub.CreateTask(agentID, taskType, args) defer s.hub.DiscardTask(taskID) diff --git a/internal/server/web/static/dbedit.js b/internal/server/web/static/dbedit.js new file mode 100644 index 0000000..12ca26d --- /dev/null +++ b/internal/server/web/static/dbedit.js @@ -0,0 +1,326 @@ +// SQLite editor of the file manager: the agent opens the database and runs +// the queries, so a database in use keeps its locks. Tables and views in +// tabs, rows a page at a time; a click on a value edits it (Enter saves, +// Esc cancels, Shift+Enter a new line, the ∅ button makes it NULL). Rows are +// added and deleted, and a tab runs any SQL. +(function () { + 'use strict'; + var PAGE = 200; + var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return {'&': '&', '<': '<', '>': '>', '"': '"'}[c]; }); }; + + function cellHTML(c) { + if (c.t === 'n') return 'NULL'; + if (c.t === 'b') return 'BLOB ' + esc(c.v) + ' Б'; + var v = c.v || ''; + return esc(v.length > 300 ? v.slice(0, 300) + '…' : v); + } + // The type a typed value goes as: what the value was, or what the column + // is declared as, when the text fits it. + function typeFor(text, was, decl) { + var d = (decl || '').toUpperCase(); + var int = /^\s*-?\d+\s*$/.test(text), num = text.trim() !== '' && isFinite(Number(text)); + if (was === 'i' || (was !== 's' && /INT/.test(d))) return int ? 'i' : num ? 'r' : 's'; + if (was === 'r' || (was !== 's' && /REAL|FLOA|DOUB|NUM|DEC/.test(d))) return num ? 'r' : 's'; + return 's'; + } + + function open(box, path, call, toast) { + var S = {tables: [], cur: null, offset: 0, data: null, sel: new Set()}; + box.innerHTML = + '
' + + '
' + + '
' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + '
' + + '
' + + '' + + '
'; + var $ = function (s) { return box.querySelector(s); }; + + function busy(on) { box.querySelector('.dbx').classList.toggle('busy', on); } + function req(op, args) { + args.path = path; + busy(true); + return call(op, args).then(function (r) { busy(false); return r; }, function (e) { busy(false); throw e; }); + } + + function tabs() { + var t = $('.dbx-tabs'); + t.innerHTML = ''; + S.tables.forEach(function (x) { + var b = document.createElement('button'); + b.type = 'button'; + b.textContent = x.name; + if (x.type === 'view') b.title = 'представление - только чтение'; + b.className = x.name === S.cur ? 'on' : ''; + b.onclick = function () { show(x.name, 0); }; + t.appendChild(b); + }); + var q = document.createElement('button'); + q.type = 'button'; + q.textContent = 'SQL'; + q.className = 'dbx-sqltab' + (S.cur === null ? ' on' : ''); + q.onclick = sqlTab; + t.appendChild(q); + } + + function show(name, offset) { + S.cur = name; S.offset = offset; S.sel.clear(); + tabs(); + $('.dbx-sql').hidden = true; + $('.dbx-grid').hidden = false; + $('.dbx-tools').hidden = false; + return req('db.rows', {table: name, offset: offset, limit: PAGE}).then(function (r) { + if (!r.ok) { $('.dbx-grid').innerHTML = '

' + esc(r.error || 'ошибка') + '

'; return; } + S.data = r.data; + draw(); + }); + } + + function draw() { + var d = S.data, t = d.table, editable = !!t.key; + // An INTEGER PRIMARY KEY column already shows the rowid. + var showRowid = t.key === 'rowid' && !t.alias; + $('.dbx-info').textContent = d.total + ' ' + plural(d.total) + (t.type === 'view' ? ' · представление' : !editable ? ' · только чтение' : ''); + $('.dbx-range').textContent = d.rows.length ? (d.offset + 1) + '–' + (d.offset + d.rows.length) : ''; + $('.dbx-prev').disabled = d.offset <= 0; + $('.dbx-next').disabled = d.offset + d.rows.length >= d.total; + $('.dbx-pager').hidden = d.total <= PAGE; + $('.dbx-add').hidden = t.type !== 'table'; + $('.dbx-del').hidden = !editable; + selInfo(); + var h = '' + (editable ? '' : '') + + (showRowid ? '' : ''); + t.columns.forEach(function (c) { + h += ''; + }); + h += ''; + d.rows.forEach(function (row, i) { + h += '' + (editable ? '' : '') + + (showRowid ? '' : ''); + row.v.forEach(function (c, j) { + h += ''; + }); + h += ''; + }); + h += '
rowid' + esc(c.name) + + (c.type ? '' + esc(c.type.toLowerCase()) + '' : '') + '
' + esc(row.k) + '' + cellHTML(c) + '
'; + if (!d.rows.length) h += '

строк нет

'; + $('.dbx-grid').innerHTML = h; + $('.dbx-grid').scrollTop = 0; + fixWidths($('.dbx-grid table')); + } + // The columns keep the widths they first came out at, so a value being + // edited does not move the table about. + function fixWidths(table) { + if (!table) return; + var ths = table.querySelectorAll('thead th'), ws = [], sum = 0; + ths.forEach(function (th) { var w = Math.ceil(th.getBoundingClientRect().width); ws.push(w); sum += w; }); + ths.forEach(function (th, i) { th.style.width = ws[i] + 'px'; }); + table.style.width = sum + 'px'; + table.style.tableLayout = 'fixed'; + } + function plural(n) { + var m10 = n % 10, m100 = n % 100; + return m10 === 1 && m100 !== 11 ? 'строка' : m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14) ? 'строки' : 'строк'; + } + function selInfo() { + var b = $('.dbx-del'); + b.disabled = !S.sel.size; + b.textContent = S.sel.size ? 'Удалить (' + S.sel.size + ')' : 'Удалить'; + } + + $('.dbx-prev').onclick = function () { show(S.cur, Math.max(0, S.offset - PAGE)); }; + $('.dbx-next').onclick = function () { show(S.cur, S.offset + PAGE); }; + + // ---- selecting rows ---- + $('.dbx-grid').addEventListener('change', function (ev) { + var el = ev.target; + if (el.classList.contains('dbx-all')) { + box.querySelectorAll('tbody .dbx-ck input').forEach(function (c, i) { c.checked = el.checked; el.checked ? S.sel.add(i) : S.sel.delete(i); }); + } else if (el.closest('tbody .dbx-ck')) { + var i = +el.closest('tr').dataset.i; + el.checked ? S.sel.add(i) : S.sel.delete(i); + } + selInfo(); + }); + + // ---- editing a value ---- + var editing = null; + $('.dbx-grid').addEventListener('click', function (ev) { + var td = ev.target.closest('td.ed'); + if (!td || td.querySelector('textarea') || ev.target.closest('.dbx-edit')) return; + edit(td); + }); + function edit(td) { + if (editing) finish(editing, true); + var i = +td.parentNode.dataset.i, j = +td.dataset.j; + var row = S.data.rows[i], cell = row.v[j], col = S.data.table.columns[j]; + var hgt = td.offsetHeight; + td.classList.add('editing'); + td.innerHTML = '
'; + var ta = td.querySelector('textarea'); + ta.value = cell.t === 'n' ? '' : cell.v; + ta.placeholder = cell.t === 'n' ? 'NULL' : ''; + ta.style.height = Math.max(hgt, 30) + 'px'; + ta.focus(); + ta.select(); + editing = {td: td, i: i, j: j, ta: ta, cell: cell, col: col, nul: false}; + ta.addEventListener('keydown', function (ev) { + if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); finish(editing, true); } + else if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); finish(editing, false); } + else if (ev.key === 'Tab') { + ev.preventDefault(); + var cur = editing, next = ev.shiftKey ? cur.td.previousElementSibling : cur.td.nextElementSibling; + finish(cur, true); + if (next && next.classList.contains('ed')) edit(next); + } + }); + ta.addEventListener('blur', function () { setTimeout(function () { if (editing && editing.ta === ta && document.activeElement !== td.querySelector('.dbx-nullb')) finish(editing, true); }, 120); }); + td.querySelector('.dbx-nullb').onmousedown = function (ev) { ev.preventDefault(); editing.nul = true; finish(editing, true); }; + } + function finish(e, keep) { + if (!e || editing !== e) return; + editing = null; + var text = e.ta.value, cell = e.cell; + var value = e.nul ? {t: 'n'} : {t: typeFor(text, cell.t, e.col.type), v: text}; + var same = e.nul ? cell.t === 'n' : (cell.t !== 'n' || text !== '') && text === (cell.v || '') && (cell.t === value.t || cell.t === 's' || value.t === 's'); + if (cell.t === 'n' && !e.nul && text === '') same = true; + e.td.classList.remove('editing'); + if (!keep || same) { e.td.innerHTML = cellHTML(cell); return; } + e.td.innerHTML = cellHTML(value); + e.td.classList.add('saving'); + req('db.set', {table: S.cur, key: S.data.rows[e.i].k, column: e.col.name, value: value}).then(function (r) { + e.td.classList.remove('saving'); + if (!r.ok) { + e.td.innerHTML = cellHTML(cell); + flash(e.td, 'bad'); + toast(r.error || 'не сохранилось', true); + return; + } + var got = r.data.value; + S.data.rows[e.i].v[e.j] = got; + // A changed key column moves the row's key too. + if (r.data.key !== undefined) S.data.rows[e.i].k = r.data.key; + e.td.innerHTML = cellHTML(got); + e.td.className = (got.t === 'i' || got.t === 'r' ? 'num' : '') + ' ed'; + flash(e.td, 'ok'); + }); + } + function flash(td, kind) { + td.classList.remove('flash-ok', 'flash-bad'); + void td.offsetWidth; + td.classList.add('flash-' + kind); + } + + // ---- adding and deleting rows ---- + $('.dbx-add').onclick = function () { + if (box.querySelector('tr.dbx-new')) return; + var t = S.data.table, tbody = box.querySelector('.dbx-t tbody'); + var tr = document.createElement('tr'); + tr.className = 'dbx-new'; + var h = (t.key ? '' : '') + (t.key === 'rowid' && !t.alias ? 'новая' : ''); + t.columns.forEach(function (c) { + h += ''; + }); + tr.innerHTML = h; + tbody.insertBefore(tr, tbody.firstChild); + var bar = document.createElement('tr'); + bar.className = 'dbx-newbar'; + bar.innerHTML = ' пустое поле - значение по умолчанию'; + tbody.insertBefore(bar, tr.nextSibling); + $('.dbx-grid').scrollTop = 0; + tr.querySelector('input').focus(); + var done = function () { tr.remove(); bar.remove(); }; + bar.querySelector('.dbx-addno').onclick = done; + var add = function () { + var values = {}; + tr.querySelectorAll('input').forEach(function (inp) { + if (inp.value === '') return; + var c = t.columns.filter(function (x) { return x.name === inp.dataset.c; })[0]; + values[c.name] = {t: typeFor(inp.value, '', c.type), v: inp.value}; + }); + req('db.add', {table: S.cur, values: values}).then(function (r) { + if (!r.ok) { toast(r.error || 'не добавилось', true); return; } + toast('строка добавлена'); + var total = S.data.total + 1; + show(S.cur, Math.floor((total - 1) / PAGE) * PAGE); + }); + }; + bar.querySelector('.dbx-addok').onclick = add; + tr.addEventListener('keydown', function (ev) { + if (ev.key === 'Enter') { ev.preventDefault(); add(); } + if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); done(); } + }); + }; + $('.dbx-del').onclick = function () { + var keys = []; + S.sel.forEach(function (i) { keys.push(S.data.rows[i].k); }); + if (!keys.length || !confirm('Удалить строк: ' + keys.length + '?')) return; + req('db.del', {table: S.cur, keys: keys}).then(function (r) { + if (!r.ok) { toast(r.error || 'не удалилось', true); return; } + toast('удалено строк: ' + r.data.deleted); + show(S.cur, S.offset); + }); + }; + + // ---- SQL ---- + function sqlTab() { + S.cur = null; + tabs(); + $('.dbx-grid').hidden = true; + $('.dbx-tools').hidden = true; + $('.dbx-sql').hidden = false; + $('.dbx-q').focus(); + } + function run() { + var q = $('.dbx-q').value; + if (!q.trim()) return; + $('.dbx-sqlmsg').textContent = ''; + req('db.sql', {sql: q}).then(function (r) { + var out = $('.dbx-sqlres'); + if (!r.ok) { out.innerHTML = ''; $('.dbx-sqlmsg').textContent = r.error || 'ошибка'; $('.dbx-sqlmsg').className = 'dbx-sqlmsg bad'; return; } + $('.dbx-sqlmsg').className = 'muted dbx-sqlmsg'; + var d = r.data; + if (d.columns) { + $('.dbx-sqlmsg').textContent = 'строк: ' + d.rows.length + (d.more ? ' (показаны первые 500)' : ''); + var h = '' + d.columns.map(function (c) { return ''; }).join('') + ''; + d.rows.forEach(function (row) { + h += '' + row.map(function (c) { return ''; }).join('') + ''; + }); + out.innerHTML = h + '
' + esc(c) + '
' + cellHTML(c) + '
'; + } else { + out.innerHTML = ''; + $('.dbx-sqlmsg').textContent = 'готово · изменено строк: ' + d.changed; + loadTables(true); + } + }); + } + $('.dbx-run').onclick = run; + $('.dbx-q').addEventListener('keydown', function (ev) { + if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) { ev.preventDefault(); run(); } + }); + + function loadTables(keep) { + return req('db.tables', {}).then(function (r) { + if (!r.ok) return r; + S.tables = r.data.tables; + if (keep) { tabs(); return r; } + if (S.tables.length) show(S.tables[0].name, 0); else sqlTab(); + return r; + }); + } + return loadTables(false); + } + + window.DBEdit = {open: open}; +})(); diff --git a/internal/server/web/templates/files.html b/internal/server/web/templates/files.html index b5a9a82..3d976c2 100644 --- a/internal/server/web/templates/files.html +++ b/internal/server/web/templates/files.html @@ -207,6 +207,53 @@ .find #frow2, .find #frow2b { grid-row:2; } /* Documents and tables, drawn in the panel's colours. */ .vdoc { height:75vh; overflow:auto; background:#0c0f13; border-radius:0 0 12px 12px; } + /* SQLite editor. */ + .dbx { display:flex; flex-direction:column; height:100%; transition:opacity .2s; } + .dbx.busy { opacity:.75; } + .dbx-tabs { display:flex; gap:4px; padding:8px 10px 0; overflow-x:auto; flex:none; border-bottom:1px solid var(--border); } + .dbx-tabs button { flex:none; background:transparent; color:var(--muted); border:1px solid transparent; border-bottom:0; border-radius:8px 8px 0 0; + padding:6px 12px; font-size:13px; transition:background .15s, color .15s; } + .dbx-tabs button:hover { color:var(--text); background:#151a21; } + .dbx-tabs button.on { color:var(--text); background:var(--panel); border-color:var(--border); } + .dbx-tabs .dbx-sqltab { margin-left:auto; font-family:ui-monospace,Menlo,monospace; } + .dbx-tools { display:flex; align-items:center; gap:8px; padding:8px 10px; flex:none; flex-wrap:wrap; font-size:13px; } + .dbx-tools[hidden], .dbx-pager[hidden], .dbx-grid[hidden], .dbx-sql[hidden], .dbx-tools button[hidden] { display:none; } + .dbx-pager { display:inline-flex; align-items:center; gap:6px; } + .dbx-sp { flex:1; } + .dbx-grid { flex:1; min-height:0; overflow:auto; } + .dbx-t { width:auto; border-collapse:separate; border-spacing:0; font-size:13px; font-variant-numeric:tabular-nums; } + .dbx-t th, .dbx-t td { border-right:1px solid var(--border); border-bottom:1px solid var(--border); padding:5px 10px; text-align:left; + max-width:420px; white-space:pre-wrap; overflow-wrap:anywhere; vertical-align:top; } + .dbx-t thead th { position:sticky; top:0; z-index:1; background:var(--panel); color:var(--text); font-weight:500; white-space:nowrap; } + .dbx-t thead th small { display:block; color:var(--muted); font-weight:400; font-size:11px; } + .dbx-t td.num { text-align:right; } + .dbx-t .dbx-k { color:var(--muted); text-align:right; } + .dbx-t .dbx-ck { width:1%; text-align:center; } + .dbx-t td.ed { cursor:text; transition:background .15s; } + .dbx-t td.ed:hover { background:#151a21; } + .dbx-t td.editing { padding:0; background:#12202f; } + .dbx-t td.saving { opacity:.6; } + .dbx-t td.flash-ok { animation:dbx-ok 1.2s ease; } + .dbx-t td.flash-bad { animation:dbx-bad 1.2s ease; } + @keyframes dbx-ok { 0% { background:rgba(55,200,113,.28); } 100% { background:transparent; } } + @keyframes dbx-bad { 0% { background:rgba(255,92,92,.3); } 100% { background:transparent; } } + .dbx-null { color:var(--muted); font-size:12px; } + .dbx-edit { position:relative; } + .dbx-edit textarea { display:block; width:100%; min-height:30px; resize:vertical; box-sizing:border-box; margin:0; padding:5px 28px 5px 10px; border:1px solid var(--accent); + border-radius:0; background:#0c0f13; color:var(--text); font:inherit; outline:none; } + .dbx-nullb { position:absolute; top:4px; right:4px; width:20px; height:20px; padding:0; font-size:12px; line-height:1; + background:#1b222c; color:var(--muted); border:1px solid var(--border); border-radius:5px; } + .dbx-nullb:hover { color:var(--text); background:#232b36; } + .dbx-t tr.dbx-new td { padding:3px; background:#12202f; } + .dbx-t tr.dbx-new input { width:100%; min-width:90px; height:28px; padding:3px 8px; font-size:13px; } + .dbx-t tr.dbx-newbar td { background:#12202f; padding:6px 8px; white-space:nowrap; } + .dbx-sql { flex:1; min-height:0; display:flex; flex-direction:column; gap:8px; padding:10px; } + .dbx-q { flex:none; height:110px; resize:vertical; font:13px/1.5 ui-monospace,Menlo,monospace; background:#0c0f13; color:var(--text); + border:1px solid var(--border); border-radius:8px; padding:8px 10px; } + .dbx-q:focus { border-color:var(--accent); outline:none; } + .dbx-sqlbar { display:flex; align-items:center; gap:10px; font-size:13px; } + .dbx-sqlmsg.bad { color:var(--danger); } + .dbx-sqlres { flex:1; min-height:0; overflow:auto; } .vdoc .dv-pdf { display:block; width:100%; height:100%; border:0; background:#525659; } .vdoc .dv-html { display:block; width:100%; height:100%; border:0; background:#fff; } .dv-doc { max-width:820px; margin:0 auto; padding:28px 32px 40px; font-size:15px; line-height:1.65; color:var(--text); overflow-wrap:anywhere; } @@ -468,6 +515,7 @@ +