diff --git a/.changeset/a-real-editor-for-driver-lua.md b/.changeset/a-real-editor-for-driver-lua.md new file mode 100644 index 00000000..24ca4527 --- /dev/null +++ b/.changeset/a-real-editor-for-driver-lua.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +The driver editor is its own full-height view with syntax highlighting, line numbers, find and replace, and two linters. Ace is vendored under `/vendor/ace/` rather than pulled from a CDN, for the same reason three.js is — a gateway has to work without the internet, and a driver editor is most needed exactly when something is wrong. It loads on first open, not on page load. Ace's own Lua linter marks problems as you type; `POST /api/drivers/{id}/lint` then asks gopher-lua, the parser that actually decides whether the driver will start, and that verdict gates running a draft. Problems are listed under the editor and clicking one jumps to its line. diff --git a/.changeset/edit-and-try-a-driver.md b/.changeset/edit-and-try-a-driver.md new file mode 100644 index 00000000..b96377aa --- /dev/null +++ b/.changeset/edit-and-try-a-driver.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +Edit a driver's Lua and try it for a fixed window. The edit runs as a local override, the driver restarts against it, and it puts the previous file back on its own unless you keep it — so the failure mode of walking away is the driver you started with. A draft that does not compile, or that renames itself into another driver's slot, never reaches the overlay; one that will not start is undone before the error is reported. Keeping a draft turns it into an ordinary override, which already shadows the channel and already tells you when a newer version exists. diff --git a/.changeset/find-a-driver-by-your-hardware.md b/.changeset/find-a-driver-by-your-hardware.md new file mode 100644 index 00000000..233cf60a --- /dev/null +++ b/.changeset/find-a-driver-by-your-hardware.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +Find a driver by the hardware you own. The catalog was a dropdown of 37 entries in alphabetical order, which asked you to translate: you know you have an SH10RT, not that you need "Sungrow SH Hybrid Inverter". There is a search field now that matches the manufacturer, the driver name and the models each driver has actually been run against — type SH10RT and one card comes back. Results are cards showing which DERs the driver covers and whether anyone has run it on hardware, with the four proven drivers first instead of scattered alphabetically among the 33 that are not. Nothing is selected until you select it; Add now says so rather than quietly adding whichever driver happened to be first. diff --git a/.changeset/read-a-drivers-lua-from-the-gateway.md b/.changeset/read-a-drivers-lua-from-the-gateway.md new file mode 100644 index 00000000..11073b2f --- /dev/null +++ b/.changeset/read-a-drivers-lua-from-the-gateway.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +See the Lua a driver is actually running. Every driver is one file and the repository is the source of truth, but from the gateway there was no way to read the code — so a working fix for someone's inverter stayed on their machine. Each configured driver now has a Source view showing the file that is running, which of the three overlays it came from, its size and hash, and a link to the same driver in device-drivers. diff --git a/.changeset/suggest-a-driver-back-to-the-repo.md b/.changeset/suggest-a-driver-back-to-the-repo.md new file mode 100644 index 00000000..63bbb9f3 --- /dev/null +++ b/.changeset/suggest-a-driver-back-to-the-repo.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +Suggest a driver change back to device-drivers from the gateway. One click opens a pre-filled issue carrying the driver, its version, where the running copy came from, the hash it was based on, and the edit as a diff. The gateway holds no GitHub token and needs none — GitHub accepts a pre-filled issue over a URL and the operator is already signed in to their own browser. The change travels as a diff rather than the whole file: drivers run to tens of kilobytes and a URL is limited to about eight, so sending the file meant the code never travelled at all. diff --git a/go/internal/api/api.go b/go/internal/api/api.go index deddd4d8..fa5ed888 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -200,6 +200,11 @@ type Server struct { versionUpdateMu sync.Mutex driverUpdateMu sync.Mutex backupMu sync.Mutex + + // Timers that put a driver back after an edit has been tried for its + // window. The record on disk is what survives a restart; these only make + // the revert prompt while the process lives. + drafts *driverDrafts } // New creates a new API server. @@ -214,8 +219,13 @@ func New(deps *Deps) *Server { deps: deps, mux: http.NewServeMux(), dailyCache: make(map[string]state.DayEnergy), + drafts: newDriverDrafts(), } s.routes() + // A draft's timer died with the previous process, so anything left behind + // goes back now. What runs after a restart should be the driver that was + // chosen, not a forgotten experiment. + s.RevertDraftsOnStart() return s } @@ -247,6 +257,12 @@ func (s *Server) routes() { s.handle("GET /api/drivers", s.handleDrivers) s.handle("GET /api/drivers/catalog", s.handleDriversCatalog) s.handle("POST /api/drivers/test", s.handleDriverTest) + s.handle("GET /api/drivers/{id}/source", s.handleDriverSource) + s.handle("POST /api/drivers/{id}/lint", s.handleDriverLint) + s.handle("POST /api/drivers/{id}/draft", s.handleDriverDraft) + s.handle("GET /api/drivers/{id}/draft", s.handleDriverDraftStatus) + s.handle("POST /api/drivers/{id}/draft/keep", s.handleDriverDraftKeep) + s.handle("POST /api/drivers/{id}/draft/revert", s.handleDriverDraftRevert) s.handle("POST /api/drivers/fingerprint", s.handleDriverFingerprint) s.handle("GET /api/drivers/{name}", s.handleDriverDetail) s.handle("GET /api/drivers/{name}/logs", s.handleDriverLogs) diff --git a/go/internal/api/api_driver_draft.go b/go/internal/api/api_driver_draft.go new file mode 100644 index 00000000..a480aba6 --- /dev/null +++ b/go/internal/api/api_driver_draft.go @@ -0,0 +1,456 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + lua "github.com/yuin/gopher-lua" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/drivers" +) + +// Editing a driver on a live battery should not be a decision you make once +// and forget. A draft runs for a fixed window and puts the old file back on its +// own unless it is kept, so the failure mode of walking away is the driver you +// started with. +const ( + draftDirName = ".ftw-draft" + draftMinWindow = time.Minute + draftMaxWindow = 60 * time.Minute + draftDefaultWindow = 10 * time.Minute +) + +// draftRecord is written next to the saved original so a restart can undo a +// draft whose timer died with the process. +type draftRecord struct { + DriverID string `json:"driver_id"` + Filename string `json:"filename"` + ExpiresAtMS int64 `json:"expires_at_ms"` + HadOriginal bool `json:"had_original"` +} + +type driverDrafts struct { + mu sync.Mutex + timers map[string]*time.Timer +} + +func newDriverDrafts() *driverDrafts { + return &driverDrafts{timers: map[string]*time.Timer{}} +} + +func (d *driverDrafts) arm(filename string, in time.Duration, revert func()) { + d.mu.Lock() + defer d.mu.Unlock() + if existing, ok := d.timers[filename]; ok { + existing.Stop() + } + d.timers[filename] = time.AfterFunc(in, revert) +} + +func (d *driverDrafts) disarm(filename string) { + d.mu.Lock() + defer d.mu.Unlock() + if existing, ok := d.timers[filename]; ok { + existing.Stop() + delete(d.timers, filename) + } +} + +func (s *Server) draftDir() string { + if s.deps.UserDriverDir == "" { + return "" + } + return filepath.Join(s.deps.UserDriverDir, draftDirName) +} + +// draftPaths names the three files a draft touches: the live overlay copy, the +// saved original, and the record describing it. +func (s *Server) draftPaths(filename string) (live, original, record string) { + return filepath.Join(s.deps.UserDriverDir, filename), + filepath.Join(s.draftDir(), filename+".original"), + filepath.Join(s.draftDir(), filename+".json") +} + +func (s *Server) handleDriverDraft(w http.ResponseWriter, r *http.Request) { + if s.deps.UserDriverDir == "" || s.deps.Registry == nil { + writeJSON(w, 503, map[string]string{"error": "custom drivers are not enabled on this gateway"}) + return + } + var body struct { + Lua string `json:"lua"` + Minutes int `json:"minutes,omitempty"` + } + if err := readJSON(r, &body); err != nil { + writeJSON(w, 400, map[string]string{"error": err.Error()}) + return + } + if strings.TrimSpace(body.Lua) == "" { + writeJSON(w, 400, map[string]string{"error": "the draft is empty"}) + return + } + if len(body.Lua) > maxDriverSourceBytes { + writeJSON(w, 400, map[string]string{"error": errDriverSourceTooLarge.Error()}) + return + } + window := draftDefaultWindow + if body.Minutes > 0 { + window = time.Duration(body.Minutes) * time.Minute + } + if window < draftMinWindow || window > draftMaxWindow { + writeJSON(w, 400, map[string]string{"error": "the window must be between 1 and 60 minutes"}) + return + } + + id := r.PathValue("id") + entry, err := s.catalogEntryByID(id) + if err != nil { + writeJSON(w, 404, map[string]string{"error": err.Error()}) + return + } + // A draft that does not compile, or that renames itself into another + // driver's slot, never reaches the overlay. + if err := validateDraftSource(body.Lua, entry); err != nil { + writeJSON(w, 422, map[string]string{"error": err.Error()}) + return + } + + if !s.driverUpdateMu.TryLock() { + writeJSON(w, 409, map[string]string{"error": "another driver update is in progress"}) + return + } + defer s.driverUpdateMu.Unlock() + + filename := driverFilename(entry) + live, original, record := s.draftPaths(filename) + if err := os.MkdirAll(s.draftDir(), 0o750); err != nil { + writeJSON(w, 500, map[string]string{"error": err.Error()}) + return + } + + // Save whatever the overlay held, so reverting restores an operator's own + // override rather than merely deleting the draft on top of it. + hadOriginal := false + if existing, err := os.ReadFile(live); err == nil { + if err := os.WriteFile(original, existing, 0o600); err != nil { + writeJSON(w, 500, map[string]string{"error": err.Error()}) + return + } + hadOriginal = true + } else if !errors.Is(err, os.ErrNotExist) { + writeJSON(w, 500, map[string]string{"error": err.Error()}) + return + } + + expiresAt := time.Now().Add(window) + meta, _ := json.Marshal(draftRecord{ + DriverID: entry.ID, Filename: filename, + ExpiresAtMS: expiresAt.UnixMilli(), HadOriginal: hadOriginal, + }) + if err := os.WriteFile(record, meta, 0o600); err != nil { + writeJSON(w, 500, map[string]string{"error": err.Error()}) + return + } + if err := replaceFile(live, []byte(body.Lua)); err != nil { + _ = os.Remove(record) + writeJSON(w, 500, map[string]string{"error": err.Error()}) + return + } + + if err := s.restartDriversUsing(r.Context(), filename); err != nil { + // The draft is what broke it, so undo before reporting. + s.revertDraft(filename) + writeJSON(w, 502, map[string]string{"error": err.Error()}) + return + } + + s.drafts.arm(filename, window, func() { s.expireDraft(filename) }) + writeJSON(w, 200, map[string]any{ + "status": "running", + "driver_id": entry.ID, + "expires_at_ms": expiresAt.UnixMilli(), + "minutes": int(window / time.Minute), + }) +} + +// handleDriverDraftKeep stops the clock. The draft stays as an ordinary local +// override, which already shadows the channel and already reports when a newer +// version exists. +func (s *Server) handleDriverDraftKeep(w http.ResponseWriter, r *http.Request) { + entry, err := s.catalogEntryByID(r.PathValue("id")) + if err != nil { + writeJSON(w, 404, map[string]string{"error": err.Error()}) + return + } + filename := driverFilename(entry) + _, original, record := s.draftPaths(filename) + if _, err := os.Stat(record); err != nil { + writeJSON(w, 409, map[string]string{"error": "no draft is running for this driver"}) + return + } + s.drafts.disarm(filename) + _ = os.Remove(original) + _ = os.Remove(record) + writeJSON(w, 200, map[string]any{"status": "kept", "driver_id": entry.ID}) +} + +func (s *Server) handleDriverDraftRevert(w http.ResponseWriter, r *http.Request) { + entry, err := s.catalogEntryByID(r.PathValue("id")) + if err != nil { + writeJSON(w, 404, map[string]string{"error": err.Error()}) + return + } + filename := driverFilename(entry) + if _, _, record := s.draftPaths(filename); !fileExists(record) { + writeJSON(w, 409, map[string]string{"error": "no draft is running for this driver"}) + return + } + if !s.driverUpdateMu.TryLock() { + writeJSON(w, 409, map[string]string{"error": "another driver update is in progress"}) + return + } + defer s.driverUpdateMu.Unlock() + s.drafts.disarm(filename) + if err := s.revertDraftAndRestart(r.Context(), filename); err != nil { + writeJSON(w, 502, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, 200, map[string]any{"status": "reverted", "driver_id": entry.ID}) +} + +// handleDriverDraftStatus lets the panel show a countdown that survives a +// reload of the page. +func (s *Server) handleDriverDraftStatus(w http.ResponseWriter, r *http.Request) { + entry, err := s.catalogEntryByID(r.PathValue("id")) + if err != nil { + writeJSON(w, 404, map[string]string{"error": err.Error()}) + return + } + _, _, record := s.draftPaths(driverFilename(entry)) + raw, err := os.ReadFile(record) + if err != nil { + writeJSON(w, 200, map[string]any{"running": false}) + return + } + var rec draftRecord + if err := json.Unmarshal(raw, &rec); err != nil { + writeJSON(w, 200, map[string]any{"running": false}) + return + } + writeJSON(w, 200, map[string]any{ + "running": true, + "expires_at_ms": rec.ExpiresAtMS, + }) +} + +func (s *Server) expireDraft(filename string) { + s.driverUpdateMu.Lock() + defer s.driverUpdateMu.Unlock() + s.drafts.disarm(filename) + if err := s.revertDraftAndRestart(context.Background(), filename); err != nil { + slog.Warn("driver draft expired but could not be reverted", "filename", filename, "err", err) + } +} + +func (s *Server) revertDraftAndRestart(ctx context.Context, filename string) error { + if !s.revertDraft(filename) { + // Nothing was running, so there is nothing to restart back onto. + return nil + } + return s.restartDriversUsing(ctx, filename) +} + +// revertDraft puts the overlay back exactly as it was: an operator's own +// override if there was one, otherwise nothing at all so the channel or the +// bundled copy resolves again. +// +// The record is what says a draft is running, and it is checked first. Keeping +// a draft removes the record, so a timer that fires in the same instant would +// otherwise delete the file the operator just chose to keep. +func (s *Server) revertDraft(filename string) bool { + live, original, record := s.draftPaths(filename) + if !fileExists(record) { + return false + } + if saved, err := os.ReadFile(original); err == nil { + _ = replaceFile(live, saved) + } else { + _ = os.Remove(live) + } + _ = os.Remove(original) + _ = os.Remove(record) + return true +} + +// RevertDraftsOnStart undoes every draft left behind by a restart. The timer +// that would have expired them died with the process, and a driver running +// after a reboot should be the one that was chosen, not a forgotten +// experiment. +func (s *Server) RevertDraftsOnStart() { + dir := s.draftDir() + if dir == "" { + return + } + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + raw, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + var rec draftRecord + if err := json.Unmarshal(raw, &rec); err != nil || rec.Filename == "" { + continue + } + if filepath.Base(rec.Filename) != rec.Filename { + continue + } + s.revertDraft(rec.Filename) + slog.Info("reverted driver draft left by a restart", "filename", rec.Filename) + } +} + +// validateDraftSource refuses a draft before it can reach the overlay: it must +// compile, and it must still be the driver whose slot it is taking. +func validateDraftSource(source string, entry *drivers.CatalogEntry) error { + L := lua.NewState() + defer L.Close() + if _, err := L.LoadString(source); err != nil { + return fmt.Errorf("the draft does not compile: %w", err) + } + tmp, err := os.CreateTemp("", "ftw-draft-*.lua") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := tmp.WriteString(source); err != nil { + tmp.Close() + return err + } + tmp.Close() + meta, err := drivers.ParseCatalogFile(tmp.Name()) + if err != nil { + return err + } + if meta.ID != entry.ID { + return fmt.Errorf("the draft declares id %q, but this is %q", meta.ID, entry.ID) + } + return nil +} + +func (s *Server) catalogEntryByID(id string) (*drivers.CatalogEntry, error) { + if id == "" { + return nil, errors.New("driver id is required") + } + catalog, err := drivers.LoadCatalogMulti(s.deps.UserDriverDir, s.managedDriverDir(), s.deps.DriverDir) + if err != nil { + return nil, err + } + for i := range catalog { + if catalog[i].ID == id { + return &catalog[i], nil + } + } + return nil, errors.New("no driver with that id") +} + +func driverFilename(entry *drivers.CatalogEntry) string { + if entry.Filename != "" { + return filepath.Base(entry.Filename) + } + return filepath.Base(entry.Path) +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// replaceFile writes through a temporary file in the same directory and renames +// it into place. +// +// Writing to the path directly fails when the file is owned by someone else, +// which is the normal case for an operator's own override: they copy it in as +// themselves, while the gateway runs as its own user. Rename needs write +// permission on the directory, not on the file -- and it is atomic, so a +// driver can never be caught half-written. +func replaceFile(path string, content []byte) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*") + if err != nil { + return err + } + name := tmp.Name() + defer os.Remove(name) + if _, err := tmp.Write(content); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(name, 0o644); err != nil { + return err + } + return os.Rename(name, path) +} + +// restartDriversUsing points every configured driver with this filename at +// whichever overlay now holds it, and restarts it. +// +// The overlay search runs once, when the config is loaded, so cfg.Lua is an +// absolute path into the overlay that answered back then. Writing a draft into +// the user overlay does not move it — the driver would restart against the +// file it was already running. This is the same repointing the managed +// installer does when it activates an artifact. +func (s *Server) restartDriversUsing(ctx context.Context, filename string) error { + target := s.resolveOverlayPath(filename) + if target == "" { + return fmt.Errorf("no copy of %s is on disk", filename) + } + s.deps.CfgMu.Lock() + var affected []config.Driver + for i := range s.deps.Cfg.Drivers { + if filepath.Base(s.deps.Cfg.Drivers[i].Lua) != filename { + continue + } + s.deps.Cfg.Drivers[i].Lua = target + affected = append(affected, s.deps.Cfg.Drivers[i]) + } + s.deps.CfgMu.Unlock() + for _, d := range affected { + if err := s.deps.Registry.Restart(ctx, d); err != nil { + return fmt.Errorf("restart driver %s: %w", d.Name, err) + } + } + return nil +} + +// resolveOverlayPath answers with the file the driver resolver would pick, +// searching the overlays in the order it uses them. +func (s *Server) resolveOverlayPath(filename string) string { + for _, dir := range []string{s.deps.UserDriverDir, s.managedDriverDir(), s.deps.DriverDir} { + if dir == "" { + continue + } + candidate := filepath.Join(dir, filename) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + return "" +} diff --git a/go/internal/api/api_driver_draft_test.go b/go/internal/api/api_driver_draft_test.go new file mode 100644 index 00000000..5906505e --- /dev/null +++ b/go/internal/api/api_driver_draft_test.go @@ -0,0 +1,302 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/drivers" +) + +// A real registry, so a draft is only green if the edited file actually loads +// and the driver actually comes back on it. +func draftServer(t *testing.T) (*Server, string, string, *config.Config) { + t.Helper() + bundled := t.TempDir() + user := t.TempDir() + writeSourceDriver(t, bundled, "demo.lua", "demo", "1.0.0", + "function driver_init(cfg) end\nfunction driver_poll() return 1000 end\n") + + reg := drivers.NewRegistry(nil) + t.Cleanup(reg.ShutdownAll) + cfg := &config.Config{Drivers: []config.Driver{ + {Name: "demo-1", Lua: filepath.Join(bundled, "demo.lua")}, + }} + srv := New(&Deps{ + DriverDir: bundled, UserDriverDir: user, + Registry: reg, Cfg: cfg, CfgMu: &sync.RWMutex{}, + }) + return srv, bundled, user, cfg +} + +// runningLua is the path the configured driver would be restarted from. +func runningLua(cfg *config.Config) string { return cfg.Drivers[0].Lua } + +func postDraft(t *testing.T, srv *Server, id, body string) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/drivers/"+id+"/draft", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + var out map[string]any + _ = json.Unmarshal(rr.Body.Bytes(), &out) + return rr.Code, out +} + +func postDraftAction(t *testing.T, srv *Server, id, action string) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/drivers/"+id+"/draft/"+action, nil) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + var out map[string]any + _ = json.Unmarshal(rr.Body.Bytes(), &out) + return rr.Code, out +} + +const draftLua = "DRIVER = {\n id = \"demo\",\n version = \"1.0.0\",\n" + + " protocols = { \"modbus\" },\n}\nfunction driver_init(cfg) end\nfunction driver_poll() return 99 end\n" + +func TestDraftRunsTheEditedDriverAndRestartsIt(t *testing.T) { + srv, _, user, cfg := draftServer(t) + + code, body := postDraft(t, srv, "demo", `{"lua":`+quote(draftLua)+`,"minutes":5}`) + if code != 200 { + t.Fatalf("draft = %d %v", code, body) + } + if body["status"] != "running" || body["minutes"] != float64(5) { + t.Fatalf("draft body = %+v", body) + } + // The overlay is searched before the bundled copy, so writing there is + // what makes the edit the driver that runs. + live, err := os.ReadFile(filepath.Join(user, "demo.lua")) + if err != nil || !strings.Contains(string(live), "return 99") { + t.Fatalf("overlay = %q, %v", live, err) + } + // The overlay search runs once, at config load, so cfg.Lua still pointed + // at the bundled copy. Without repointing it the driver restarts on the + // file it was already running and the edit does nothing. + if got := runningLua(cfg); got != filepath.Join(user, "demo.lua") { + t.Fatalf("running lua = %q, want the draft in the overlay", got) + } +} + +func TestDraftRevertsOnItsOwnWhenTheWindowPasses(t *testing.T) { + srv, bundled, user, cfg := draftServer(t) + if code, body := postDraft(t, srv, "demo", `{"lua":`+quote(draftLua)+`,"minutes":1}`); code != 200 { + t.Fatalf("draft = %d %v", code, body) + } + + // Walking away is the common case, so expiry is what the promise rests on. + srv.expireDraft("demo.lua") + + if _, err := os.Stat(filepath.Join(user, "demo.lua")); !os.IsNotExist(err) { + t.Fatalf("overlay still holds the draft: %v", err) + } + if got := runningLua(cfg); got != filepath.Join(bundled, "demo.lua") { + t.Fatalf("running lua = %q, want the bundled copy back", got) + } +} + +func TestDraftRevertRestoresAnOperatorsOwnOverride(t *testing.T) { + srv, _, user, _ := draftServer(t) + // An override that existed before the edit must come back, not be deleted + // along with the draft that was laid on top of it. + own := "DRIVER = {\n id = \"demo\",\n version = \"1.0.0\",\n}\nfunction driver_poll() return 7 end\n" + if err := os.WriteFile(filepath.Join(user, "demo.lua"), []byte(own), 0o600); err != nil { + t.Fatal(err) + } + + if code, body := postDraft(t, srv, "demo", `{"lua":`+quote(draftLua)+`}`); code != 200 { + t.Fatalf("draft = %d %v", code, body) + } + if code, body := postDraftAction(t, srv, "demo", "revert"); code != 200 { + t.Fatalf("revert = %d %v", code, body) + } + + restored, err := os.ReadFile(filepath.Join(user, "demo.lua")) + if err != nil || !strings.Contains(string(restored), "return 7") { + t.Fatalf("restored = %q, %v; want the operator's own file back", restored, err) + } +} + +func TestKeepingADraftStopsTheClock(t *testing.T) { + srv, _, user, _ := draftServer(t) + if code, _ := postDraft(t, srv, "demo", `{"lua":`+quote(draftLua)+`,"minutes":1}`); code != 200 { + t.Fatal("draft did not start") + } + if code, body := postDraftAction(t, srv, "demo", "keep"); code != 200 { + t.Fatalf("keep = %d %v", code, body) + } + + // Kept means an ordinary override: expiring now must not undo it, and a + // restart must not either. + srv.expireDraft("demo.lua") + srv.RevertDraftsOnStart() + kept, err := os.ReadFile(filepath.Join(user, "demo.lua")) + if err != nil || !strings.Contains(string(kept), "return 99") { + t.Fatalf("kept draft = %q, %v", kept, err) + } +} + +// An operator copies their own override in as themselves; the gateway runs as +// its own user. On a real Raspberry Pi that meant the draft could not be +// written at all -- "permission denied" on a file the gateway does not own, +// even though it owns the directory. Rename needs the directory, not the file. +func TestDraftReplacesAnOverrideItDoesNotOwn(t *testing.T) { + srv, _, user, _ := draftServer(t) + override := filepath.Join(user, "demo.lua") + own := "DRIVER = {\n id = \"demo\",\n version = \"1.0.0\",\n}\n" + + "function driver_init(cfg) end\nfunction driver_poll() return 7 end\n" + if err := os.WriteFile(override, []byte(own), 0o444); err != nil { + t.Fatal(err) + } + + if code, body := postDraft(t, srv, "demo", `{"lua":`+quote(draftLua)+`}`); code != 200 { + t.Fatalf("draft over a read-only override = %d %v", code, body) + } + live, err := os.ReadFile(override) + if err != nil || !strings.Contains(string(live), "return 99") { + t.Fatalf("overlay = %q, %v", live, err) + } + + // And it still comes back, so the operator does not lose their file. + if code, _ := postDraftAction(t, srv, "demo", "revert"); code != 200 { + t.Fatal("revert failed") + } + restored, err := os.ReadFile(override) + if err != nil || !strings.Contains(string(restored), "return 7") { + t.Fatalf("restored = %q, %v", restored, err) + } +} + +// Keep removes the record; a timer that fires in the same instant must not +// then delete the file the operator just chose to keep. +func TestAnExpiryRacingKeepDoesNotDeleteTheKeptFile(t *testing.T) { + srv, _, user, _ := draftServer(t) + if code, _ := postDraft(t, srv, "demo", `{"lua":`+quote(draftLua)+`,"minutes":1}`); code != 200 { + t.Fatal("draft did not start") + } + if code, _ := postDraftAction(t, srv, "demo", "keep"); code != 200 { + t.Fatal("keep failed") + } + + // The timer was disarmed, but a firing already in flight lands here. + srv.expireDraft("demo.lua") + + if _, err := os.Stat(filepath.Join(user, "demo.lua")); err != nil { + t.Fatalf("the kept file was deleted by a late expiry: %v", err) + } +} + +// The timer dies with the process, so the record on disk is what makes the +// window mean anything across a restart. +func TestARestartUndoesADraftLeftRunning(t *testing.T) { + srv, _, user, _ := draftServer(t) + if code, _ := postDraft(t, srv, "demo", `{"lua":`+quote(draftLua)+`,"minutes":60}`); code != 200 { + t.Fatal("draft did not start") + } + + srv.RevertDraftsOnStart() + + if _, err := os.Stat(filepath.Join(user, "demo.lua")); !os.IsNotExist(err) { + t.Fatal("a draft survived a restart; the window would never expire") + } +} + +func TestDraftRefusesSourceThatCannotRun(t *testing.T) { + srv, bundled, user, cfg := draftServer(t) + + broken := "DRIVER = {\n id = \"demo\",\n" // never closed + code, body := postDraft(t, srv, "demo", `{"lua":`+quote(broken)+`}`) + if code != 422 || !strings.Contains(toString(body["error"]), "does not compile") { + t.Fatalf("broken draft = %d %v", code, body) + } + + // A driver may not take another driver's slot by renaming itself. + wrongID := strings.Replace(draftLua, `id = "demo"`, `id = "somethingelse"`, 1) + code, body = postDraft(t, srv, "demo", `{"lua":`+quote(wrongID)+`}`) + if code != 422 || !strings.Contains(toString(body["error"]), "declares id") { + t.Fatalf("renamed draft = %d %v", code, body) + } + + if _, err := os.Stat(filepath.Join(user, "demo.lua")); !os.IsNotExist(err) { + t.Fatal("a refused draft reached the overlay") + } + if got := runningLua(cfg); got != filepath.Join(bundled, "demo.lua") { + t.Fatalf("running lua = %q; a refused draft moved the driver", got) + } +} + +func TestDraftWindowIsBounded(t *testing.T) { + srv, _, _, _ := draftServer(t) + for _, minutes := range []int{-1, 61, 1440} { + body := `{"lua":` + quote(draftLua) + `,"minutes":` + itoa(minutes) + `}` + if code, _ := postDraft(t, srv, "demo", body); code == 200 && minutes != -1 { + t.Fatalf("minutes=%d was accepted", minutes) + } + } +} + +// A draft that stops the driver from starting is not something to leave +// running: the edit is undone before the error is reported. +func TestADraftThatWillNotStartIsUndoneImmediately(t *testing.T) { + srv, _, user, _ := draftServer(t) + // Compiles and keeps its id, so it passes validation -- and then throws on + // the way up, which is exactly the edit worth protecting against. + throws := "DRIVER = {\n id = \"demo\",\n version = \"1.0.0\",\n}\n" + + "function driver_init(cfg) error(\"nope\") end\nfunction driver_poll() return 1 end\n" + + code, _ := postDraft(t, srv, "demo", `{"lua":`+quote(throws)+`}`) + if code != 502 { + t.Fatalf("failed restart = %d, want 502", code) + } + if _, err := os.Stat(filepath.Join(user, "demo.lua")); !os.IsNotExist(err) { + t.Fatal("a draft that would not start was left in place") + } +} + +func TestDraftStatusReportsTheRemainingWindow(t *testing.T) { + srv, _, _, _ := draftServer(t) + before := time.Now().UnixMilli() + if code, _ := postDraft(t, srv, "demo", `{"lua":`+quote(draftLua)+`,"minutes":5}`); code != 200 { + t.Fatal("draft did not start") + } + + req := httptest.NewRequest(http.MethodGet, "/api/drivers/demo/draft", nil) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + var out map[string]any + _ = json.Unmarshal(rr.Body.Bytes(), &out) + + // So the countdown survives a reload of the page rather than living only + // in the tab that started it. + if out["running"] != true { + t.Fatalf("status = %+v", out) + } + expires, _ := out["expires_at_ms"].(float64) + if int64(expires) <= before { + t.Fatalf("expires_at_ms = %v, want a time in the future", expires) + } +} + +func toString(v any) string { + s, _ := v.(string) + return s +} + +func quote(s string) string { + out, _ := json.Marshal(s) + return string(out) +} + +func itoa(n int) string { + out, _ := json.Marshal(n) + return string(out) +} diff --git a/go/internal/api/api_driver_lint.go b/go/internal/api/api_driver_lint.go new file mode 100644 index 00000000..75ecf7e2 --- /dev/null +++ b/go/internal/api/api_driver_lint.go @@ -0,0 +1,131 @@ +package api + +import ( + "net/http" + "regexp" + "strconv" + "strings" + + lua "github.com/yuin/gopher-lua" +) + +// The editor also lints as you type, using Ace's own luaparse. That is a +// different implementation from the one the driver runs under, and it can +// disagree. This endpoint asks gopher-lua — the parser that actually decides +// whether the driver will start — so a green tick never lies about that. +func (s *Server) handleDriverLint(w http.ResponseWriter, r *http.Request) { + var body struct { + Lua string `json:"lua"` + } + if err := readJSON(r, &body); err != nil { + writeJSON(w, 400, map[string]string{"error": err.Error()}) + return + } + if len(body.Lua) > maxDriverSourceBytes { + writeJSON(w, 400, map[string]string{"error": errDriverSourceTooLarge.Error()}) + return + } + + problems := []map[string]any{} + + L := lua.NewState() + defer L.Close() + if _, err := L.LoadString(body.Lua); err != nil { + line, message := splitLuaError(err.Error()) + problems = append(problems, map[string]any{ + "line": line, "severity": "error", "message": message, + }) + // Nothing further is meaningful: the parse stopped here. + writeJSON(w, 200, map[string]any{"ok": false, "problems": problems}) + return + } + + // It compiles. Whether it is a usable driver is a separate question, and + // the answers are warnings rather than errors: a draft mid-edit may not + // have its entrypoints back yet. + if entry := r.PathValue("id"); entry != "" { + problems = append(problems, driverShapeProblems(body.Lua, entry)...) + } + writeJSON(w, 200, map[string]any{"ok": true, "problems": problems}) +} + +// gopher-lua writes: +// +// line:5(column:3) near 'end': syntax error +// +// The chunk name and the column are noise next to a marker in the gutter; the +// line places it and the token is what makes the message actionable. +var gopherLuaError = regexp.MustCompile( + `line:(\d+)\(column:(-?\d+)\)\s*(?:near '([^']*)':)?\s*(.*)$`) + +// Older builds and other callers use `chunk:line: message`, so that form is +// still understood rather than falling through to the raw string. +var plainLuaError = regexp.MustCompile(`^(?:[^:]*:)?(\d+):\s*(.+)$`) + +func splitLuaError(raw string) (int, string) { + raw = strings.TrimSpace(raw) + if m := gopherLuaError.FindStringSubmatch(raw); len(m) == 5 { + line, err := strconv.Atoi(m[1]) + if err == nil { + message := strings.TrimSpace(m[4]) + if message == "" { + message = "syntax error" + } + if token := m[3]; token != "" { + message += " near '" + token + "'" + } + return line, message + } + } + if m := plainLuaError.FindStringSubmatch(raw); len(m) == 3 { + if line, err := strconv.Atoi(m[1]); err == nil { + return line, strings.TrimSpace(m[2]) + } + } + return 0, raw +} + +// driverShapeProblems reports what would stop this from working as a driver +// even though it parses: a missing entrypoint, or an identity that no longer +// matches the slot it is being edited in. +func driverShapeProblems(source, driverID string) []map[string]any { + var out []map[string]any + + for _, required := range []string{"driver_init", "driver_poll"} { + if !declaresFunction(source, required) { + out = append(out, map[string]any{ + "line": 0, "severity": "warning", + "message": "no " + required + "() — the driver will not start without it", + }) + } + } + + if id := declaredDriverID(source); id != "" && id != driverID { + out = append(out, map[string]any{ + "line": 0, "severity": "warning", + "message": "this declares id \"" + id + "\", but it is being edited as \"" + + driverID + "\"; running it will be refused", + }) + } + return out +} + +func declaresFunction(source, name string) bool { + re := regexp.MustCompile(`(?m)^\s*(?:local\s+)?function\s+` + regexp.QuoteMeta(name) + `\s*\(`) + return re.MatchString(source) +} + +var driverIDField = regexp.MustCompile(`(?m)^\s*id\s*=\s*"([^"]*)"`) + +// declaredDriverID reads the id out of the DRIVER table without running the +// file. The catalog parser wants a path on disk, and a draft has none yet. +func declaredDriverID(source string) string { + block := source + if start := strings.Index(source, "DRIVER"); start >= 0 { + block = source[start:] + } + if m := driverIDField.FindStringSubmatch(block); len(m) == 2 { + return m[1] + } + return "" +} diff --git a/go/internal/api/api_driver_lint_test.go b/go/internal/api/api_driver_lint_test.go new file mode 100644 index 00000000..1a31bf59 --- /dev/null +++ b/go/internal/api/api_driver_lint_test.go @@ -0,0 +1,159 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func lint(t *testing.T, srv *Server, id, source string) map[string]any { + t.Helper() + body, _ := json.Marshal(map[string]string{"lua": source}) + req := httptest.NewRequest(http.MethodPost, "/api/drivers/"+id+"/lint", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + var out map[string]any + _ = json.Unmarshal(rr.Body.Bytes(), &out) + return out +} + +func problemsOf(out map[string]any) []map[string]any { + raw, _ := out["problems"].([]any) + var problems []map[string]any + for _, p := range raw { + if m, ok := p.(map[string]any); ok { + problems = append(problems, m) + } + } + return problems +} + +func lintServer(t *testing.T) *Server { + t.Helper() + dir := t.TempDir() + writeSourceDriver(t, dir, "demo.lua", "demo", "1.0.0", "function driver_poll() return 1 end\n") + return New(&Deps{DriverDir: dir}) +} + +// The point of asking the server is that this is the parser which decides +// whether the driver starts. Ace's own linter is a different implementation +// and can disagree. +func TestLintReportsTheLineASyntaxErrorIsOn(t *testing.T) { + srv := lintServer(t) + source := "local a = 1\nlocal b = 2\nif a == b then\n print(\"x\"\nend\n" + + out := lint(t, srv, "demo", source) + if out["ok"] != false { + t.Fatalf("broken source linted ok: %+v", out) + } + problems := problemsOf(out) + if len(problems) == 0 { + t.Fatal("no problem reported for source that does not compile") + } + // Without a line the operator has to hunt through 35 kB by eye. + if line, _ := problems[0]["line"].(float64); line < 4 { + t.Fatalf("line = %v, want the unclosed call on line 4 or after", line) + } + if msg, _ := problems[0]["message"].(string); msg == "" || strings.Contains(msg, ".lua:") { + t.Fatalf("message = %q; the chunk name is noise on screen", msg) + } +} + +func TestLintAcceptsADriverThatCompiles(t *testing.T) { + srv := lintServer(t) + source := "DRIVER = { id = \"demo\" }\nfunction driver_init(cfg) end\nfunction driver_poll() return 1 end\n" + + out := lint(t, srv, "demo", source) + if out["ok"] != true { + t.Fatalf("valid driver rejected: %+v", out) + } + if len(problemsOf(out)) != 0 { + t.Fatalf("clean driver reported problems: %+v", problemsOf(out)) + } +} + +// These are warnings, not errors: a draft mid-edit may not have its +// entrypoints back yet, and refusing to lint at all would be unhelpful. +func TestLintWarnsAboutWhatWouldStopTheDriverStarting(t *testing.T) { + srv := lintServer(t) + out := lint(t, srv, "demo", "DRIVER = { id = \"demo\" }\nlocal x = 1\n") + + if out["ok"] != true { + t.Fatal("source that compiles should lint ok even when it is not yet a driver") + } + var messages []string + for _, p := range problemsOf(out) { + if p["severity"] != "warning" { + t.Fatalf("severity = %v, want warning", p["severity"]) + } + messages = append(messages, p["message"].(string)) + } + joined := strings.Join(messages, " | ") + if !strings.Contains(joined, "driver_init") || !strings.Contains(joined, "driver_poll") { + t.Fatalf("warnings = %q, want both missing entrypoints named", joined) + } +} + +// Running it would be refused for this reason, so saying it while editing +// saves a round trip through a failed draft. +func TestLintWarnsWhenTheDriverRenamesItself(t *testing.T) { + srv := lintServer(t) + source := "DRIVER = {\n id = \"somethingelse\",\n}\n" + + "function driver_init(cfg) end\nfunction driver_poll() return 1 end\n" + + out := lint(t, srv, "demo", source) + var found bool + for _, p := range problemsOf(out) { + if strings.Contains(p["message"].(string), "somethingelse") { + found = true + } + } + if !found { + t.Fatalf("no warning about the renamed id: %+v", problemsOf(out)) + } +} + +func TestLintRefusesSomethingTooLargeToBeADriver(t *testing.T) { + srv := lintServer(t) + body, _ := json.Marshal(map[string]string{"lua": strings.Repeat("x", maxDriverSourceBytes+1)}) + req := httptest.NewRequest(http.MethodPost, "/api/drivers/demo/lint", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != 400 { + t.Fatalf("oversized lint = %d, want 400", rr.Code) + } +} + +func TestSplitLuaErrorKeepsTheLineAndDropsTheChunkName(t *testing.T) { + // What gopher-lua actually writes. The chunk name and column are noise + // next to a gutter marker; the token is what makes it actionable. + line, message := splitLuaError(" line:5(column:3) near 'end': syntax error") + if line != 5 { + t.Fatalf("line = %d, want 5", line) + } + if message != "syntax error near 'end'" { + t.Fatalf("message = %q", message) + } + + // End-of-file errors carry column -1 and no token. + if line, message := splitLuaError(" line:9(column:-1) near '': syntax error"); line != 9 || + !strings.Contains(message, "eof") { + t.Fatalf("eof error = %d %q", line, message) + } + + // The plain form is still understood. + if line, message := splitLuaError("driver.lua:12: unexpected symbol"); line != 12 || + message != "unexpected symbol" { + t.Fatalf("plain form = %d %q", line, message) + } + + // Anything it cannot place still reaches the operator intact rather than + // being swallowed. + if line, message := splitLuaError("something unplaceable"); line != 0 || message != "something unplaceable" { + t.Fatalf("unparseable = %d %q", line, message) + } +} diff --git a/go/internal/api/api_driver_source.go b/go/internal/api/api_driver_source.go new file mode 100644 index 00000000..3dd6bf10 --- /dev/null +++ b/go/internal/api/api_driver_source.go @@ -0,0 +1,133 @@ +package api + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/srcfl/ftw/go/internal/drivers" +) + +var errDriverSourceTooLarge = errors.New("driver file is larger than the channel allows") + +// maxDriverSourceBytes bounds what this endpoint will read into memory. The +// channel refuses to publish anything larger, so a file above it is not a +// driver we shipped. +const maxDriverSourceBytes = 512 * 1024 + +// handleDriverSource returns the Lua actually running for a driver, and says +// which of the three overlays it came from. +// +// A driver is one Lua file and the repository is the source of truth, but from +// the gateway there was no way to look at the code that is running. Reading it +// is the first half of being able to fix it. +func (s *Server) handleDriverSource(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + writeJSON(w, 400, map[string]string{"error": "driver id is required"}) + return + } + catalog, err := drivers.LoadCatalogMulti(s.deps.UserDriverDir, s.managedDriverDir(), s.deps.DriverDir) + if err != nil { + writeJSON(w, 503, map[string]string{"error": err.Error()}) + return + } + var entry *drivers.CatalogEntry + for i := range catalog { + if catalog[i].ID == id { + entry = &catalog[i] + break + } + } + if entry == nil { + writeJSON(w, 404, map[string]string{"error": "no driver with that id"}) + return + } + + path, source := s.resolveDriverSourcePath(entry) + if path == "" { + writeJSON(w, 404, map[string]string{"error": "the driver's file is not on disk"}) + return + } + raw, err := readDriverSourceFile(path) + if err != nil { + writeJSON(w, 500, map[string]string{"error": err.Error()}) + return + } + digest := sha256.Sum256(raw) + + writeJSON(w, 200, map[string]any{ + "id": entry.ID, + "path": entry.Path, + "filename": entry.Filename, + "version": entry.Version, + "source": source, + "sha256": hex.EncodeToString(digest[:]), + "bytes": len(raw), + "lua": string(raw), + // Where this driver lives upstream, so the operator can read the + // history behind the file rather than only its current contents. + "repository_url": driverRepositoryURL(entry.Path), + "read_only": entry.ReadOnly, + }) +} + +// resolveDriverSourcePath walks the same overlays the driver resolver does, in +// the same order, and reports which one answered. +func (s *Server) resolveDriverSourcePath(entry *drivers.CatalogEntry) (string, string) { + name := entry.Filename + if name == "" { + name = filepath.Base(entry.Path) + } + for _, overlay := range []struct { + dir string + source string + }{ + {s.deps.UserDriverDir, "local"}, + {s.managedDriverDir(), "managed"}, + {s.deps.DriverDir, "bundled"}, + } { + if overlay.dir == "" { + continue + } + candidate := filepath.Join(overlay.dir, name) + // A managed entry is a symlink into the content-addressed store. + // EvalSymlinks keeps the read inside the directory we meant to read. + if resolved, err := filepath.EvalSymlinks(candidate); err == nil { + if info, err := os.Stat(resolved); err == nil && info.Mode().IsRegular() { + return resolved, overlay.source + } + } + } + return "", "" +} + +// readDriverSourceFile refuses anything past the limit rather than truncating +// it: half a driver shown as the whole driver is worse than an error. +func readDriverSourceFile(path string) ([]byte, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if info.Size() > maxDriverSourceBytes { + return nil, errDriverSourceTooLarge + } + return os.ReadFile(path) +} + +const driverRepositoryBase = "https://github.com/srcfl/device-drivers/blob/main/drivers/lua/" + +// driverRepositoryURL points at the shared source a driver was built from. +// The published artifact carries generated metadata the repository copy does +// not, so this is a link to the file's history, not to identical bytes. +func driverRepositoryURL(logicalPath string) string { + name := filepath.Base(filepath.ToSlash(logicalPath)) + if name == "" || name == "." || name == "/" || !strings.HasSuffix(name, ".lua") { + return "" + } + return driverRepositoryBase + name +} diff --git a/go/internal/api/api_driver_source_test.go b/go/internal/api/api_driver_source_test.go new file mode 100644 index 00000000..57b50374 --- /dev/null +++ b/go/internal/api/api_driver_source_test.go @@ -0,0 +1,128 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeSourceDriver(t *testing.T, dir, filename, id, version, body string) { + t.Helper() + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + src := "DRIVER = {\n" + + " id = \"" + id + "\",\n" + + " version = \"" + version + "\",\n" + + " protocols = { \"modbus\" },\n" + + "}\n" + body + if err := os.WriteFile(filepath.Join(dir, filename), []byte(src), 0o644); err != nil { + t.Fatalf("write %s: %v", filename, err) + } +} + +func getDriverSource(t *testing.T, srv *Server, id string) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/api/drivers/"+id+"/source", nil) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + var body map[string]any + if rr.Body.Len() > 0 { + _ = json.Unmarshal(rr.Body.Bytes(), &body) + } + return rr.Code, body +} + +// The three overlays are searched in the order the driver resolver uses them, +// so the file returned is the file running. Showing the bundled copy while an +// override is what actually runs would send someone debugging the wrong code. +func TestDriverSourceReturnsTheFileThatIsRunning(t *testing.T) { + bundled := t.TempDir() + user := t.TempDir() + writeSourceDriver(t, bundled, "demo.lua", "demo", "1.0.0", "function driver_poll() return 1 end\n") + + srv := New(&Deps{DriverDir: bundled, UserDriverDir: user}) + + code, body := getDriverSource(t, srv, "demo") + if code != 200 { + t.Fatalf("bundled read = %d %v", code, body) + } + if body["source"] != "bundled" || body["version"] != "1.0.0" { + t.Fatalf("bundled entry = %+v", body) + } + if lua, _ := body["lua"].(string); lua == "" || !strings.Contains(lua, "driver_poll") { + t.Fatalf("bundled lua = %q", lua) + } + + // An override shadows the channel and the build, so it must win here too. + writeSourceDriver(t, user, "demo.lua", "demo", "1.0.0", "function driver_poll() return 2 end\n") + code, body = getDriverSource(t, srv, "demo") + if code != 200 { + t.Fatalf("override read = %d %v", code, body) + } + if body["source"] != "local" { + t.Fatalf("source = %v, want local once an override exists", body["source"]) + } + if lua, _ := body["lua"].(string); !strings.Contains(lua, "return 2") { + t.Fatalf("override lua = %q, want the operator's own file", lua) + } +} + +func TestDriverSourceReportsTheHashOfWhatItReturned(t *testing.T) { + bundled := t.TempDir() + writeSourceDriver(t, bundled, "demo.lua", "demo", "1.0.0", "function driver_poll() return 1 end\n") + srv := New(&Deps{DriverDir: bundled}) + + _, body := getDriverSource(t, srv, "demo") + // The hash is what an edit is diffed against, so it has to describe the + // bytes in this response rather than a manifest entry that may be stale. + raw, err := os.ReadFile(filepath.Join(bundled, "demo.lua")) + if err != nil { + t.Fatal(err) + } + if got, want := body["bytes"], float64(len(raw)); got != want { + t.Fatalf("bytes = %v, want %v", got, want) + } + if digest, _ := body["sha256"].(string); len(digest) != 64 { + t.Fatalf("sha256 = %q", digest) + } +} + +func TestDriverSourceLinksToTheFileInTheRepository(t *testing.T) { + bundled := t.TempDir() + writeSourceDriver(t, bundled, "sungrow.lua", "sungrow", "1.5.0", "function driver_poll() return 1 end\n") + srv := New(&Deps{DriverDir: bundled}) + + _, body := getDriverSource(t, srv, "sungrow") + want := "https://github.com/srcfl/device-drivers/blob/main/drivers/lua/sungrow.lua" + if body["repository_url"] != want { + t.Fatalf("repository_url = %v, want %v", body["repository_url"], want) + } +} + +func TestDriverSourceRefusesAnUnknownDriver(t *testing.T) { + srv := New(&Deps{DriverDir: t.TempDir()}) + if code, _ := getDriverSource(t, srv, "absent"); code != 404 { + t.Fatalf("unknown driver = %d, want 404", code) + } +} + +// A logical path is remote input on a managed entry. Only the base name is +// used, and only when it names a Lua file. +func TestDriverRepositoryURLIgnoresPathsItCannotVouchFor(t *testing.T) { + for _, path := range []string{"", ".", "/", "drivers/", "drivers/evil.sh"} { + if got := driverRepositoryURL(path); got != "" { + t.Errorf("driverRepositoryURL(%q) = %q, want empty", path, got) + } + } + if got := driverRepositoryURL("drivers/../../etc/passwd"); got != "" { + t.Errorf("traversal produced %q", got) + } + if got := driverRepositoryURL("drivers/pixii.lua"); got != driverRepositoryBase+"pixii.lua" { + t.Errorf("normal path produced %q", got) + } +} diff --git a/web/app.css b/web/app.css index b9e90d43..2014f4c9 100644 --- a/web/app.css +++ b/web/app.css @@ -1530,6 +1530,9 @@ body.ftw-app .driver-module-status { flex-wrap: wrap; gap: 8px; justify-content: flex-end; + /* Itself a flex item in the device header. Without this it grows to fit the + widest line of a driver's Lua and pushes the row out of the modal. */ + min-width: 0; } body.ftw-app .driver-module-status .btn-add { margin-top: 0; @@ -1543,6 +1546,220 @@ body.ftw-app .drv-version-detail { color: var(--fg-muted); font-size: 0.75rem; } +/* The driver's Lua. Scrolls in its own box rather than stretching the modal, + and keeps the operator's own indentation. */ +body.ftw-app .drv-source-code { + background: var(--surface2, rgba(0, 0, 0, 0.25)); + border: 1px solid var(--line-soft); + border-radius: 6px; + font-family: var(--mono); + font-size: 0.72rem; + line-height: 1.5; + margin: 0; + max-height: 340px; + overflow: auto; + padding: 10px 12px; + tab-size: 2; + white-space: pre; +} +body.ftw-app .drv-module-source-panel { + border-top: 1px solid var(--line-soft); + flex-basis: 100%; + padding-top: 8px; + text-align: left; + /* A flex item sizes to its content by default, so a long Lua line would + stretch the whole row past the modal instead of scrolling inside the + block. min-width lets it shrink; the pre scrolls. */ + min-width: 0; +} +body.ftw-app .drv-source-code { + max-width: 100%; +} +/* Finding a driver for hardware you own. Cards rather than a dropdown, because + which DERs a driver covers and whether anyone has ever run it are the two + things that decide the choice, and a line of text hides both. */ +body.ftw-app .drv-catalog-results { + display: grid; + gap: 8px; + grid-template-columns: repeat(auto-fill, minmax(232px, 1fr)); + margin: 8px 0 4px; + max-height: 340px; + overflow-y: auto; + padding-right: 4px; +} +body.ftw-app .drv-catalog-card { + background: transparent; + border: 1px solid var(--line); + border-radius: 8px; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + text-align: left; +} +body.ftw-app .drv-catalog-card:hover { border-color: var(--fg-dim); } +body.ftw-app .drv-catalog-card.is-chosen { + border-color: var(--accent-e); + box-shadow: inset 0 0 0 1px var(--accent-e); +} +body.ftw-app .drv-catalog-title { + color: var(--fg); + font-size: 0.85rem; + font-weight: 500; + line-height: 1.25; +} +body.ftw-app .drv-catalog-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +body.ftw-app .drv-catalog-tag { + border: 1px solid var(--line-soft); + border-radius: 999px; + color: var(--fg-muted); + font-family: var(--mono); + font-size: 0.62rem; + letter-spacing: 0.06em; + padding: 1px 7px; + text-transform: uppercase; +} +/* Four of thirty-seven have run on customer hardware. That is the difference + worth showing, so it is the only tag that carries colour. */ +body.ftw-app .drv-catalog-proven { + border-color: color-mix(in srgb, var(--green-e) 40%, transparent); + color: var(--green-e); +} +body.ftw-app .drv-catalog-unproven { + border-color: color-mix(in srgb, var(--accent-e) 30%, transparent); + color: var(--accent-e); +} +body.ftw-app .drv-catalog-foot { + color: var(--fg-muted); + font-family: var(--mono); + font-size: 0.66rem; + line-height: 1.35; +} + +/* The driver editor: its own surface over everything else. A driver is tens of + kilobytes of Lua, and the settings modal is not the shape for reading it. */ +body.ftw-app .drv-editor-overlay { + align-items: center; + background: rgba(0, 0, 0, 0.72); + display: flex; + inset: 0; + justify-content: center; + padding: 24px; + position: fixed; + z-index: 1200; +} +body.ftw-app .drv-editor-shell { + background: var(--bg, #0b0d10); + border: 1px solid var(--line); + border-radius: 10px; + display: flex; + flex-direction: column; + height: 100%; + max-height: 100%; + max-width: 1180px; + overflow: hidden; + width: 100%; +} +body.ftw-app .drv-editor-head, +body.ftw-app .drv-editor-foot { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 10px; + padding: 12px 16px; +} +body.ftw-app .drv-editor-head { + border-bottom: 1px solid var(--line-soft); +} +body.ftw-app .drv-editor-foot { + border-top: 1px solid var(--line-soft); + flex-direction: column; + align-items: stretch; + gap: 8px; +} +body.ftw-app .drv-editor-provenance { + color: var(--fg-muted); + font-family: var(--mono); + font-size: 0.72rem; +} +body.ftw-app .drv-editor-link { + color: var(--accent-e); + font-size: 0.75rem; +} +body.ftw-app .drv-editor-head .btn-add, +body.ftw-app .drv-editor-controls .btn-add { + margin-top: 0; + padding: 4px 12px; + width: auto; +} +/* The editor takes whatever height is left, which is the point of the view. */ +body.ftw-app .drv-editor-surface { + flex: 1; + min-height: 0; + width: 100%; +} +body.ftw-app .drv-editor-controls { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; +} +/* Problems are clickable: each one jumps the cursor to its line. */ +body.ftw-app .drv-editor-problems { + display: flex; + flex-direction: column; + gap: 3px; + max-height: 108px; + overflow-y: auto; +} +body.ftw-app .drv-editor-problem { + background: transparent; + border: 0; + border-left: 2px solid transparent; + color: var(--fg-dim); + cursor: pointer; + font-family: var(--mono); + font-size: 0.72rem; + padding: 1px 0 1px 8px; + text-align: left; +} +body.ftw-app .drv-editor-problem:hover { color: var(--fg); } +body.ftw-app .drv-editor-error { border-left-color: var(--red-e); } +body.ftw-app .drv-editor-warning { border-left-color: var(--accent-e); } +body.ftw-app .drv-editor-clean { + color: var(--green-e); + font-family: var(--mono); + font-size: 0.72rem; +} + +/* Same box as the read view, so switching to edit does not move the code. */ +body.ftw-app .drv-source-editor { + background: var(--surface2, rgba(0, 0, 0, 0.25)); + border: 1px solid var(--line-soft); + border-radius: 6px; + color: var(--fg); + font-family: var(--mono); + font-size: 0.72rem; + line-height: 1.5; + max-width: 100%; + min-height: 300px; + padding: 10px 12px; + resize: vertical; + tab-size: 2; + white-space: pre; + width: 100%; +} +body.ftw-app .drv-draft-status, +body.ftw-app .drv-editor-status { + color: var(--fg-muted); + font-family: var(--mono); + font-size: 0.72rem; +} body.ftw-app .drv-module-versions-panel { /* Its own row under the controls, so the list reads as a list. */ border-top: 1px solid var(--line-soft); diff --git a/web/driver-catalog-search.test.mjs b/web/driver-catalog-search.test.mjs new file mode 100644 index 00000000..7010e0f3 --- /dev/null +++ b/web/driver-catalog-search.test.mjs @@ -0,0 +1,107 @@ +// Finding a driver for hardware you own. +// +// The catalog was a dropdown of 37 entries in alphabetical order, and it made +// the operator translate: you know you have an SH10RT, not that you need +// "Sungrow SH Hybrid Inverter". Four of those 37 have run on customer +// hardware; the other 33 sat mixed in among them. + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; + +const source = readFileSync(new URL("./settings/tabs/devices.js", import.meta.url), "utf8"); + +function load() { + const window = { FTWSettings: { tabs: {} } }; + vm.runInNewContext(source, { + document: { createElement: () => ({ style: {}, dataset: {}, appendChild() {}, addEventListener() {} }), getElementById: () => null }, + fetch: async () => ({ ok: true, json: async () => ({}) }), + window, + }); + return window.FTWSettings.driverVersions; +} + +// A slice of the real catalog, including the things that made the old list +// hard to read. +const CATALOG = [ + { + id: "ambibox_v2x", name: "Ambibox V2X", manufacturer: "Ambibox", + protocols: ["mqtt"], capabilities: ["v2x_charger"], + verification_status: "experimental", version: "1.0.0", + }, + { + id: "sungrow", name: "Sungrow SH Hybrid Inverter", manufacturer: "Sungrow", + protocols: ["modbus"], capabilities: ["meter", "pv", "battery"], + verification_status: "production", version: "1.4.0", + tested_models: ["SH5.0RT", "SH6.0RT", "SH8.0RT", "SH10RT"], + }, + { + id: "ctek-chargestorm", name: "CTEK Chargestorm (API v1)", manufacturer: "CTEK", + protocols: ["modbus"], capabilities: ["ev"], + verification_status: "beta", version: "0.2.0", + tested_models: ["Chargestorm Connected 2", "Chargestorm Connected 3"], + }, + { + id: "ferroamp", name: "Ferroamp EnergyHub", manufacturer: "Ferroamp", + protocols: ["mqtt"], capabilities: ["meter", "pv", "battery"], + verification_status: "production", version: "1.0.0", + tested_models: ["EnergyHub XL"], + }, +]; + +const names = (entries) => entries.map((e) => e.id).join(" "); + +test("searching finds the driver by the model printed on the unit", () => { + const api = load(); + // The whole point: an operator reads SH10RT off the inverter, not the + // driver's name out of a catalog. + assert.equal(names(api.searchCatalog(CATALOG, "SH10RT")), "sungrow"); + assert.equal(names(api.searchCatalog(CATALOG, "Chargestorm Connected 3")), "ctek-chargestorm"); + assert.equal(names(api.searchCatalog(CATALOG, "EnergyHub XL")), "ferroamp"); +}); + +test("searching also takes the manufacturer or the driver's own name", () => { + const api = load(); + assert.equal(names(api.searchCatalog(CATALOG, "ferroamp")), "ferroamp"); + assert.equal(names(api.searchCatalog(CATALOG, "hybrid")), "sungrow"); + assert.equal(names(api.searchCatalog(CATALOG, "mqtt")), "ferroamp ambibox_v2x"); +}); + +test("every word has to match, so more typing narrows", () => { + const api = load(); + assert.equal(names(api.searchCatalog(CATALOG, "sungrow hybrid")), "sungrow"); + // Two terms that never occur together find nothing rather than everything. + assert.equal(api.searchCatalog(CATALOG, "sungrow ferroamp").length, 0); +}); + +test("drivers proven on hardware come first", () => { + const api = load(); + // Alphabetically Ambibox leads and the two proven drivers are buried. That + // ordering is what made the old list actively misleading. + assert.equal(names(api.searchCatalog(CATALOG, "")), + "ferroamp sungrow ctek-chargestorm ambibox_v2x"); +}); + +test("a driver whose name already starts with its maker is not repeated", () => { + const api = load(); + // "CTEK Chargestorm (API v1) — CTEK" was how the old list read. + assert.equal(api.catalogTitle(CATALOG[2]), "CTEK Chargestorm (API v1)"); + assert.equal(api.catalogTitle(CATALOG[1]), "Sungrow SH Hybrid Inverter"); + // But one that does not carry it still gets it. + assert.equal( + api.catalogTitle({ name: "EnergyHub", manufacturer: "Ferroamp" }), + "Ferroamp EnergyHub"); +}); + +test("an empty search shows everything rather than nothing", () => { + const api = load(); + assert.equal(api.searchCatalog(CATALOG, "").length, CATALOG.length); + assert.equal(api.searchCatalog(CATALOG, " ").length, CATALOG.length); +}); + +test("searching is not case sensitive", () => { + const api = load(); + assert.equal(names(api.searchCatalog(CATALOG, "sh10rt")), "sungrow"); + assert.equal(names(api.searchCatalog(CATALOG, "FERROAMP")), "ferroamp"); +}); diff --git a/web/driver-source-suggest.test.mjs b/web/driver-source-suggest.test.mjs new file mode 100644 index 00000000..c6917fc1 --- /dev/null +++ b/web/driver-source-suggest.test.mjs @@ -0,0 +1,212 @@ +// Suggesting a driver back to the repository it came from. +// +// The gateway holds no GitHub token and needs none: GitHub accepts a +// pre-filled issue over a URL, and the operator is already signed in to their +// own browser. What matters is that the link carries enough context to act on +// without a conversation, and that it never opens a page GitHub will reject. + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; + +const source = readFileSync(new URL("./settings/tabs/devices.js", import.meta.url), "utf8"); + +function element(tag) { + const listeners = new Map(); + let text = ""; + const el = { + tag, + children: [], + className: "", + disabled: false, + style: {}, + dataset: {}, + href: "", + type: "", + spellcheck: true, + value: "", + addEventListener(name, handler) { listeners.set(name, handler); }, + click() { return listeners.get("click")?.({ target: el }); }, + appendChild(child) { el.children.push(child); return child; }, + insertBefore(child) { el.children.push(child); return child; }, + remove() {}, + querySelector(selector) { + const wanted = selector.replace(/^\./, ""); + for (const child of el.children) { + if (String(child.className).split(" ").includes(wanted)) return child; + const nested = child.querySelector ? child.querySelector(selector) : null; + if (nested) return nested; + } + return null; + }, + querySelectorAll() { return []; }, + }; + // Setting textContent clears the children, the way the DOM does. A stub that + // kept them let a test find a button from a view that had been replaced. + Object.defineProperty(el, "textContent", { + get() { return text; }, + set(value) { text = String(value); el.children.length = 0; }, + enumerable: true, + }); + return el; +} + +function load() { + const opened = []; + const window = { + FTWSettings: { tabs: {} }, + open: (url) => { opened.push(url); }, + }; + vm.runInNewContext(source, { + document: { createElement: element, getElementById: () => null }, + fetch: async () => ({ ok: true, json: async () => ({ running: false }) }), + window, + }); + return { api: window.FTWSettings.driverVersions, opened, window }; +} + +const SOURCE_BODY = { + id: "sungrow", + version: "1.5.0", + filename: "sungrow.lua", + source: "managed", + sha256: "abc123def456", + lua: "DRIVER = { id = \"sungrow\" }\n", + repository_url: "https://github.com/srcfl/device-drivers/blob/main/drivers/lua/sungrow.lua", +}; + +function decodedBody(url) { + return decodeURIComponent(new URL(url).searchParams.get("body")); +} + +test("the suggestion opens an issue against the driver's own repository", () => { + const { api } = load(); + const url = api.suggestUpstreamURL(SOURCE_BODY, ""); + + assert.ok(url.startsWith("https://github.com/srcfl/device-drivers/issues/new"), + "everything points at the repository the driver came from"); + const title = decodeURIComponent(new URL(url).searchParams.get("title")); + assert.match(title, /sungrow/, "the driver is named before anyone opens it"); +}); + +test("the issue carries what someone needs to act without asking", () => { + const { api } = load(); + const body = decodedBody(api.suggestUpstreamURL(SOURCE_BODY, "")); + + // Which driver, which version, where the running copy came from, and the + // hash it was based on — a maintainer can find the exact bytes from that. + assert.match(body, /sungrow/); + assert.match(body, /v1\.5\.0/); + assert.match(body, /official/); + assert.match(body, /abc123def456/); +}); + +test("an edit travels as a diff, which is what fits in a URL", () => { + const { api } = load(); + const edited = SOURCE_BODY.lua + "-- fixed the SG battery registers\n"; + const body = decodedBody(api.suggestUpstreamURL(SOURCE_BODY, edited)); + + assert.match(body, /```diff/, "so GitHub renders it as a change, not a file"); + assert.match(body, /\+-- fixed the SG battery registers/, + "the suggestion is the work, not a description of it"); +}); + +test("the diff carries the change and skips the rest of the driver", () => { + const { api } = load(); + // A real driver is thousands of lines and a fix is a handful. Sending the + // whole file put the URL past what GitHub accepts, so the code never + // travelled at all. + const before = Array.from({ length: 400 }, (_, i) => "line " + i).join("\n"); + const after = before.replace("line 200", "line 200 -- corrected"); + + const diff = api.unifiedDiff(before, after); + assert.match(diff, /-line 200$/m); + assert.match(diff, /\+line 200 -- corrected/); + assert.ok(!diff.includes("line 5"), "untouched regions are elided"); + assert.ok(diff.length < before.length / 4, "a small fix produces a small diff"); + + // Three lines of context on each side, so a reviewer can see where it lands. + assert.match(diff, / line 197/); + assert.match(diff, / line 203/); +}); + +test("an unchanged file produces no diff at all", () => { + const { api } = load(); + const body = decodedBody(api.suggestUpstreamURL(SOURCE_BODY, SOURCE_BODY.lua)); + assert.ok(!body.includes("```diff"), "nothing was changed, so there is nothing to show"); +}); + +test("the read view can suggest without an edit", () => { + const { api } = load(); + const body = decodedBody(api.suggestUpstreamURL(SOURCE_BODY, "")); + + // A driver that is wrong for your hardware is worth reporting even when you + // have not written the fix. + assert.ok(!body.includes("```lua"), "no empty code block when there is no edit"); + assert.match(body, /What I changed and why/); +}); + +// The editor is its own surface now, so the devices tab hands it the driver +// and a set of actions. These test that boundary: what the tab passes in. +function openEditorFor(api, panel, body) { + api.renderSource(panel, body); + findButton(panel, "Edit and try").click(); +} + +test("opening the editor hands it the driver and the actions it needs", () => { + const { api, window } = load(); + const opened = []; + window.FTWSettings.openDriverEditor = (driver, actions) => opened.push({ driver, actions }); + + openEditorFor(api, element("div"), SOURCE_BODY); + + assert.equal(opened.length, 1, "the tab opens the editor rather than drawing one"); + const { driver, actions } = opened[0]; + assert.equal(driver.id, "sungrow"); + assert.equal(driver.lua, SOURCE_BODY.lua); + // Provenance is resolved here, so the editor does not need to know the + // three overlays exist. + assert.match(driver.sourceLabel, /official/); + for (const name of ["runDraft", "keepDraft", "revertDraft", "draftStatus", "lint", "suggest"]) { + assert.equal(typeof actions[name], "function", `actions.${name} is missing`); + } +}); + +test("a driver too large to prefill opens the issue anyway, and says so", () => { + const { api, opened, window } = load(); + let actions = null; + window.FTWSettings.openDriverEditor = (_driver, a) => { actions = a; }; + openEditorFor(api, element("div"), SOURCE_BODY); + + // GitHub rejects a URL past roughly 8k, and a real driver is tens of + // kilobytes — so this is the normal case for an edit, not an edge case. + const said = []; + actions.suggest("x".repeat(api.maxSuggestionURL() * 3), (m) => said.push(m)); + + assert.equal(opened.length, 1, "one tab, and it is not a page GitHub will reject"); + assert.ok(opened[0].length <= api.maxSuggestionURL(), + "the oversized draft was dropped from the URL rather than sent"); + assert.ok(!decodedBody(opened[0]).includes("```diff"), "and the diff goes with it"); + assert.match(said.join(" "), /too large to prefill/, + "silently dropping the operator's edit would look like it was sent"); +}); + +test("an edit that fits is carried in full", () => { + const { api, opened, window } = load(); + let actions = null; + window.FTWSettings.openDriverEditor = (_driver, a) => { actions = a; }; + openEditorFor(api, element("div"), SOURCE_BODY); + + actions.suggest(SOURCE_BODY.lua + "-- read holding 5017 instead of 5016\n", () => {}); + assert.match(decodedBody(opened[0]), /read holding 5017 instead of 5016/); +}); + +function findButton(el, label) { + for (const child of el.children) { + if (child.tag === "button" && child.textContent === label) return child; + const nested = findButton(child, label); + if (nested) return nested; + } + return null; +} diff --git a/web/driver-versions.test.mjs b/web/driver-versions.test.mjs index 1bd25cbc..67529a64 100644 --- a/web/driver-versions.test.mjs +++ b/web/driver-versions.test.mjs @@ -21,13 +21,13 @@ const source = readFileSync(new URL("./settings/tabs/devices.js", import.meta.ur function element(tag) { const listeners = new Map(); + let text = ""; const el = { tag, children: [], className: "", disabled: false, style: {}, - textContent: "", dataset: {}, type: "", addEventListener(name, handler) { listeners.set(name, handler); }, @@ -48,6 +48,13 @@ function element(tag) { }, querySelectorAll() { return []; }, }; + // Setting textContent clears the children, the way the DOM does. A stub that + // kept them let a test find a button from a view that had been replaced. + Object.defineProperty(el, "textContent", { + get() { return text; }, + set(value) { text = String(value); el.children.length = 0; }, + enumerable: true, + }); return el; } @@ -509,14 +516,21 @@ test("an override is not offered an Update button", () => { assert.equal(overridden.updatable, false); }); -test("manifest data becomes text, never markup", () => { +test("manifest data and driver source become text, never markup", () => { const { api } = load(); - const picker = source.slice( + // Everything between these two builds DOM from remote or operator-supplied + // data: version strings from a signed manifest, and the Lua of a driver an + // operator may have written themselves. + const builders = source.slice( source.indexOf("function renderVersionPicker"), source.indexOf("function offerUndo")); - assert.ok(!/innerHTML/.test(picker), - "version strings come from a signed manifest, but a signed manifest is " + - "still remote input and must not be able to inject markup"); - assert.match(picker, /createElement\("button"\)/); + // Assignment, not the word — a comment explaining why innerHTML is avoided + // is not a use of it. + assert.ok(!/innerHTML\s*=/.test(builders), + "a signed manifest is still remote input, and a driver file is whatever " + + "is on disk; neither may inject markup"); + assert.match(builders, /createElement\("button"\)/); + assert.match(builders, /pre\.textContent = body\.lua/, + "driver source is set as text so it renders as code, not as HTML"); }); diff --git a/web/index.html b/web/index.html index 69d74fc1..aa3c9684 100644 --- a/web/index.html +++ b/web/index.html @@ -846,7 +846,8 @@

Price bars (top of the chart)

- + + diff --git a/web/settings/driver-editor.js b/web/settings/driver-editor.js new file mode 100644 index 00000000..a825b335 --- /dev/null +++ b/web/settings/driver-editor.js @@ -0,0 +1,324 @@ +// The driver editor: a full-height view over the settings modal, with syntax +// highlighting and two linters. +// +// A driver is tens of kilobytes of Lua. Editing that in a 300-pixel textarea +// wedged into a device row is not editing, it is squinting -- so this opens as +// its own surface with room to read the code. +// +// Ace is vendored under /vendor/ace/ rather than pulled from a CDN, for the +// same reason three.js is: a gateway has to work without the internet, and a +// driver editor is most needed exactly when something is wrong. It loads on +// first open, not on page load. +(function () { + var S = (window.FTWSettings = window.FTWSettings || { tabs: {} }); + + var ACE_BASE = "/vendor/ace"; + var acePromise = null; + + function loadScript(src) { + return new Promise(function (resolve, reject) { + var el = document.createElement("script"); + el.src = src; + el.onload = resolve; + el.onerror = function () { reject(new Error("could not load " + src)); }; + document.head.appendChild(el); + }); + } + + // Ace's own files reference each other by base path, and the Lua worker is + // fetched separately when the mode starts. + function loadAce() { + if (acePromise) return acePromise; + acePromise = loadScript(ACE_BASE + "/ace.js") + .then(function () { + window.ace.config.set("basePath", ACE_BASE); + window.ace.config.set("workerPath", ACE_BASE); + return Promise.all([ + loadScript(ACE_BASE + "/mode-lua.js"), + loadScript(ACE_BASE + "/theme-tomorrow_night.js"), + loadScript(ACE_BASE + "/ext-searchbox.js") + ]); + }) + .then(function () { return window.ace; }) + .catch(function (err) { + // Let the next attempt retry rather than caching the failure. + acePromise = null; + throw err; + }); + return acePromise; + } + + function el(tag, className, text) { + var node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + } + + // open() takes the driver's source payload and the actions the devices tab + // owns, so this file knows how to edit a driver and nothing about how a + // draft is run. + // + // actions: { runDraft(lua, minutes), keepDraft(), revertDraft(), + // draftStatus(), lint(lua), suggest(lua) } + S.openDriverEditor = function (body, actions) { + var overlay = el("div", "drv-editor-overlay"); + var shell = el("div", "drv-editor-shell"); + + var head = el("div", "drv-editor-head"); + head.appendChild(el("strong", null, body.filename || body.id)); + var provenance = el("span", "drv-editor-provenance", + [body.version ? "v" + body.version : "", body.sourceLabel, describeSize(body.bytes)] + .filter(Boolean).join(" · ")); + head.appendChild(provenance); + + var spacer = el("span"); + spacer.style.flex = "1"; + head.appendChild(spacer); + + if (body.repository_url) { + var link = document.createElement("a"); + link.href = body.repository_url; + link.target = "_blank"; + link.rel = "noopener"; + link.className = "drv-editor-link"; + link.textContent = "Open in device-drivers"; + head.appendChild(link); + } + + var close = el("button", "btn-add", "Close"); + close.type = "button"; + head.appendChild(close); + shell.appendChild(head); + + var host = el("div", "drv-editor-surface"); + shell.appendChild(host); + + var footer = el("div", "drv-editor-foot"); + var problems = el("div", "drv-editor-problems"); + footer.appendChild(problems); + shell.appendChild(footer); + + var controls = el("div", "drv-editor-controls"); + var windowPicker = document.createElement("select"); + [5, 10, 30, 60].forEach(function (minutes) { + var opt = document.createElement("option"); + opt.value = String(minutes); + opt.textContent = "Try for " + minutes + " min"; + if (minutes === 10) opt.selected = true; + windowPicker.appendChild(opt); + }); + controls.appendChild(windowPicker); + + var run = el("button", "btn-add", "Run this draft"); + run.type = "button"; + controls.appendChild(run); + + var suggest = el("button", "btn-add", "Suggest to repo"); + suggest.type = "button"; + controls.appendChild(suggest); + + // Its own class: the devices tab has a status line too, and two + // elements sharing one name makes both harder to reason about. + var status = el("span", "drv-editor-status"); + controls.appendChild(status); + footer.appendChild(controls); + + overlay.appendChild(shell); + document.body.appendChild(overlay); + + var editor = null; + var closed = false; + + function dispose() { + if (closed) return; + closed = true; + if (editor) editor.destroy(); + overlay.remove(); + document.removeEventListener("keydown", onKey); + } + function onKey(e) { + // Escape closes, but not while the find bar has it. + if (e.key === "Escape" && !overlay.querySelector(".ace_search")) dispose(); + } + document.addEventListener("keydown", onKey); + close.addEventListener("click", dispose); + + host.textContent = "Loading editor…"; + loadAce().then(function (ace) { + host.textContent = ""; + editor = ace.edit(host); + editor.setTheme("ace/theme/tomorrow_night"); + editor.session.setMode("ace/mode/lua"); + editor.session.setValue(body.lua || "", -1); + editor.setOptions({ + fontSize: "12px", + showPrintMargin: false, + useSoftTabs: true, + tabSize: 2, + // A 35 kB driver is unreadable without these. + showFoldWidgets: true, + highlightActiveLine: true, + scrollPastEnd: 0.4 + }); + editor.focus(); + + wireLinting(editor, actions, problems); + resumeDraft(); + }).catch(function (err) { + host.textContent = err.message; + }); + + function currentSource() { + return editor ? editor.getValue() : (body.lua || ""); + } + + run.addEventListener("click", function () { + if (!editor) return; + run.disabled = true; + status.textContent = "Checking…"; + // The server's parser is the one that decides whether the driver + // starts, so it gates running rather than merely advising. + actions.lint(currentSource()).then(function (result) { + if (result && result.ok === false) { + status.textContent = "Not run: the draft does not compile."; + run.disabled = false; + return null; + } + status.textContent = "Starting…"; + return actions.runDraft(currentSource(), parseInt(windowPicker.value, 10)); + }).then(function (b) { + if (!b) return; + showRunning(b.expires_at_ms); + }).catch(function (err) { + status.textContent = err.message; + run.disabled = false; + }); + }); + + suggest.addEventListener("click", function () { + actions.suggest(currentSource(), function (message) { + status.textContent = message; + }); + }); + + function resumeDraft() { + actions.draftStatus().then(function (b) { + if (b && b.running) showRunning(b.expires_at_ms); + }).catch(function () { /* nothing running is the normal case */ }); + } + + var ticker = null; + function showRunning(expiresAtMS) { + run.disabled = true; + controls.querySelectorAll(".drv-editor-action").forEach(function (n) { n.remove(); }); + + function tick() { + var left = Math.max(0, Math.round((expiresAtMS - Date.now()) / 1000)); + if (left <= 0) { + status.textContent = "The draft expired and the previous driver is back."; + clearInterval(ticker); + run.disabled = false; + controls.querySelectorAll(".drv-editor-action").forEach(function (n) { n.remove(); }); + return; + } + status.textContent = "Draft running · reverts in " + Math.floor(left / 60) + ":" + + (left % 60 < 10 ? "0" : "") + (left % 60); + } + clearInterval(ticker); + ticker = setInterval(tick, 1000); + tick(); + + function action(label, call, done) { + var btn = el("button", "btn-add drv-editor-action", label); + btn.type = "button"; + btn.addEventListener("click", function () { + btn.disabled = true; + call().then(function () { + clearInterval(ticker); + status.textContent = done; + run.disabled = false; + controls.querySelectorAll(".drv-editor-action").forEach(function (n) { n.remove(); }); + }).catch(function (err) { + status.textContent = err.message; + btn.disabled = false; + }); + }); + controls.insertBefore(btn, status); + } + action("Keep it", actions.keepDraft, "Kept. This is your own file now."); + action("Put it back", actions.revertDraft, "Reverted."); + } + + return dispose; + }; + + // Ace lints as you type with its own luaparse; the server is asked once + // typing pauses. They can disagree, and when they do the server wins on + // screen -- it is the parser that decides whether the driver starts. + function wireLinting(editor, actions, problemsEl) { + var pending = null; + + function render(serverProblems) { + problemsEl.textContent = ""; + var aceAnnotations = editor.session.getAnnotations() || []; + var list = (serverProblems || []).slice(); + + // Ace's syntax errors are already in the gutter. Only surface them here + // when the server has not spoken yet, so the two do not stack up. + if (list.length === 0) { + aceAnnotations.forEach(function (a) { + if (a.type === "error") { + list.push({line: (a.row || 0) + 1, severity: "error", message: a.text}); + } + }); + } + + if (list.length === 0) { + problemsEl.appendChild(el("span", "drv-editor-clean", "No problems found.")); + return; + } + list.slice(0, 12).forEach(function (p) { + var row = el("button", "drv-editor-problem drv-editor-" + (p.severity || "error")); + row.type = "button"; + row.textContent = (p.line ? "line " + p.line + " · " : "") + p.message; + if (p.line) { + row.addEventListener("click", function () { + editor.gotoLine(p.line, 0, true); + editor.focus(); + }); + } + problemsEl.appendChild(row); + }); + } + + // The gutter updates on its own; this mirrors it into the panel and asks + // the server once the operator stops typing. + editor.session.on("changeAnnotation", function () { render(null); }); + editor.session.on("change", function () { + clearTimeout(pending); + pending = setTimeout(function () { + actions.lint(editor.getValue()).then(function (result) { + var problems = (result && result.problems) || []; + // Mark the server's verdict in the gutter too, so the two linters + // do not point at different lines. + editor.session.setAnnotations(problems.map(function (p) { + return { + row: Math.max(0, (p.line || 1) - 1), + column: 0, + text: p.message, + type: p.severity === "warning" ? "warning" : "error" + }; + })); + render(problems); + }).catch(function () { /* keep whatever Ace already showed */ }); + }, 600); + }); + } + + function describeSize(bytes) { + if (typeof bytes !== "number" || bytes <= 0) return ""; + if (bytes < 1024) return bytes + " bytes"; + return (bytes / 1024).toFixed(1) + " kB"; + } +})(); diff --git a/web/settings/tabs/devices.js b/web/settings/tabs/devices.js index be1b2f49..c1e6f3b8 100644 --- a/web/settings/tabs/devices.js +++ b/web/settings/tabs/devices.js @@ -142,13 +142,64 @@ // /versions payload. The previous version of this code read the wrong field // and rendered nothing; the test that covered it matched the source with a // regex, so it passed while the panel was empty on screen. + // Everything an operator might type: the manufacturer, the driver's name, + // and the models it has been run against. tested_models is the one that + // matters -- you know you own an SH10RT, not that you need "Sungrow SH + // Hybrid Inverter". + function catalogHaystack(e) { + return [e.name, e.manufacturer, e.id, e.filename] + .concat(e.tested_models || []) + .concat(e.protocols || []) + .join(" ").toLowerCase(); + } + + // Proven first. Four of thirty-seven have run on customer hardware and the + // rest have not; alphabetical order buries that. + var VERIFICATION_RANK = {production: 0, beta: 1, experimental: 2}; + + function catalogRank(e) { + var rank = VERIFICATION_RANK[e.verification_status]; + return rank === undefined ? 3 : rank; + } + + // Every term has to match somewhere, so "sungrow hybrid" narrows rather + // than widens. + function searchCatalog(entries, query) { + var terms = String(query || "").trim().toLowerCase().split(/\s+/).filter(Boolean); + var matches = (entries || []).filter(function (e) { + if (terms.length === 0) return true; + var hay = catalogHaystack(e); + return terms.every(function (t) { return hay.indexOf(t) >= 0; }); + }); + return matches.sort(function (a, b) { + var byRank = catalogRank(a) - catalogRank(b); + if (byRank !== 0) return byRank; + return String(a.name || "").localeCompare(String(b.name || "")); + }); + } + + // Several drivers already begin with the manufacturer, which read as + // "CTEK CTEK Chargestorm". + function catalogTitle(e) { + var name = e.name || e.filename || e.id || ""; + var maker = e.manufacturer || ""; + if (!maker || name.toLowerCase().indexOf(maker.toLowerCase()) === 0) return name; + return maker + " " + name; + } + S.driverVersions = { runningSummary: runningSummary, verificationLabel: verificationLabel, versionRows: versionRows, render: function (panel, driverID, body, opts) { return renderVersionPicker(panel, driverID, body, opts); - } + }, + renderSource: function (panel, body) { return renderDriverSource(panel, body); }, + unifiedDiff: function (before, after, context) { return unifiedDiff(before, after, context); }, + suggestUpstreamURL: function (body, edited) { return suggestUpstreamURL(body, edited); }, + maxSuggestionURL: function () { return MAX_SUGGESTION_URL; }, + searchCatalog: function (entries, query) { return searchCatalog(entries, query); }, + catalogTitle: function (entry) { return catalogTitle(entry); } }; // One list of everything this driver could run, and one click to switch. @@ -384,6 +435,314 @@ }); } + // The Lua that is actually running, and where it came from. Built as DOM: + // driver source is a file on disk that an operator may have written, and + // setting it as innerHTML would execute whatever is in it. + function renderDriverSource(panel, body) { + panel.textContent = ""; + + var header = document.createElement("div"); + header.style.display = "flex"; + header.style.alignItems = "center"; + header.style.flexWrap = "wrap"; + header.style.gap = "8px"; + header.style.marginBottom = "6px"; + + var badge = document.createElement("span"); + badge.className = "creds-badge"; + badge.textContent = body.version ? "v" + body.version : body.filename || "driver"; + header.appendChild(badge); + + var detail = document.createElement("span"); + detail.className = "drv-version-detail"; + detail.textContent = [originLabel(body.source), body.filename, describeSize(body.bytes)] + .filter(Boolean).join(" · "); + header.appendChild(detail); + + // The published artifact carries generated metadata the repository copy + // does not, so this is a link to the file's history, not to these bytes. + if (body.repository_url) { + var link = document.createElement("a"); + link.href = body.repository_url; + link.target = "_blank"; + link.rel = "noopener"; + link.textContent = "Open in device-drivers"; + link.style.color = "var(--accent-e)"; + link.style.fontSize = "0.75rem"; + header.appendChild(link); + } + panel.appendChild(header); + + var pre = document.createElement("pre"); + pre.className = "drv-source-code"; + pre.textContent = body.lua || ""; + panel.appendChild(pre); + + var footer = document.createElement("div"); + footer.style.alignItems = "center"; + footer.style.display = "flex"; + footer.style.flexWrap = "wrap"; + footer.style.gap = "8px"; + footer.style.marginTop = "6px"; + + var digest = document.createElement("span"); + digest.className = "drv-version-detail"; + digest.textContent = "sha256 " + String(body.sha256 || "").slice(0, 16) + "…"; + footer.appendChild(digest); + + var edit = document.createElement("button"); + edit.type = "button"; + edit.className = "btn-add"; + edit.textContent = "Edit and try"; + edit.addEventListener("click", function () { + renderDriverEditor(panel, body); + }); + footer.appendChild(edit); + + // Everything points at the repository, including from the read view: a + // driver that is wrong for your hardware is worth reporting even if you + // have not written the fix yourself. + appendSuggestButton(footer, body, null); + + var status = document.createElement("span"); + status.className = "drv-draft-status"; + footer.appendChild(status); + + panel.appendChild(footer); + + // A draft may already be running from an earlier visit, or from another + // tab. The countdown belongs to the gateway, not to this page. + resumeDraft(panel, body, status); + } + + // The editor is its own surface now: a driver is tens of kilobytes of Lua, + // and editing that in a textarea wedged into a device row is squinting, not + // editing. This hands it the driver and the actions, and knows nothing about + // how the editor is drawn. + function renderDriverEditor(panel, body) { + if (!S.openDriverEditor) { + panel.textContent = "The editor did not load."; + return; + } + var id = encodeURIComponent(body.id); + + function post(path, payload) { + return apiFetch("/api/drivers/" + id + path, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: payload === undefined ? null : JSON.stringify(payload) + }).then(function (r) { + return r.json().then(function (b) { + if (!r.ok) throw new Error(b.error || "the request was refused"); + return b; + }); + }); + } + + S.openDriverEditor({ + id: body.id, + filename: body.filename, + version: body.version, + bytes: body.bytes, + lua: body.lua, + sha256: body.sha256, + source: body.source, + sourceLabel: originLabel(body.source), + repository_url: body.repository_url + }, { + runDraft: function (lua, minutes) { return post("/draft", {lua: lua, minutes: minutes}); }, + keepDraft: function () { return post("/draft/keep"); }, + revertDraft: function () { return post("/draft/revert"); }, + draftStatus: function () { + return apiFetch("/api/drivers/" + id + "/draft").then(function (r) { return r.json(); }); + }, + lint: function (lua) { return post("/lint", {lua: lua}); }, + suggest: function (lua, say) { + var url = suggestUpstreamURL(body, lua); + if (url.length > MAX_SUGGESTION_URL) { + url = suggestUpstreamURL(body, ""); + say("The driver is too large to prefill; paste it into the issue."); + } + window.open(url, "_blank", "noopener"); + } + }); + } + + function resumeDraft(panel, body, status) { + apiFetch("/api/drivers/" + encodeURIComponent(body.id) + "/draft") + .then(function (r) { return r.json(); }) + .then(function (b) { + if (b && b.running) showDraftRunning(panel, body, status, b.expires_at_ms); + }) + .catch(function () { /* nothing is running, which is the normal case */ }); + } + + // While a draft runs, the only two things worth offering are keeping it and + // putting it back. Both are one click, and the clock says how long the + // decision stays open. + function showDraftRunning(panel, body, status, expiresAtMS) { + var host = status.parentElement || panel; + host.querySelectorAll(".drv-draft-action").forEach(function (el) { el.remove(); }); + + function tick() { + var left = Math.max(0, Math.round((expiresAtMS - Date.now()) / 1000)); + if (left <= 0) { + status.textContent = "The draft expired and the previous driver is back."; + clearInterval(timer); + return; + } + var minutes = Math.floor(left / 60); + var seconds = left % 60; + status.textContent = "Draft running · reverts in " + minutes + ":" + + (seconds < 10 ? "0" : "") + seconds; + } + var timer = setInterval(tick, 1000); + tick(); + + function act(path, label, done) { + var btn = document.createElement("button"); + btn.type = "button"; + btn.className = "btn-add drv-draft-action"; + btn.textContent = label; + btn.addEventListener("click", function () { + btn.disabled = true; + apiFetch("/api/drivers/" + encodeURIComponent(body.id) + "/draft/" + path, {method: "POST"}) + .then(function (r) { + return r.json().then(function (b) { + if (!r.ok) throw new Error(b.error || "could not " + path + " the draft"); + return b; + }); + }).then(function () { + clearInterval(timer); + status.textContent = done; + host.querySelectorAll(".drv-draft-action").forEach(function (el) { el.remove(); }); + }).catch(function (err) { + status.textContent = err.message; + btn.disabled = false; + }); + }); + host.insertBefore(btn, status); + } + + act("keep", "Keep it", "Kept. This is your own file now."); + act("revert", "Put it back", "Reverted."); + } + + // A line diff with a little context around each change, which is what fits + // in a URL and what a maintainer reads anyway. + // + // Longest-common-subsequence over lines. A driver is a few thousand lines, + // so the quadratic table is fine here and worth the exactness: a cheaper + // heuristic would drift out of alignment after the first edit and report + // changes that were never made. + function unifiedDiff(before, after, context) { + var a = String(before).split("\n"); + var b = String(after).split("\n"); + var contextLines = context === undefined ? 3 : context; + + var lcs = []; + for (var i = 0; i <= a.length; i++) lcs.push(new Array(b.length + 1).fill(0)); + for (var i = a.length - 1; i >= 0; i--) { + for (var j = b.length - 1; j >= 0; j--) { + lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 + : Math.max(lcs[i + 1][j], lcs[i][j + 1]); + } + } + + var ops = []; + var x = 0, y = 0; + while (x < a.length && y < b.length) { + if (a[x] === b[y]) { ops.push([" ", a[x]]); x++; y++; } + else if (lcs[x + 1][y] >= lcs[x][y + 1]) { ops.push(["-", a[x]]); x++; } + else { ops.push(["+", b[y]]); y++; } + } + while (x < a.length) { ops.push(["-", a[x]]); x++; } + while (y < b.length) { ops.push(["+", b[y]]); y++; } + + // Only the changed regions, so an unchanged driver body does not fill the + // issue with lines nobody needs to read. + var keep = new Array(ops.length).fill(false); + ops.forEach(function (op, index) { + if (op[0] === " ") return; + for (var k = Math.max(0, index - contextLines); + k <= Math.min(ops.length - 1, index + contextLines); k++) { + keep[k] = true; + } + }); + + var out = []; + var skipping = false; + ops.forEach(function (op, index) { + if (!keep[index]) { + if (!skipping) { out.push("@@"); skipping = true; } + return; + } + skipping = false; + out.push(op[0] + op[1]); + }); + return out.join("\n"); + } + + // Suggest the edit back to the repository the driver came from. + // + // The gateway holds no GitHub token and needs none: GitHub accepts a + // pre-filled issue over a URL, and the operator is already signed in to + // their own browser. One click opens it with the driver, the version, the + // hardware and the edit already written out. + function suggestUpstreamURL(body, edited) { + var title = "[" + (body.id || "driver") + "] "; + var lines = [ + "What I changed and why:", + "", + "", + "---", + "Driver: " + (body.id || "") + " " + (body.version ? "v" + body.version : ""), + "Came from: " + originLabel(body.source), + "File: " + (body.filename || ""), + "Original sha256: " + (body.sha256 || "") + ]; + if (edited && edited !== body.lua) { + // A diff, not the file. Drivers run to tens of kilobytes and GitHub + // rejects a URL past about 8k, so sending the whole file meant the code + // never travelled at all -- while a fix is usually a handful of lines. + lines.push("", "```diff", unifiedDiff(body.lua || "", edited), "```"); + } + return "https://github.com/srcfl/device-drivers/issues/new" + + "?title=" + encodeURIComponent(title) + + "&body=" + encodeURIComponent(lines.join("\n")); + } + + // GitHub rejects a URL past roughly 8k, and an edited driver is usually + // larger than that. Past the limit the issue is opened without the file and + // says so, rather than opening a page that errors. + var MAX_SUGGESTION_URL = 8000; + + function appendSuggestButton(host, body, getEdited) { + var suggest = document.createElement("button"); + suggest.type = "button"; + suggest.className = "btn-add"; + suggest.textContent = "Suggest to repo"; + suggest.addEventListener("click", function () { + var edited = getEdited ? getEdited() : ""; + var url = suggestUpstreamURL(body, edited); + var note = host.querySelector(".drv-draft-status"); + if (url.length > MAX_SUGGESTION_URL) { + url = suggestUpstreamURL(body, ""); + if (note) { + note.textContent = "The driver is too large to prefill; paste it into the issue."; + } + } + window.open(url, "_blank", "noopener"); + }); + host.appendChild(suggest); + } + + function describeSize(bytes) { + if (typeof bytes !== "number" || bytes <= 0) return ""; + if (bytes < 1024) return bytes + " bytes"; + return (bytes / 1024).toFixed(1) + " kB"; + } + // Put back whatever was running before the switch. Which call does that // depends on what it was, not on its version number: a managed artifact of // the same version can sit on disk from an earlier trial, and activating @@ -467,9 +826,17 @@ '' + '' + '
' + - '' + - '' + - '
' + + // You know your hardware, not which driver covers it. The catalog + // carries tested_models for most drivers, so searching those is what + // turns "I have an SH10RT" into the right answer. + '' + + '' + + '
' + + // The picker stays as the selection the rest of the form reads, but + // the cards are what an operator actually looks at. + '' + + '
Loading catalog…
' + + '
' + '' + '