From 895349bc57189d4514e28ad2260a4962536b3d83 Mon Sep 17 00:00:00 2001 From: zx06 <12474586+zx06@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:48:41 +0800 Subject: [PATCH] feat: add webui --- .github/workflows/ci.yml | 45 + .github/workflows/release.yml | 10 +- .gitignore | 3 + .goreleaser.yaml | 8 +- cmd/xsql/command_unit_test.go | 49 + cmd/xsql/main.go | 2 + cmd/xsql/profile.go | 55 +- cmd/xsql/query.go | 27 +- cmd/xsql/schema.go | 28 +- cmd/xsql/web.go | 210 ++ docs/ai.md | 10 + docs/architecture.md | 1 + docs/cli-spec.md | 63 + docs/config.md | 14 + docs/dev.md | 9 + docs/env.md | 2 + docs/error-contract.md | 14 +- docs/rfcs/0007-web-ui.md | 114 ++ internal/app/app.go | 20 + internal/app/service.go | 273 +++ internal/config/types.go | 13 + internal/db/mysql/schema.go | 219 +- internal/db/pg/schema.go | 248 ++- internal/db/schema.go | 95 +- internal/db/schema_test.go | 93 +- internal/errors/codes.go | 6 + internal/errors/exitcode_test.go | 4 +- internal/output/writer.go | 24 +- internal/output/writer_test.go | 6 +- internal/web/handler.go | 448 +++++ internal/web/handler_test.go | 260 +++ internal/web/server.go | 58 + tests/e2e/web_test.go | 110 + tests/integration/schema_dump_test.go | 42 + webui/embed.go | 20 + webui/index.html | 12 + webui/package-lock.json | 1774 +++++++++++++++++ webui/package.json | 26 + webui/skills/svelte-code-writer/SKILL.md | 66 + .../skills/svelte-core-bestpractices/SKILL.md | 176 ++ .../references/$inspect.md | 53 + .../references/@attach.md | 166 ++ .../references/@render.md | 35 + .../references/await-expressions.md | 180 ++ .../references/bind.md | 16 + .../references/each.md | 42 + .../references/hydratable.md | 100 + .../references/snippet.md | 276 +++ .../references/svelte-reactivity.md | 61 + webui/src/App.svelte | 102 + webui/src/app.css | 234 +++ webui/src/lib/components/ObjectTree.svelte | 48 + webui/src/lib/components/QueryEditor.svelte | 183 ++ webui/src/lib/components/ResultsTable.svelte | 268 +++ webui/src/lib/components/SectionHeader.svelte | 12 + webui/src/lib/components/Sidebar.svelte | 59 + .../src/lib/components/StructureTable.svelte | 52 + .../components/ThemeProfileControls.svelte | 52 + webui/src/lib/components/WorkspaceTabs.svelte | 31 + webui/src/lib/result-grid.js | 117 ++ webui/src/lib/sql-editor.js | 359 ++++ webui/src/lib/web-ui.svelte.js | 379 ++++ webui/src/main.js | 7 + webui/svelte.config.js | 1 + webui/vite.config.js | 19 + 65 files changed, 7192 insertions(+), 317 deletions(-) create mode 100644 cmd/xsql/web.go create mode 100644 docs/rfcs/0007-web-ui.md create mode 100644 internal/app/service.go create mode 100644 internal/web/handler.go create mode 100644 internal/web/handler_test.go create mode 100644 internal/web/server.go create mode 100644 tests/e2e/web_test.go create mode 100644 webui/embed.go create mode 100644 webui/index.html create mode 100644 webui/package-lock.json create mode 100644 webui/package.json create mode 100644 webui/skills/svelte-code-writer/SKILL.md create mode 100644 webui/skills/svelte-core-bestpractices/SKILL.md create mode 100644 webui/skills/svelte-core-bestpractices/references/$inspect.md create mode 100644 webui/skills/svelte-core-bestpractices/references/@attach.md create mode 100644 webui/skills/svelte-core-bestpractices/references/@render.md create mode 100644 webui/skills/svelte-core-bestpractices/references/await-expressions.md create mode 100644 webui/skills/svelte-core-bestpractices/references/bind.md create mode 100644 webui/skills/svelte-core-bestpractices/references/each.md create mode 100644 webui/skills/svelte-core-bestpractices/references/hydratable.md create mode 100644 webui/skills/svelte-core-bestpractices/references/snippet.md create mode 100644 webui/skills/svelte-core-bestpractices/references/svelte-reactivity.md create mode 100644 webui/src/App.svelte create mode 100644 webui/src/app.css create mode 100644 webui/src/lib/components/ObjectTree.svelte create mode 100644 webui/src/lib/components/QueryEditor.svelte create mode 100644 webui/src/lib/components/ResultsTable.svelte create mode 100644 webui/src/lib/components/SectionHeader.svelte create mode 100644 webui/src/lib/components/Sidebar.svelte create mode 100644 webui/src/lib/components/StructureTable.svelte create mode 100644 webui/src/lib/components/ThemeProfileControls.svelte create mode 100644 webui/src/lib/components/WorkspaceTabs.svelte create mode 100644 webui/src/lib/result-grid.js create mode 100644 webui/src/lib/sql-editor.js create mode 100644 webui/src/lib/web-ui.svelte.js create mode 100644 webui/src/main.js create mode 100644 webui/svelte.config.js create mode 100644 webui/vite.config.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b890771..76f942c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,21 @@ jobs: go-version: ${{ matrix.go-version }} cache: true + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '20.19.0' + cache: 'npm' + cache-dependency-path: webui/package-lock.json + + - name: Install web UI dependencies + run: npm ci + working-directory: webui + + - name: Build web UI + run: npm run build + working-directory: webui + - name: Download dependencies run: go mod download @@ -99,6 +114,21 @@ jobs: go-version: '1.24' cache: true + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '20.19.0' + cache: 'npm' + cache-dependency-path: webui/package-lock.json + + - name: Install web UI dependencies + run: npm ci + working-directory: webui + + - name: Build web UI + run: npm run build + working-directory: webui + - name: Download dependencies run: go mod download @@ -168,6 +198,21 @@ jobs: go-version: '1.24' cache: true + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '20.19.0' + cache: 'npm' + cache-dependency-path: webui/package-lock.json + + - name: Install web UI dependencies + run: npm ci + working-directory: webui + + - name: Build web UI + run: npm run build + working-directory: webui + - name: Build Linux run: GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o xsql-linux-amd64 ./cmd/xsql diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e4a4799..9853cdb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,13 @@ jobs: go-version: "1.24" cache: true + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "20.19.0" + cache: "npm" + cache-dependency-path: webui/package-lock.json + - name: Run GoReleaser uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 with: @@ -47,7 +54,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: "20" + node-version: "20.19.0" registry-url: "https://registry.npmjs.org" - name: Configure npm auth @@ -59,4 +66,3 @@ jobs: run: node scripts/npm-publish.js "${{ github.ref_name }}" env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - diff --git a/.gitignore b/.gitignore index 898a52c..5b7ee3c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ npm/*/bin/xsql npm/*/bin/xsql.exe coverage.txt +webui/node_modules/ +webui/dist/* +!webui/dist/.keep diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 6321a7f..8dac074 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -2,9 +2,11 @@ version: 2 project_name: xsql -before: - hooks: - - go mod tidy +before: + hooks: + - npm --prefix webui ci + - npm --prefix webui run build + - go mod tidy builds: - id: xsql diff --git a/cmd/xsql/command_unit_test.go b/cmd/xsql/command_unit_test.go index d8b8e3c..4ca30dd 100644 --- a/cmd/xsql/command_unit_test.go +++ b/cmd/xsql/command_unit_test.go @@ -49,6 +49,55 @@ func TestNormalizeErr(t *testing.T) { } } +func TestResolveWebOptions_DefaultLoopback(t *testing.T) { + resolved, xe := resolveWebOptions(&webCommandOptions{}, config.File{}) + if xe != nil { + t.Fatalf("unexpected error: %v", xe) + } + if resolved.addr != "127.0.0.1:8788" { + t.Fatalf("addr=%q", resolved.addr) + } + if resolved.authRequired { + t.Fatal("loopback address should not require auth") + } +} + +func TestResolveWebOptions_RemoteRequiresToken(t *testing.T) { + _, xe := resolveWebOptions(&webCommandOptions{ + addr: "0.0.0.0:8788", + addrSet: true, + }, config.File{}) + if xe == nil { + t.Fatal("expected error") + } + if xe.Code != errors.CodeCfgInvalid { + t.Fatalf("code=%s", xe.Code) + } +} + +func TestResolveWebOptions_ConfigToken(t *testing.T) { + resolved, xe := resolveWebOptions(&webCommandOptions{ + addr: "0.0.0.0:8788", + addrSet: true, + }, config.File{ + Web: config.WebConfig{ + HTTP: config.WebHTTPConfig{ + AuthToken: "token", + AllowPlaintextToken: true, + }, + }, + }) + if xe != nil { + t.Fatalf("unexpected error: %v", xe) + } + if !resolved.authRequired { + t.Fatal("expected authRequired=true") + } + if resolved.authToken != "token" { + t.Fatalf("authToken=%q", resolved.authToken) + } +} + func TestRun_SpecCommandSuccess(t *testing.T) { prev := GlobalConfig GlobalConfig = &Config{} diff --git a/cmd/xsql/main.go b/cmd/xsql/main.go index d31472e..28e6200 100644 --- a/cmd/xsql/main.go +++ b/cmd/xsql/main.go @@ -31,6 +31,8 @@ func run() int { root.AddCommand(NewMCPCommand()) root.AddCommand(NewProxyCommand(&w)) root.AddCommand(NewConfigCommand(&w)) + root.AddCommand(NewServeCommand(&w)) + root.AddCommand(NewWebCommand(&w)) // Execute and handle errors if err := root.Execute(); err != nil { diff --git a/cmd/xsql/profile.go b/cmd/xsql/profile.go index 6a8035a..4166736 100644 --- a/cmd/xsql/profile.go +++ b/cmd/xsql/profile.go @@ -3,8 +3,8 @@ package main import ( "github.com/spf13/cobra" + "github.com/zx06/xsql/internal/app" "github.com/zx06/xsql/internal/config" - "github.com/zx06/xsql/internal/errors" "github.com/zx06/xsql/internal/output" ) @@ -32,23 +32,13 @@ func newProfileListCommand(w *output.Writer) *cobra.Command { return err } - cfg, cfgPath, xe := config.LoadConfig(config.Options{ + result, xe := app.LoadProfiles(config.Options{ ConfigPath: GlobalConfig.ConfigStr, }) if xe != nil { return xe } - profiles := make([]config.ProfileInfo, 0, len(cfg.Profiles)) - for name, p := range cfg.Profiles { - profiles = append(profiles, config.ProfileToInfo(name, p)) - } - - result := map[string]any{ - "config_path": cfgPath, - "profiles": profiles, - } - return w.WriteOK(format, result) }, } @@ -67,50 +57,13 @@ func newProfileShowCommand(w *output.Writer) *cobra.Command { return err } - cfg, cfgPath, xe := config.LoadConfig(config.Options{ + result, xe := app.LoadProfileDetail(config.Options{ ConfigPath: GlobalConfig.ConfigStr, - }) + }, name) if xe != nil { return xe } - profile, ok := cfg.Profiles[name] - if !ok { - return errors.New(errors.CodeCfgInvalid, "profile not found", map[string]any{"name": name}) - } - - // Redact sensitive information: hide password - result := map[string]any{ - "config_path": cfgPath, - "name": name, - "description": profile.Description, - "db": profile.DB, - "host": profile.Host, - "port": profile.Port, - "user": profile.User, - "database": profile.Database, - "unsafe_allow_write": profile.UnsafeAllowWrite, - "allow_plaintext": profile.AllowPlaintext, - } - - if profile.DSN != "" { - result["dsn"] = "***" - } - if profile.Password != "" { - result["password"] = "***" - } - if profile.SSHProxy != "" { - result["ssh_proxy"] = profile.SSHProxy - if proxy, ok := cfg.SSHProxies[profile.SSHProxy]; ok { - result["ssh_host"] = proxy.Host - result["ssh_port"] = proxy.Port - result["ssh_user"] = proxy.User - if proxy.IdentityFile != "" { - result["ssh_identity_file"] = proxy.IdentityFile - } - } - } - return w.WriteOK(format, result) }, } diff --git a/cmd/xsql/query.go b/cmd/xsql/query.go index 61d54f7..2217c2f 100644 --- a/cmd/xsql/query.go +++ b/cmd/xsql/query.go @@ -7,8 +7,6 @@ import ( "github.com/spf13/cobra" "github.com/zx06/xsql/internal/app" - "github.com/zx06/xsql/internal/db" - "github.com/zx06/xsql/internal/errors" "github.com/zx06/xsql/internal/output" ) @@ -54,33 +52,16 @@ func runQuery(cmd *cobra.Command, args []string, flags *QueryFlags, w *output.Wr } p := GlobalConfig.Resolved.Profile - if p.DB == "" { - return errors.New(errors.CodeCfgInvalid, "db type is required (mysql|pg)", nil) - } - - timeout := DefaultQueryTimeout - if flags.QueryTimeoutSet && flags.QueryTimeout > 0 { - timeout = time.Duration(flags.QueryTimeout) * time.Second - } else if p.QueryTimeout > 0 { - timeout = time.Duration(p.QueryTimeout) * time.Second - } + timeout := app.QueryTimeout(p, flags.QueryTimeout, flags.QueryTimeoutSet, DefaultQueryTimeout) ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - conn, xe := app.ResolveConnection(ctx, app.ConnectionOptions{ + result, xe := app.Query(ctx, app.QueryRequest{ Profile: p, + SQL: sql, AllowPlaintext: flags.AllowPlaintext, SkipHostKeyCheck: flags.SSHSkipHostKey, - }) - if xe != nil { - return xe - } - defer func() { _ = conn.Close() }() - - unsafeAllowWrite := flags.UnsafeAllowWrite || p.UnsafeAllowWrite - result, xe := db.Query(ctx, conn.DB, sql, db.QueryOptions{ - UnsafeAllowWrite: unsafeAllowWrite, - DBType: p.DB, + UnsafeAllowWrite: flags.UnsafeAllowWrite || p.UnsafeAllowWrite, }) if xe != nil { return xe diff --git a/cmd/xsql/schema.go b/cmd/xsql/schema.go index ed4e974..dac0946 100644 --- a/cmd/xsql/schema.go +++ b/cmd/xsql/schema.go @@ -7,8 +7,6 @@ import ( "github.com/spf13/cobra" "github.com/zx06/xsql/internal/app" - "github.com/zx06/xsql/internal/db" - "github.com/zx06/xsql/internal/errors" "github.com/zx06/xsql/internal/output" ) @@ -67,38 +65,20 @@ func runSchemaDump(cmd *cobra.Command, args []string, flags *SchemaFlags, w *out } p := GlobalConfig.Resolved.Profile - if p.DB == "" { - return errors.New(errors.CodeCfgInvalid, "db type is required (mysql|pg)", nil) - } - - timeout := DefaultSchemaTimeout - if flags.SchemaTimeoutSet && flags.SchemaTimeout > 0 { - timeout = time.Duration(flags.SchemaTimeout) * time.Second - } else if p.SchemaTimeout > 0 { - timeout = time.Duration(p.SchemaTimeout) * time.Second - } + timeout := app.SchemaTimeout(p, flags.SchemaTimeout, flags.SchemaTimeoutSet, DefaultSchemaTimeout) ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - conn, xe := app.ResolveConnection(ctx, app.ConnectionOptions{ + result, xe := app.DumpSchema(ctx, app.SchemaDumpRequest{ Profile: p, + TablePattern: flags.TablePattern, + IncludeSystem: flags.IncludeSystem, AllowPlaintext: flags.AllowPlaintext, SkipHostKeyCheck: flags.SSHSkipHostKey, }) if xe != nil { return xe } - defer func() { _ = conn.Close() }() - - schemaOpts := db.SchemaOptions{ - TablePattern: flags.TablePattern, - IncludeSystem: flags.IncludeSystem, - } - - result, xe := db.DumpSchema(ctx, p.DB, conn.DB, schemaOpts) - if xe != nil { - return xe - } return w.WriteOK(format, result) } diff --git a/cmd/xsql/web.go b/cmd/xsql/web.go new file mode 100644 index 0000000..30be30c --- /dev/null +++ b/cmd/xsql/web.go @@ -0,0 +1,210 @@ +package main + +import ( + "context" + stderrors "errors" + "log" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "runtime" + "syscall" + "time" + + "github.com/spf13/cobra" + + "github.com/zx06/xsql/internal/config" + "github.com/zx06/xsql/internal/errors" + "github.com/zx06/xsql/internal/output" + "github.com/zx06/xsql/internal/secret" + webpkg "github.com/zx06/xsql/internal/web" +) + +var openBrowser = openBrowserDefault + +type webCommandOptions struct { + addr string + addrSet bool + authToken string + authTokenSet bool + allowPlaintext bool + skipHostKey bool + openBrowser bool +} + +type resolvedWebOptions struct { + addr string + authToken string + authRequired bool +} + +// NewServeCommand starts the embedded web server. +func NewServeCommand(w *output.Writer) *cobra.Command { + return newWebCommand("serve", "Start the local web management server", false, w) +} + +// NewWebCommand starts the embedded web server and opens a browser. +func NewWebCommand(w *output.Writer) *cobra.Command { + return newWebCommand("web", "Start the local web management server and open a browser", true, w) +} + +func newWebCommand(use, short string, shouldOpenBrowser bool, w *output.Writer) *cobra.Command { + opts := &webCommandOptions{openBrowser: shouldOpenBrowser} + cmd := &cobra.Command{ + Use: use, + Short: short, + RunE: func(cmd *cobra.Command, args []string) error { + opts.addrSet = cmd.Flags().Changed("addr") + opts.authTokenSet = cmd.Flags().Changed("auth-token") + return runWebCommand(opts, w) + }, + } + cmd.Flags().StringVar(&opts.addr, "addr", webpkg.DefaultAddr, "Web HTTP listen address") + cmd.Flags().StringVar(&opts.authToken, "auth-token", "", "Bearer auth token (required for non-loopback addresses)") + cmd.Flags().BoolVar(&opts.allowPlaintext, "allow-plaintext", false, "Allow plaintext secrets in config") + cmd.Flags().BoolVar(&opts.skipHostKey, "ssh-skip-known-hosts-check", false, "Skip SSH known_hosts check (dangerous)") + return cmd +} + +func runWebCommand(opts *webCommandOptions, w *output.Writer) error { + cfg, _, xe := config.LoadConfig(config.Options{ + ConfigPath: GlobalConfig.ConfigStr, + }) + if xe != nil { + return xe + } + + resolved, xe := resolveWebOptions(opts, cfg) + if xe != nil { + return xe + } + + listener, err := net.Listen("tcp", resolved.addr) + if err != nil { + if opErr := (*net.OpError)(nil); stderrors.As(err, &opErr) { + return errors.Wrap(errors.CodePortInUse, "failed to listen on web address", map[string]any{"addr": resolved.addr}, err) + } + return errors.Wrap(errors.CodeInternal, "failed to listen on web address", map[string]any{"addr": resolved.addr}, err) + } + defer listener.Close() + + handler := webpkg.NewHandler(webpkg.HandlerOptions{ + ConfigPath: GlobalConfig.ConfigStr, + InitialProfile: GlobalConfig.ProfileStr, + AllowPlaintext: opts.allowPlaintext, + SkipHostKeyCheck: opts.skipHostKey, + AuthRequired: resolved.authRequired, + AuthToken: resolved.authToken, + }) + server := webpkg.NewServer(listener, handler) + url := webpkg.PublicURL(server.Addr()) + + format, err := parseOutputFormat(GlobalConfig.FormatStr) + if err != nil { + return err + } + if err := w.WriteOK(format, map[string]any{ + "addr": server.Addr(), + "url": url, + "auth_required": resolved.authRequired, + "mode": modeForWebCommand(opts.openBrowser), + }); err != nil { + return err + } + + if opts.openBrowser { + if err := openBrowser(url); err != nil { + log.Printf("[web] failed to open browser: %v", err) + } + } + + errCh := make(chan error, 1) + go func() { + errCh <- server.Serve() + }() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigChan) + + select { + case sig := <-sigChan: + _ = sig + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if shutdownErr := server.Shutdown(ctx); shutdownErr != nil { + return errors.Wrap(errors.CodeInternal, "failed to shutdown web server", nil, shutdownErr) + } + return nil + case err := <-errCh: + if err == nil || err == http.ErrServerClosed { + return nil + } + return errors.Wrap(errors.CodeInternal, "web server stopped unexpectedly", nil, err) + } +} + +func resolveWebOptions(opts *webCommandOptions, cfg config.File) (resolvedWebOptions, *errors.XError) { + if opts == nil { + opts = &webCommandOptions{} + } + + addr := firstNonEmpty( + valueIfSet(opts.addrSet, opts.addr), + os.Getenv("XSQL_WEB_HTTP_ADDR"), + cfg.Web.HTTP.Addr, + ) + if addr == "" { + addr = webpkg.DefaultAddr + } + if _, _, err := net.SplitHostPort(addr); err != nil { + return resolvedWebOptions{}, errors.New(errors.CodeCfgInvalid, "invalid web listen address", map[string]any{"addr": addr}) + } + + authToken := firstNonEmpty( + valueIfSet(opts.authTokenSet, opts.authToken), + os.Getenv("XSQL_WEB_HTTP_AUTH_TOKEN"), + ) + if authToken == "" && cfg.Web.HTTP.AuthToken != "" { + secretValue, xe := secret.Resolve(cfg.Web.HTTP.AuthToken, secret.Options{ + AllowPlaintext: cfg.Web.HTTP.AllowPlaintextToken, + }) + if xe != nil { + return resolvedWebOptions{}, xe + } + authToken = secretValue + } + + authRequired := !webpkg.IsLoopbackAddr(addr) + if authRequired && authToken == "" { + return resolvedWebOptions{}, errors.New(errors.CodeCfgInvalid, "web auth token is required for non-loopback addresses", map[string]any{"addr": addr}) + } + + return resolvedWebOptions{ + addr: addr, + authToken: authToken, + authRequired: authRequired, + }, nil +} + +func modeForWebCommand(open bool) string { + if open { + return "web" + } + return "serve" +} + +func openBrowserDefault(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + cmd = exec.Command("xdg-open", url) + } + return cmd.Start() +} diff --git a/docs/ai.md b/docs/ai.md index 1ab43ed..0a31991 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -95,3 +95,13 @@ MCP Server 提供以下 tools: ### 详细规范 详见 `docs/cli-spec.md` 中的 `xsql mcp server` 命令说明。 + +## Web UI +xsql 还提供本地 Web UI 模式,用于人工交互式查询和 schema 浏览: + +```bash +xsql serve +xsql web +``` + +Web UI 复用 xsql 的 profile、SSH、只读策略和结构化错误契约,但其 HTTP API 面向浏览器,不等同于 MCP 协议。 diff --git a/docs/architecture.md b/docs/architecture.md index 6d0460f..5e640e1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,3 +26,4 @@ - DB driver:通过 registry 注册,实现新数据库无需改动核心逻辑。 - 输出 formatter:通过接口注册新格式。 - Frontend:CLI/MCP/TUI/Web 仅是调用 `internal/app` 的不同适配层。 +- Web:本地 HTTP server 与嵌入式前端通过 `internal/web` 适配,复用 `internal/app` 业务服务。 diff --git a/docs/cli-spec.md b/docs/cli-spec.md index 18b7854..506c4f9 100644 --- a/docs/cli-spec.md +++ b/docs/cli-spec.md @@ -179,6 +179,69 @@ Table: public.users (用户表) > **注意**:schema dump 是只读操作,遵循 profile 的只读策略。 +### `xsql serve` + +启动本地 Web 管理服务,提供 profile 浏览、schema 浏览和只读 SQL 查询能力。 + +```bash +# 启动服务 +xsql serve + +# 监听自定义地址 +xsql serve --addr 127.0.0.1:9000 + +# 远程监听(必须提供 token) +xsql serve --addr 0.0.0.0:8788 --auth-token "your-token" +``` + +**Flags:** +| Flag | 默认值 | 说明 | +|------|--------|------| +| `--addr` | `127.0.0.1:8788` | Web 服务监听地址 | +| `--auth-token` | - | Bearer token;非 loopback 地址必填 | +| `--allow-plaintext` | false | 允许配置中使用明文 secrets | +| `--ssh-skip-known-hosts-check` | false | 跳过 SSH 主机密钥验证(危险) | + +**输出示例(JSON):** +```json +{ + "ok": true, + "schema_version": 1, + "data": { + "addr": "127.0.0.1:8788", + "url": "http://127.0.0.1:8788/", + "auth_required": false, + "mode": "serve" + } +} +``` + +### `xsql web` + +启动 Web 管理服务,并在服务就绪后尝试打开默认浏览器。 + +```bash +xsql web +xsql web --addr 127.0.0.1:9000 +``` + +> **注意**:浏览器打开为 best-effort;若打开失败,服务仍继续运行。 + +### Web API + +Web UI 使用以下 HTTP API,统一返回 JSON Envelope: + +| Method | Path | 说明 | +|--------|------|------| +| `GET` | `/api/v1/health` | 服务健康检查 | +| `GET` | `/api/v1/profiles` | 列出 profiles | +| `GET` | `/api/v1/profiles/{name}` | 查看 profile 详情(脱敏) | +| `GET` | `/api/v1/schema/tables` | 列出表(支持 `profile`/`table`/`include_system`) | +| `GET` | `/api/v1/schema/tables/{schema}/{table}` | 查看单表结构(支持 `profile`) | +| `POST` | `/api/v1/query` | 执行只读 SQL 查询 | + +Web 查询强制只读,即使 profile 配置了 `unsafe_allow_write: true`,也不会在 Web 接口中生效。 + ### `xsql spec` 导出 tool spec(供 AI/agent 自动发现)。 diff --git a/docs/config.md b/docs/config.md index 53b072c..e70aad5 100644 --- a/docs/config.md +++ b/docs/config.md @@ -39,6 +39,12 @@ mcp: auth_token: "keyring:mcp/http_token" allow_plaintext_token: false +web: + http: + addr: 127.0.0.1:8788 + auth_token: "keyring:web/http_token" + allow_plaintext_token: false + ssh_proxies: bastion: host: bastion.example.com @@ -104,6 +110,14 @@ profiles: | `mcp.http.auth_token` | string | Streamable HTTP 鉴权 token(支持 `keyring:` 引用) | | `mcp.http.allow_plaintext_token` | bool | 允许在配置中使用明文 token | +## Web 配置项 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `web.http.addr` | string | Web 服务监听地址 | +| `web.http.auth_token` | string | Web 鉴权 token(支持 `keyring:` 引用) | +| `web.http.allow_plaintext_token` | bool | 允许在配置中使用明文 token | + ## SSH Proxy 配置项 | 字段 | 类型 | 说明 | diff --git a/docs/dev.md b/docs/dev.md index 4775e99..8a3cdf8 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -16,6 +16,14 @@ ## 快速开始 ```bash +# 构建 web UI +npm --prefix webui install +npm --prefix webui run build + +# 前端联调(默认代理到 http://127.0.0.1:8788) +xsql serve --addr 127.0.0.1:8788 +npm --prefix webui run dev + # 构建 go build -o xsql ./cmd/xsql @@ -104,6 +112,7 @@ internal/ secret/ # keyring + 明文策略 spec/ # tool spec 导出 ssh/ # SSH client + dial +webui/ # Svelte 前端源码 + 嵌入资源 tests/ e2e/ # E2E 测试 integration/ # 集成测试(需要数据库) diff --git a/docs/env.md b/docs/env.md index d6c4624..22b0ff8 100644 --- a/docs/env.md +++ b/docs/env.md @@ -8,6 +8,8 @@ - `XSQL_MCP_TRANSPORT`:MCP 传输(`stdio` 或 `streamable_http`) - `XSQL_MCP_HTTP_ADDR`:Streamable HTTP 监听地址 - `XSQL_MCP_HTTP_AUTH_TOKEN`:Streamable HTTP 鉴权 token +- `XSQL_WEB_HTTP_ADDR`:Web 服务监听地址 +- `XSQL_WEB_HTTP_AUTH_TOKEN`:Web 鉴权 token ## 2. 连接参数(计划中,当前未实现) > 当前版本连接参数通过 config 文件的 profile 配置,ENV 支持计划在后续版本实现。 diff --git a/docs/error-contract.md b/docs/error-contract.md index f41fa05..31696ca 100644 --- a/docs/error-contract.md +++ b/docs/error-contract.md @@ -55,11 +55,15 @@ DB 类: 只读策略: - `XSQL_RO_BLOCKED` - 写操作被只读策略拦截 -端口: -- `XSQL_PORT_IN_USE` - 代理端口被占用 - -内部: -- `XSQL_INTERNAL` - 内部错误 +端口: +- `XSQL_PORT_IN_USE` - 代理端口被占用 + +鉴权: +- `XSQL_AUTH_REQUIRED` - 请求缺少鉴权 token +- `XSQL_AUTH_INVALID` - 请求提供的鉴权 token 无效 + +内部: +- `XSQL_INTERNAL` - 内部错误 ## 5. 查询结果格式 diff --git a/docs/rfcs/0007-web-ui.md b/docs/rfcs/0007-web-ui.md new file mode 100644 index 0000000..a468345 --- /dev/null +++ b/docs/rfcs/0007-web-ui.md @@ -0,0 +1,114 @@ +# RFC 0007: Web 查询界面(serve / web) + +Status: Draft + +## 摘要 +为 xsql 增加一个本地优先的 Web 适配层,提供数据库查询与 schema 浏览能力。新增 `xsql serve` 和 `xsql web` 两个命令,其中 `web` 会在服务就绪后尝试打开默认浏览器。前端采用 Svelte,源码位于仓库子目录,CI/release 阶段先构建前端,再将产物嵌入 Go 二进制中发布。首版 Web 仅支持只读查询,不提供写操作入口。Query 编辑区使用 CodeMirror 6,提供 SQL 高亮、关键字补全、基于 schema API 的表/列补全,以及浏览器内本地 SQL 格式化。常用动作支持编辑器内快捷键:`Mod-Enter` 运行查询,`Shift-Alt-F` / `Mod-Shift-F` 格式化 SQL。Results 区采用紧凑结果网格:单元格默认单行截断,桌面端 hover 快速预览,点击后在底部详情区查看完整值并支持复制。 + +## 背景 / 动机 +- 当前痛点: + - xsql 目前仅提供 CLI/MCP 入口,交互式查询和 schema 浏览体验较弱。 + - 用户希望在本地以图形化方式浏览 profile、查看 schema、编写查询。 +- 目标: + - 保持 xsql 现有配置、错误码、SSH、只读策略不变,新增一个 Web 入口。 + - 复用 internal 层已有能力,避免 CLI/MCP/Web 三套业务实现。 + - 发布时仍然只交付单个 xsql 二进制。 +- 非目标: + - 不提供写操作能力。 + - 不实现登录系统、查询历史、保存 SQL、多标签页等复杂状态管理。 + +## 方案(Proposed) +### 用户视角(CLI/配置/输出) +- 新增命令: + - `xsql serve`:启动 Web 服务 + - `xsql web`:启动 Web 服务并尝试打开浏览器 +- 新增配置: + ```yaml + web: + http: + addr: 127.0.0.1:8788 + auth_token: "keyring:web/http_token" + allow_plaintext_token: false + ``` +- 新增环境变量: + - `XSQL_WEB_HTTP_ADDR` + - `XSQL_WEB_HTTP_AUTH_TOKEN` +- 新增命令参数: + - `--addr` + - `--auth-token` + - `--allow-plaintext` + - `--ssh-skip-known-hosts-check` +- 默认行为: + - 默认监听 `127.0.0.1:8788` + - loopback 地址允许免鉴权访问 + - 非 loopback 地址必须提供 Bearer token + - Web 查询始终强制只读,不继承 profile 的 `unsafe_allow_write` +- HTTP API 返回继续沿用 xsql JSON 契约: + - 成功:`{"ok":true,"schema_version":1,"data":{...}}` + - 失败:`{"ok":false,"schema_version":1,"error":{"code":"...","message":"...","details":{...}}}` + +### 技术设计(Architecture) +- 涉及模块: + - `internal/app`:提炼 profile/query/schema 服务 + - `internal/web`:HTTP server、鉴权、静态资源服务、API handler + - `cmd/xsql`:`serve` / `web` CLI 命令 + - `webui/`:Svelte 前端源码和嵌入资源 +- 数据结构/接口: + - API 前缀固定 `/api/v1` + - `GET /api/v1/health` + - `GET /api/v1/profiles` + - `GET /api/v1/profiles/{name}` + - `GET /api/v1/schema/tables?profile=&table=&include_system=` + - `GET /api/v1/schema/tables/{schema}/{table}?profile=` + - `POST /api/v1/query`,body 为 `{"profile":"dev","sql":"select 1"}` + - CLI `xsql schema dump` 对外保持不变,但内部由“表列表 + 单表结构”组合生成 +- 前端构建: + - 使用 Vite 构建到 `webui/dist/` + - Go 通过 `go:embed` 嵌入 `webui/dist/` + - Query 编辑区使用 CodeMirror 6,方言按 profile 的 `db` 自动切换 MySQL / PostgreSQL / 通用 SQL + - schema 补全复用现有 `schema/tables` 与 `schema/tables/{schema}/{table}` 接口,不新增后端 editor 专用 API + - Query 提供本地 `Format` 动作,按当前 profile 方言对整个 SQL 文本进行格式化,不新增后端格式化接口 + - Results 表格为紧凑网格,长单元格不直接撑高行;hover 显示快速预览,点击后在结果面板内展开底部详情区 +- 兼容性策略: + - 新增能力,不修改现有 CLI/MCP 行为 + - Web API 继续使用现有 schema_version=1 契约 + - 旧 `GET /api/v1/schema` 在引入拆分接口时移除 + +## 备选方案(Alternatives) +- 方案 A:独立部署前后端 + - 优点:开发模式灵活 + - 缺点:发布复杂,不符合单二进制目标 +- 方案 B:基于 MCP streamable HTTP 直接构建前端 + - 优点:减少新增 HTTP 层 + - 缺点:MCP 面向 agent,不适合作为浏览器 UI 的直接 API + +## 兼容性与迁移(Compatibility & Migration) +- 是否破坏兼容:否 +- 迁移步骤:无需迁移;有需要时补充 `web` 配置即可 +- deprecation 计划:无 + +## 安全与隐私(Security/Privacy) +- 默认安全策略: + - Web 查询强制只读 + - loopback 地址默认免鉴权 + - 非 loopback 地址强制 Bearer token + - 错误细节不得泄露密码、私钥、完整 DSN 或 token +- secrets 暴露风险: + - `auth_token` 支持 keyring,引导优先使用 keyring + - `profile show` 和 Web profile 详情接口继续脱敏输出 + +## 测试计划(Test Plan) +- 单元测试: + - `serve/web` 参数优先级与 remote token 校验 + - Web API 鉴权、错误映射、只读策略 + - 静态资源嵌入和 SPA fallback +- 集成测试: + - MySQL/PostgreSQL 查询与 schema 浏览 + - SSH profile 下的 Web 查询 +- E2E: + - `xsql serve --format json` 启动和健康检查 + - `xsql web` 浏览器打开逻辑 + +## 未决问题(Open Questions) +- 首版是否需要提供 schema 结果的搜索/过滤增强 +- 后续是否将 MCP 工具实现也统一迁移到新的 app service 层 diff --git a/internal/app/app.go b/internal/app/app.go index 0145271..d574bde 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -74,6 +74,26 @@ func (a App) BuildSpec() spec.Spec { spec.FlagSpec{Name: "ssh-skip-known-hosts-check", Default: "false", Description: "Skip SSH known_hosts check (dangerous)"}, ), }, + { + Name: "serve", + Description: "Start the local web management server", + Flags: append(globalFlags, + spec.FlagSpec{Name: "addr", Default: "127.0.0.1:8788", Env: "XSQL_WEB_HTTP_ADDR", Description: "Web HTTP listen address"}, + spec.FlagSpec{Name: "auth-token", Default: "", Env: "XSQL_WEB_HTTP_AUTH_TOKEN", Description: "Bearer auth token required for non-loopback addresses"}, + spec.FlagSpec{Name: "allow-plaintext", Default: "false", Description: "Allow plaintext secrets in config"}, + spec.FlagSpec{Name: "ssh-skip-known-hosts-check", Default: "false", Description: "Skip SSH known_hosts check (dangerous)"}, + ), + }, + { + Name: "web", + Description: "Start the local web management server and open a browser", + Flags: append(globalFlags, + spec.FlagSpec{Name: "addr", Default: "127.0.0.1:8788", Env: "XSQL_WEB_HTTP_ADDR", Description: "Web HTTP listen address"}, + spec.FlagSpec{Name: "auth-token", Default: "", Env: "XSQL_WEB_HTTP_AUTH_TOKEN", Description: "Bearer auth token required for non-loopback addresses"}, + spec.FlagSpec{Name: "allow-plaintext", Default: "false", Description: "Allow plaintext secrets in config"}, + spec.FlagSpec{Name: "ssh-skip-known-hosts-check", Default: "false", Description: "Skip SSH known_hosts check (dangerous)"}, + ), + }, }, ErrorCodes: errors.AllCodes(), } diff --git a/internal/app/service.go b/internal/app/service.go new file mode 100644 index 0000000..98ee975 --- /dev/null +++ b/internal/app/service.go @@ -0,0 +1,273 @@ +package app + +import ( + "context" + "sort" + "time" + + "github.com/zx06/xsql/internal/config" + "github.com/zx06/xsql/internal/db" + "github.com/zx06/xsql/internal/errors" + "github.com/zx06/xsql/internal/output" +) + +// ProfileListResult is the structured result for profile listing. +type ProfileListResult struct { + ConfigPath string `json:"config_path" yaml:"config_path"` + Profiles []config.ProfileInfo `json:"profiles" yaml:"profiles"` +} + +// ToProfileListData implements output.ProfileListFormatter. +func (r *ProfileListResult) ToProfileListData() (string, []output.ProfileListItem, bool) { + if r == nil { + return "", nil, false + } + + profiles := make([]output.ProfileListItem, 0, len(r.Profiles)) + for _, profile := range r.Profiles { + profiles = append(profiles, output.ProfileListItem{ + Name: profile.Name, + Description: profile.Description, + DB: profile.DB, + Mode: profile.Mode, + }) + } + return r.ConfigPath, profiles, true +} + +// QueryRequest contains options for a query operation. +type QueryRequest struct { + Profile config.Profile + SQL string + AllowPlaintext bool + SkipHostKeyCheck bool + UnsafeAllowWrite bool +} + +// SchemaDumpRequest contains options for a schema dump operation. +type SchemaDumpRequest struct { + Profile config.Profile + TablePattern string + IncludeSystem bool + AllowPlaintext bool + SkipHostKeyCheck bool +} + +// TableListRequest contains options for loading the lightweight table list. +type TableListRequest struct { + Profile config.Profile + TablePattern string + IncludeSystem bool + AllowPlaintext bool + SkipHostKeyCheck bool +} + +// TableDescribeRequest contains options for loading a single table schema. +type TableDescribeRequest struct { + Profile config.Profile + Schema string + Name string + AllowPlaintext bool + SkipHostKeyCheck bool +} + +// LoadProfiles loads and summarizes the configured profiles. +func LoadProfiles(opts config.Options) (*ProfileListResult, *errors.XError) { + cfg, cfgPath, xe := config.LoadConfig(opts) + if xe != nil { + return nil, xe + } + + profiles := make([]config.ProfileInfo, 0, len(cfg.Profiles)) + for name, profile := range cfg.Profiles { + profiles = append(profiles, config.ProfileToInfo(name, profile)) + } + sort.Slice(profiles, func(i, j int) bool { + return profiles[i].Name < profiles[j].Name + }) + + return &ProfileListResult{ + ConfigPath: cfgPath, + Profiles: profiles, + }, nil +} + +// LoadProfileDetail loads a single profile and redacts sensitive fields. +func LoadProfileDetail(opts config.Options, name string) (map[string]any, *errors.XError) { + cfg, cfgPath, xe := config.LoadConfig(opts) + if xe != nil { + return nil, xe + } + + profile, xe := ResolveProfile(cfg, name) + if xe != nil { + return nil, xe + } + + result := map[string]any{ + "config_path": cfgPath, + "name": name, + "description": profile.Description, + "db": profile.DB, + "host": profile.Host, + "port": profile.Port, + "user": profile.User, + "database": profile.Database, + "unsafe_allow_write": profile.UnsafeAllowWrite, + "allow_plaintext": profile.AllowPlaintext, + } + + if profile.DSN != "" { + result["dsn"] = "***" + } + if profile.Password != "" { + result["password"] = "***" + } + if profile.SSHProxy != "" { + result["ssh_proxy"] = profile.SSHProxy + if profile.SSHConfig != nil { + result["ssh_host"] = profile.SSHConfig.Host + result["ssh_port"] = profile.SSHConfig.Port + result["ssh_user"] = profile.SSHConfig.User + if profile.SSHConfig.IdentityFile != "" { + result["ssh_identity_file"] = profile.SSHConfig.IdentityFile + } + } + } + + return result, nil +} + +// ResolveProfile returns a fully prepared profile with ssh config and default ports. +func ResolveProfile(cfg config.File, name string) (config.Profile, *errors.XError) { + profile, ok := cfg.Profiles[name] + if !ok { + return config.Profile{}, errors.New(errors.CodeCfgInvalid, "profile not found", map[string]any{"name": name}) + } + if profile.SSHProxy != "" { + proxy, ok := cfg.SSHProxies[profile.SSHProxy] + if !ok { + return config.Profile{}, errors.New(errors.CodeCfgInvalid, "ssh_proxy not found", map[string]any{"profile": name, "ssh_proxy": profile.SSHProxy}) + } + profile.SSHConfig = &proxy + } + if profile.Port == 0 { + switch profile.DB { + case "mysql": + profile.Port = 3306 + case "pg": + profile.Port = 5432 + } + } + return profile, nil +} + +// Query executes a SQL query using a resolved profile. +func Query(ctx context.Context, req QueryRequest) (*db.QueryResult, *errors.XError) { + if req.Profile.DB == "" { + return nil, errors.New(errors.CodeCfgInvalid, "db type is required (mysql|pg)", nil) + } + + conn, xe := ResolveConnection(ctx, ConnectionOptions{ + Profile: req.Profile, + AllowPlaintext: req.AllowPlaintext, + SkipHostKeyCheck: req.SkipHostKeyCheck, + }) + if xe != nil { + return nil, xe + } + defer func() { _ = conn.Close() }() + + return db.Query(ctx, conn.DB, req.SQL, db.QueryOptions{ + UnsafeAllowWrite: req.UnsafeAllowWrite, + DBType: req.Profile.DB, + }) +} + +// DumpSchema exports the schema using a resolved profile. +func DumpSchema(ctx context.Context, req SchemaDumpRequest) (*db.SchemaInfo, *errors.XError) { + if req.Profile.DB == "" { + return nil, errors.New(errors.CodeCfgInvalid, "db type is required (mysql|pg)", nil) + } + + conn, xe := ResolveConnection(ctx, ConnectionOptions{ + Profile: req.Profile, + AllowPlaintext: req.AllowPlaintext, + SkipHostKeyCheck: req.SkipHostKeyCheck, + }) + if xe != nil { + return nil, xe + } + defer func() { _ = conn.Close() }() + + return db.DumpSchema(ctx, req.Profile.DB, conn.DB, db.SchemaOptions{ + TablePattern: req.TablePattern, + IncludeSystem: req.IncludeSystem, + }) +} + +// ListTables loads the lightweight table list using a resolved profile. +func ListTables(ctx context.Context, req TableListRequest) (*db.TableList, *errors.XError) { + if req.Profile.DB == "" { + return nil, errors.New(errors.CodeCfgInvalid, "db type is required (mysql|pg)", nil) + } + + conn, xe := ResolveConnection(ctx, ConnectionOptions{ + Profile: req.Profile, + AllowPlaintext: req.AllowPlaintext, + SkipHostKeyCheck: req.SkipHostKeyCheck, + }) + if xe != nil { + return nil, xe + } + defer func() { _ = conn.Close() }() + + return db.ListTables(ctx, req.Profile.DB, conn.DB, db.SchemaOptions{ + TablePattern: req.TablePattern, + IncludeSystem: req.IncludeSystem, + }) +} + +// DescribeTable loads the schema for a single table using a resolved profile. +func DescribeTable(ctx context.Context, req TableDescribeRequest) (*db.Table, *errors.XError) { + if req.Profile.DB == "" { + return nil, errors.New(errors.CodeCfgInvalid, "db type is required (mysql|pg)", nil) + } + + conn, xe := ResolveConnection(ctx, ConnectionOptions{ + Profile: req.Profile, + AllowPlaintext: req.AllowPlaintext, + SkipHostKeyCheck: req.SkipHostKeyCheck, + }) + if xe != nil { + return nil, xe + } + defer func() { _ = conn.Close() }() + + return db.DescribeTable(ctx, req.Profile.DB, conn.DB, db.TableDescribeOptions{ + Schema: req.Schema, + Name: req.Name, + }) +} + +// QueryTimeout resolves the effective query timeout. +func QueryTimeout(profile config.Profile, overrideSeconds int, overrideSet bool, fallback time.Duration) time.Duration { + if overrideSet && overrideSeconds > 0 { + return time.Duration(overrideSeconds) * time.Second + } + if profile.QueryTimeout > 0 { + return time.Duration(profile.QueryTimeout) * time.Second + } + return fallback +} + +// SchemaTimeout resolves the effective schema dump timeout. +func SchemaTimeout(profile config.Profile, overrideSeconds int, overrideSet bool, fallback time.Duration) time.Duration { + if overrideSet && overrideSeconds > 0 { + return time.Duration(overrideSeconds) * time.Second + } + if profile.SchemaTimeout > 0 { + return time.Duration(profile.SchemaTimeout) * time.Second + } + return fallback +} diff --git a/internal/config/types.go b/internal/config/types.go index 6eb1e0a..5048109 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -6,6 +6,7 @@ type File struct { SSHProxies map[string]SSHProxy `yaml:"ssh_proxies"` Profiles map[string]Profile `yaml:"profiles"` MCP MCPConfig `yaml:"mcp"` + Web WebConfig `yaml:"web"` } // SSHProxy defines a reusable SSH proxy configuration. @@ -63,6 +64,18 @@ type MCPHTTPConfig struct { AllowPlaintextToken bool `yaml:"allow_plaintext_token"` // allow plaintext token } +// WebConfig defines the local web server configuration. +type WebConfig struct { + HTTP WebHTTPConfig `yaml:"http"` +} + +// WebHTTPConfig defines the web HTTP transport configuration. +type WebHTTPConfig struct { + Addr string `yaml:"addr"` + AuthToken string `yaml:"auth_token"` // supports keyring:xxx reference + AllowPlaintextToken bool `yaml:"allow_plaintext_token"` // allow plaintext token +} + type Resolved struct { ConfigPath string ProfileName string diff --git a/internal/db/mysql/schema.go b/internal/db/mysql/schema.go index c3e2f42..a0e548f 100644 --- a/internal/db/mysql/schema.go +++ b/internal/db/mysql/schema.go @@ -3,76 +3,109 @@ package mysql import ( "context" "database/sql" + "sort" "strings" "github.com/zx06/xsql/internal/db" "github.com/zx06/xsql/internal/errors" + "golang.org/x/sync/errgroup" ) -// DumpSchema exports the MySQL database schema. -func (d *Driver) DumpSchema(ctx context.Context, conn *sql.DB, opts db.SchemaOptions) (*db.SchemaInfo, *errors.XError) { - info := &db.SchemaInfo{} +// ListTables returns the lightweight MySQL table list. +func (d *Driver) ListTables(ctx context.Context, conn *sql.DB, opts db.SchemaOptions) (*db.TableList, *errors.XError) { + database, xe := currentDatabase(ctx, conn) + if xe != nil { + return nil, xe + } - // Get the current database name - var database string - if err := conn.QueryRowContext(ctx, "SELECT DATABASE()").Scan(&database); err != nil { - return nil, errors.Wrap(errors.CodeDBExecFailed, "failed to get database name", nil, err) + tables, xe := d.listTables(ctx, conn, database, opts.TablePattern) + if xe != nil { + return nil, xe } - info.Database = database - // Get table list - tables, xe := d.listTables(ctx, conn, database, opts) + return &db.TableList{ + Database: database, + Tables: tables, + }, nil +} + +// DescribeTable returns the schema details for a single MySQL table. +func (d *Driver) DescribeTable(ctx context.Context, conn *sql.DB, opts db.TableDescribeOptions) (*db.Table, *errors.XError) { + database, xe := currentDatabase(ctx, conn) if xe != nil { return nil, xe } - // Get detailed information for each table - for _, table := range tables { - // Get column information - columns, xe := d.getColumns(ctx, conn, database, table.Name) + schemaName := opts.Schema + if schemaName == "" { + schemaName = database + } + + table, xe := d.loadTableSummary(ctx, conn, schemaName, opts.Name) + if xe != nil { + return nil, xe + } + + var ( + columns []db.Column + indexes []db.Index + fks []db.ForeignKey + ) + + g, gctx := errgroup.WithContext(ctx) + g.Go(func() error { + result, xe := d.getColumns(gctx, conn, schemaName, opts.Name) if xe != nil { - return nil, xe + return xe } - table.Columns = columns - - // Get index information - indexes, xe := d.getIndexes(ctx, conn, database, table.Name) + columns = result + return nil + }) + g.Go(func() error { + result, xe := d.getIndexes(gctx, conn, schemaName, opts.Name) if xe != nil { - return nil, xe + return xe } - table.Indexes = indexes - - // Get foreign key information - fks, xe := d.getForeignKeys(ctx, conn, database, table.Name) + indexes = result + return nil + }) + g.Go(func() error { + result, xe := d.getForeignKeys(gctx, conn, schemaName, opts.Name) if xe != nil { - return nil, xe + return xe } - table.ForeignKeys = fks - - info.Tables = append(info.Tables, table) + fks = result + return nil + }) + if err := g.Wait(); err != nil { + return nil, errors.AsOrWrap(err) } - return info, nil + table.Columns = columns + table.Indexes = indexes + table.ForeignKeys = fks + return table, nil } -// listTables retrieves the list of tables. -func (d *Driver) listTables(ctx context.Context, conn *sql.DB, database string, opts db.SchemaOptions) ([]db.Table, *errors.XError) { +func currentDatabase(ctx context.Context, conn *sql.DB) (string, *errors.XError) { + var database string + if err := conn.QueryRowContext(ctx, "SELECT DATABASE()").Scan(&database); err != nil { + return "", errors.Wrap(errors.CodeDBExecFailed, "failed to get database name", nil, err) + } + return database, nil +} + +func (d *Driver) listTables(ctx context.Context, conn *sql.DB, database, tablePattern string) ([]db.TableSummary, *errors.XError) { query := ` SELECT table_name, table_comment FROM information_schema.tables WHERE table_schema = ? AND table_type = 'BASE TABLE' ` args := []any{database} - - // Table name filter - if opts.TablePattern != "" { - // Convert wildcards * and ? to SQL LIKE patterns - likePattern := strings.ReplaceAll(opts.TablePattern, "*", "%") - likePattern = strings.ReplaceAll(likePattern, "?", "_") + if tablePattern != "" { query += " AND table_name LIKE ?" - args = append(args, likePattern) + args = append(args, toLikePattern(tablePattern)) } - query += " ORDER BY table_name" rows, err := conn.QueryContext(ctx, query, args...) @@ -81,29 +114,52 @@ func (d *Driver) listTables(ctx context.Context, conn *sql.DB, database string, } defer rows.Close() - var tables []db.Table + var tables []db.TableSummary for rows.Next() { var name, comment string if err := rows.Scan(&name, &comment); err != nil { return nil, errors.Wrap(errors.CodeDBExecFailed, "failed to scan table row", nil, err) } - tables = append(tables, db.Table{ + tables = append(tables, db.TableSummary{ Schema: database, Name: name, Comment: comment, }) } - if err := rows.Err(); err != nil { return nil, errors.Wrap(errors.CodeDBExecFailed, "rows iteration error", nil, err) } - return tables, nil } -// getColumns retrieves column information for a table. -func (d *Driver) getColumns(ctx context.Context, conn *sql.DB, database, tableName string) ([]db.Column, *errors.XError) { - query := ` +func (d *Driver) loadTableSummary(ctx context.Context, conn *sql.DB, schemaName, tableName string) (*db.Table, *errors.XError) { + const query = ` + SELECT table_name, table_comment + FROM information_schema.tables + WHERE table_schema = ? AND table_type = 'BASE TABLE' AND table_name = ? + ` + + var name, comment string + if err := conn.QueryRowContext(ctx, query, schemaName, tableName).Scan(&name, &comment); err != nil { + if err == sql.ErrNoRows { + return nil, errors.New(errors.CodeCfgInvalid, "table not found", map[string]any{ + "schema": schemaName, + "name": tableName, + "reason": "table_not_found", + }) + } + return nil, errors.Wrap(errors.CodeDBExecFailed, "failed to load table", map[string]any{"schema": schemaName, "name": tableName}, err) + } + + return &db.Table{ + Schema: schemaName, + Name: name, + Comment: comment, + }, nil +} + +func (d *Driver) getColumns(ctx context.Context, conn *sql.DB, schemaName, tableName string) ([]db.Column, *errors.XError) { + const query = ` SELECT column_name, column_type, @@ -116,7 +172,7 @@ func (d *Driver) getColumns(ctx context.Context, conn *sql.DB, database, tableNa ORDER BY ordinal_position ` - rows, err := conn.QueryContext(ctx, query, database, tableName) + rows, err := conn.QueryContext(ctx, query, schemaName, tableName) if err != nil { return nil, errors.Wrap(errors.CodeDBExecFailed, "failed to get columns", nil, err) } @@ -144,17 +200,14 @@ func (d *Driver) getColumns(ctx context.Context, conn *sql.DB, database, tableNa } columns = append(columns, col) } - if err := rows.Err(); err != nil { return nil, errors.Wrap(errors.CodeDBExecFailed, "rows iteration error", nil, err) } - return columns, nil } -// getIndexes retrieves index information for a table. -func (d *Driver) getIndexes(ctx context.Context, conn *sql.DB, database, tableName string) ([]db.Index, *errors.XError) { - query := ` +func (d *Driver) getIndexes(ctx context.Context, conn *sql.DB, schemaName, tableName string) ([]db.Index, *errors.XError) { + const query = ` SELECT index_name, column_name, @@ -166,13 +219,12 @@ func (d *Driver) getIndexes(ctx context.Context, conn *sql.DB, database, tableNa ORDER BY index_name, seq_in_index ` - rows, err := conn.QueryContext(ctx, query, database, tableName) + rows, err := conn.QueryContext(ctx, query, schemaName, tableName) if err != nil { return nil, errors.Wrap(errors.CodeDBExecFailed, "failed to get indexes", nil, err) } defer rows.Close() - // Group by index_name indexMap := make(map[string]*db.Index) for rows.Next() { var indexName, columnName string @@ -193,43 +245,44 @@ func (d *Driver) getIndexes(ctx context.Context, conn *sql.DB, database, tableNa } } } - if err := rows.Err(); err != nil { return nil, errors.Wrap(errors.CodeDBExecFailed, "rows iteration error", nil, err) } - // Convert to slice - indexes := make([]db.Index, 0, len(indexMap)) - for _, idx := range indexMap { - indexes = append(indexes, *idx) + names := make([]string, 0, len(indexMap)) + for name := range indexMap { + names = append(names, name) } + sort.Strings(names) + indexes := make([]db.Index, 0, len(names)) + for _, name := range names { + indexes = append(indexes, *indexMap[name]) + } return indexes, nil } -// getForeignKeys retrieves foreign key information for a table. -func (d *Driver) getForeignKeys(ctx context.Context, conn *sql.DB, database, tableName string) ([]db.ForeignKey, *errors.XError) { - query := ` +func (d *Driver) getForeignKeys(ctx context.Context, conn *sql.DB, schemaName, tableName string) ([]db.ForeignKey, *errors.XError) { + const query = ` SELECT - kcu.constraint_name, - kcu.column_name, - kcu.referenced_table_name, - kcu.referenced_column_name, - kcu.ordinal_position - FROM information_schema.key_column_usage kcu - WHERE kcu.table_schema = ? - AND kcu.table_name = ? - AND kcu.referenced_table_name IS NOT NULL - ORDER BY kcu.constraint_name, kcu.ordinal_position + constraint_name, + column_name, + referenced_table_name, + referenced_column_name, + ordinal_position + FROM information_schema.key_column_usage + WHERE table_schema = ? + AND table_name = ? + AND referenced_table_name IS NOT NULL + ORDER BY constraint_name, ordinal_position ` - rows, err := conn.QueryContext(ctx, query, database, tableName) + rows, err := conn.QueryContext(ctx, query, schemaName, tableName) if err != nil { return nil, errors.Wrap(errors.CodeDBExecFailed, "failed to get foreign keys", nil, err) } defer rows.Close() - // Group by constraint_name fkMap := make(map[string]*db.ForeignKey) for rows.Next() { var constraintName, columnName, refTable, refColumn string @@ -250,16 +303,24 @@ func (d *Driver) getForeignKeys(ctx context.Context, conn *sql.DB, database, tab } } } - if err := rows.Err(); err != nil { return nil, errors.Wrap(errors.CodeDBExecFailed, "rows iteration error", nil, err) } - // Convert to slice - fks := make([]db.ForeignKey, 0, len(fkMap)) - for _, fk := range fkMap { - fks = append(fks, *fk) + names := make([]string, 0, len(fkMap)) + for name := range fkMap { + names = append(names, name) } + sort.Strings(names) + fks := make([]db.ForeignKey, 0, len(names)) + for _, name := range names { + fks = append(fks, *fkMap[name]) + } return fks, nil } + +func toLikePattern(pattern string) string { + pattern = strings.ReplaceAll(pattern, "*", "%") + return strings.ReplaceAll(pattern, "?", "_") +} diff --git a/internal/db/pg/schema.go b/internal/db/pg/schema.go index e9197b5..4cb27e0 100644 --- a/internal/db/pg/schema.go +++ b/internal/db/pg/schema.go @@ -3,79 +3,103 @@ package pg import ( "context" "database/sql" + "sort" "strings" "github.com/zx06/xsql/internal/db" "github.com/zx06/xsql/internal/errors" + "golang.org/x/sync/errgroup" ) -// DumpSchema exports the PostgreSQL database schema. -func (d *Driver) DumpSchema(ctx context.Context, conn *sql.DB, opts db.SchemaOptions) (*db.SchemaInfo, *errors.XError) { - info := &db.SchemaInfo{} - - // Get the current database name - var database string - if err := conn.QueryRowContext(ctx, "SELECT current_database()").Scan(&database); err != nil { - return nil, errors.Wrap(errors.CodeDBExecFailed, "failed to get database name", nil, err) +// ListTables returns the lightweight PostgreSQL table list. +func (d *Driver) ListTables(ctx context.Context, conn *sql.DB, opts db.SchemaOptions) (*db.TableList, *errors.XError) { + database, xe := currentDatabase(ctx, conn) + if xe != nil { + return nil, xe } - info.Database = database - // Get schema list (excluding system schemas) - schemas, xe := d.listSchemas(ctx, conn, opts) + schemas, xe := d.listSchemas(ctx, conn, opts.IncludeSystem) if xe != nil { return nil, xe } - // Get tables under each schema - for _, schema := range schemas { - tables, xe := d.listTables(ctx, conn, schema, opts) - if xe != nil { - return nil, xe - } + tablePattern := toLikePattern(opts.TablePattern) + tables, xe := d.listTables(ctx, conn, schemas, tablePattern) + if xe != nil { + return nil, xe + } - // Get detailed information for each table - for _, table := range tables { - // Get column information - columns, xe := d.getColumns(ctx, conn, schema, table.Name) - if xe != nil { - return nil, xe - } - table.Columns = columns + return &db.TableList{ + Database: database, + Tables: tables, + }, nil +} - // Get index information - indexes, xe := d.getIndexes(ctx, conn, schema, table.Name) - if xe != nil { - return nil, xe - } - table.Indexes = indexes +// DescribeTable returns the schema details for a single PostgreSQL table. +func (d *Driver) DescribeTable(ctx context.Context, conn *sql.DB, opts db.TableDescribeOptions) (*db.Table, *errors.XError) { + table, xe := d.loadTableSummary(ctx, conn, opts.Schema, opts.Name) + if xe != nil { + return nil, xe + } - // Get foreign key information - fks, xe := d.getForeignKeys(ctx, conn, schema, table.Name) - if xe != nil { - return nil, xe - } - table.ForeignKeys = fks + var ( + columns []db.Column + indexes []db.Index + fks []db.ForeignKey + ) - info.Tables = append(info.Tables, table) + g, gctx := errgroup.WithContext(ctx) + g.Go(func() error { + result, xe := d.getColumns(gctx, conn, opts.Schema, opts.Name) + if xe != nil { + return xe + } + columns = result + return nil + }) + g.Go(func() error { + result, xe := d.getIndexes(gctx, conn, opts.Schema, opts.Name) + if xe != nil { + return xe + } + indexes = result + return nil + }) + g.Go(func() error { + result, xe := d.getForeignKeys(gctx, conn, opts.Schema, opts.Name) + if xe != nil { + return xe } + fks = result + return nil + }) + if err := g.Wait(); err != nil { + return nil, errors.AsOrWrap(err) } - return info, nil + table.Columns = columns + table.Indexes = indexes + table.ForeignKeys = fks + return table, nil } -// listSchemas retrieves the list of schemas. -func (d *Driver) listSchemas(ctx context.Context, conn *sql.DB, opts db.SchemaOptions) ([]string, *errors.XError) { +func currentDatabase(ctx context.Context, conn *sql.DB) (string, *errors.XError) { + var database string + if err := conn.QueryRowContext(ctx, "SELECT current_database()").Scan(&database); err != nil { + return "", errors.Wrap(errors.CodeDBExecFailed, "failed to get database name", nil, err) + } + return database, nil +} + +func (d *Driver) listSchemas(ctx context.Context, conn *sql.DB, includeSystem bool) ([]string, *errors.XError) { query := ` SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') ` - - if !opts.IncludeSystem { - // Exclude additional system schemas + if !includeSystem { query += " AND schema_name NOT LIKE 'pg_%'" } - query += " ORDER BY schema_name" rows, err := conn.QueryContext(ctx, query) @@ -92,35 +116,27 @@ func (d *Driver) listSchemas(ctx context.Context, conn *sql.DB, opts db.SchemaOp } schemas = append(schemas, schema) } - if err := rows.Err(); err != nil { return nil, errors.Wrap(errors.CodeDBExecFailed, "rows iteration error", nil, err) } - return schemas, nil } -// listTables retrieves the list of tables. -func (d *Driver) listTables(ctx context.Context, conn *sql.DB, schema string, opts db.SchemaOptions) ([]db.Table, *errors.XError) { +func (d *Driver) listTables(ctx context.Context, conn *sql.DB, schemas []string, tablePattern string) ([]db.TableSummary, *errors.XError) { query := ` SELECT + t.table_schema, t.table_name, - obj_description((quote_ident($1) || '.' || quote_ident(t.table_name))::regclass, 'pg_class') as table_comment + obj_description((quote_ident(t.table_schema) || '.' || quote_ident(t.table_name))::regclass, 'pg_class') AS table_comment FROM information_schema.tables t - WHERE t.table_schema = $1 AND t.table_type = 'BASE TABLE' + WHERE t.table_schema = ANY($1) AND t.table_type = 'BASE TABLE' ` - args := []any{schema} - - // Table name filter - if opts.TablePattern != "" { - // Convert wildcards * and ? to SQL LIKE patterns - likePattern := strings.ReplaceAll(opts.TablePattern, "*", "%") - likePattern = strings.ReplaceAll(likePattern, "?", "_") + args := []any{schemas} + if tablePattern != "" { query += " AND t.table_name LIKE $2" - args = append(args, likePattern) + args = append(args, tablePattern) } - - query += " ORDER BY t.table_name" + query += " ORDER BY t.table_schema, t.table_name" rows, err := conn.QueryContext(ctx, query, args...) if err != nil { @@ -128,30 +144,57 @@ func (d *Driver) listTables(ctx context.Context, conn *sql.DB, schema string, op } defer rows.Close() - var tables []db.Table + var tables []db.TableSummary for rows.Next() { - var name string + var schema, name string var comment sql.NullString - if err := rows.Scan(&name, &comment); err != nil { + if err := rows.Scan(&schema, &name, &comment); err != nil { return nil, errors.Wrap(errors.CodeDBExecFailed, "failed to scan table row", nil, err) } - tables = append(tables, db.Table{ + tables = append(tables, db.TableSummary{ Schema: schema, Name: name, Comment: comment.String, }) } - if err := rows.Err(); err != nil { return nil, errors.Wrap(errors.CodeDBExecFailed, "rows iteration error", nil, err) } - return tables, nil } -// getColumns retrieves column information for a table. -func (d *Driver) getColumns(ctx context.Context, conn *sql.DB, schema, tableName string) ([]db.Column, *errors.XError) { - query := ` +func (d *Driver) loadTableSummary(ctx context.Context, conn *sql.DB, schemaName, tableName string) (*db.Table, *errors.XError) { + const query = ` + SELECT + t.table_schema, + t.table_name, + obj_description((quote_ident(t.table_schema) || '.' || quote_ident(t.table_name))::regclass, 'pg_class') AS table_comment + FROM information_schema.tables t + WHERE t.table_schema = $1 AND t.table_type = 'BASE TABLE' AND t.table_name = $2 + ` + + var schema, name string + var comment sql.NullString + if err := conn.QueryRowContext(ctx, query, schemaName, tableName).Scan(&schema, &name, &comment); err != nil { + if err == sql.ErrNoRows { + return nil, errors.New(errors.CodeCfgInvalid, "table not found", map[string]any{ + "schema": schemaName, + "name": tableName, + "reason": "table_not_found", + }) + } + return nil, errors.Wrap(errors.CodeDBExecFailed, "failed to load table", map[string]any{"schema": schemaName, "name": tableName}, err) + } + + return &db.Table{ + Schema: schema, + Name: name, + Comment: comment.String, + }, nil +} + +func (d *Driver) getColumns(ctx context.Context, conn *sql.DB, schemaName, tableName string) ([]db.Column, *errors.XError) { + const query = ` SELECT c.column_name, CASE @@ -163,10 +206,10 @@ func (d *Driver) getColumns(ctx context.Context, conn *sql.DB, schema, tableName WHEN c.numeric_precision IS NOT NULL THEN c.data_type || '(' || c.numeric_precision || ')' ELSE c.data_type - END as column_type, + END AS column_type, c.is_nullable, c.column_default, - col_description((quote_ident(c.table_schema) || '.' || quote_ident(c.table_name))::regclass, c.ordinal_position) as column_comment, + col_description((quote_ident(c.table_schema) || '.' || quote_ident(c.table_name))::regclass, c.ordinal_position) AS column_comment, CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END AS is_primary FROM information_schema.columns c LEFT JOIN ( @@ -183,7 +226,7 @@ func (d *Driver) getColumns(ctx context.Context, conn *sql.DB, schema, tableName ORDER BY c.ordinal_position ` - rows, err := conn.QueryContext(ctx, query, schema, tableName) + rows, err := conn.QueryContext(ctx, query, schemaName, tableName) if err != nil { return nil, errors.Wrap(errors.CodeDBExecFailed, "failed to get columns", nil, err) } @@ -212,23 +255,20 @@ func (d *Driver) getColumns(ctx context.Context, conn *sql.DB, schema, tableName } columns = append(columns, col) } - if err := rows.Err(); err != nil { return nil, errors.Wrap(errors.CodeDBExecFailed, "rows iteration error", nil, err) } - return columns, nil } -// getIndexes retrieves index information for a table. -func (d *Driver) getIndexes(ctx context.Context, conn *sql.DB, schema, tableName string) ([]db.Index, *errors.XError) { - query := ` +func (d *Driver) getIndexes(ctx context.Context, conn *sql.DB, schemaName, tableName string) ([]db.Index, *errors.XError) { + const query = ` SELECT - i.relname as index_name, - a.attname as column_name, - NOT ix.indisunique as is_non_unique, - ix.indisprimary as is_primary, - array_position(ix.indkey, a.attnum) as column_position + i.relname AS index_name, + a.attname AS column_name, + NOT ix.indisunique AS is_non_unique, + ix.indisprimary AS is_primary, + array_position(ix.indkey, a.attnum) AS column_position FROM pg_class t JOIN pg_index ix ON t.oid = ix.indrelid JOIN pg_class i ON i.oid = ix.indexrelid @@ -238,13 +278,12 @@ func (d *Driver) getIndexes(ctx context.Context, conn *sql.DB, schema, tableName ORDER BY i.relname, array_position(ix.indkey, a.attnum) ` - rows, err := conn.QueryContext(ctx, query, schema, tableName) + rows, err := conn.QueryContext(ctx, query, schemaName, tableName) if err != nil { return nil, errors.Wrap(errors.CodeDBExecFailed, "failed to get indexes", nil, err) } defer rows.Close() - // Group by index_name indexMap := make(map[string]*db.Index) for rows.Next() { var indexName, columnName string @@ -265,23 +304,25 @@ func (d *Driver) getIndexes(ctx context.Context, conn *sql.DB, schema, tableName } } } - if err := rows.Err(); err != nil { return nil, errors.Wrap(errors.CodeDBExecFailed, "rows iteration error", nil, err) } - // Convert to slice - indexes := make([]db.Index, 0, len(indexMap)) - for _, idx := range indexMap { - indexes = append(indexes, *idx) + names := make([]string, 0, len(indexMap)) + for name := range indexMap { + names = append(names, name) } + sort.Strings(names) + indexes := make([]db.Index, 0, len(names)) + for _, name := range names { + indexes = append(indexes, *indexMap[name]) + } return indexes, nil } -// getForeignKeys retrieves foreign key information for a table. -func (d *Driver) getForeignKeys(ctx context.Context, conn *sql.DB, schema, tableName string) ([]db.ForeignKey, *errors.XError) { - query := ` +func (d *Driver) getForeignKeys(ctx context.Context, conn *sql.DB, schemaName, tableName string) ([]db.ForeignKey, *errors.XError) { + const query = ` SELECT tc.constraint_name, kcu.column_name, @@ -301,13 +342,12 @@ func (d *Driver) getForeignKeys(ctx context.Context, conn *sql.DB, schema, table ORDER BY tc.constraint_name, kcu.ordinal_position ` - rows, err := conn.QueryContext(ctx, query, schema, tableName) + rows, err := conn.QueryContext(ctx, query, schemaName, tableName) if err != nil { return nil, errors.Wrap(errors.CodeDBExecFailed, "failed to get foreign keys", nil, err) } defer rows.Close() - // Group by constraint_name fkMap := make(map[string]*db.ForeignKey) for rows.Next() { var constraintName, columnName, refTable, refColumn string @@ -328,16 +368,24 @@ func (d *Driver) getForeignKeys(ctx context.Context, conn *sql.DB, schema, table } } } - if err := rows.Err(); err != nil { return nil, errors.Wrap(errors.CodeDBExecFailed, "rows iteration error", nil, err) } - // Convert to slice - fks := make([]db.ForeignKey, 0, len(fkMap)) - for _, fk := range fkMap { - fks = append(fks, *fk) + names := make([]string, 0, len(fkMap)) + for name := range fkMap { + names = append(names, name) } + sort.Strings(names) + fks := make([]db.ForeignKey, 0, len(names)) + for _, name := range names { + fks = append(fks, *fkMap[name]) + } return fks, nil } + +func toLikePattern(pattern string) string { + pattern = strings.ReplaceAll(pattern, "*", "%") + return strings.ReplaceAll(pattern, "?", "_") +} diff --git a/internal/db/schema.go b/internal/db/schema.go index 41626b8..68dc015 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -6,6 +6,7 @@ import ( "github.com/zx06/xsql/internal/errors" "github.com/zx06/xsql/internal/output" + "golang.org/x/sync/errgroup" ) // SchemaInfo represents database schema information. @@ -14,6 +15,19 @@ type SchemaInfo struct { Tables []Table `json:"tables" yaml:"tables"` } +// TableList contains the lightweight table listing for a database. +type TableList struct { + Database string `json:"database" yaml:"database"` + Tables []TableSummary `json:"tables" yaml:"tables"` +} + +// TableSummary represents a lightweight table entry for object navigation. +type TableSummary struct { + Schema string `json:"schema" yaml:"schema"` + Name string `json:"name" yaml:"name"` + Comment string `json:"comment,omitempty" yaml:"comment,omitempty"` +} + // ToSchemaData implements the output.SchemaFormatter interface. func (s *SchemaInfo) ToSchemaData() (string, []output.SchemaTable, bool) { if s == nil || len(s.Tables) == 0 { @@ -83,26 +97,85 @@ type SchemaOptions struct { IncludeSystem bool // Whether to include system tables } -// SchemaDriver is the schema export interface. -// A Driver may optionally implement this interface to support schema export. -type SchemaDriver interface { +// TableDescribeOptions identifies a single table to describe. +type TableDescribeOptions struct { + Schema string + Name string +} + +// SchemaExplorerDriver is the schema browsing interface. +// A Driver may optionally implement this interface to support table listing and description. +type SchemaExplorerDriver interface { Driver - // DumpSchema exports the database schema. - DumpSchema(ctx context.Context, db *sql.DB, opts SchemaOptions) (*SchemaInfo, *errors.XError) + // ListTables returns the lightweight table list for the target database. + ListTables(ctx context.Context, db *sql.DB, opts SchemaOptions) (*TableList, *errors.XError) + // DescribeTable returns the schema details for a single table. + DescribeTable(ctx context.Context, db *sql.DB, opts TableDescribeOptions) (*Table, *errors.XError) } -// DumpSchema exports the database schema. -// It checks whether the driver implements the SchemaDriver interface. -func DumpSchema(ctx context.Context, driverName string, db *sql.DB, opts SchemaOptions) (*SchemaInfo, *errors.XError) { +// ListTables returns the lightweight table list for the target database. +func ListTables(ctx context.Context, driverName string, db *sql.DB, opts SchemaOptions) (*TableList, *errors.XError) { + d, ok := Get(driverName) + if !ok { + return nil, errors.New(errors.CodeDBDriverUnsupported, "unsupported driver: "+driverName, nil) + } + + sd, ok := d.(SchemaExplorerDriver) + if !ok { + return nil, errors.New(errors.CodeDBDriverUnsupported, "driver does not support schema browsing: "+driverName, nil) + } + + return sd.ListTables(ctx, db, opts) +} + +// DescribeTable returns the schema details for a single table. +func DescribeTable(ctx context.Context, driverName string, db *sql.DB, opts TableDescribeOptions) (*Table, *errors.XError) { d, ok := Get(driverName) if !ok { return nil, errors.New(errors.CodeDBDriverUnsupported, "unsupported driver: "+driverName, nil) } - sd, ok := d.(SchemaDriver) + sd, ok := d.(SchemaExplorerDriver) if !ok { - return nil, errors.New(errors.CodeDBDriverUnsupported, "driver does not support schema dump: "+driverName, nil) + return nil, errors.New(errors.CodeDBDriverUnsupported, "driver does not support schema browsing: "+driverName, nil) + } + + return sd.DescribeTable(ctx, db, opts) +} + +// DumpSchema exports the database schema. +// It composes the full schema dump from table listing and per-table descriptions. +func DumpSchema(ctx context.Context, driverName string, db *sql.DB, opts SchemaOptions) (*SchemaInfo, *errors.XError) { + tableList, xe := ListTables(ctx, driverName, db, opts) + if xe != nil { + return nil, xe + } + + info := &SchemaInfo{ + Database: tableList.Database, + Tables: make([]Table, len(tableList.Tables)), + } + + g, gctx := errgroup.WithContext(ctx) + g.SetLimit(4) + for i, table := range tableList.Tables { + i := i + table := table + g.Go(func() error { + detail, xe := DescribeTable(gctx, driverName, db, TableDescribeOptions{ + Schema: table.Schema, + Name: table.Name, + }) + if xe != nil { + return xe + } + info.Tables[i] = *detail + return nil + }) + } + if err := g.Wait(); err != nil { + return nil, errors.AsOrWrap(err) } - return sd.DumpSchema(ctx, db, opts) + return info, nil } diff --git a/internal/db/schema_test.go b/internal/db/schema_test.go index 19334f6..004a85b 100644 --- a/internal/db/schema_test.go +++ b/internal/db/schema_test.go @@ -1,7 +1,12 @@ package db import ( + "context" + "database/sql" + "fmt" "testing" + + "github.com/zx06/xsql/internal/errors" ) func TestSchemaInfo_ToSchemaData(t *testing.T) { @@ -300,10 +305,75 @@ func TestDumpSchema_UnsupportedDriver(t *testing.T) { } } -// Mock driver that doesn't implement SchemaDriver +func TestListTables_UnsupportedDriver(t *testing.T) { + _, xe := ListTables(nil, "nonexistent", nil, SchemaOptions{}) + if xe == nil { + t.Fatal("expected error for unsupported driver") + } + if xe.Code != "XSQL_DB_DRIVER_UNSUPPORTED" { + t.Fatalf("error code = %v, want XSQL_DB_DRIVER_UNSUPPORTED", xe.Code) + } +} + +func TestDescribeTable_UnsupportedDriver(t *testing.T) { + _, xe := DescribeTable(nil, "nonexistent", nil, TableDescribeOptions{}) + if xe == nil { + t.Fatal("expected error for unsupported driver") + } + if xe.Code != "XSQL_DB_DRIVER_UNSUPPORTED" { + t.Fatalf("error code = %v, want XSQL_DB_DRIVER_UNSUPPORTED", xe.Code) + } +} + +func TestDumpSchema_ComposesListAndDescribe(t *testing.T) { + driverName := fmt.Sprintf("schema-mock-%s", t.Name()) + Register(driverName, &mockSchemaExplorerDriver{ + tableList: &TableList{ + Database: "app", + Tables: []TableSummary{ + {Schema: "public", Name: "users", Comment: "Users"}, + {Schema: "public", Name: "orders", Comment: "Orders"}, + }, + }, + tables: map[string]Table{ + "public.users": { + Schema: "public", + Name: "users", + Comment: "Users", + Columns: []Column{{Name: "id", Type: "bigint", PrimaryKey: true}}, + }, + "public.orders": { + Schema: "public", + Name: "orders", + Comment: "Orders", + Columns: []Column{{Name: "id", Type: "bigint", PrimaryKey: true}}, + ForeignKeys: []ForeignKey{{Name: "fk_user", Columns: []string{"user_id"}, ReferencedTable: "users", ReferencedColumns: []string{"id"}}}, + }, + }, + }) + + info, xe := DumpSchema(context.Background(), driverName, &sql.DB{}, SchemaOptions{}) + if xe != nil { + t.Fatalf("DumpSchema error: %v", xe) + } + if info.Database != "app" { + t.Fatalf("database = %q want app", info.Database) + } + if len(info.Tables) != 2 { + t.Fatalf("len(tables)=%d want 2", len(info.Tables)) + } + if info.Tables[0].Name != "users" || len(info.Tables[0].Columns) != 1 { + t.Fatalf("unexpected first table: %#v", info.Tables[0]) + } + if info.Tables[1].Name != "orders" || len(info.Tables[1].ForeignKeys) != 1 { + t.Fatalf("unexpected second table: %#v", info.Tables[1]) + } +} + +// mockNonSchemaDriver is a placeholder for drivers without schema support. type mockNonSchemaDriver struct{} -func (d *mockNonSchemaDriver) Open(ctx interface{}, opts ConnOptions) (interface{}, error) { +func (d *mockNonSchemaDriver) Open(ctx context.Context, opts ConnOptions) (*sql.DB, *errors.XError) { return nil, nil } @@ -312,3 +382,22 @@ func TestDumpSchema_DriverNotImplementSchema(t *testing.T) { // Note: This test would need to register/unregister which could affect other tests // Skipping for now as the interface check is straightforward } + +type mockSchemaExplorerDriver struct { + tableList *TableList + tables map[string]Table +} + +func (d *mockSchemaExplorerDriver) Open(ctx context.Context, opts ConnOptions) (*sql.DB, *errors.XError) { + return nil, nil +} + +func (d *mockSchemaExplorerDriver) ListTables(ctx context.Context, db *sql.DB, opts SchemaOptions) (*TableList, *errors.XError) { + return d.tableList, nil +} + +func (d *mockSchemaExplorerDriver) DescribeTable(ctx context.Context, db *sql.DB, opts TableDescribeOptions) (*Table, *errors.XError) { + key := opts.Schema + "." + opts.Name + table := d.tables[key] + return &table, nil +} diff --git a/internal/errors/codes.go b/internal/errors/codes.go index 6119faf..a1c68dc 100644 --- a/internal/errors/codes.go +++ b/internal/errors/codes.go @@ -27,6 +27,10 @@ const ( // Port CodePortInUse Code = "XSQL_PORT_IN_USE" + // Auth + CodeAuthRequired Code = "XSQL_AUTH_REQUIRED" + CodeAuthInvalid Code = "XSQL_AUTH_INVALID" + // Internal CodeInternal Code = "XSQL_INTERNAL" ) @@ -45,6 +49,8 @@ func AllCodes() []Code { CodeDBExecFailed, CodeROBlocked, CodePortInUse, + CodeAuthRequired, + CodeAuthInvalid, CodeInternal, } } diff --git a/internal/errors/exitcode_test.go b/internal/errors/exitcode_test.go index 1d78cf4..667257a 100644 --- a/internal/errors/exitcode_test.go +++ b/internal/errors/exitcode_test.go @@ -102,8 +102,8 @@ func TestAs(t *testing.T) { func TestAllCodes(t *testing.T) { codes := AllCodes() - if len(codes) != 13 { - t.Errorf("AllCodes() should return 13 codes, got %d", len(codes)) + if len(codes) != 15 { + t.Errorf("AllCodes() should return 15 codes, got %d", len(codes)) } // Check for duplicates diff --git a/internal/output/writer.go b/internal/output/writer.go index c2b88ab..ae8802f 100644 --- a/internal/output/writer.go +++ b/internal/output/writer.go @@ -81,7 +81,7 @@ type SchemaTable struct { // ProfileListFormatter is the interface for data structures that support profile list output. type ProfileListFormatter interface { - ToProfileListData() (configPath string, profiles []profileListItem, ok bool) + ToProfileListData() (configPath string, profiles []ProfileListItem, ok bool) } func writeTable(out io.Writer, env Envelope) error { @@ -164,7 +164,7 @@ func writeTable(out io.Writer, env Envelope) error { } // writeProfileListTable writes the profile list as a table. -func writeProfileListTable(out io.Writer, cfgPath string, profiles []profileListItem) error { +func writeProfileListTable(out io.Writer, cfgPath string, profiles []ProfileListItem) error { tw := tabwriter.NewWriter(out, 0, 2, 2, ' ', 0) // Output config_path first if cfgPath != "" { @@ -231,24 +231,24 @@ func extractMapSlice(v any) ([]map[string]any, bool) { return nil, false } -type profileListItem struct { +type ProfileListItem struct { Name string `json:"name"` Description string `json:"description"` DB string `json:"db"` Mode string `json:"mode"` } -func tryAsProfileList(data any) ([]profileListItem, bool) { - // Handle []profileListItem - if arr, ok := data.([]profileListItem); ok { +func tryAsProfileList(data any) ([]ProfileListItem, bool) { + // Handle []ProfileListItem + if arr, ok := data.([]ProfileListItem); ok { return arr, len(arr) > 0 } // Handle []map[string]any if arr, ok := data.([]map[string]any); ok { - result := make([]profileListItem, 0, len(arr)) + result := make([]ProfileListItem, 0, len(arr)) for _, m := range arr { - p := profileListItem{} + p := ProfileListItem{} if v, ok := m["name"].(string); ok { p.Name = v } @@ -272,7 +272,7 @@ func tryAsProfileList(data any) ([]profileListItem, bool) { // Use reflection to handle arbitrary struct slices (e.g., []profileInfo in cmd/xsql/profile.go) v := reflect.ValueOf(data) if v.IsValid() && v.Kind() == reflect.Slice { - result := make([]profileListItem, 0, v.Len()) + result := make([]ProfileListItem, 0, v.Len()) for i := 0; i < v.Len(); i++ { elem := v.Index(i) // Dereference pointer @@ -283,7 +283,7 @@ func tryAsProfileList(data any) ([]profileListItem, bool) { if elem.Kind() != reflect.Struct { return nil, false } - p := profileListItem{} + p := ProfileListItem{} // Read fields if f := elem.FieldByName("Name"); f.IsValid() && f.Kind() == reflect.String { p.Name = f.String() @@ -311,13 +311,13 @@ func tryAsProfileList(data any) ([]profileListItem, bool) { return nil, false } - result := make([]profileListItem, 0, len(arr)) + result := make([]ProfileListItem, 0, len(arr)) for _, item := range arr { m, ok := item.(map[string]any) if !ok { return nil, false } - p := profileListItem{} + p := ProfileListItem{} if v, ok := m["name"].(string); ok { p.Name = v } diff --git a/internal/output/writer_test.go b/internal/output/writer_test.go index c369e6f..8b27d3c 100644 --- a/internal/output/writer_test.go +++ b/internal/output/writer_test.go @@ -561,13 +561,13 @@ func TestExtractMapSlice(t *testing.T) { } func TestTryAsProfileList(t *testing.T) { - t.Run("profileListItem slice", func(t *testing.T) { - input := []profileListItem{ + t.Run("ProfileListItem slice", func(t *testing.T) { + input := []ProfileListItem{ {Name: "dev", Description: "Dev", DB: "mysql", Mode: "read-only"}, } got, ok := tryAsProfileList(input) if !ok { - t.Error("expected ok=true for []profileListItem") + t.Error("expected ok=true for []ProfileListItem") } if len(got) != 1 || got[0].Name != "dev" { t.Errorf("got %+v, want [{dev Dev mysql read-only}]", got) diff --git a/internal/web/handler.go b/internal/web/handler.go new file mode 100644 index 0000000..8efcd34 --- /dev/null +++ b/internal/web/handler.go @@ -0,0 +1,448 @@ +package web + +import ( + "context" + "encoding/json" + "fmt" + "io" + "io/fs" + "mime" + "net" + "net/http" + "net/url" + "path" + "strconv" + "strings" + "time" + + "github.com/zx06/xsql/internal/app" + "github.com/zx06/xsql/internal/config" + "github.com/zx06/xsql/internal/errors" + "github.com/zx06/xsql/internal/output" + frontend "github.com/zx06/xsql/webui" +) + +// HandlerOptions configures the web HTTP handlers. +type HandlerOptions struct { + ConfigPath string + InitialProfile string + AllowPlaintext bool + SkipHostKeyCheck bool + AuthRequired bool + AuthToken string + Assets fs.FS +} + +type handler struct { + configPath string + initialProfile string + allowPlaintext bool + skipHostKeyCheck bool + authRequired bool + authToken string + assets fs.FS +} + +type queryRequest struct { + Profile string `json:"profile"` + SQL string `json:"sql"` +} + +// NewHandler creates the web server handler. +func NewHandler(opts HandlerOptions) http.Handler { + assets := opts.Assets + if assets == nil { + assets = frontend.Dist() + } + + h := &handler{ + configPath: opts.ConfigPath, + initialProfile: opts.InitialProfile, + allowPlaintext: opts.AllowPlaintext, + skipHostKeyCheck: opts.SkipHostKeyCheck, + authRequired: opts.AuthRequired, + authToken: opts.AuthToken, + assets: assets, + } + + mux := http.NewServeMux() + mux.HandleFunc(apiPrefix+"/health", h.handleHealth) + mux.Handle(apiPrefix+"/profiles", h.withAuth(http.HandlerFunc(h.handleProfiles))) + mux.Handle(apiPrefix+"/profiles/", h.withAuth(http.HandlerFunc(h.handleProfileShow))) + mux.Handle(apiPrefix+"/schema/tables/", h.withAuth(http.HandlerFunc(h.handleSchemaTable))) + mux.Handle(apiPrefix+"/schema/tables", h.withAuth(http.HandlerFunc(h.handleSchemaTables))) + mux.Handle(apiPrefix+"/query", h.withAuth(http.HandlerFunc(h.handleQuery))) + mux.HandleFunc("/config.js", h.handleConfigJS) + mux.HandleFunc("/", h.handleFrontend) + return mux +} + +func (h *handler) withAuth(next http.Handler) http.Handler { + if !h.authRequired { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := strings.TrimSpace(r.Header.Get("Authorization")) + if authHeader == "" { + writeError(w, http.StatusUnauthorized, errors.New(errors.CodeAuthRequired, "authorization token is required", nil)) + return + } + const prefix = "Bearer " + if !strings.HasPrefix(authHeader, prefix) || strings.TrimSpace(strings.TrimPrefix(authHeader, prefix)) != h.authToken { + writeError(w, http.StatusUnauthorized, errors.New(errors.CodeAuthInvalid, "authorization token is invalid", nil)) + return + } + next.ServeHTTP(w, r) + }) +} + +func (h *handler) handleHealth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ok", + "auth_required": h.authRequired, + "initial_profile": h.initialProfile, + "frontend_embedded": h.hasIndex(), + }) +} + +func (h *handler) handleProfiles(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + result, xe := app.LoadProfiles(config.Options{ConfigPath: h.configPath}) + if xe != nil { + writeError(w, statusCodeFor(xe.Code), xe) + return + } + writeJSON(w, http.StatusOK, result) +} + +func (h *handler) handleProfileShow(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + name := strings.TrimPrefix(r.URL.Path, apiPrefix+"/profiles/") + if name == "" || strings.Contains(name, "/") { + writeError(w, http.StatusNotFound, errors.New(errors.CodeCfgInvalid, "profile not found", map[string]any{"name": name})) + return + } + result, xe := app.LoadProfileDetail(config.Options{ConfigPath: h.configPath}, name) + if xe != nil { + writeError(w, statusCodeFor(xe.Code), xe) + return + } + writeJSON(w, http.StatusOK, result) +} + +func (h *handler) handleSchemaTables(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + profileName := strings.TrimSpace(r.URL.Query().Get("profile")) + profile, xe := h.loadProfile(profileName) + if xe != nil { + writeError(w, statusCodeFor(xe.Code), xe) + return + } + + includeSystem, xe := parseIncludeSystem(r) + if xe != nil { + writeError(w, http.StatusBadRequest, xe) + return + } + + timeout := app.SchemaTimeout(profile, 0, false, 60*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), timeout) + defer cancel() + + result, xe := app.ListTables(ctx, app.TableListRequest{ + Profile: profile, + TablePattern: r.URL.Query().Get("table"), + IncludeSystem: includeSystem, + AllowPlaintext: h.allowPlaintext, + SkipHostKeyCheck: h.skipHostKeyCheck, + }) + if xe != nil { + writeError(w, statusCodeFor(xe.Code), xe) + return + } + writeJSON(w, http.StatusOK, result) +} + +func (h *handler) handleSchemaTable(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + + profileName := strings.TrimSpace(r.URL.Query().Get("profile")) + profile, xe := h.loadProfile(profileName) + if xe != nil { + writeError(w, statusCodeFor(xe.Code), xe) + return + } + + schemaName, tableName, ok := parseSchemaTablePath(r.URL.Path) + if !ok { + writeError(w, http.StatusNotFound, errors.New(errors.CodeCfgInvalid, "table not found", map[string]any{"reason": "table_not_found"})) + return + } + + timeout := app.SchemaTimeout(profile, 0, false, 60*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), timeout) + defer cancel() + + result, xe := app.DescribeTable(ctx, app.TableDescribeRequest{ + Profile: profile, + Schema: schemaName, + Name: tableName, + AllowPlaintext: h.allowPlaintext, + SkipHostKeyCheck: h.skipHostKeyCheck, + }) + if xe != nil { + status := statusCodeFor(xe.Code) + if xe.Code == errors.CodeCfgInvalid { + if reason, _ := xe.Details["reason"].(string); reason == "table_not_found" { + status = http.StatusNotFound + } + } + writeError(w, status, xe) + return + } + writeJSON(w, http.StatusOK, result) +} + +func (h *handler) handleQuery(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeMethodNotAllowed(w) + return + } + defer r.Body.Close() + + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + writeError(w, http.StatusBadRequest, errors.Wrap(errors.CodeCfgInvalid, "failed to read request body", nil, err)) + return + } + + var req queryRequest + if err := json.Unmarshal(body, &req); err != nil { + writeError(w, http.StatusBadRequest, errors.Wrap(errors.CodeCfgInvalid, "invalid request body", nil, err)) + return + } + profile, xe := h.loadProfile(req.Profile) + if xe != nil { + writeError(w, statusCodeFor(xe.Code), xe) + return + } + + timeout := app.QueryTimeout(profile, 0, false, 30*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), timeout) + defer cancel() + + result, xe := app.Query(ctx, app.QueryRequest{ + Profile: profile, + SQL: req.SQL, + AllowPlaintext: h.allowPlaintext, + SkipHostKeyCheck: h.skipHostKeyCheck, + UnsafeAllowWrite: false, + }) + if xe != nil { + writeError(w, statusCodeFor(xe.Code), xe) + return + } + writeJSON(w, http.StatusOK, result) +} + +func (h *handler) handleConfigJS(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w) + return + } + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + _, _ = fmt.Fprintf(w, "window.__XSQL_WEB_CONFIG__ = %s;\n", mustJSON(map[string]any{ + "initialProfile": h.initialProfile, + "authRequired": h.authRequired, + })) +} + +func (h *handler) handleFrontend(w http.ResponseWriter, r *http.Request) { + name := strings.TrimPrefix(path.Clean(r.URL.Path), "/") + if name == "." { + name = "" + } + if name != "" { + if file, err := h.assets.Open(name); err == nil { + defer file.Close() + if info, statErr := file.Stat(); statErr == nil && !info.IsDir() { + if contentType := mime.TypeByExtension(path.Ext(name)); contentType != "" { + w.Header().Set("Content-Type", contentType) + } + http.ServeContent(w, r, info.Name(), info.ModTime(), file.(io.ReadSeeker)) + return + } + } + } + + index, err := h.assets.Open("index.html") + if err != nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = io.WriteString(w, "

xsql web assets are not built

Run the frontend build before serving the UI.

") + return + } + defer index.Close() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + http.ServeContent(w, r, "index.html", time.Time{}, index.(io.ReadSeeker)) +} + +func (h *handler) hasIndex() bool { + file, err := h.assets.Open("index.html") + if err != nil { + return false + } + _ = file.Close() + return true +} + +func (h *handler) loadProfile(name string) (config.Profile, *errors.XError) { + if name == "" { + name = h.initialProfile + } + if name == "" { + return config.Profile{}, errors.New(errors.CodeCfgInvalid, "profile is required", nil) + } + cfg, _, xe := config.LoadConfig(config.Options{ConfigPath: h.configPath}) + if xe != nil { + return config.Profile{}, xe + } + return app.ResolveProfile(cfg, name) +} + +func writeMethodNotAllowed(w http.ResponseWriter) { + writeError(w, http.StatusMethodNotAllowed, errors.New(errors.CodeCfgInvalid, "method not allowed", nil)) +} + +func parseIncludeSystem(r *http.Request) (bool, *errors.XError) { + includeSystem := false + if raw := r.URL.Query().Get("include_system"); raw != "" { + parsed, err := strconv.ParseBool(raw) + if err != nil { + return false, errors.New(errors.CodeCfgInvalid, "include_system must be a boolean", nil) + } + includeSystem = parsed + } + return includeSystem, nil +} + +func parseSchemaTablePath(rawPath string) (string, string, bool) { + trimmed := strings.TrimPrefix(rawPath, apiPrefix+"/schema/tables/") + parts := strings.Split(trimmed, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + + schemaName, err := url.PathUnescape(parts[0]) + if err != nil { + return "", "", false + } + tableName, err := url.PathUnescape(parts[1]) + if err != nil { + return "", "", false + } + return schemaName, tableName, true +} + +func writeJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + _ = enc.Encode(output.Envelope{ + OK: true, + SchemaVersion: output.SchemaVersion, + Data: data, + }) +} + +func writeError(w http.ResponseWriter, status int, xe *errors.XError) { + if xe == nil { + xe = errors.New(errors.CodeInternal, "internal error", nil) + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + _ = enc.Encode(output.Envelope{ + OK: false, + SchemaVersion: output.SchemaVersion, + Error: &output.ErrorObject{ + Code: xe.Code, + Message: xe.Message, + Details: xe.Details, + }, + }) +} + +func statusCodeFor(code errors.Code) int { + switch code { + case errors.CodeCfgInvalid, errors.CodeCfgNotFound, errors.CodeSecretNotFound: + return http.StatusBadRequest + case errors.CodeAuthRequired, errors.CodeAuthInvalid: + return http.StatusUnauthorized + case errors.CodeROBlocked: + return http.StatusForbidden + case errors.CodeDBConnectFailed, errors.CodeDBAuthFailed, errors.CodeSSHDialFailed, errors.CodeSSHAuthFailed, errors.CodeSSHHostKeyMismatch: + return http.StatusBadGateway + case errors.CodeDBDriverUnsupported: + return http.StatusBadRequest + case errors.CodeDBExecFailed: + return http.StatusBadRequest + default: + return http.StatusInternalServerError + } +} + +func mustJSON(v any) string { + b, err := json.Marshal(v) + if err != nil { + return "{}" + } + return string(b) +} + +// IsLoopbackAddr reports whether the listen address is bound only to loopback. +func IsLoopbackAddr(addr string) bool { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return false + } + if host == "localhost" { + return true + } + ip := net.ParseIP(strings.Trim(host, "[]")) + return ip != nil && ip.IsLoopback() +} + +// PublicURL converts an effective listener address to a browser URL. +func PublicURL(addr string) string { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return "http://" + addr + "/" + } + displayHost := host + if host == "" || host == "0.0.0.0" || host == "::" || host == "[::]" { + displayHost = "127.0.0.1" + } + if strings.Contains(displayHost, ":") && !strings.HasPrefix(displayHost, "[") { + displayHost = "[" + displayHost + "]" + } + return "http://" + net.JoinHostPort(displayHost, port) + "/" +} diff --git a/internal/web/handler_test.go b/internal/web/handler_test.go new file mode 100644 index 0000000..1d7be19 --- /dev/null +++ b/internal/web/handler_test.go @@ -0,0 +1,260 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" +) + +type envelope struct { + OK bool `json:"ok"` + SchemaVersion int `json:"schema_version"` + Data any `json:"data"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + Details map[string]any `json:"details"` + } `json:"error"` +} + +func TestIsLoopbackAddr(t *testing.T) { + cases := []struct { + addr string + want bool + }{ + {addr: "127.0.0.1:8788", want: true}, + {addr: "[::1]:8788", want: true}, + {addr: "localhost:8788", want: true}, + {addr: "0.0.0.0:8788", want: false}, + {addr: ":8788", want: false}, + } + + for _, tc := range cases { + if got := IsLoopbackAddr(tc.addr); got != tc.want { + t.Fatalf("IsLoopbackAddr(%q)=%v want %v", tc.addr, got, tc.want) + } + } +} + +func TestPublicURL(t *testing.T) { + if got := PublicURL("0.0.0.0:8788"); got != "http://127.0.0.1:8788/" { + t.Fatalf("PublicURL()=%q", got) + } +} + +func TestHandler_HealthAndConfig(t *testing.T) { + handler := NewHandler(HandlerOptions{ + InitialProfile: "dev", + Assets: fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte("ok")}, + }, + }) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("health status=%d", rec.Code) + } + + req = httptest.NewRequest(http.MethodGet, "/config.js", nil) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("config.js status=%d", rec.Code) + } + if !strings.Contains(rec.Body.String(), `"initialProfile":"dev"`) { + t.Fatalf("config.js missing initial profile: %s", rec.Body.String()) + } +} + +func TestHandler_ProfilesAuth(t *testing.T) { + configPath := createConfigFile(t, ` +profiles: + dev: + db: mysql + host: 127.0.0.1 + user: root + database: app +`) + handler := NewHandler(HandlerOptions{ + ConfigPath: configPath, + AuthRequired: true, + AuthToken: "secret", + InitialProfile: "dev", + Assets: fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte("ok")}, + }, + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles", nil) + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } + resp := decodeEnvelope(t, rec.Body.Bytes()) + if resp.Error == nil || resp.Error.Code != "XSQL_AUTH_REQUIRED" { + t.Fatalf("unexpected error: %+v", resp.Error) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/api/v1/profiles", nil) + req.Header.Set("Authorization", "Bearer secret") + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + resp = decodeEnvelope(t, rec.Body.Bytes()) + data, ok := resp.Data.(map[string]any) + if !ok { + t.Fatalf("unexpected data: %#v", resp.Data) + } + profiles, ok := data["profiles"].([]any) + if !ok || len(profiles) != 1 { + t.Fatalf("unexpected profiles payload: %#v", data["profiles"]) + } +} + +func TestHandler_FrontendFallbackWhenDistMissing(t *testing.T) { + handler := NewHandler(HandlerOptions{ + Assets: fstest.MapFS{ + "asset.txt": &fstest.MapFile{Data: []byte("x")}, + }, + }) + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "assets are not built") { + t.Fatalf("unexpected body: %s", rec.Body.String()) + } +} + +func TestHandler_SchemaTablesRejectsInvalidIncludeSystem(t *testing.T) { + configPath := createConfigFile(t, ` +profiles: + dev: + db: mysql + host: 127.0.0.1 + user: root + database: app +`) + handler := NewHandler(HandlerOptions{ + ConfigPath: configPath, + InitialProfile: "dev", + Assets: fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte("ok")}, + }, + }) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/schema/tables?profile=dev&include_system=wat", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String()) + } + + resp := decodeEnvelope(t, rec.Body.Bytes()) + if resp.Error == nil || resp.Error.Code != "XSQL_CFG_INVALID" { + t.Fatalf("unexpected error: %+v", resp.Error) + } +} + +func TestHandler_SchemaTableRejectsInvalidPath(t *testing.T) { + configPath := createConfigFile(t, ` +profiles: + dev: + db: mysql + host: 127.0.0.1 + user: root + database: app +`) + handler := NewHandler(HandlerOptions{ + ConfigPath: configPath, + InitialProfile: "dev", + Assets: fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte("ok")}, + }, + }) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/schema/tables/public_only?profile=dev", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d body=%s", rec.Code, rec.Body.String()) + } + + resp := decodeEnvelope(t, rec.Body.Bytes()) + if resp.Error == nil || resp.Error.Code != "XSQL_CFG_INVALID" { + t.Fatalf("unexpected error: %+v", resp.Error) + } +} + +func TestParseSchemaTablePath(t *testing.T) { + cases := []struct { + name string + path string + ok bool + want [2]string + }{ + { + name: "plain path", + path: "/api/v1/schema/tables/public/users", + ok: true, + want: [2]string{"public", "users"}, + }, + { + name: "escaped path", + path: "/api/v1/schema/tables/public%20x/user%2Flogs", + ok: true, + want: [2]string{"public x", "user/logs"}, + }, + { + name: "missing table", + path: "/api/v1/schema/tables/public", + ok: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + schemaName, tableName, ok := parseSchemaTablePath(tc.path) + if ok != tc.ok { + t.Fatalf("ok=%v want %v", ok, tc.ok) + } + if !tc.ok { + return + } + if schemaName != tc.want[0] || tableName != tc.want[1] { + t.Fatalf("got (%q,%q) want (%q,%q)", schemaName, tableName, tc.want[0], tc.want[1]) + } + }) + } +} + +func createConfigFile(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "xsql.yaml") + if err := os.WriteFile(path, []byte(strings.TrimSpace(content)), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func decodeEnvelope(t *testing.T, body []byte) envelope { + t.Helper() + var resp envelope + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("invalid response: %v body=%s", err, string(body)) + } + return resp +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..8038665 --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,58 @@ +package web + +import ( + "context" + "net" + "net/http" + "time" +) + +const ( + // DefaultAddr is the default listen address for the web server. + DefaultAddr = "127.0.0.1:8788" + apiPrefix = "/api/v1" +) + +// Server wraps an HTTP server plus its listener. +type Server struct { + listener net.Listener + server *http.Server +} + +// NewServer creates a server for the provided listener and handler. +func NewServer(listener net.Listener, handler http.Handler) *Server { + return &Server{ + listener: listener, + server: &http.Server{ + Handler: handler, + ReadHeaderTimeout: 15 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + }, + } +} + +// Addr returns the effective listen address. +func (s *Server) Addr() string { + if s == nil || s.listener == nil { + return "" + } + return s.listener.Addr().String() +} + +// Serve starts serving HTTP requests. +func (s *Server) Serve() error { + if s == nil || s.server == nil || s.listener == nil { + return http.ErrServerClosed + } + return s.server.Serve(s.listener) +} + +// Shutdown gracefully stops the server. +func (s *Server) Shutdown(ctx context.Context) error { + if s == nil || s.server == nil { + return nil + } + return s.server.Shutdown(ctx) +} diff --git a/tests/e2e/web_test.go b/tests/e2e/web_test.go new file mode 100644 index 0000000..825ae83 --- /dev/null +++ b/tests/e2e/web_test.go @@ -0,0 +1,110 @@ +//go:build e2e + +package e2e + +import ( + "bufio" + "bytes" + "encoding/json" + "net/http" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestServe_JSONHealthCheck(t *testing.T) { + cmd := exec.Command(testBinary, "serve", "--addr", "127.0.0.1:0", "--format", "json") + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatalf("StdoutPipe: %v", err) + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + t.Fatalf("start serve: %v", err) + } + + reader := bufio.NewReader(stdout) + lineCh := make(chan string, 1) + errCh := make(chan error, 1) + go func() { + line, readErr := reader.ReadString('\n') + if readErr != nil { + errCh <- readErr + return + } + lineCh <- line + }() + + var line string + select { + case line = <-lineCh: + case err := <-errCh: + t.Fatalf("failed to read startup output: %v stderr=%s", err, stderr.String()) + case <-time.After(10 * time.Second): + t.Fatalf("timed out waiting for startup output; stderr=%s", stderr.String()) + } + + var resp Response + if err := json.Unmarshal([]byte(line), &resp); err != nil { + t.Fatalf("invalid JSON startup output: %v line=%s", err, line) + } + if !resp.OK { + t.Fatalf("startup failed: %+v", resp.Error) + } + + data, ok := resp.Data.(map[string]any) + if !ok { + t.Fatalf("unexpected data payload: %#v", resp.Data) + } + baseURL, ok := data["url"].(string) + if !ok || baseURL == "" { + t.Fatalf("missing url in startup payload: %#v", data) + } + + httpResp, err := http.Get(strings.TrimRight(baseURL, "/") + "/api/v1/health") + if err != nil { + t.Fatalf("health request failed: %v", err) + } + defer httpResp.Body.Close() + if httpResp.StatusCode != http.StatusOK { + t.Fatalf("health status=%d", httpResp.StatusCode) + } + + if err := cmd.Process.Signal(os.Interrupt); err != nil { + t.Fatalf("interrupt serve: %v", err) + } + waitCh := make(chan error, 1) + go func() { waitCh <- cmd.Wait() }() + select { + case err := <-waitCh: + if err != nil { + t.Fatalf("serve exited with error: %v stderr=%s", err, stderr.String()) + } + case <-time.After(10 * time.Second): + _ = cmd.Process.Kill() + t.Fatalf("timed out waiting for serve shutdown") + } +} + +func TestServe_RemoteRequiresToken(t *testing.T) { + stdout, _, exitCode := runXSQL(t, "serve", "--addr", "0.0.0.0:8788", "--format", "json") + + if exitCode != 2 { + t.Fatalf("expected exit code 2, got %d", exitCode) + } + + var resp Response + if err := json.Unmarshal([]byte(stdout), &resp); err != nil { + t.Fatalf("invalid JSON: %v output=%s", err, stdout) + } + if resp.OK { + t.Fatal("expected ok=false") + } + if resp.Error == nil || resp.Error.Code != "XSQL_CFG_INVALID" { + t.Fatalf("unexpected error: %+v", resp.Error) + } +} diff --git a/tests/integration/schema_dump_test.go b/tests/integration/schema_dump_test.go index b2d9242..87da7a2 100644 --- a/tests/integration/schema_dump_test.go +++ b/tests/integration/schema_dump_test.go @@ -163,6 +163,27 @@ func TestSchemaDump_MySQL_RealDB(t *testing.T) { if !hasCompositeForeignKeyTo(orders, usersTable) { t.Fatalf("orders table missing composite FK to %s", usersTable) } + + tableList, xe := db.ListTables(ctx, "mysql", conn, db.SchemaOptions{ + TablePattern: prefix + "*", + }) + if xe != nil { + t.Fatalf("ListTables error: %v", xe) + } + if tableList.Database == "" || len(tableList.Tables) != 2 { + t.Fatalf("unexpected table list: %#v", tableList) + } + + describeUsers, xe := db.DescribeTable(ctx, "mysql", conn, db.TableDescribeOptions{ + Schema: tableList.Database, + Name: usersTable, + }) + if xe != nil { + t.Fatalf("DescribeTable users error: %v", xe) + } + if describeUsers.Name != usersTable || len(describeUsers.Columns) == 0 { + t.Fatalf("unexpected describe users result: %#v", describeUsers) + } } func TestSchemaDump_Pg_RealDB(t *testing.T) { @@ -347,6 +368,27 @@ func TestSchemaDump_Pg_RealDB(t *testing.T) { if !hasCompositeForeignKeyTo(orders, prefix+usersTable) { t.Fatalf("orders table missing composite FK to %s", prefix+usersTable) } + + tableList, xe := db.ListTables(ctx, "pg", conn, db.SchemaOptions{ + TablePattern: prefix + "*", + }) + if xe != nil { + t.Fatalf("ListTables error: %v", xe) + } + if tableList.Database == "" || len(tableList.Tables) != 2 { + t.Fatalf("unexpected table list: %#v", tableList) + } + + describeUsers, xe := db.DescribeTable(ctx, "pg", conn, db.TableDescribeOptions{ + Schema: schema, + Name: prefix + usersTable, + }) + if xe != nil { + t.Fatalf("DescribeTable users error: %v", xe) + } + if describeUsers.Name != prefix+usersTable || len(describeUsers.Columns) == 0 { + t.Fatalf("unexpected describe users result: %#v", describeUsers) + } } func findTable(tables []db.Table, name string) *db.Table { diff --git a/webui/embed.go b/webui/embed.go new file mode 100644 index 0000000..8dbf9e9 --- /dev/null +++ b/webui/embed.go @@ -0,0 +1,20 @@ +package webui + +import ( + "embed" + "io/fs" +) + +// DistFiles contains the built frontend assets. CI/release builds populate dist/. +// +//go:embed all:dist +var DistFiles embed.FS + +// Dist returns the embedded frontend dist filesystem. +func Dist() fs.FS { + sub, err := fs.Sub(DistFiles, "dist") + if err != nil { + return DistFiles + } + return sub +} diff --git a/webui/index.html b/webui/index.html new file mode 100644 index 0000000..0e53434 --- /dev/null +++ b/webui/index.html @@ -0,0 +1,12 @@ + + + + + + xsql web + + +
+ + + diff --git a/webui/package-lock.json b/webui/package-lock.json new file mode 100644 index 0000000..aa67718 --- /dev/null +++ b/webui/package-lock.json @@ -0,0 +1,1774 @@ +{ + "name": "xsql-webui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "xsql-webui", + "version": "0.0.0", + "dependencies": { + "@codemirror/autocomplete": "^6.20.1", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.41.0", + "codemirror": "^6.0.2", + "sql-formatter": "^15.7.3" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "7.0.0", + "@tailwindcss/vite": "^4.2.2", + "svelte": "5.55.2", + "tailwindcss": "^4.2.2", + "vite": "8.0.8" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.1", + "resolved": "https://registry.npmmirror.com/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmmirror.com/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-sql": { + "version": "6.10.0", + "resolved": "https://registry.npmmirror.com/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", + "integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmmirror.com/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.5", + "resolved": "https://registry.npmmirror.com/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.6.0", + "resolved": "https://registry.npmmirror.com/@codemirror/search/-/search-6.6.0.tgz", + "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.41.0", + "resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-6.41.0.tgz", + "integrity": "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmmirror.com/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.8", + "resolved": "https://registry.npmmirror.com/@lezer/lr/-/lr-1.4.8.tgz", + "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.0.0.tgz", + "integrity": "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.1", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.58.1.tgz", + "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.7.1", + "resolved": "https://registry.npmmirror.com/devalue/-/devalue-5.7.1.tgz", + "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==", + "dev": true, + "license": "MIT" + }, + "node_modules/discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.4", + "resolved": "https://registry.npmmirror.com/esrap/-/esrap-2.2.4.tgz", + "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@typescript-eslint/types": "^8.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/moo": { + "version": "0.5.3", + "resolved": "https://registry.npmmirror.com/moo/-/moo-0.5.3.tgz", + "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "license": "BSD-3-Clause" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nearley": { + "version": "2.20.1", + "resolved": "https://registry.npmmirror.com/nearley/-/nearley-2.20.1.tgz", + "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "license": "MIT", + "dependencies": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + }, + "bin": { + "nearley-railroad": "bin/nearley-railroad.js", + "nearley-test": "bin/nearley-test.js", + "nearley-unparse": "bin/nearley-unparse.js", + "nearleyc": "bin/nearleyc.js" + }, + "funding": { + "type": "individual", + "url": "https://nearley.js.org/#give-to-nearley" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", + "license": "CC0-1.0" + }, + "node_modules/randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmmirror.com/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "license": "MIT", + "dependencies": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmmirror.com/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "license": "MIT", + "engines": { + "node": ">=0.12" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sql-formatter": { + "version": "15.7.3", + "resolved": "https://registry.npmmirror.com/sql-formatter/-/sql-formatter-15.7.3.tgz", + "integrity": "sha512-5+zl9Nqg5aNjss0tb1G+StpC4dJKbjv3+g8CL/+V+00PfZop+2RKGyi53ScFl0dr+Dkx1LjmUO54Q3N7K3EtMw==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "nearley": "^2.20.1" + }, + "bin": { + "sql-formatter": "bin/sql-formatter-cli.cjs" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/svelte": { + "version": "5.55.2", + "resolved": "https://registry.npmmirror.com/svelte/-/svelte-5.55.2.tgz", + "integrity": "sha512-z41M/hi0ZPTzrwVKLvB/R1/Oo08gL1uIib8HZ+FncqxxtY9MLb01emg2fqk+WLZ/lNrrtNDFh7BZLDxAHvMgLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.4", + "esm-env": "^1.2.1", + "esrap": "^2.2.4", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmmirror.com/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..78a89ee --- /dev/null +++ b/webui/package.json @@ -0,0 +1,26 @@ +{ + "name": "xsql-webui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "7.0.0", + "@tailwindcss/vite": "^4.2.2", + "svelte": "5.55.2", + "tailwindcss": "^4.2.2", + "vite": "8.0.8" + }, + "dependencies": { + "@codemirror/autocomplete": "^6.20.1", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.41.0", + "codemirror": "^6.0.2", + "sql-formatter": "^15.7.3" + } +} diff --git a/webui/skills/svelte-code-writer/SKILL.md b/webui/skills/svelte-code-writer/SKILL.md new file mode 100644 index 0000000..b50ccf9 --- /dev/null +++ b/webui/skills/svelte-code-writer/SKILL.md @@ -0,0 +1,66 @@ +--- +name: svelte-code-writer +description: CLI tools for Svelte 5 documentation lookup and code analysis. MUST be used whenever creating, editing or analyzing any Svelte component (.svelte) or Svelte module (.svelte.ts/.svelte.js). If possible, this skill should be executed within the svelte-file-editor agent for optimal results. +--- + +# Svelte 5 Code Writer + +## CLI Tools + +You have access to `@sveltejs/mcp` CLI for Svelte-specific assistance. Use these commands via `npx`: + +### List Documentation Sections + +```bash +npx @sveltejs/mcp list-sections +``` + +Lists all available Svelte 5 and SvelteKit documentation sections with titles and paths. + +### Get Documentation + +```bash +npx @sveltejs/mcp get-documentation ",,..." +``` + +Retrieves full documentation for specified sections. Use after `list-sections` to fetch relevant docs. + +**Example:** + +```bash +npx @sveltejs/mcp get-documentation "$state,$derived,$effect" +``` + +### Svelte Autofixer + +```bash +npx @sveltejs/mcp svelte-autofixer "" [options] +``` + +Analyzes Svelte code and suggests fixes for common issues. + +**Options:** + +- `--async` - Enable async Svelte mode (default: false) +- `--svelte-version` - Target version: 4 or 5 (default: 5) + +**Examples:** + +```bash +# Analyze inline code (escape $ as \$) +npx @sveltejs/mcp svelte-autofixer '' + +# Analyze a file +npx @sveltejs/mcp svelte-autofixer ./src/lib/Component.svelte + +# Target Svelte 4 +npx @sveltejs/mcp svelte-autofixer ./Component.svelte --svelte-version 4 +``` + +**Important:** When passing code with runes (`$state`, `$derived`, etc.) via the terminal, escape the `$` character as `\$` to prevent shell variable substitution. + +## Workflow + +1. **Uncertain about syntax?** Run `list-sections` then `get-documentation` for relevant topics +2. **Reviewing/debugging?** Run `svelte-autofixer` on the code to detect issues +3. **Always validate** - Run `svelte-autofixer` before finalizing any Svelte component diff --git a/webui/skills/svelte-core-bestpractices/SKILL.md b/webui/skills/svelte-core-bestpractices/SKILL.md new file mode 100644 index 0000000..ffa73ee --- /dev/null +++ b/webui/skills/svelte-core-bestpractices/SKILL.md @@ -0,0 +1,176 @@ +--- +name: svelte-core-bestpractices +description: Guidance on writing fast, robust, modern Svelte code. Load this skill whenever in a Svelte project and asked to write/edit or analyze a Svelte component or module. Covers reactivity, event handling, styling, integration with libraries and more. +--- + +## `$state` + +Only use the `$state` rune for variables that should be _reactive_ — in other words, variables that cause an `$effect`, `$derived` or template expression to update. Everything else can be a normal variable. + +Objects and arrays (`$state({...})` or `$state([...])`) are made deeply reactive, meaning mutation will trigger updates. This has a trade-off: in exchange for fine-grained reactivity, the objects must be proxied, which has performance overhead. In cases where you're dealing with large objects that are only ever reassigned (rather than mutated), use `$state.raw` instead. This is often the case with API responses, for example. + +## `$derived` + +To compute something from state, use `$derived` rather than `$effect`: + +```js +// do this +let square = $derived(num * num); + +// don't do this +let square; + +$effect(() => { + square = num * num; +}); +``` + +> [!NOTE] `$derived` is given an expression, _not_ a function. If you need to use a function (because the expression is complex, for example) use `$derived.by`. + +Deriveds are writable — you can assign to them, just like `$state`, except that they will re-evaluate when their expression changes. + +If the derived expression is an object or array, it will be returned as-is — it is _not_ made deeply reactive. You can, however, use `$state` inside `$derived.by` in the rare cases that you need this. + +## `$effect` + +Effects are an escape hatch and should mostly be avoided. In particular, avoid updating state inside effects. + +- If you need to sync state to an external library such as D3, it is often neater to use [`{@attach ...}`](references/@attach.md) +- If you need to run some code in response to user interaction, put the code directly in an event handler or use a [function binding](references/bind.md) as appropriate +- If you need to log values for debugging purposes, use [`$inspect`](references/$inspect.md) +- If you need to observe something external to Svelte, use [`createSubscriber`](references/svelte-reactivity.md) + +Never wrap the contents of an effect in `if (browser) {...}` or similar — effects do not run on the server. + +## `$props` + +Treat props as though they will change. For example, values that depend on props should usually use `$derived`: + +```js +// @errors: 2451 +let { type } = $props(); + +// do this +let color = $derived(type === 'danger' ? 'red' : 'green'); + +// don't do this — `color` will not update if `type` changes +let color = type === 'danger' ? 'red' : 'green'; +``` + +## `$inspect.trace` + +`$inspect.trace` is a debugging tool for reactivity. If something is not updating properly or running more than it should you can add `$inspect.trace(label)` as the first line of an `$effect` or `$derived.by` (or any function they call) to trace their dependencies and discover which one triggered an update. + +## Events + +Any element attribute starting with `on` is treated as an event listener: + +```svelte + + + + + + + +``` + +If you need to attach listeners to `window` or `document` you can use `` and ``: + +```svelte + + +``` + +Avoid using `onMount` or `$effect` for this. + +## Snippets + +[Snippets](references/snippet.md) are a way to define reusable chunks of markup that can be instantiated with the [`{@render ...}`](references/@render.md) tag, or passed to components as props. They must be declared within the template. + +```svelte +{#snippet greeting(name)} +

hello {name}!

+{/snippet} + +{@render greeting('world')} +``` + +> [!NOTE] Snippets declared at the top level of a component (i.e. not inside elements or blocks) can be referenced inside ` + + + +``` + +On updates, a stack trace will be printed, making it easy to find the origin of a state change (unless you're in the playground, due to technical limitations). + +## $inspect(...).with + +`$inspect` returns a property `with`, which you can invoke with a callback, which will then be invoked instead of `console.log`. The first argument to the callback is either `"init"` or `"update"`; subsequent arguments are the values passed to `$inspect` (demo: + +```svelte + + + +``` + +## $inspect.trace(...) + +This rune, added in 5.14, causes the surrounding function to be _traced_ in development. Any time the function re-runs as part of an [effect]($effect) or a [derived]($derived), information will be printed to the console about which pieces of reactive state caused the effect to fire. + +```svelte + +``` + +`$inspect.trace` takes an optional first argument which will be used as the label. diff --git a/webui/skills/svelte-core-bestpractices/references/@attach.md b/webui/skills/svelte-core-bestpractices/references/@attach.md new file mode 100644 index 0000000..5c113b1 --- /dev/null +++ b/webui/skills/svelte-core-bestpractices/references/@attach.md @@ -0,0 +1,166 @@ +Attachments are functions that run in an [effect]($effect) when an element is mounted to the DOM or when [state]($state) read inside the function updates. + +Optionally, they can return a function that is called before the attachment re-runs, or after the element is later removed from the DOM. + +> [!NOTE] +> Attachments are available in Svelte 5.29 and newer. + +```svelte + + + +
...
+``` + +An element can have any number of attachments. + +## Attachment factories + +A useful pattern is for a function, such as `tooltip` in this example, to _return_ an attachment (demo: + +```svelte + + + + + + +``` + +Since the `tooltip(content)` expression runs inside an [effect]($effect), the attachment will be destroyed and recreated whenever `content` changes. The same thing would happen for any state read _inside_ the attachment function when it first runs. (If this isn't what you want, see [Controlling when attachments re-run](#Controlling-when-attachments-re-run).) + +## Inline attachments + +Attachments can also be created inline (demo: + +```svelte + + { + const context = canvas.getContext('2d'); + + $effect(() => { + context.fillStyle = color; + context.fillRect(0, 0, canvas.width, canvas.height); + }); + }} +> +``` + +> [!NOTE] +> The nested effect runs whenever `color` changes, while the outer effect (where `canvas.getContext(...)` is called) only runs once, since it doesn't read any reactive state. + +## Conditional attachments + +Falsy values like `false` or `undefined` are treated as no attachment, enabling conditional usage: + +```svelte +
...
+``` + +## Passing attachments to components + +When used on a component, `{@attach ...}` will create a prop whose key is a [`Symbol`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol). If the component then [spreads](/tutorial/svelte/spread-props) props onto an element, the element will receive those attachments. + +This allows you to create _wrapper components_ that augment elements (demo: + +```svelte + + + + + +``` + +```svelte + + + + + + +``` + +## Controlling when attachments re-run + +Attachments, unlike [actions](use), are fully reactive: `{@attach foo(bar)}` will re-run on changes to `foo` _or_ `bar` (or any state read inside `foo`): + +```js +// @errors: 7006 2304 2552 +function foo(bar) { + return (node) => { + veryExpensiveSetupWork(node); + update(node, bar); + }; +} +``` + +In the rare case that this is a problem (for example, if `foo` does expensive and unavoidable setup work) consider passing the data inside a function and reading it in a child effect: + +```js +// @errors: 7006 2304 2552 +function foo(+++getBar+++) { + return (node) => { + veryExpensiveSetupWork(node); + ++++ $effect(() => { + update(node, getBar()); + });+++ + } +} +``` + +## Creating attachments programmatically + +To add attachments to an object that will be spread onto a component or element, use [`createAttachmentKey`](svelte-attachments#createAttachmentKey). + +## Converting actions to attachments + +If you're using a library that only provides actions, you can convert them to attachments with [`fromAction`](svelte-attachments#fromAction), allowing you to (for example) use them with components. diff --git a/webui/skills/svelte-core-bestpractices/references/@render.md b/webui/skills/svelte-core-bestpractices/references/@render.md new file mode 100644 index 0000000..2e60685 --- /dev/null +++ b/webui/skills/svelte-core-bestpractices/references/@render.md @@ -0,0 +1,35 @@ +To render a [snippet](snippet), use a `{@render ...}` tag. + +```svelte +{#snippet sum(a, b)} +

{a} + {b} = {a + b}

+{/snippet} + +{@render sum(1, 2)} +{@render sum(3, 4)} +{@render sum(5, 6)} +``` + +The expression can be an identifier like `sum`, or an arbitrary JavaScript expression: + +```svelte +{@render (cool ? coolSnippet : lameSnippet)()} +``` + +## Optional snippets + +If the snippet is potentially undefined — for example, because it's an incoming prop — then you can use optional chaining to only render it when it _is_ defined: + +```svelte +{@render children?.()} +``` + +Alternatively, use an [`{#if ...}`](if) block with an `:else` clause to render fallback content: + +```svelte +{#if children} + {@render children()} +{:else} +

fallback content

+{/if} +``` diff --git a/webui/skills/svelte-core-bestpractices/references/await-expressions.md b/webui/skills/svelte-core-bestpractices/references/await-expressions.md new file mode 100644 index 0000000..18c2231 --- /dev/null +++ b/webui/skills/svelte-core-bestpractices/references/await-expressions.md @@ -0,0 +1,180 @@ +As of Svelte 5.36, you can use the `await` keyword inside your components in three places where it was previously unavailable: + +- at the top level of your component's ` + + + + +

{a} + {b} = {await add(a, b)}

+``` + +...if you increment `a`, the contents of the `

` will _not_ immediately update to read this — + +```html +

2 + 2 = 3

+``` + +— instead, the text will update to `2 + 2 = 4` when `add(a, b)` resolves. + +Updates can overlap — a fast update will be reflected in the UI while an earlier slow update is still ongoing. + +## Concurrency + +Svelte will do as much asynchronous work as it can in parallel. For example if you have two `await` expressions in your markup... + +```svelte +

{await one()}

{await two()}

+``` + +...both functions will run at the same time, as they are independent expressions, even though they are _visually_ sequential. + +This does not apply to sequential `await` expressions inside your ` + + + +{#if open} + + (open = false)} /> +{/if} +``` + +## Caveats + +As an experimental feature, the details of how `await` is handled (and related APIs like `$effect.pending()`) are subject to breaking changes outside of a semver major release, though we intend to keep such changes to a bare minimum. + +## Breaking changes + +Effects run in a slightly different order when the `experimental.async` option is `true`. Specifically, _block_ effects like `{#if ...}` and `{#each ...}` now run before an `$effect.pre` or `beforeUpdate` in the same component, which means that in very rare situations. diff --git a/webui/skills/svelte-core-bestpractices/references/bind.md b/webui/skills/svelte-core-bestpractices/references/bind.md new file mode 100644 index 0000000..80b2f4c --- /dev/null +++ b/webui/skills/svelte-core-bestpractices/references/bind.md @@ -0,0 +1,16 @@ +## Function bindings + +You can also use `bind:property={get, set}`, where `get` and `set` are functions, allowing you to perform validation and transformation: + +```svelte + value, (v) => (value = v.toLowerCase())} /> +``` + +In the case of readonly bindings like [dimension bindings](#Dimensions), the `get` value should be `null`: + +```svelte +
...
+``` + +> [!NOTE] +> Function bindings are available in Svelte 5.9.0 and newer. diff --git a/webui/skills/svelte-core-bestpractices/references/each.md b/webui/skills/svelte-core-bestpractices/references/each.md new file mode 100644 index 0000000..283b754 --- /dev/null +++ b/webui/skills/svelte-core-bestpractices/references/each.md @@ -0,0 +1,42 @@ +## Keyed each blocks + +```svelte + +{#each expression as name (key)}...{/each} +``` + +```svelte + +{#each expression as name, index (key)}...{/each} +``` + +If a _key_ expression is provided — which must uniquely identify each list item — Svelte will use it to intelligently update the list when data changes by inserting, moving and deleting items, rather than adding or removing items at the end and updating the state in the middle. + +The key can be any object, but strings and numbers are recommended since they allow identity to persist when the objects themselves change. + +```svelte +{#each items as item (item.id)} +
  • {item.name} x {item.qty}
  • +{/each} + + +{#each items as item, i (item.id)} +
  • {i + 1}: {item.name} x {item.qty}
  • +{/each} +``` + +You can freely use destructuring and rest patterns in each blocks. + +```svelte +{#each items as { id, name, qty }, i (id)} +
  • {i + 1}: {name} x {qty}
  • +{/each} + +{#each objects as { id, ...rest }} +
  • {id}
  • +{/each} + +{#each items as [id, ...rest]} +
  • {id}
  • +{/each} +``` diff --git a/webui/skills/svelte-core-bestpractices/references/hydratable.md b/webui/skills/svelte-core-bestpractices/references/hydratable.md new file mode 100644 index 0000000..a7baf74 --- /dev/null +++ b/webui/skills/svelte-core-bestpractices/references/hydratable.md @@ -0,0 +1,100 @@ +In Svelte, when you want to render asynchronous content data on the server, you can simply `await` it. This is great! However, it comes with a pitfall: when hydrating that content on the client, Svelte has to redo the asynchronous work, which blocks hydration for however long it takes: + +```svelte + + +

    {user.name}

    +``` + +That's silly, though. If we've already done the hard work of getting the data on the server, we don't want to get it again during hydration on the client. `hydratable` is a low-level API built to solve this problem. You probably won't need this very often — it will be used behind the scenes by whatever datafetching library you use. For example, it powers [remote functions in SvelteKit](/docs/kit/remote-functions). + +To fix the example above: + +```svelte + + +

    {user.name}

    +``` + +This API can also be used to provide access to random or time-based values that are stable between server rendering and hydration. For example, to get a random number that doesn't update on hydration: + +```ts +import { hydratable } from 'svelte'; +const rand = hydratable('random', () => Math.random()); +``` + +If you're a library author, be sure to prefix the keys of your `hydratable` values with the name of your library so that your keys don't conflict with other libraries. + +## Serialization + +All data returned from a `hydratable` function must be serializable. But this doesn't mean you're limited to JSON — Svelte uses [`devalue`](https://npmjs.com/package/devalue), which can serialize all sorts of things including `Map`, `Set`, `URL`, and `BigInt`. Check the documentation page for a full list. In addition to these, thanks to some Svelte magic, you can also fearlessly use promises: + +```svelte + + +{await promises.one} +{await promises.two} +``` + +## CSP + +`hydratable` adds an inline ` + +{#snippet hello(name)} +

    hello {name}! {message}!

    +{/snippet} + +{@render hello('alice')} +{@render hello('bob')} +``` + +...and they are 'visible' to everything in the same lexical scope (i.e. siblings, and children of those siblings): + +```svelte +
    + {#snippet x()} + {#snippet y()}...{/snippet} + + + {@render y()} + {/snippet} + + + {@render y()} +
    + + +{@render x()} +``` + +Snippets can reference themselves and each other (demo: + +```svelte +{#snippet blastoff()} + 🚀 +{/snippet} + +{#snippet countdown(n)} + {#if n > 0} + {n}... + {@render countdown(n - 1)} + {:else} + {@render blastoff()} + {/if} +{/snippet} + +{@render countdown(10)} +``` + +## Passing snippets to components + +### Explicit props + +Within the template, snippets are values just like any other. As such, they can be passed to components as props (demo: + +```svelte + + +{#snippet header()} + fruit + qty + price + total +{/snippet} + +{#snippet row(d)} + {d.name} + {d.qty} + {d.price} + {d.qty * d.price} +{/snippet} + + +``` + +Think about it like passing content instead of data to a component. The concept is similar to slots in web components. + +### Implicit props + +As an authoring convenience, snippets declared directly _inside_ a component implicitly become props _on_ the component (demo: + +```svelte + +
    + {#snippet header()} + + + + + {/snippet} + + {#snippet row(d)} + + + + + {/snippet} +
    fruitqtypricetotal{d.name}{d.qty}{d.price}{d.qty * d.price}
    +``` + +### Implicit `children` snippet + +Any content inside the component tags that is _not_ a snippet declaration implicitly becomes part of the `children` snippet (demo: + +```svelte + + +``` + +```svelte + + + + + +``` + +> [!NOTE] Note that you cannot have a prop called `children` if you also have content inside the component — for this reason, you should avoid having props with that name + +### Optional snippet props + +You can declare snippet props as being optional. You can either use optional chaining to not render anything if the snippet isn't set... + +```svelte + + +{@render children?.()} +``` + +...or use an `#if` block to render fallback content: + +```svelte + + +{#if children} + {@render children()} +{:else} + fallback content +{/if} +``` + +## Typing snippets + +Snippets implement the `Snippet` interface imported from `'svelte'`: + +```svelte + +``` + +With this change, red squigglies will appear if you try and use the component without providing a `data` prop and a `row` snippet. Notice that the type argument provided to `Snippet` is a tuple, since snippets can have multiple parameters. + +We can tighten things up further by declaring a generic, so that `data` and `row` refer to the same type: + +```svelte + +``` + +## Exporting snippets + +Snippets declared at the top level of a `.svelte` file can be exported from a ` + +{#snippet add(a, b)} + {a} + {b} = {a + b} +{/snippet} +``` + +> [!NOTE] +> This requires Svelte 5.5.0 or newer + +## Programmatic snippets + +Snippets can be created programmatically with the [`createRawSnippet`](svelte#createRawSnippet) API. This is intended for advanced use cases. + +## Snippets and slots + +In Svelte 4, content can be passed to components using [slots](legacy-slots). Snippets are more powerful and flexible, and so slots have been deprecated in Svelte 5. diff --git a/webui/skills/svelte-core-bestpractices/references/svelte-reactivity.md b/webui/skills/svelte-core-bestpractices/references/svelte-reactivity.md new file mode 100644 index 0000000..262e361 --- /dev/null +++ b/webui/skills/svelte-core-bestpractices/references/svelte-reactivity.md @@ -0,0 +1,61 @@ +## createSubscriber + +
    + +Available since 5.7.0 + +
    + +Returns a `subscribe` function that integrates external event-based systems with Svelte's reactivity. +It's particularly useful for integrating with web APIs like `MediaQuery`, `IntersectionObserver`, or `WebSocket`. + +If `subscribe` is called inside an effect (including indirectly, for example inside a getter), +the `start` callback will be called with an `update` function. Whenever `update` is called, the effect re-runs. + +If `start` returns a cleanup function, it will be called when the effect is destroyed. + +If `subscribe` is called in multiple effects, `start` will only be called once as long as the effects +are active, and the returned teardown function will only be called when all effects are destroyed. + +It's best understood with an example. Here's an implementation of [`MediaQuery`](/docs/svelte/svelte-reactivity#MediaQuery): + +```js +// @errors: 7031 +import { createSubscriber } from 'svelte/reactivity'; +import { on } from 'svelte/events'; + +export class MediaQuery { + #query; + #subscribe; + + constructor(query) { + this.#query = window.matchMedia(`(${query})`); + + this.#subscribe = createSubscriber((update) => { + // when the `change` event occurs, re-run any effects that read `this.current` + const off = on(this.#query, 'change', update); + + // stop listening when all the effects are destroyed + return () => off(); + }); + } + + get current() { + // This makes the getter reactive, if read in an effect + this.#subscribe(); + + // Return the current state of the query, whether or not we're in an effect + return this.#query.matches; + } +} +``` + +
    + +```dts +function createSubscriber( + start: (update: () => void) => (() => void) | void +): () => void; +``` + +
    diff --git a/webui/src/App.svelte b/webui/src/App.svelte new file mode 100644 index 0000000..da7c2b5 --- /dev/null +++ b/webui/src/App.svelte @@ -0,0 +1,102 @@ + + + + xsql web + + +
    +
    + ui.setThemeMode(mode)} + onProfileChange={(profileName) => ui.selectProfile(profileName)} + onTokenChange={(token) => ui.setAuthToken(token)} + onSelectTable={(table) => ui.previewTable(table)} + /> + +
    + ui.ensureCompletionTableDetail(schemaName, tableName)} + onFormat={() => ui.formatSQL()} + onGetTableDetail={(schemaName, tableName) => ui.getCompletionTableDetail(schemaName, tableName)} + onRun={() => ui.runQuery()} + onSqlChange={(sql) => ui.setSQL(sql)} + /> + + {#if ui.errorMessage} +
    + Error + {ui.errorMessage} +
    + {/if} + + ui.setActiveTab(tab)}> + {#snippet results()} + + {/snippet} + + {#snippet structure()} + + {/snippet} + +
    +
    +
    diff --git a/webui/src/app.css b/webui/src/app.css new file mode 100644 index 0000000..baab834 --- /dev/null +++ b/webui/src/app.css @@ -0,0 +1,234 @@ +@import "tailwindcss"; + +:root { + color-scheme: light; +} + +html, +body, +#app { + height: 100%; + overflow: hidden; +} + +@layer base { + body { + margin: 0; + background: var(--app-bg); + color: var(--text); + font-family: Georgia, "Times New Roman", serif; + overflow: hidden; + } + + button, + input, + select, + textarea { + font: inherit; + } + + button { + cursor: pointer; + } + + button:disabled { + cursor: not-allowed; + } +} + +.theme-white { + --app-bg: #ede8df; + --text: #1d2529; + --muted: #66737a; + --panel-bg: rgba(255, 255, 255, 0.82); + --panel-border: rgba(29, 37, 41, 0.1); + --panel-shadow: rgba(29, 37, 41, 0.05); + --panel-inner: rgba(255, 255, 255, 0.58); + --input-bg: #fcfbf8; + --input-border: rgba(29, 37, 41, 0.14); + --table-bg: #fcfbf8; + --table-head-bg: #f7f2eb; + --table-border: rgba(29, 37, 41, 0.08); + --accent: #214f7b; + --accent-soft: #fff5eb; + --accent-border: #b36a41; + --tag-bg: #e7ddd0; + --tag-text: #7b5137; + --pill-bg: #e6edf6; + --pill-text: #2d5679; + --error-bg: rgba(255, 237, 232, 0.92); + --error-text: #8f281d; + --editor-bg: #f6f1ea; + --editor-border: rgba(29, 37, 41, 0.12); +} + +.theme-black { + --app-bg: #121518; + --text: #e8ecef; + --muted: #a4b0b7; + --panel-bg: rgba(24, 28, 32, 0.92); + --panel-border: rgba(232, 236, 239, 0.08); + --panel-shadow: rgba(0, 0, 0, 0.28); + --panel-inner: rgba(38, 44, 50, 0.72); + --input-bg: #181d22; + --input-border: rgba(232, 236, 239, 0.1); + --table-bg: #171c20; + --table-head-bg: #20262c; + --table-border: rgba(232, 236, 239, 0.08); + --accent: #5f95c8; + --accent-soft: rgba(95, 149, 200, 0.14); + --accent-border: #5f95c8; + --tag-bg: #2b3138; + --tag-text: #d4dde3; + --pill-bg: #243340; + --pill-text: #9bc0e5; + --error-bg: rgba(95, 31, 25, 0.55); + --error-text: #ffd8d1; + --editor-bg: #14191d; + --editor-border: rgba(232, 236, 239, 0.08); +} + +@layer components { + .xsql-panel { + @apply rounded-xl border border-[var(--panel-border)] bg-[var(--panel-bg)] shadow-[0_10px_24px_var(--panel-shadow)]; + } + + .xsql-input { + @apply w-full rounded-lg border border-[var(--input-border)] bg-[var(--input-bg)] px-3 py-2 text-sm text-[var(--text)] outline-none transition placeholder:text-[var(--muted)] focus:border-[var(--accent)] focus:ring-2 focus:ring-[color:var(--accent-soft)]; + } + + .xsql-button { + @apply inline-flex items-center justify-center rounded-lg border px-3 py-2 text-sm font-medium transition disabled:opacity-60; + } + + .xsql-button-primary { + @apply border-[var(--accent-border)] bg-[var(--accent)] text-white hover:brightness-105; + } + + .xsql-tab { + @apply rounded-md px-3 py-1.5 text-xs font-medium text-[var(--muted)] transition hover:bg-[var(--accent-soft)] hover:text-[var(--text)]; + } + + .xsql-tab-active { + @apply bg-[var(--accent-soft)] text-[var(--text)] ring-1 ring-[var(--panel-border)]; + } + + .xsql-scroll { + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--muted) 55%, transparent) transparent; + } + + .xsql-scroll::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + .xsql-scroll::-webkit-scrollbar-track { + background: transparent; + } + + .xsql-scroll::-webkit-scrollbar-thumb { + border: 2px solid transparent; + border-radius: 999px; + background: color-mix(in srgb, var(--muted) 50%, transparent); + background-clip: padding-box; + } + + .xsql-scroll::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--muted) 70%, transparent); + background-clip: padding-box; + } +} + +.xsql-table { + width: 100%; + min-width: 100%; + border-collapse: collapse; + table-layout: fixed; + background: var(--table-bg); +} + +.xsql-table th, +.xsql-table td { + border-bottom: 1px solid var(--table-border); + padding: 0.55rem 0.75rem; + text-align: left; + vertical-align: top; +} + +.xsql-table th { + position: sticky; + top: 0; + background: var(--table-head-bg); + color: var(--muted); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.xsql-table td { + font-size: 0.84rem; + color: var(--text); + word-break: break-word; +} + +.xsql-table-compact th { + padding: 0.45rem 0.65rem; +} + +.xsql-table-compact td { + padding: 0.15rem 0.25rem; + vertical-align: middle; +} + +.xsql-cm { + min-height: 0; +} + +.xsql-cm .cm-editor { + height: 100%; +} + +.xsql-cm .cm-scroller, +.xsql-cm .cm-tooltip-autocomplete > ul { + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--muted) 55%, transparent) transparent; +} + +.xsql-cm .cm-scroller::-webkit-scrollbar, +.xsql-cm .cm-tooltip-autocomplete > ul::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.xsql-cm .cm-scroller::-webkit-scrollbar-track, +.xsql-cm .cm-tooltip-autocomplete > ul::-webkit-scrollbar-track { + background: transparent; +} + +.xsql-cm .cm-scroller::-webkit-scrollbar-thumb, +.xsql-cm .cm-tooltip-autocomplete > ul::-webkit-scrollbar-thumb { + border: 2px solid transparent; + border-radius: 999px; + background: color-mix(in srgb, var(--muted) 50%, transparent); + background-clip: padding-box; +} + +.xsql-cm .cm-scroller::-webkit-scrollbar-thumb:hover, +.xsql-cm .cm-tooltip-autocomplete > ul::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--muted) 70%, transparent); + background-clip: padding-box; +} + +.xsql-cm .cm-tooltip-autocomplete ul { + padding: 0.25rem; +} + +.xsql-cm .cm-tooltip-autocomplete ul li .cm-completionLabel { + font-size: 0.82rem; +} + +.xsql-cm .cm-tooltip-autocomplete ul li .cm-completionDetail { + color: var(--muted); +} diff --git a/webui/src/lib/components/ObjectTree.svelte b/webui/src/lib/components/ObjectTree.svelte new file mode 100644 index 0000000..2b6defe --- /dev/null +++ b/webui/src/lib/components/ObjectTree.svelte @@ -0,0 +1,48 @@ + + +
    + + + {#if configPath} +

    {configPath}

    + {/if} + + {#if selectedProfile === ''} +

    Select a profile to load tables.

    + {:else if tableCount === 0 && !schemaLoading} +

    No schema data available.

    + {:else} +
      + {#each schemaTables as table (`${table.schema}.${table.name}`)} +
    • + +
    • + {/each} +
    + {/if} +
    diff --git a/webui/src/lib/components/QueryEditor.svelte b/webui/src/lib/components/QueryEditor.svelte new file mode 100644 index 0000000..1a5f056 --- /dev/null +++ b/webui/src/lib/components/QueryEditor.svelte @@ -0,0 +1,183 @@ + + +
    +
    +
    + +
    +
    + + +
    +
    + +
    +
    diff --git a/webui/src/lib/components/ResultsTable.svelte b/webui/src/lib/components/ResultsTable.svelte new file mode 100644 index 0000000..2968714 --- /dev/null +++ b/webui/src/lib/components/ResultsTable.svelte @@ -0,0 +1,268 @@ + + +
    + + + {#if pageLoading} +

    Loading UI state…

    + {:else if columns.length === 0} +

    Run a query or click a table to preview data.

    + {:else} +
    +
    + + + + {#each columns as column (column)} + + {/each} + + + + {#each rows as row, rowIndex (rowIndex)} + + {#each columns as column (column)} + {@const formatted = formatResultCellValue(row[column])} + + {/each} + + {/each} + +
    {column}
    + +
    +
    + + {#if selectedCell} +
    +
    +
    +
    + {selectedCell.columnName} + + {selectedCell.kind} + + Row {selectedCell.rowIndex + 1} +
    +
    +
    + + +
    +
    +
    + {#if selectedCell.isEmptyString} +

    Empty string

    + {:else} +
    {selectedCell.fullText}
    + {/if} +
    +
    + {/if} +
    + + {#if tooltip} +
    +
    {tooltip.content}
    +
    + {/if} + {/if} +
    diff --git a/webui/src/lib/components/SectionHeader.svelte b/webui/src/lib/components/SectionHeader.svelte new file mode 100644 index 0000000..e0d6961 --- /dev/null +++ b/webui/src/lib/components/SectionHeader.svelte @@ -0,0 +1,12 @@ + + +
    + {label} + {#if meta} + + {meta} + + {/if} +
    diff --git a/webui/src/lib/components/Sidebar.svelte b/webui/src/lib/components/Sidebar.svelte new file mode 100644 index 0000000..c027ae2 --- /dev/null +++ b/webui/src/lib/components/Sidebar.svelte @@ -0,0 +1,59 @@ + + + diff --git a/webui/src/lib/components/StructureTable.svelte b/webui/src/lib/components/StructureTable.svelte new file mode 100644 index 0000000..a6f09f2 --- /dev/null +++ b/webui/src/lib/components/StructureTable.svelte @@ -0,0 +1,52 @@ + + +
    + + + {#if selectedTable} + {#if structureLoading} +

    Loading table structure…

    + {:else if selectedTableDetail} + {#if selectedTableDetail.comment} +

    {selectedTableDetail.comment}

    + {/if} +
    + + + + + + + + + + + + {#each selectedTableDetail.columns || [] as column (column.name)} + + + + + + + + {/each} + +
    NameTypeNullKeyDefault / Comment
    {column.name}{column.type}{column.nullable ? 'YES' : 'NO'}{column.primary_key ? 'PK' : ''}{column.default || column.comment || ''}
    +
    + {:else} +

    Select a table to inspect its structure.

    + {/if} + {:else} +

    Pick a table from the object tree.

    + {/if} +
    diff --git a/webui/src/lib/components/ThemeProfileControls.svelte b/webui/src/lib/components/ThemeProfileControls.svelte new file mode 100644 index 0000000..e447d15 --- /dev/null +++ b/webui/src/lib/components/ThemeProfileControls.svelte @@ -0,0 +1,52 @@ + + +
    + + + + + {#if authRequired} + + {/if} +
    diff --git a/webui/src/lib/components/WorkspaceTabs.svelte b/webui/src/lib/components/WorkspaceTabs.svelte new file mode 100644 index 0000000..927e43b --- /dev/null +++ b/webui/src/lib/components/WorkspaceTabs.svelte @@ -0,0 +1,31 @@ + + +
    +
    + + +
    + + {#if activeTab === 'results'} + {@render results?.()} + {:else} + {@render structure?.()} + {/if} +
    diff --git a/webui/src/lib/result-grid.js b/webui/src/lib/result-grid.js new file mode 100644 index 0000000..569ec45 --- /dev/null +++ b/webui/src/lib/result-grid.js @@ -0,0 +1,117 @@ +const PREVIEW_LENGTH = 96; +const TOOLTIP_LENGTH = 1200; + +function safeJSONStringify(value, indent = 0) { + try { + return JSON.stringify(value, null, indent); + } catch { + return String(value); + } +} + +function collapseWhitespace(value) { + return String(value).replace(/\s+/g, ' ').trim(); +} + +function buildPreviewText(value, kind) { + if (kind === 'null') { + return 'NULL'; + } + if (kind === 'string') { + return value === '' ? "''" : collapseWhitespace(value); + } + if (kind === 'json') { + return collapseWhitespace(safeJSONStringify(value)); + } + return String(value); +} + +function buildFullText(value, kind) { + if (kind === 'null') { + return 'NULL'; + } + if (kind === 'json') { + return safeJSONStringify(value, 2); + } + return String(value); +} + +function detectValueKind(value) { + if (value === null || value === undefined) { + return 'null'; + } + if (Array.isArray(value)) { + return 'json'; + } + if (typeof value === 'string') { + return 'string'; + } + if (typeof value === 'number') { + return 'number'; + } + if (typeof value === 'boolean') { + return 'boolean'; + } + if (typeof value === 'object') { + return 'json'; + } + return 'other'; +} + +export function formatResultCellValue(value) { + const kind = detectValueKind(value); + const previewText = buildPreviewText(value, kind); + const fullText = buildFullText(value, kind); + + return { + kind, + raw: value, + previewText, + previewDisplay: previewText.length > PREVIEW_LENGTH ? `${previewText.slice(0, PREVIEW_LENGTH - 1)}…` : previewText, + fullText, + tooltipText: fullText.length > TOOLTIP_LENGTH ? `${fullText.slice(0, TOOLTIP_LENGTH - 1)}…` : fullText, + isEmptyString: kind === 'string' && value === '', + isLong: previewText.length > PREVIEW_LENGTH || /\n/.test(fullText) + }; +} + +export function buildSelectedResultCell({ rowIndex, columnName, value }) { + const formatted = formatResultCellValue(value); + return { + rowIndex, + columnName, + ...formatted + }; +} + +export async function copyText(value) { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return; + } + + if (typeof document === 'undefined') { + throw new Error('Clipboard is unavailable'); + } + + const element = document.createElement('textarea'); + element.value = value; + element.setAttribute('readonly', 'true'); + element.style.position = 'absolute'; + element.style.left = '-9999px'; + document.body.appendChild(element); + element.select(); + + const copied = document.execCommand('copy'); + document.body.removeChild(element); + if (!copied) { + throw new Error('Clipboard copy failed'); + } +} + +export function isCellTruncated(element) { + if (!element) { + return false; + } + return element.scrollWidth > element.clientWidth || element.scrollHeight > element.clientHeight; +} diff --git a/webui/src/lib/sql-editor.js b/webui/src/lib/sql-editor.js new file mode 100644 index 0000000..c340d1a --- /dev/null +++ b/webui/src/lib/sql-editor.js @@ -0,0 +1,359 @@ +import { autocompletion } from '@codemirror/autocomplete'; +import { EditorState } from '@codemirror/state'; +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { EditorView } from '@codemirror/view'; +import { MySQL, PostgreSQL, StandardSQL, keywordCompletionSource, sql } from '@codemirror/lang-sql'; +import { minimalSetup } from 'codemirror'; +import { tags } from '@lezer/highlight'; +import { format as formatSQL } from 'sql-formatter'; + +const identifierPattern = /^[A-Za-z_][A-Za-z0-9_$]*$/; +const validIdentifierChars = /[A-Za-z0-9_$.]/; +const completionFilterPattern = /^[A-Za-z0-9_$]*$/; + +const sqlHighlightStyle = HighlightStyle.define([ + { tag: [tags.keyword, tags.operatorKeyword], color: 'var(--accent)', fontWeight: '600' }, + { tag: tags.string, color: 'var(--accent-border)' }, + { tag: [tags.number, tags.bool, tags.null], color: 'var(--tag-text)' }, + { tag: [tags.comment, tags.lineComment, tags.blockComment], color: 'var(--muted)', fontStyle: 'italic' }, + { tag: tags.typeName, color: 'var(--pill-text)' }, + { tag: [tags.name, tags.propertyName], color: 'var(--text)' } +]); + +export const sqlEditorBaseExtensions = [ + minimalSetup, + EditorState.tabSize.of(2), + EditorView.lineWrapping, + EditorView.theme({ + '&': { + height: '100%', + borderRadius: '0.75rem', + border: '1px solid var(--input-border)', + backgroundColor: 'var(--input-bg)', + color: 'var(--text)' + }, + '&.cm-focused': { + outline: 'none', + borderColor: 'var(--accent)', + boxShadow: '0 0 0 2px var(--accent-soft)' + }, + '.cm-scroller': { + overflow: 'auto', + fontFamily: 'ui-monospace, SFMono-Regular, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace' + }, + '.cm-content': { + minHeight: '7rem', + padding: '0.75rem 0.9rem', + caretColor: 'var(--text)', + fontSize: '13px', + lineHeight: '1.65' + }, + '.cm-cursor, .cm-dropCursor': { + borderLeftColor: 'var(--accent)' + }, + '.cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection': { + backgroundColor: 'var(--accent-soft)' + }, + '.cm-activeLine': { + backgroundColor: 'transparent' + }, + '.cm-placeholder': { + color: 'var(--muted)' + }, + '.cm-tooltip': { + border: '1px solid var(--panel-border)', + backgroundColor: 'var(--panel-bg)', + color: 'var(--text)', + boxShadow: '0 10px 24px var(--panel-shadow)', + backdropFilter: 'blur(10px)' + }, + '.cm-tooltip-autocomplete > ul': { + maxHeight: '15rem', + overflow: 'auto' + }, + '.cm-tooltip-autocomplete ul li': { + borderRadius: '0.5rem', + margin: '0.1rem 0.2rem', + padding: '0.3rem 0.45rem' + }, + '.cm-tooltip-autocomplete ul li[aria-selected]': { + backgroundColor: 'var(--accent-soft)', + color: 'var(--text)' + } + }), + syntaxHighlighting(sqlHighlightStyle) +]; + +function dialectForName(dialectName) { + if (dialectName === 'mysql') { + return MySQL; + } + if (dialectName === 'postgresql') { + return PostgreSQL; + } + return StandardSQL; +} + +function buildTableInfo(table) { + return [table.schema, table.comment].filter(Boolean).join(' · '); +} + +function buildColumnInfo(column) { + const flags = []; + if (column.primary_key) { + flags.push('PK'); + } + if (!column.nullable) { + flags.push('NOT NULL'); + } + if (column.default != null && column.default !== '') { + flags.push(`DEFAULT ${column.default}`); + } + if (column.comment) { + flags.push(column.comment); + } + return flags.join(' · '); +} + +function buildTopLevelTableOptions(catalog) { + return (catalog.tables || []).map((table) => ({ + label: table.name, + type: 'class', + detail: table.schema, + info: buildTableInfo(table), + apply: `${table.schema}.${table.name}` + })); +} + +function buildSchemaTableOptions(catalog, schemaName) { + return (catalog.tablesBySchema?.[schemaName] || []).map((table) => ({ + label: table.name, + type: 'class', + detail: 'table', + info: buildTableInfo(table) + })); +} + +function buildColumnOptions(detail) { + return (detail?.columns || []).map((column) => ({ + label: column.name, + type: 'property', + detail: column.type || '', + info: buildColumnInfo(column) + })); +} + +function readCompletionChain(doc, pos) { + let from = pos; + while (from > 0) { + const char = doc.sliceString(from - 1, from); + if (!validIdentifierChars.test(char)) { + break; + } + from -= 1; + } + return { + from, + text: doc.sliceString(from, pos) + }; +} + +function parseCompletionTarget(context) { + const { from, text } = readCompletionChain(context.state.doc, context.pos); + if (!text) { + return context.explicit + ? { + kind: 'top-level', + from: context.pos, + to: context.pos, + prefix: '' + } + : null; + } + + const trailingDot = text.endsWith('.'); + const raw = trailingDot ? text.slice(0, -1) : text; + const parts = raw ? raw.split('.') : []; + if (parts.some((part) => !identifierPattern.test(part))) { + return null; + } + + if (parts.length === 1) { + if (trailingDot) { + return { + kind: 'schema-or-table', + from: context.pos, + to: context.pos, + identifier: parts[0] + }; + } + return { + kind: 'top-level', + from, + to: context.pos, + prefix: parts[0] + }; + } + + if (parts.length === 2) { + if (trailingDot) { + return { + kind: 'qualified-table', + from: context.pos, + to: context.pos, + first: parts[0], + second: parts[1] + }; + } + return { + kind: 'member', + from: context.pos - parts[1].length, + to: context.pos, + first: parts[0], + prefix: parts[1] + }; + } + + if (parts.length === 3 && !trailingDot) { + return { + kind: 'qualified-member', + from: context.pos - parts[2].length, + to: context.pos, + schema: parts[0], + table: parts[1], + prefix: parts[2] + }; + } + + return null; +} + +function resolveUnqualifiedTable(catalog, tableName) { + const candidateSchemas = catalog.tableSchemas?.[tableName] || []; + if (candidateSchemas.length === 0) { + return null; + } + if (catalog.activeSchema && candidateSchemas.includes(catalog.activeSchema)) { + return { schema: catalog.activeSchema, name: tableName }; + } + if (catalog.defaultSchema && candidateSchemas.includes(catalog.defaultSchema)) { + return { schema: catalog.defaultSchema, name: tableName }; + } + if (candidateSchemas.length === 1) { + return { schema: candidateSchemas[0], name: tableName }; + } + return null; +} + +function buildCompletionResult(from, to, options) { + if (!options || options.length === 0) { + return null; + } + return { + from, + to, + options, + validFor: completionFilterPattern + }; +} + +function createSchemaCompletionSource({ getCatalog, getTableDetail, ensureTableDetail }) { + return async (context) => { + const catalog = getCatalog(); + if (!catalog || catalog.profile === '') { + return null; + } + + const target = parseCompletionTarget(context); + if (!target) { + return null; + } + + if (target.kind === 'top-level') { + return buildCompletionResult(target.from, target.to, buildTopLevelTableOptions(catalog)); + } + + if (target.kind === 'schema-or-table') { + if (catalog.tablesBySchema?.[target.identifier]) { + return buildCompletionResult(target.from, target.to, buildSchemaTableOptions(catalog, target.identifier)); + } + + const tableRef = resolveUnqualifiedTable(catalog, target.identifier); + if (!tableRef) { + return null; + } + + const detail = + getTableDetail(tableRef.schema, tableRef.name) || + (await ensureTableDetail(tableRef.schema, tableRef.name)); + return buildCompletionResult(target.from, target.to, buildColumnOptions(detail)); + } + + if (target.kind === 'member') { + if (catalog.tablesBySchema?.[target.first]) { + return buildCompletionResult(target.from, target.to, buildSchemaTableOptions(catalog, target.first)); + } + + const tableRef = resolveUnqualifiedTable(catalog, target.first); + if (!tableRef) { + return null; + } + + const detail = + getTableDetail(tableRef.schema, tableRef.name) || + (await ensureTableDetail(tableRef.schema, tableRef.name)); + return buildCompletionResult(target.from, target.to, buildColumnOptions(detail)); + } + + if (target.kind === 'qualified-table') { + const detail = + getTableDetail(target.first, target.second) || + (await ensureTableDetail(target.first, target.second)); + return buildCompletionResult(target.from, target.to, buildColumnOptions(detail)); + } + + if (target.kind === 'qualified-member') { + const detail = + getTableDetail(target.schema, target.table) || + (await ensureTableDetail(target.schema, target.table)); + return buildCompletionResult(target.from, target.to, buildColumnOptions(detail)); + } + + return null; + }; +} + +export function resolveSQLDialectName(dbName) { + const normalized = String(dbName || '').trim().toLowerCase(); + if (normalized === 'mysql') { + return 'mysql'; + } + if (normalized === 'postgres' || normalized === 'postgresql') { + return 'postgresql'; + } + return 'sql'; +} + +export function createSQLLanguageSupport(dialectName) { + return sql({ dialect: dialectForName(dialectName) }); +} + +export function createSQLAutocompletion({ dialectName, getCatalog, getTableDetail, ensureTableDetail }) { + const dialect = dialectForName(dialectName); + return autocompletion({ + activateOnTyping: true, + closeOnBlur: true, + override: [ + createSchemaCompletionSource({ getCatalog, getTableDetail, ensureTableDetail }), + keywordCompletionSource(dialect, true) + ] + }); +} + +export function formatSQLQuery(sqlText, dialectName) { + return formatSQL(sqlText, { + language: dialectName === 'postgresql' ? 'postgresql' : dialectName === 'mysql' ? 'mysql' : 'sql', + tabWidth: 2, + linesBetweenQueries: 1 + }); +} diff --git a/webui/src/lib/web-ui.svelte.js b/webui/src/lib/web-ui.svelte.js new file mode 100644 index 0000000..f5c7ede --- /dev/null +++ b/webui/src/lib/web-ui.svelte.js @@ -0,0 +1,379 @@ +import { formatSQLQuery, resolveSQLDialectName } from './sql-editor.js'; + +function readSessionValue(key) { + if (typeof sessionStorage === 'undefined') { + return ''; + } + return sessionStorage.getItem(key) || ''; +} + +function readThemeMode() { + if (typeof localStorage === 'undefined') { + return 'auto'; + } + const storedTheme = localStorage.getItem('xsql-web-theme'); + if (storedTheme === 'auto' || storedTheme === 'white' || storedTheme === 'black') { + return storedTheme; + } + return 'auto'; +} + +function formatTableName(table) { + return `${table.schema}.${table.name}`; +} + +function buildPreviewSQL(table) { + return `SELECT * FROM ${formatTableName(table)} LIMIT 10`; +} + +function createEmptyCompletionCatalog(profile = '') { + return { + profile, + defaultSchema: '', + activeSchema: '', + tables: [], + tablesBySchema: {}, + tableSchemas: {} + }; +} + +function buildCompletionCatalog(profile, tables, activeSchema = '') { + const sortedTables = [...tables].sort((left, right) => formatTableName(left).localeCompare(formatTableName(right))); + const tablesBySchema = {}; + const tableSchemas = {}; + + for (const table of sortedTables) { + if (!tablesBySchema[table.schema]) { + tablesBySchema[table.schema] = []; + } + tablesBySchema[table.schema].push(table); + + if (!tableSchemas[table.name]) { + tableSchemas[table.name] = []; + } + tableSchemas[table.name].push(table.schema); + } + + const schemaNames = Object.keys(tablesBySchema).sort((left, right) => left.localeCompare(right)); + const defaultSchema = schemaNames.length === 1 ? schemaNames[0] : ''; + + return { + profile, + defaultSchema, + activeSchema: activeSchema || defaultSchema, + tables: sortedTables, + tablesBySchema, + tableSchemas + }; +} + +export class WebUIController { + authRequired = $state(false); + authToken = $state(readSessionValue('xsql-web-auth-token')); + profiles = $state.raw([]); + selectedProfile = $state(''); + schemaTables = $state.raw([]); + selectedTable = $state(null); + selectedTableDetail = $state(null); + schemaLoading = $state(false); + structureLoading = $state(false); + queryLoading = $state(false); + pageLoading = $state(true); + errorMessage = $state(''); + sql = $state('SELECT 1'); + columns = $state.raw([]); + rows = $state.raw([]); + configPath = $state(''); + activeTab = $state('structure'); + themeMode = $state(readThemeMode()); + systemPrefersDark = $state(false); + completionCatalog = $state.raw(createEmptyCompletionCatalog()); + + rowCount = $derived(this.rows.length); + tableCount = $derived(this.schemaTables.length); + selectedProfileMeta = $derived(this.profiles.find((profile) => profile.name === this.selectedProfile) ?? null); + selectedTableName = $derived(this.selectedTable ? formatTableName(this.selectedTable) : ''); + resolvedTheme = $derived(this.themeMode === 'auto' ? (this.systemPrefersDark ? 'black' : 'white') : this.themeMode); + sqlDialect = $derived(resolveSQLDialectName(this.selectedProfileMeta?.db)); + + #schemaRequestSeq = 0; + #structureRequestSeq = 0; + #tableDetailCache = new Map(); + #tableDetailRequestCache = new Map(); + + authHeaders() { + const headers = { 'Content-Type': 'application/json' }; + const token = this.authToken.trim(); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + return headers; + } + + async api(path, init = {}) { + const response = await fetch(path, { + ...init, + headers: { + ...this.authHeaders(), + ...(init.headers || {}) + } + }); + + const payload = await response.json().catch(() => null); + if (!response.ok || !payload?.ok) { + const code = payload?.error?.code ? ` [${payload.error.code}]` : ''; + throw new Error(`${payload?.error?.message || 'Request failed'}${code}`); + } + return payload.data; + } + + setThemeMode(mode) { + this.themeMode = mode; + localStorage.setItem('xsql-web-theme', mode); + } + + setSystemPrefersDark(matches) { + this.systemPrefersDark = matches; + } + + setAuthToken(token) { + this.authToken = token; + sessionStorage.setItem('xsql-web-auth-token', token.trim()); + } + + setSQL(sql) { + this.sql = sql; + } + + setActiveTab(tab) { + this.activeTab = tab; + } + + formatSQL() { + const input = this.sql.trim(); + if (!input) { + return; + } + + try { + this.sql = formatSQLQuery(input, this.sqlDialect); + this.errorMessage = ''; + } catch (error) { + this.errorMessage = error instanceof Error ? `Format failed: ${error.message}` : 'Format failed'; + } + } + + #tableDetailCacheKey(profileName, schemaName, tableName) { + return `${profileName}:${schemaName}.${tableName}`; + } + + #resetCompletionState(profileName) { + this.completionCatalog = createEmptyCompletionCatalog(profileName); + this.#tableDetailCache.clear(); + this.#tableDetailRequestCache.clear(); + } + + #setCompletionCatalog(tables, activeSchema = '') { + this.completionCatalog = buildCompletionCatalog(this.selectedProfile, tables, activeSchema); + } + + #setCompletionActiveSchema(schemaName = '') { + const nextActiveSchema = schemaName || this.completionCatalog.defaultSchema; + if (this.completionCatalog.activeSchema === nextActiveSchema) { + return; + } + this.completionCatalog = { + ...this.completionCatalog, + activeSchema: nextActiveSchema + }; + } + + #cacheTableDetail(profileName, table, detail) { + const key = this.#tableDetailCacheKey(profileName, table.schema, table.name); + this.#tableDetailCache.set(key, detail); + return detail; + } + + getCompletionTableDetail(schemaName, tableName) { + if (!this.selectedProfile) { + return null; + } + const key = this.#tableDetailCacheKey(this.selectedProfile, schemaName, tableName); + return this.#tableDetailCache.get(key) || null; + } + + async #fetchTableDetail(profileName, table) { + const cacheKey = this.#tableDetailCacheKey(profileName, table.schema, table.name); + const cachedDetail = this.#tableDetailCache.get(cacheKey); + if (cachedDetail) { + return cachedDetail; + } + + const pendingRequest = this.#tableDetailRequestCache.get(cacheKey); + if (pendingRequest) { + return pendingRequest; + } + + const request = this.api( + `/api/v1/schema/tables/${encodeURIComponent(table.schema)}/${encodeURIComponent(table.name)}?profile=${encodeURIComponent(profileName)}` + ) + .then((detail) => this.#cacheTableDetail(profileName, table, detail)) + .finally(() => { + this.#tableDetailRequestCache.delete(cacheKey); + }); + + this.#tableDetailRequestCache.set(cacheKey, request); + return request; + } + + async ensureCompletionTableDetail(schemaName, tableName) { + if (!this.selectedProfile || !schemaName || !tableName) { + return null; + } + + try { + return await this.#fetchTableDetail(this.selectedProfile, { schema: schemaName, name: tableName }); + } catch { + return null; + } + } + + async initialize() { + this.pageLoading = true; + this.errorMessage = ''; + try { + const [health, profileData] = await Promise.all([ + this.api('/api/v1/health'), + this.api('/api/v1/profiles') + ]); + this.authRequired = Boolean(health.auth_required); + if (!this.selectedProfile && typeof health.initial_profile === 'string') { + this.selectedProfile = health.initial_profile; + } + + this.profiles = profileData.profiles || []; + this.configPath = profileData.config_path || ''; + if (!this.selectedProfile && this.profiles.length > 0) { + this.selectedProfile = this.profiles[0].name; + } + } catch (error) { + this.errorMessage = error.message; + } finally { + this.pageLoading = false; + } + + await this.loadTables(); + } + + async loadTables() { + const requestSeq = ++this.#schemaRequestSeq; + if (!this.selectedProfile) { + this.schemaTables = []; + this.selectedTable = null; + this.selectedTableDetail = null; + this.#resetCompletionState(''); + return; + } + + this.schemaLoading = true; + this.errorMessage = ''; + this.#resetCompletionState(this.selectedProfile); + try { + const data = await this.api(`/api/v1/schema/tables?profile=${encodeURIComponent(this.selectedProfile)}`); + if (requestSeq !== this.#schemaRequestSeq) { + return; + } + this.schemaTables = data.tables || []; + this.selectedTable = this.schemaTables[0] || null; + this.selectedTableDetail = null; + this.activeTab = 'structure'; + this.#setCompletionCatalog(this.schemaTables, this.selectedTable?.schema || ''); + + if (this.selectedTable) { + await this.loadTableDetail(this.selectedTable); + } + } catch (error) { + if (requestSeq !== this.#schemaRequestSeq) { + return; + } + this.errorMessage = error.message; + this.schemaTables = []; + this.selectedTable = null; + this.selectedTableDetail = null; + this.#resetCompletionState(this.selectedProfile); + } finally { + this.schemaLoading = false; + } + } + + async loadTableDetail(table) { + const requestSeq = ++this.#structureRequestSeq; + if (!this.selectedProfile || !table) { + this.selectedTableDetail = null; + return; + } + + this.structureLoading = true; + this.errorMessage = ''; + const profileName = this.selectedProfile; + try { + const data = await this.#fetchTableDetail(profileName, table); + if (requestSeq !== this.#structureRequestSeq || profileName !== this.selectedProfile) { + return; + } + this.selectedTableDetail = data; + } catch (error) { + if (requestSeq !== this.#structureRequestSeq || profileName !== this.selectedProfile) { + return; + } + this.selectedTableDetail = null; + this.errorMessage = error.message; + } finally { + this.structureLoading = false; + } + } + + async runQuery() { + if (!this.selectedProfile || !this.sql.trim()) { + return; + } + + this.queryLoading = true; + this.errorMessage = ''; + try { + const data = await this.api('/api/v1/query', { + method: 'POST', + body: JSON.stringify({ profile: this.selectedProfile, sql: this.sql }) + }); + this.columns = data.columns || []; + this.rows = data.rows || []; + this.activeTab = 'results'; + } catch (error) { + this.columns = []; + this.rows = []; + this.errorMessage = error.message; + } finally { + this.queryLoading = false; + } + } + + async selectProfile(profileName) { + this.selectedProfile = profileName; + this.columns = []; + this.rows = []; + this.selectedTable = null; + this.selectedTableDetail = null; + this.activeTab = 'structure'; + this.#resetCompletionState(profileName); + await this.loadTables(); + } + + async previewTable(table) { + this.selectedTable = table; + this.activeTab = 'results'; + this.#setCompletionActiveSchema(table.schema); + void this.loadTableDetail(table); + this.sql = buildPreviewSQL(table); + await this.runQuery(); + } +} diff --git a/webui/src/main.js b/webui/src/main.js new file mode 100644 index 0000000..f9889d0 --- /dev/null +++ b/webui/src/main.js @@ -0,0 +1,7 @@ +import { mount } from 'svelte'; +import App from './App.svelte'; +import './app.css'; + +mount(App, { + target: document.getElementById('app') +}); diff --git a/webui/svelte.config.js b/webui/svelte.config.js new file mode 100644 index 0000000..ff8b4c5 --- /dev/null +++ b/webui/svelte.config.js @@ -0,0 +1 @@ +export default {}; diff --git a/webui/vite.config.js b/webui/vite.config.js new file mode 100644 index 0000000..0eaac5c --- /dev/null +++ b/webui/vite.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import tailwindcss from '@tailwindcss/vite'; + +const backendTarget = process.env.XSQL_WEB_PROXY_TARGET || 'http://127.0.0.1:8788'; + +export default defineConfig({ + plugins: [tailwindcss(), svelte()], + server: { + host: '127.0.0.1', + port: 5173, + proxy: { + '/api/v1': { + target: backendTarget, + changeOrigin: true + } + } + } +});