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

This commit is contained in:
redtea committed 2026-10-15 16:50:56 +03:00
1 parent 0279c1d887
commit 0fa3de757c
7 files changed
+935 -1

No files matched your search

+1 -1
View File
@@ -20,7 +20,7 @@
а шаги подключения видны в статусе. Ключ хоста запоминается при первом входе а шаги подключения видны в статусе. Ключ хоста запоминается при первом входе
(`ssh_known_hosts` рядом с `state.json` агента); если он сменится, вход откажет. (`ssh_known_hosts` рядом с `state.json` агента); если он сменится, вход откажет.
- **Файлы** - просмотр, редактор с подсветкой, hex-редактор, предпросмотр картинок, видео, PDF, - **Файлы** - просмотр, редактор с подсветкой, hex-редактор, предпросмотр картинок, видео, PDF,
таблиц SQLite; загрузка и скачивание до 512 МБ кусками по 4 МБ (проходит через Cloudflare); баз SQLite с правкой (значение по клику, добавление и удаление строк, вкладка SQL; агент меняет базу через сам SQLite, так что работающая база не ломается); загрузка и скачивание до 512 МБ кусками по 4 МБ (проходит через Cloudflare);
свойства (chmod/chown), zip/unzip. свойства (chmod/chown), zip/unzip.
- **Перемещение и копирование** - внутри машины или, кнопкой «сеть» в том же окне, **на другую - **Перемещение и копирование** - внутри машины или, кнопкой «сеть» в том же окне, **на другую
машину или на SSH-хост**: папка уходит потоком tar без промежуточных файлов, с прогрессом, машину или на SSH-хост**: папка уходит потоком tar без промежуточных файлов, с прогрессом,
+512
View File
@@ -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, &notnull, &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
}
+2
View File
@@ -39,6 +39,8 @@ func dispatchTask(env proto.TaskEnvelope) (any, error) {
var a proto.SysUserAddArgs var a proto.SysUserAddArgs
_ = json.Unmarshal(env.Args, &a) _ = json.Unmarshal(env.Args, &a)
return nil, addUser(a) return nil, addUser(a)
case proto.TaskDBTables, proto.TaskDBRows, proto.TaskDBSet, proto.TaskDBAdd, proto.TaskDBDel, proto.TaskDBSQL:
return dbTask(env)
} }
return dispatchFS(env) return dispatchFS(env)
} }
+6
View File
@@ -161,6 +161,12 @@ const (
TaskSysUserAdd = "sys.useradd" // create a new OS user TaskSysUserAdd = "sys.useradd" // create a new OS user
TaskSysStat = "sys.stat" // CPU load, RAM and disk usage right now TaskSysStat = "sys.stat" // CPU load, RAM and disk usage right now
TaskSysSpeed = "sys.speed" // measure the link to the server (ping, down, up) 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 TaskFSShipOut = "fs.ship.out" // pack a file or folder and send it to another machine
TaskFSShipIn = "fs.ship.in" // unpack what another machine sends TaskFSShipIn = "fs.ship.in" // unpack what another machine sends
) )
+27
View File
@@ -1,6 +1,7 @@
package server package server
import ( import (
"encoding/json"
"io" "io"
"net/http" "net/http"
"time" "time"
@@ -22,6 +23,12 @@ var fsOps = map[string]string{
"stat": proto.TaskFSStat, "stat": proto.TaskFSStat,
"chmod": proto.TaskFSChmod, "chmod": proto.TaskFSChmod,
"copy": proto.TaskFSCopy, "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. // 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 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) taskID, resultCh := s.hub.CreateTask(agentID, taskType, args)
defer s.hub.DiscardTask(taskID) defer s.hub.DiscardTask(taskID)
+326
View File
@@ -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 {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c]; }); };
function cellHTML(c) {
if (c.t === 'n') return '<i class="dbx-null">NULL</i>';
if (c.t === 'b') return '<i class="dbx-null">BLOB ' + esc(c.v) + ' Б</i>';
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 =
'<div class="dbx">' +
'<div class="dbx-tabs" role="tablist"></div>' +
'<div class="dbx-tools">' +
' <span class="muted dbx-info"></span>' +
' <span class="dbx-pager"><button type="button" class="btn ghost small icon dbx-prev" title="Назад" aria-label="Назад">‹</button>' +
' <span class="muted dbx-range"></span><button type="button" class="btn ghost small icon dbx-next" title="Дальше" aria-label="Дальше">›</button></span>' +
' <span class="dbx-sp"></span>' +
' <button type="button" class="btn ghost small dbx-add">+ Строка</button>' +
' <button type="button" class="btn danger small dbx-del" disabled>Удалить</button>' +
'</div>' +
'<div class="dbx-grid"></div>' +
'<div class="dbx-sql" hidden>' +
' <textarea class="dbx-q" spellcheck="false" placeholder="SELECT * FROM … - Ctrl+Enter выполнить"></textarea>' +
' <div class="dbx-sqlbar"><button type="button" class="dbx-run">Выполнить</button><span class="muted dbx-sqlmsg"></span></div>' +
' <div class="dbx-sqlres"></div>' +
'</div>' +
'</div>';
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 = '<p class="dv-note">' + esc(r.error || 'ошибка') + '</p>'; 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 = '<table class="dbx-t"><thead><tr>' + (editable ? '<th class="dbx-ck"><input type="checkbox" class="dbx-all" aria-label="Выбрать все"></th>' : '') +
(showRowid ? '<th class="dbx-k">rowid</th>' : '');
t.columns.forEach(function (c) {
h += '<th title="' + esc(c.type + (c.pk ? ' · ключ' : '') + (c.notnull ? ' · NOT NULL' : '')) + '">' + esc(c.name) +
(c.type ? '<small>' + esc(c.type.toLowerCase()) + '</small>' : '') + '</th>';
});
h += '</tr></thead><tbody>';
d.rows.forEach(function (row, i) {
h += '<tr data-i="' + i + '">' + (editable ? '<td class="dbx-ck"><input type="checkbox" aria-label="Выбрать строку"></td>' : '') +
(showRowid ? '<td class="dbx-k">' + esc(row.k) + '</td>' : '');
row.v.forEach(function (c, j) {
h += '<td data-j="' + j + '" class="' + (c.t === 'i' || c.t === 'r' ? 'num' : '') + (editable && c.t !== 'b' ? ' ed' : '') + '">' + cellHTML(c) + '</td>';
});
h += '</tr>';
});
h += '</tbody></table>';
if (!d.rows.length) h += '<p class="dv-note">строк нет</p>';
$('.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 = '<div class="dbx-edit"><textarea spellcheck="false"></textarea><button type="button" class="dbx-nullb" title="Сделать NULL" aria-label="Сделать NULL">∅</button></div>';
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 ? '<td class="dbx-ck"></td>' : '') + (t.key === 'rowid' && !t.alias ? '<td class="dbx-k">новая</td>' : '');
t.columns.forEach(function (c) {
h += '<td><input type="text" data-c="' + esc(c.name) + '" placeholder="' + esc(c.default ? 'по умолч.: ' + c.default : c.notnull ? 'обязательно' : 'NULL') + '"></td>';
});
tr.innerHTML = h;
tbody.insertBefore(tr, tbody.firstChild);
var bar = document.createElement('tr');
bar.className = 'dbx-newbar';
bar.innerHTML = '<td colspan="' + (t.columns.length + 2) + '"><button type="button" class="small dbx-addok">Добавить</button> <button type="button" class="btn ghost small dbx-addno">Отмена</button> <span class="muted">пустое поле - значение по умолчанию</span></td>';
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 = '<table class="dbx-t"><thead><tr>' + d.columns.map(function (c) { return '<th>' + esc(c) + '</th>'; }).join('') + '</tr></thead><tbody>';
d.rows.forEach(function (row) {
h += '<tr>' + row.map(function (c) { return '<td class="' + (c.t === 'i' || c.t === 'r' ? 'num' : '') + '">' + cellHTML(c) + '</td>'; }).join('') + '</tr>';
});
out.innerHTML = h + '</tbody></table>';
} 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};
})();
+61
View File
@@ -207,6 +207,53 @@
.find #frow2, .find #frow2b { grid-row:2; } .find #frow2, .find #frow2b { grid-row:2; }
/* Documents and tables, drawn in the panel's colours. */ /* Documents and tables, drawn in the panel's colours. */
.vdoc { height:75vh; overflow:auto; background:#0c0f13; border-radius:0 0 12px 12px; } .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-pdf { display:block; width:100%; height:100%; border:0; background:#525659; }
.vdoc .dv-html { display:block; width:100%; height:100%; border:0; background:#fff; } .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; } .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 @@
<script src="{{asset "codemirror/codemirror.js"}}"></script> <script src="{{asset "codemirror/codemirror.js"}}"></script>
<script src="{{asset "docview.js"}}"></script> <script src="{{asset "docview.js"}}"></script>
<script src="{{asset "dbedit.js"}}"></script>
<script> <script>
(function () { (function () {
const agentID = {{.Agent.ID}}; const agentID = {{.Agent.ID}};
@@ -865,6 +913,19 @@
if (r.data.truncated) $('vmsg').textContent += ' · показаны первые ' + fmtSize(EDIT_LIMIT); if (r.data.truncated) $('vmsg').textContent += ' · показаны первые ' + fmtSize(EDIT_LIMIT);
return; return;
} }
if (/^(db|sqlite3?|db3)$/.test(ext)) {
// The agent opens the database and edits it; one that predates that
// gets the file read here, for viewing only.
box.innerHTML = '<p class="dv-note">загрузка…</p>';
const r = await DBEdit.open(box, path, (op, args) => api(op, args), toast);
if (V.path !== path || V.kind !== 'doc') return;
if (r && r.ok) return;
if (!/неизвестная операция/.test((r && r.error) || '')) {
box.innerHTML = '<p class="dv-note">не удалось открыть: ' + esc((r && r.error) || 'ошибка') + '</p>';
return;
}
$('vmsg').append(' · только просмотр: обновите агента, чтобы править');
}
if (size > DOC_CAP) { box.innerHTML = '<p class="dv-note">файл больше ' + fmtSize(DOC_CAP) + ' - для просмотра скачайте его</p>'; return; } if (size > DOC_CAP) { box.innerHTML = '<p class="dv-note">файл больше ' + fmtSize(DOC_CAP) + ' - для просмотра скачайте его</p>'; return; }
box.innerHTML = '<p class="dv-note">загрузка…</p>'; box.innerHTML = '<p class="dv-note">загрузка…</p>';
let html; let html;