engine: resources: Add an http ui resource

Many years ago I built and demoed a prototype of a simple web ui with a
slider, and as you moved it left and right, it started up or shutdown
some number of virtual machines.

The webui was standalone code, but the rough idea of having events from
a high-level overview flow into mgmt, was what I wanted to test out. At
this stage, I didn't even have the language built yet. This prototype
helped convince me of the way a web ui would fit into everything.

Years later, I build an autogrouping prototype which looks quite similar
to what we have today. I recently picked it back up to polish it a bit
more. It's certainly not perfect, and might even be buggy, but it's
useful enough that it's worth sharing.

If I had more cycles, I'd probably consider removing the "store" mode,
and replace it with the normal "value" system, but we would need the
resource "mutate" API if we wanted this. This would allow us to directly
change the "value" field, without triggering a graph swap, which would
be a lot less clunky than the "store" situation.

Of course I'd love to see a GTK version of this concept, but I figured
it would be more practical to have a web ui over HTTP.

One notable missing feature, is that if the "web ui" changes (rather
than just a value changing) we need to offer to the user to reload it.
It currently doesn't get an event for that, and so don't confuse your
users. We also need to be better at validating "untrusted" input here.

There's also no major reason to use the "gin" framework, we should
probably redo this with the standard library alone, but it was easier
for me to push out something quick this way. We can optimize that later.

Lastly, this is all quite ugly since I'm not a very good web dev, so if
you want to make this polished, please do! The wasm code is also quite
terrible due to limitations in the compiler, and maybe one day when that
works better and doesn't constantly deadlock, we can improve it.
This commit is contained in:
James Shubin
2025-05-02 02:14:14 -04:00
parent 6b10477ebc
commit 807c4b3430
27 changed files with 3266 additions and 43 deletions

43
engine/resources/Makefile Normal file
View File

@@ -0,0 +1,43 @@
# Mgmt
# Copyright (C) James Shubin and the project contributors
# Written by James Shubin <james@shubin.ca> and the project contributors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# Additional permission under GNU GPL version 3 section 7
#
# If you modify this program, or any covered work, by linking or combining it
# with embedded mcl code and modules (and that the embedded mcl code and
# modules which link with this program, contain a copy of their source code in
# the authoritative form) containing parts covered by the terms of any other
# license, the licensors of this program grant you additional permission to
# convey the resulting work. Furthermore, the licensors of this program grant
# the original author, James Shubin, additional permission to update this
# additional permission if he deems it necessary to achieve the goals of this
# additional permission.
SHELL = /usr/bin/env bash
.PHONY: build clean
default: build
WASM_FILE = http_ui/main.wasm
build: $(WASM_FILE)
$(WASM_FILE): http_ui/main.go
@echo "Generating: wasm..."
cd http_ui/ && env GOOS=js GOARCH=wasm go build -o `basename $(WASM_FILE)`
clean:
@rm -f $(WASM_FILE) || true

791
engine/resources/http_ui.go Normal file
View File

@@ -0,0 +1,791 @@
// Mgmt
// Copyright (C) James Shubin and the project contributors
// Written by James Shubin <james@shubin.ca> and the project contributors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// Additional permission under GNU GPL version 3 section 7
//
// If you modify this program, or any covered work, by linking or combining it
// with embedded mcl code and modules (and that the embedded mcl code and
// modules which link with this program, contain a copy of their source code in
// the authoritative form) containing parts covered by the terms of any other
// license, the licensors of this program grant you additional permission to
// convey the resulting work. Furthermore, the licensors of this program grant
// the original author, James Shubin, additional permission to update this
// additional permission if he deems it necessary to achieve the goals of this
// additional permission.
package resources
import (
"context"
_ "embed" // embed data with go:embed
"fmt"
"html/template"
"net/http"
"sort"
"strings"
"sync"
"github.com/purpleidea/mgmt/engine"
"github.com/purpleidea/mgmt/engine/resources/http_ui/common"
"github.com/purpleidea/mgmt/engine/resources/http_ui/static"
"github.com/purpleidea/mgmt/engine/traits"
"github.com/purpleidea/mgmt/pgraph"
"github.com/purpleidea/mgmt/util/errwrap"
"github.com/gin-gonic/gin"
)
const (
httpUIKind = httpKind + ":ui"
httpUIIndexHTMLTmpl = "index.html.tmpl"
)
var (
//go:embed http_ui/index.html.tmpl
httpUIIndexHTMLTmplData string
//go:embed http_ui/wasm_exec.js
httpUIWasmExecData []byte
//go:embed http_ui/main.wasm
httpUIMainWasmData []byte
)
func init() {
engine.RegisterResource(httpUIKind, func() engine.Res { return &HTTPUIRes{} })
}
// HTTPServerUIGroupableRes is the interface that you must implement if you want
// to allow a resource the ability to be grouped into the http server ui
// resource. As an added safety, the Kind must also begin with "http:ui:", and
// not have more than one colon to avoid accidents of unwanted grouping.
type HTTPServerUIGroupableRes interface {
engine.Res
// ParentName is used to limit which resources autogroup into this one.
// If it's empty then it's ignored, otherwise it must match the Name of
// the parent to get grouped.
ParentName() string
// GetKind returns the "kind" of resource that this UI element is. This
// is technically different than the Kind() field, because it can be a
// unique kind that's specific to the HTTP form UI resources.
GetKind() string
// GetID returns the unique ID that this UI element responds to. Note
// that this is NOT replaceable by Name() because this ID is used in
// places that might be public, such as in webui form source code.
GetID() string
// SetValue sends the new value that was obtained from submitting the
// form. This is the raw, unsafe value that you must validate first.
SetValue(context.Context, []string) error
// GetValue gets a string representation for the form value, that we'll
// use in our html form.
GetValue(context.Context) (string, error)
// GetType returns a map that you can use to build the input field in
// the ui.
GetType() map[string]string
// GetSort returns a string that you can use to determine the global
// sorted display order of all the elements in a ui.
GetSort() string
}
// HTTPUIResData represents some additional data to attach to the resource.
type HTTPUIResData struct {
// Title is the generated page title that is displayed to the user.
Title string `lang:"title" yaml:"title"`
// Head is a list of strings to insert into the <head> and </head> tags
// of your page. This string allows HTML, so choose carefully!
// XXX: a *string should allow a partial struct here without having this
// field, but our type unification algorithm isn't this fancy yet...
Head string `lang:"head" yaml:"head"`
}
// HTTPUIRes is a web UI resource that exists within an http server. The name is
// used as the public path of the ui, unless the path field is specified, and in
// that case it is used instead. The way this works is that it autogroups at
// runtime with an existing http server resource, and in doing so makes the form
// associated with this resource available for serving from that http server.
type HTTPUIRes struct {
traits.Base // add the base methods without re-implementation
traits.Edgeable // XXX: add autoedge support
traits.Groupable // can be grouped into HTTPServerRes
init *engine.Init
// Server is the name of the http server resource to group this into. If
// it is omitted, and there is only a single http resource, then it will
// be grouped into it automatically. If there is more than one main http
// resource being used, then the grouping behaviour is *undefined* when
// this is not specified, and it is not recommended to leave this blank!
Server string `lang:"server" yaml:"server"`
// Path is the name of the path that this should be exposed under. For
// example, you might want to name this "/ui/" to expose it as "ui"
// under the server root. This overrides the name variable that is set.
Path string `lang:"path" yaml:"path"`
// Data represents some additional data to attach to the resource.
Data *HTTPUIResData `lang:"data" yaml:"data"`
//eventStream chan error
eventsChanMap map[engine.Res]chan error
// notifications contains a channel for every long poller waiting for a
// reply.
notifications map[engine.Res]map[chan struct{}]struct{}
// rwmutex guards the notifications map.
rwmutex *sync.RWMutex
ctx context.Context // set by Watch
}
// Default returns some sensible defaults for this resource.
func (obj *HTTPUIRes) Default() engine.Res {
return &HTTPUIRes{}
}
// getPath returns the actual path we respond to. When Path is not specified, we
// use the Name. Note that this is the handler path that will be seen on the
// root http server, and this ui application might use a querystring and/or POST
// data as well.
func (obj *HTTPUIRes) getPath() string {
if obj.Path != "" {
return obj.Path
}
return obj.Name()
}
// routerPath returns an appropriate path for our router based on what we want
// to achieve using our parent prefix.
func (obj *HTTPUIRes) routerPath(p string) string {
if strings.HasPrefix(p, "/") {
return obj.getPath() + p[1:]
}
return obj.getPath() + p
}
// ParentName is used to limit which resources autogroup into this one. If it's
// empty then it's ignored, otherwise it must match the Name of the parent to
// get grouped.
func (obj *HTTPUIRes) ParentName() string {
return obj.Server
}
// AcceptHTTP determines whether we will respond to this request. Return nil to
// accept, or any error to pass.
func (obj *HTTPUIRes) AcceptHTTP(req *http.Request) error {
requestPath := req.URL.Path // TODO: is this what we want here?
//if requestPath != obj.getPath() {
// return fmt.Errorf("unhandled path")
//}
if !strings.HasPrefix(requestPath, obj.getPath()) {
return fmt.Errorf("unhandled path")
}
return nil
}
// getResByID returns the grouped resource with the id we're searching for if it
// exists, otherwise nil and false.
func (obj *HTTPUIRes) getResByID(id string) (HTTPServerUIGroupableRes, bool) {
for _, x := range obj.GetGroup() { // grouped elements
res, ok := x.(HTTPServerUIGroupableRes) // convert from Res
if !ok {
continue
}
if obj.init.Debug {
obj.init.Logf("Got grouped resource: %s", res.String())
}
if id != res.GetID() {
continue
}
return res, true
}
return nil, false
}
// ginLogger is a helper to get structured logs out of gin.
func (obj *HTTPUIRes) ginLogger() gin.HandlerFunc {
return func(c *gin.Context) {
//start := time.Now()
c.Next()
//duration := time.Since(start)
//timestamp := time.Now().Format(time.RFC3339)
method := c.Request.Method
path := c.Request.URL.Path
status := c.Writer.Status()
//latency := duration
clientIP := c.ClientIP()
if obj.init.Debug {
return
}
obj.init.Logf("%v %s %s (%d)", clientIP, method, path, status)
}
}
// getTemplate builds the super template that contains the map of each file name
// so that it can be used easily to send out named, templated documents.
func (obj *HTTPUIRes) getTemplate() (*template.Template, error) {
// XXX: get this from somewhere
m := make(map[string]string)
//m["foo.tmpl"] = "hello from file1" // TODO: add more content?
m[httpUIIndexHTMLTmpl] = httpUIIndexHTMLTmplData // index.html.tmpl
filenames := []string{}
for filename := range m {
filenames = append(filenames, filename)
}
sort.Strings(filenames) // deterministic order
var t *template.Template
// This logic from golang/src/html/template/template.go:parseFiles(...)
for _, filename := range filenames {
data := m[filename]
var tmpl *template.Template
if t == nil {
t = template.New(filename)
}
if filename == t.Name() {
tmpl = t
} else {
tmpl = t.New(filename)
}
if _, err := tmpl.Parse(data); err != nil {
return nil, err
}
}
t = t.Option("missingkey=error") // be thorough
return t, nil
}
// ServeHTTP is the standard HTTP handler that will be used here.
func (obj *HTTPUIRes) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// XXX: do all the router bits in Init() if we can...
gin.SetMode(gin.ReleaseMode) // for production
router := gin.New()
router.Use(obj.ginLogger(), gin.Recovery())
templ, err := obj.getTemplate() // do in init?
if err != nil {
obj.init.Logf("template error: %+v", err)
return
}
router.SetHTMLTemplate(templ)
router.GET(obj.routerPath("/index.html"), func(c *gin.Context) {
h := gin.H{}
h["program"] = obj.init.Program
h["version"] = obj.init.Version
h["hostname"] = obj.init.Hostname
h["embedded"] = static.HTTPUIStaticEmbedded // true or false
h["title"] = "" // key must be specified
h["path"] = obj.getPath()
if obj.Data != nil {
h["title"] = obj.Data.Title // template var
h["head"] = template.HTML(obj.Data.Head)
}
c.HTML(http.StatusOK, httpUIIndexHTMLTmpl, h)
})
router.GET(obj.routerPath("/main.wasm"), func(c *gin.Context) {
c.Data(http.StatusOK, "application/wasm", httpUIMainWasmData)
})
router.GET(obj.routerPath("/wasm_exec.js"), func(c *gin.Context) {
// the version of this file has to match compiler version
// the original came from: ~golang/lib/wasm/wasm_exec.js
// XXX: add a test to ensure this matches the compiler version
// the content-type matters or this won't work in the browser
c.Data(http.StatusOK, "text/javascript;charset=UTF-8", httpUIWasmExecData)
})
if static.HTTPUIStaticEmbedded {
router.GET(obj.routerPath("/"+static.HTTPUIIndexBootstrapCSS), func(c *gin.Context) {
c.Data(http.StatusOK, "text/css;charset=UTF-8", static.HTTPUIIndexStaticBootstrapCSS)
})
router.GET(obj.routerPath("/"+static.HTTPUIIndexBootstrapJS), func(c *gin.Context) {
c.Data(http.StatusOK, "text/javascript;charset=UTF-8", static.HTTPUIIndexStaticBootstrapJS)
})
}
router.POST(obj.routerPath("/save/"), func(c *gin.Context) {
id, ok := c.GetPostForm("id")
if !ok || id == "" {
msg := "missing id"
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
return
}
values, ok := c.GetPostFormArray("value")
if !ok {
msg := "missing value"
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
return
}
res, ok := obj.getResByID(id)
if !ok {
msg := fmt.Sprintf("id `%s` not found", id)
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
return
}
// we're storing data...
if err := res.SetValue(obj.ctx, values); err != nil {
msg := fmt.Sprintf("bad data: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
return
}
// XXX: instead of an event to everything, instead if SetValue
// is an active sub resource (instead of something that noop's)
// that should send an event and eventually propagate to here,
// so skip sending this global one...
// Trigger a Watch() event so that CheckApply() calls Send/Recv,
// so our newly received POST value gets sent through the graph.
//select {
//case obj.eventStream <- nil: // send an event
//case <-obj.ctx.Done(): // in case Watch dies
// c.JSON(http.StatusInternalServerError, gin.H{
// "error": "Internal Server Error",
// "code": 500,
// })
//}
c.JSON(http.StatusOK, nil)
})
router.GET(obj.routerPath("/list/"), func(c *gin.Context) {
elements := []*common.FormElement{}
for _, x := range obj.GetGroup() { // grouped elements
res, ok := x.(HTTPServerUIGroupableRes) // convert from Res
if !ok {
continue
}
element := &common.FormElement{
Kind: res.GetKind(),
ID: res.GetID(),
Type: res.GetType(),
Sort: res.GetSort(),
}
elements = append(elements, element)
}
form := &common.Form{
Elements: elements,
}
// XXX: c.JSON or c.PureJSON ?
c.JSON(http.StatusOK, form) // send the struct as json
})
router.GET(obj.routerPath("/list/:id"), func(c *gin.Context) {
id := c.Param("id")
res, ok := obj.getResByID(id)
if !ok {
msg := fmt.Sprintf("id `%s` not found", id)
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
return
}
val, err := res.GetValue(obj.ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal Server Error",
"code": 500,
})
return
}
el := &common.FormElementGeneric{ // XXX: text or string?
Value: val,
}
c.JSON(http.StatusOK, el) // send the struct as json
})
router.GET(obj.routerPath("/watch/:id"), func(c *gin.Context) {
id := c.Param("id")
res, ok := obj.getResByID(id)
if !ok {
msg := fmt.Sprintf("id `%s` not found", id)
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
return
}
ch := make(chan struct{})
//defer close(ch) // don't close, let it gc instead
obj.rwmutex.Lock()
obj.notifications[res][ch] = struct{}{} // add to notification "list"
obj.rwmutex.Unlock()
defer func() {
obj.rwmutex.Lock()
delete(obj.notifications[res], ch)
obj.rwmutex.Unlock()
}()
select {
case <-ch: // http long poll
// pass
//case <-obj.???[res].Done(): // in case Watch dies
// c.JSON(http.StatusInternalServerError, gin.H{
// "error": "Internal Server Error",
// "code": 500,
// })
case <-obj.ctx.Done(): // in case Watch dies
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal Server Error",
"code": 500,
})
return
}
val, err := res.GetValue(obj.ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal Server Error",
"code": 500,
})
return
}
el := &common.FormElementGeneric{ // XXX: text or string?
Value: val,
}
c.JSON(http.StatusOK, el) // send the struct as json
})
router.GET(obj.routerPath("/ping"), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "pong",
})
})
router.ServeHTTP(w, req)
return
}
// Validate checks if the resource data structure was populated correctly.
func (obj *HTTPUIRes) Validate() error {
if obj.getPath() == "" {
return fmt.Errorf("empty path")
}
// FIXME: does getPath need to start with a slash or end with one?
if !strings.HasPrefix(obj.getPath(), "/") {
return fmt.Errorf("the Path must be absolute")
}
if !strings.HasSuffix(obj.getPath(), "/") {
return fmt.Errorf("the Path must end with a slash")
}
return nil
}
// Init runs some startup code for this resource.
func (obj *HTTPUIRes) Init(init *engine.Init) error {
obj.init = init // save for later
//obj.eventStream = make(chan error)
obj.eventsChanMap = make(map[engine.Res]chan error)
obj.notifications = make(map[engine.Res]map[chan struct{}]struct{})
obj.rwmutex = &sync.RWMutex{}
// NOTE: If we don't Init anything that's autogrouped, then it won't
// even get an Init call on it.
// TODO: should we do this in the engine? Do we want to decide it here?
for _, res := range obj.GetGroup() { // grouped elements
// NOTE: We build a new init, but it's not complete. We only add
// what we're planning to use, and we ignore the rest for now...
r := res // bind the variable!
obj.eventsChanMap[r] = make(chan error)
obj.notifications[r] = make(map[chan struct{}]struct{})
event := func() {
select {
case obj.eventsChanMap[r] <- nil:
// send!
}
obj.rwmutex.RLock()
for ch := range obj.notifications[r] {
select {
case ch <- struct{}{}:
// send!
default:
// skip immediately if nobody is listening
}
}
obj.rwmutex.RUnlock()
// We don't do this here (why?) we instead read from the
// above channel and then send on multiplexedChan to the
// main loop, where it runs the obj.init.Event function.
//obj.init.Event() // notify engine of an event (this can block)
}
newInit := &engine.Init{
Program: obj.init.Program,
Version: obj.init.Version,
Hostname: obj.init.Hostname,
// Watch:
Running: event,
Event: event,
// CheckApply:
//Refresh: func() bool { // TODO: do we need this?
// innerRes, ok := r.(engine.RefreshableRes)
// if !ok {
// panic("res does not support the Refreshable trait")
// }
// return innerRes.Refresh()
//},
Send: engine.GenerateSendFunc(r),
Recv: engine.GenerateRecvFunc(r), // unused
FilteredGraph: func() (*pgraph.Graph, error) {
panic("FilteredGraph for HTTP:UI not implemented")
},
Local: obj.init.Local,
World: obj.init.World,
//VarDir: obj.init.VarDir, // TODO: wrap this
Debug: obj.init.Debug,
Logf: func(format string, v ...interface{}) {
obj.init.Logf(res.Kind()+": "+format, v...)
},
}
if err := res.Init(newInit); err != nil {
return errwrap.Wrapf(err, "autogrouped Init failed")
}
}
return nil
}
// Cleanup is run by the engine to clean up after the resource is done.
func (obj *HTTPUIRes) Cleanup() error {
return nil
}
// Watch is the primary listener for this resource and it outputs events. This
// particular one does absolutely nothing but block until we've received a done
// signal.
func (obj *HTTPUIRes) Watch(ctx context.Context) error {
multiplexedChan := make(chan error)
defer close(multiplexedChan) // closes after everyone below us is finished
wg := &sync.WaitGroup{}
defer wg.Wait()
innerCtx, cancel := context.WithCancel(ctx) // store for ServeHTTP
defer cancel()
obj.ctx = innerCtx
for _, r := range obj.GetGroup() { // grouped elements
res := r // optional in newer golang
wg.Add(1)
go func() {
defer wg.Done()
defer close(obj.eventsChanMap[res]) // where Watch sends events
if err := res.Watch(ctx); err != nil {
select {
case multiplexedChan <- err:
case <-ctx.Done():
}
}
}()
// wait for Watch first Running() call or immediate error...
select {
case <-obj.eventsChanMap[res]: // triggers on start or on err...
}
wg.Add(1)
go func() {
defer wg.Done()
for {
var ok bool
var err error
select {
// receive
case err, ok = <-obj.eventsChanMap[res]:
if !ok {
return
}
}
// send (multiplex)
select {
case multiplexedChan <- err:
case <-ctx.Done():
return
}
}
}()
}
// we block until all the children are started first...
obj.init.Running() // when started, notify engine that we're running
startupChan := make(chan struct{})
close(startupChan) // send one initial signal
var send = false // send event?
for {
if obj.init.Debug {
obj.init.Logf("Looping...")
}
select {
case <-startupChan:
startupChan = nil
send = true
//case err, ok := <-obj.eventStream:
// if !ok { // shouldn't happen
// obj.eventStream = nil
// continue
// }
// if err != nil {
// return err
// }
// send = true
case err, ok := <-multiplexedChan:
if !ok { // shouldn't happen
multiplexedChan = nil
continue
}
if err != nil {
return err
}
send = true
case <-ctx.Done(): // closed by the engine to signal shutdown
return nil
}
// do all our event sending all together to avoid duplicate msgs
if send {
send = false
obj.init.Event() // notify engine of an event (this can block)
}
}
//return nil // unreachable
}
// CheckApply is responsible for the Send/Recv aspects of the autogrouped
// resources. It recursively calls any autogrouped children.
func (obj *HTTPUIRes) CheckApply(ctx context.Context, apply bool) (bool, error) {
if obj.init.Debug {
obj.init.Logf("CheckApply")
}
checkOK := true
for _, res := range obj.GetGroup() { // grouped elements
if c, err := res.CheckApply(ctx, apply); err != nil {
return false, errwrap.Wrapf(err, "autogrouped CheckApply failed")
} else if !c {
checkOK = false
}
}
return checkOK, nil // w00t
}
// Cmp compares two resources and returns an error if they are not equivalent.
func (obj *HTTPUIRes) Cmp(r engine.Res) error {
// we can only compare HTTPUIRes to others of the same resource kind
res, ok := r.(*HTTPUIRes)
if !ok {
return fmt.Errorf("res is not the same kind")
}
if obj.Server != res.Server {
return fmt.Errorf("the Server field differs")
}
if obj.Path != res.Path {
return fmt.Errorf("the Path differs")
}
return nil
}
// UnmarshalYAML is the custom unmarshal handler for this struct. It is
// primarily useful for setting the defaults.
func (obj *HTTPUIRes) UnmarshalYAML(unmarshal func(interface{}) error) error {
type rawRes HTTPUIRes // indirection to avoid infinite recursion
def := obj.Default() // get the default
res, ok := def.(*HTTPUIRes) // put in the right format
if !ok {
return fmt.Errorf("could not convert to HTTPUIRes")
}
raw := rawRes(*res) // convert; the defaults go here
if err := unmarshal(&raw); err != nil {
return err
}
*obj = HTTPUIRes(raw) // restore from indirection with type conversion!
return nil
}
// GroupCmp returns whether two resources can be grouped together or not. Can
// these two resources be merged, aka, does this resource support doing so? Will
// resource allow itself to be grouped _into_ this obj?
func (obj *HTTPUIRes) GroupCmp(r engine.GroupableRes) error {
res, ok := r.(HTTPServerUIGroupableRes) // different from what we usually do!
if !ok {
return fmt.Errorf("resource is not the right kind")
}
// If the http resource has the parent name field specified, then it
// must match against our name field if we want it to group with us.
if pn := res.ParentName(); pn != "" && pn != obj.Name() {
return fmt.Errorf("resource groups with a different parent name")
}
p := httpUIKind + ":"
// http:ui:foo is okay, but http:file is not
if !strings.HasPrefix(r.Kind(), p) {
return fmt.Errorf("not one of our children")
}
// http:ui:foo is okay, but http:ui:foo:bar is not
s := strings.TrimPrefix(r.Kind(), p)
if len(s) != len(r.Kind()) && strings.Count(s, ":") > 0 { // has prefix
return fmt.Errorf("maximum one resource after `%s` prefix", httpUIKind)
}
return nil
}

1
engine/resources/http_ui/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
main.wasm

View File

@@ -0,0 +1,8 @@
This directory contains the golang wasm source for the `http_ui` resource. It
gets built automatically when you run `make` from the main project root
directory.
After it gets built, the compiled artifact gets bundled into the main project
binary via go embed.
It is not a normal package that should get built with everything else.

View File

@@ -0,0 +1,82 @@
// Mgmt
// Copyright (C) James Shubin and the project contributors
// Written by James Shubin <james@shubin.ca> and the project contributors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// Additional permission under GNU GPL version 3 section 7
//
// If you modify this program, or any covered work, by linking or combining it
// with embedded mcl code and modules (and that the embedded mcl code and
// modules which link with this program, contain a copy of their source code in
// the authoritative form) containing parts covered by the terms of any other
// license, the licensors of this program grant you additional permission to
// convey the resulting work. Furthermore, the licensors of this program grant
// the original author, James Shubin, additional permission to update this
// additional permission if he deems it necessary to achieve the goals of this
// additional permission.
// Package common contains some code that is shared between the wasm and the
// http:ui packages.
package common
const (
// HTTPUIInputType represents the field in the "Type" map that specifies
// which input type we're using.
HTTPUIInputType = "type"
// HTTPUIInputTypeText is the representation of the html "text" type.
HTTPUIInputTypeText = "text"
// HTTPUIInputTypeRange is the representation of the html "range" type.
HTTPUIInputTypeRange = "range"
// HTTPUIInputTypeRangeMin is the html input "range" min field.
HTTPUIInputTypeRangeMin = "min"
// HTTPUIInputTypeRangeMax is the html input "range" max field.
HTTPUIInputTypeRangeMax = "max"
// HTTPUIInputTypeRangeStep is the html input "range" step field.
HTTPUIInputTypeRangeStep = "step"
)
// Form represents the entire form containing all the desired elements.
type Form struct {
// Elements is a list of form elements in this form.
// TODO: Maybe this should be an interface?
Elements []*FormElement `json:"elements"`
}
// FormElement represents each form element.
type FormElement struct {
// Kind is the kind of form element that this is.
Kind string `json:"kind"`
// ID is the unique public id for this form element.
ID string `json:"id"`
// Type is a map that you can use to build the input field in the ui.
Type map[string]string `json:"type"`
// Sort is a string that you can use to determine the global sorted
// display order of all the elements in a ui.
Sort string `json:"sort"`
}
// FormElementGeneric is a value store.
type FormElementGeneric struct {
// Value holds the string value we're interested in.
Value string `json:"value"`
}

View File

@@ -0,0 +1,163 @@
{{- /*
Mgmt
Copyright (C) James Shubin and the project contributors
Written by James Shubin <james@shubin.ca> and the project contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Additional permission under GNU GPL version 3 section 7
If you modify this program, or any covered work, by linking or combining it
with embedded mcl code and modules (and that the embedded mcl code and
modules which link with this program, contain a copy of their source code in
the authoritative form) containing parts covered by the terms of any other
license, the licensors of this program grant you additional permission to
convey the resulting work. Furthermore, the licensors of this program grant
the original author, James Shubin, additional permission to update this
additional permission if he deems it necessary to achieve the goals of this
additional permission.
This was modified from the boiler-plate in the ~golang/misc/wasm/* directory.
*/ -}}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
{{ if .title }}
<title>{{ .title }}</title>
{{ end }}
{{ if .head }}
{{ .head }}
{{ end }}
{{ if .embedded }}
<link href="static/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
<script src="static/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
{{ else }}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.5/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-SgOJa3DmI69IUzQ2PVdRZhwQ+dy64/BUtbMJw1MZ8t5HZApcHrRKUc4W0kG879m7" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.5/dist/js/bootstrap.bundle.min.js" integrity="sha384-k6d4wzSIapyDyv1kpU366/PK5hCdSbCRGRCMv+eplOQJWyd1fbcAu9OCUj5zNLiq" crossorigin="anonymous"></script>
{{ end }}
<style>
/* Auto-apply Bootstrap-like blue (primary) styling based on element type. */
body {
--bs-primary: #0d6efd; /* Bootstrap 5 default primary color */
}
h1, h2, h3, h4, h5, h6, strong, b {
color: var(--bs-primary);
}
a {
color: var(--bs-primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
color: #0b5ed7; /* slightly darker blue */
}
button, input[type="submit"], input[type="button"] {
background-color: var(--bs-primary);
color: #fff;
border: none;
padding: 0.375rem 0.75rem;
border-radius: 0.25rem;
cursor: pointer;
}
button:hover, input[type="submit"]:hover, input[type="button"]:hover {
background-color: #0b5ed7;
}
p, span, li {
color: #212529; /* standard text color */
}
code, pre {
background-color: #e7f1ff;
color: #084298;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
fieldset {
background-color: #e7f1ff;
border: 1px solid blue;
padding: 10px; /* optional: adds spacing inside the border */
margin-bottom: 20px; /* optional: adds spacing below the fieldset */
margin: 0 20px; /* adds 20px space on left and right */
}
label {
display: inline-block;
width: 100px; /* arbitrary */
text-align: right; /* aligns label text to the right */
margin-right: 10px; /* spacing between label and input */
margin-bottom: 8px; /* small vertical space below each label */
}
input[type="text"] {
width: 30ch; /* the number of characters you want to fit */
box-sizing: border-box; /* ensures padding and border are included in the width */
}
input[type="range"] {
vertical-align: middle; /* aligns the range input vertically with other elements */
width: 30ch; /* the number of characters you want to fit (to match text) */
box-sizing: border-box; /* ensures padding and border are included in the width */
}
</style>
</head>
<body>
<!--
Add the following polyfill for Microsoft Edge 17/18 support:
<script src="https://cdn.jsdelivr.net/npm/text-encoding@0.7.0/lib/encoding.min.js"></script>
(see https://caniuse.com/#feat=textencoder)
-->
<script src="wasm_exec.js"></script>
<script>
// These values can be read from inside the wasm program.
window._mgmt_program = "{{ .program }}";
window._mgmt_version = "{{ .version }}";
window._mgmt_hostname = "{{ .hostname }}";
window._mgmt_title = "{{ .title }}";
window._mgmt_path = "{{ .path }}";
if (!WebAssembly.instantiateStreaming) { // polyfill
WebAssembly.instantiateStreaming = async (resp, importObject) => {
const source = await (await resp).arrayBuffer();
return await WebAssembly.instantiate(source, importObject);
};
}
const go = new Go();
//let mod, inst;
WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
//mod = result.module;
//inst = result.instance;
go.run(result.instance);
}).catch((err) => {
console.error(err);
});
//async function run() {
// console.clear();
// await go.run(inst);
// inst = await WebAssembly.instantiate(mod, go.importObject); // reset instance
//}
</script>
</body>
</html>

View File

@@ -0,0 +1,314 @@
// Mgmt
// Copyright (C) James Shubin and the project contributors
// Written by James Shubin <james@shubin.ca> and the project contributors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// Additional permission under GNU GPL version 3 section 7
//
// If you modify this program, or any covered work, by linking or combining it
// with embedded mcl code and modules (and that the embedded mcl code and
// modules which link with this program, contain a copy of their source code in
// the authoritative form) containing parts covered by the terms of any other
// license, the licensors of this program grant you additional permission to
// convey the resulting work. Furthermore, the licensors of this program grant
// the original author, James Shubin, additional permission to update this
// additional permission if he deems it necessary to achieve the goals of this
// additional permission.
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"syscall/js"
"time"
"github.com/purpleidea/mgmt/engine/resources/http_ui/common"
"github.com/purpleidea/mgmt/util/errwrap"
)
// Main is the main implementation of this process. It holds our shared data.
type Main struct {
// some values we pull in
program string
version string
hostname string
title string
path string
document js.Value
body js.Value
// window.location.origin (the base url with port for XHR)
wlo string
// base is the wlo + the specific path suffix
base string
response chan *Response
}
// Init must be called before the Main struct is used.
func (obj *Main) Init() error {
fmt.Println("Hello from mgmt wasm!")
obj.program = js.Global().Get("_mgmt_program").String()
obj.version = js.Global().Get("_mgmt_version").String()
obj.hostname = js.Global().Get("_mgmt_hostname").String()
obj.title = js.Global().Get("_mgmt_title").String()
obj.path = js.Global().Get("_mgmt_path").String()
obj.document = js.Global().Get("document")
obj.body = obj.document.Get("body")
obj.wlo = js.Global().Get("window").Get("location").Get("origin").String()
obj.base = obj.wlo + obj.path
obj.response = make(chan *Response)
return nil
}
// Run is the main execution of this program.
func (obj *Main) Run() error {
h1 := obj.document.Call("createElement", "h1")
h1.Set("innerHTML", obj.title)
obj.body.Call("appendChild", h1)
h6 := obj.document.Call("createElement", "h6")
pre := obj.document.Call("createElement", "pre")
pre.Set("textContent", fmt.Sprintf("This is: %s, version: %s, on %s", obj.program, obj.version, obj.hostname))
//pre.Set("innerHTML", fmt.Sprintf("This is: %s, version: %s, on %s", obj.program, obj.version, obj.hostname))
h6.Call("appendChild", pre)
obj.body.Call("appendChild", h6)
obj.body.Call("appendChild", obj.document.Call("createElement", "hr"))
//document.baseURI
// XXX: how to get the base so we can add our own querystring???
fmt.Println("URI: ", obj.document.Get("baseURI").String())
fmt.Println("window.location.origin: ", obj.wlo)
fmt.Println("BASE: ", obj.base)
fieldset := obj.document.Call("createElement", "fieldset")
legend := obj.document.Call("createElement", "legend")
legend.Set("textContent", "live!") // XXX: pick some message here
fieldset.Call("appendChild", legend)
// XXX: consider using this instead: https://github.com/hashicorp/go-retryablehttp
//client := retryablehttp.NewClient()
//client.RetryMax = 10
client := &http.Client{
//Timeout: time.Duration(timeout) * time.Second,
//CheckRedirect: checkRedirectFunc,
}
// Startup form building...
// XXX: Add long polling to know if the form shape changes, and offer a
// refresh to the end-user to see the new form.
listURL := obj.base + "list/"
watchURL := obj.base + "watch/"
resp, err := client.Get(listURL) // works
if err != nil {
return errwrap.Wrapf(err, "could not list ui")
}
s, err := io.ReadAll(resp.Body) // TODO: apparently we can stream
resp.Body.Close()
if err != nil {
return errwrap.Wrapf(err, "could read from listed ui")
}
fmt.Printf("Response: %+v\n", string(s))
var form *common.Form
if err := json.Unmarshal(s, &form); err != nil {
return errwrap.Wrapf(err, "could not unmarshal form")
}
//fmt.Printf("%+v\n", form) // debug
// Sort according to the "sort" field so elements are in expected order.
sort.Slice(form.Elements, func(i, j int) bool {
return form.Elements[i].Sort < form.Elements[j].Sort
})
for _, x := range form.Elements {
id := x.ID
resp, err := client.Get(listURL + id)
if err != nil {
return errwrap.Wrapf(err, "could not get id %s", id)
}
s, err := io.ReadAll(resp.Body) // TODO: apparently we can stream
resp.Body.Close()
if err != nil {
return errwrap.Wrapf(err, "could not read from id %s", id)
}
fmt.Printf("Response: %+v\n", string(s))
var element *common.FormElementGeneric // XXX: switch based on x.Kind
if err := json.Unmarshal(s, &element); err != nil {
return errwrap.Wrapf(err, "could not unmarshal id %s", id)
}
//fmt.Printf("%+v\n", element) // debug
inputType, exists := x.Type[common.HTTPUIInputType] // "text" or "range" ...
if !exists {
fmt.Printf("Element has no input type: %+v\n", element)
continue
}
label := obj.document.Call("createElement", "label")
label.Call("setAttribute", "for", id)
label.Set("innerHTML", fmt.Sprintf("%s: ", id))
fieldset.Call("appendChild", label)
el := obj.document.Call("createElement", "input")
el.Set("id", id)
//el.Call("setAttribute", "id", id)
//el.Call("setAttribute", "name", id)
el.Set("type", inputType)
if inputType == common.HTTPUIInputTypeRange {
if val, exists := x.Type[common.HTTPUIInputTypeRangeMin]; exists {
el.Set("min", val)
}
if val, exists := x.Type[common.HTTPUIInputTypeRangeMax]; exists {
el.Set("max", val)
}
if val, exists := x.Type[common.HTTPUIInputTypeRangeStep]; exists {
el.Set("step", val)
}
}
el.Set("value", element.Value) // XXX: here or after change handler?
// event handler
changeEvent := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
event := args[0]
value := event.Get("target").Get("value").String()
//obj.wg.Add(1)
go func() {
//defer obj.wg.Done()
fmt.Println("Action!")
u := obj.base + "save/"
values := url.Values{
"id": {id},
"value": {value},
}
resp, err := http.PostForm(u, values)
//fmt.Println(resp, err) // debug
s, err := io.ReadAll(resp.Body) // TODO: apparently we can stream
resp.Body.Close()
fmt.Printf("Response: %+v\n", string(s))
fmt.Printf("Error: %+v\n", err)
obj.response <- &Response{
Str: string(s),
Err: err,
}
}()
return nil
})
defer changeEvent.Release()
el.Call("addEventListener", "change", changeEvent)
// http long poll
go func() {
for {
fmt.Printf("About to long poll for: %s\n", id)
//resp, err := client.Get(watchURL + id) // XXX: which?
resp, err := http.Get(watchURL + id)
if err != nil {
fmt.Println("Error fetching:", watchURL+id, err) // XXX: test error paths
time.Sleep(2 * time.Second)
continue
}
s, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
fmt.Println("Error reading response:", err)
time.Sleep(2 * time.Second)
continue
}
var element *common.FormElementGeneric // XXX: switch based on x.Kind
if err := json.Unmarshal(s, &element); err != nil {
fmt.Println("could not unmarshal id %s: %v", id, err)
time.Sleep(2 * time.Second)
continue
}
//fmt.Printf("%+v\n", element) // debug
fmt.Printf("Long poll for %s got: %s\n", id, element.Value)
obj.document.Call("getElementById", id).Set("value", element.Value)
//time.Sleep(1 * time.Second)
}
}()
fieldset.Call("appendChild", el)
br := obj.document.Call("createElement", "br")
fieldset.Call("appendChild", br)
}
obj.body.Call("appendChild", fieldset)
// We need this mainloop for receiving the results of our async stuff...
for {
select {
case resp, ok := <-obj.response:
if !ok {
break
}
if err := resp.Err; err != nil {
fmt.Printf("Err: %+v\n", err)
continue
}
fmt.Printf("Str: %+v\n", resp.Str)
}
}
return nil
}
// Response is a standard response struct which we pass through.
type Response struct {
Str string
Err error
}
func main() {
m := &Main{}
if err := m.Init(); err != nil {
fmt.Printf("Error: %+v\n", err)
return
}
if err := m.Run(); err != nil {
fmt.Printf("Error: %+v\n", err)
return
}
select {} // don't shutdown wasm
}

View File

@@ -0,0 +1,2 @@
*.css
*.js

View File

@@ -0,0 +1,51 @@
// Mgmt
// Copyright (C) James Shubin and the project contributors
// Written by James Shubin <james@shubin.ca> and the project contributors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// Additional permission under GNU GPL version 3 section 7
//
// If you modify this program, or any covered work, by linking or combining it
// with embedded mcl code and modules (and that the embedded mcl code and
// modules which link with this program, contain a copy of their source code in
// the authoritative form) containing parts covered by the terms of any other
// license, the licensors of this program grant you additional permission to
// convey the resulting work. Furthermore, the licensors of this program grant
// the original author, James Shubin, additional permission to update this
// additional permission if he deems it necessary to achieve the goals of this
// additional permission.
//go:build httpuistatic
package static
import (
_ "embed" // embed data with go:embed
)
const (
// HTTPUIStaticEmbedded specifies whether files have been embedded.
HTTPUIStaticEmbedded = true
)
var (
// HTTPUIIndexStaticBootstrapCSS is the embedded data. It is embedded.
//go:embed http_ui/static/bootstrap.min.css
HTTPUIIndexStaticBootstrapCSS []byte
// HTTPUIIndexStaticBootstrapJS is the embedded data. It is embedded.
//go:embed http_ui/static/bootstrap.bundle.min.js
HTTPUIIndexStaticBootstrapJS []byte
)

View File

@@ -0,0 +1,45 @@
// Mgmt
// Copyright (C) James Shubin and the project contributors
// Written by James Shubin <james@shubin.ca> and the project contributors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// Additional permission under GNU GPL version 3 section 7
//
// If you modify this program, or any covered work, by linking or combining it
// with embedded mcl code and modules (and that the embedded mcl code and
// modules which link with this program, contain a copy of their source code in
// the authoritative form) containing parts covered by the terms of any other
// license, the licensors of this program grant you additional permission to
// convey the resulting work. Furthermore, the licensors of this program grant
// the original author, James Shubin, additional permission to update this
// additional permission if he deems it necessary to achieve the goals of this
// additional permission.
//go:build !httpuistatic
package static
const (
// HTTPUIStaticEmbedded specifies whether files have been embedded.
HTTPUIStaticEmbedded = false
)
var (
// HTTPUIIndexStaticBootstrapCSS is the embedded data. It is empty here.
HTTPUIIndexStaticBootstrapCSS []byte
// HTTPUIIndexStaticBootstrapJS is the embedded data. It is empty here.
HTTPUIIndexStaticBootstrapJS []byte
)

View File

@@ -0,0 +1,42 @@
// Mgmt
// Copyright (C) James Shubin and the project contributors
// Written by James Shubin <james@shubin.ca> and the project contributors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// Additional permission under GNU GPL version 3 section 7
//
// If you modify this program, or any covered work, by linking or combining it
// with embedded mcl code and modules (and that the embedded mcl code and
// modules which link with this program, contain a copy of their source code in
// the authoritative form) containing parts covered by the terms of any other
// license, the licensors of this program grant you additional permission to
// convey the resulting work. Furthermore, the licensors of this program grant
// the original author, James Shubin, additional permission to update this
// additional permission if he deems it necessary to achieve the goals of this
// additional permission.
// Package static contains some optional embedded data which can be useful if we
// are running from an entirely offline, internet-absent scenario.
package static
const (
// HTTPUIIndexBootstrapCSS is the path to the bootstrap css file when
// embedded, relative to the parent directory.
HTTPUIIndexBootstrapCSS = "static/bootstrap.min.css"
// HTTPUIIndexBootstrapJS is the path to the bootstrap js file when
// embedded, relative to the parent directory.
HTTPUIIndexBootstrapJS = "static/bootstrap.bundle.min.js"
)

View File

@@ -0,0 +1,577 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// This was copied from the original in the ~golang/lib/wasm/* directory.
"use strict";
(() => {
const enosys = () => {
const err = new Error("not implemented");
err.code = "ENOSYS";
return err;
};
if (!globalThis.fs) {
let outputBuf = "";
globalThis.fs = {
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
writeSync(fd, buf) {
outputBuf += decoder.decode(buf);
const nl = outputBuf.lastIndexOf("\n");
if (nl != -1) {
console.log(outputBuf.substring(0, nl));
outputBuf = outputBuf.substring(nl + 1);
}
return buf.length;
},
write(fd, buf, offset, length, position, callback) {
if (offset !== 0 || length !== buf.length || position !== null) {
callback(enosys());
return;
}
const n = this.writeSync(fd, buf);
callback(null, n);
},
chmod(path, mode, callback) { callback(enosys()); },
chown(path, uid, gid, callback) { callback(enosys()); },
close(fd, callback) { callback(enosys()); },
fchmod(fd, mode, callback) { callback(enosys()); },
fchown(fd, uid, gid, callback) { callback(enosys()); },
fstat(fd, callback) { callback(enosys()); },
fsync(fd, callback) { callback(null); },
ftruncate(fd, length, callback) { callback(enosys()); },
lchown(path, uid, gid, callback) { callback(enosys()); },
link(path, link, callback) { callback(enosys()); },
lstat(path, callback) { callback(enosys()); },
mkdir(path, perm, callback) { callback(enosys()); },
open(path, flags, mode, callback) { callback(enosys()); },
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
readdir(path, callback) { callback(enosys()); },
readlink(path, callback) { callback(enosys()); },
rename(from, to, callback) { callback(enosys()); },
rmdir(path, callback) { callback(enosys()); },
stat(path, callback) { callback(enosys()); },
symlink(path, link, callback) { callback(enosys()); },
truncate(path, length, callback) { callback(enosys()); },
unlink(path, callback) { callback(enosys()); },
utimes(path, atime, mtime, callback) { callback(enosys()); },
};
}
if (!globalThis.process) {
globalThis.process = {
getuid() { return -1; },
getgid() { return -1; },
geteuid() { return -1; },
getegid() { return -1; },
getgroups() { throw enosys(); },
pid: -1,
ppid: -1,
umask() { throw enosys(); },
cwd() { throw enosys(); },
chdir() { throw enosys(); },
}
}
if (!globalThis.path) {
globalThis.path = {
resolve(...pathSegments) {
return pathSegments.join("/");
}
}
}
if (!globalThis.crypto) {
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
}
if (!globalThis.performance) {
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
}
if (!globalThis.TextEncoder) {
throw new Error("globalThis.TextEncoder is not available, polyfill required");
}
if (!globalThis.TextDecoder) {
throw new Error("globalThis.TextDecoder is not available, polyfill required");
}
const encoder = new TextEncoder("utf-8");
const decoder = new TextDecoder("utf-8");
globalThis.Go = class {
constructor() {
this.argv = ["js"];
this.env = {};
this.exit = (code) => {
if (code !== 0) {
console.warn("exit code:", code);
}
};
this._exitPromise = new Promise((resolve) => {
this._resolveExitPromise = resolve;
});
this._pendingEvent = null;
this._scheduledTimeouts = new Map();
this._nextCallbackTimeoutID = 1;
const setInt64 = (addr, v) => {
this.mem.setUint32(addr + 0, v, true);
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
}
const setInt32 = (addr, v) => {
this.mem.setUint32(addr + 0, v, true);
}
const getInt64 = (addr) => {
const low = this.mem.getUint32(addr + 0, true);
const high = this.mem.getInt32(addr + 4, true);
return low + high * 4294967296;
}
const loadValue = (addr) => {
const f = this.mem.getFloat64(addr, true);
if (f === 0) {
return undefined;
}
if (!isNaN(f)) {
return f;
}
const id = this.mem.getUint32(addr, true);
return this._values[id];
}
const storeValue = (addr, v) => {
const nanHead = 0x7FF80000;
if (typeof v === "number" && v !== 0) {
if (isNaN(v)) {
this.mem.setUint32(addr + 4, nanHead, true);
this.mem.setUint32(addr, 0, true);
return;
}
this.mem.setFloat64(addr, v, true);
return;
}
if (v === undefined) {
this.mem.setFloat64(addr, 0, true);
return;
}
let id = this._ids.get(v);
if (id === undefined) {
id = this._idPool.pop();
if (id === undefined) {
id = this._values.length;
}
this._values[id] = v;
this._goRefCounts[id] = 0;
this._ids.set(v, id);
}
this._goRefCounts[id]++;
let typeFlag = 0;
switch (typeof v) {
case "object":
if (v !== null) {
typeFlag = 1;
}
break;
case "string":
typeFlag = 2;
break;
case "symbol":
typeFlag = 3;
break;
case "function":
typeFlag = 4;
break;
}
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
this.mem.setUint32(addr, id, true);
}
const loadSlice = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
}
const loadSliceOfValues = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
const a = new Array(len);
for (let i = 0; i < len; i++) {
a[i] = loadValue(array + i * 8);
}
return a;
}
const loadString = (addr) => {
const saddr = getInt64(addr + 0);
const len = getInt64(addr + 8);
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
}
const testCallExport = (a, b) => {
this._inst.exports.testExport0();
return this._inst.exports.testExport(a, b);
}
const timeOrigin = Date.now() - performance.now();
this.importObject = {
_gotest: {
add: (a, b) => a + b,
callExport: testCallExport,
},
gojs: {
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
// This changes the SP, thus we have to update the SP used by the imported function.
// func wasmExit(code int32)
"runtime.wasmExit": (sp) => {
sp >>>= 0;
const code = this.mem.getInt32(sp + 8, true);
this.exited = true;
delete this._inst;
delete this._values;
delete this._goRefCounts;
delete this._ids;
delete this._idPool;
this.exit(code);
},
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
"runtime.wasmWrite": (sp) => {
sp >>>= 0;
const fd = getInt64(sp + 8);
const p = getInt64(sp + 16);
const n = this.mem.getInt32(sp + 24, true);
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
},
// func resetMemoryDataView()
"runtime.resetMemoryDataView": (sp) => {
sp >>>= 0;
this.mem = new DataView(this._inst.exports.mem.buffer);
},
// func nanotime1() int64
"runtime.nanotime1": (sp) => {
sp >>>= 0;
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
},
// func walltime() (sec int64, nsec int32)
"runtime.walltime": (sp) => {
sp >>>= 0;
const msec = (new Date).getTime();
setInt64(sp + 8, msec / 1000);
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
},
// func scheduleTimeoutEvent(delay int64) int32
"runtime.scheduleTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this._nextCallbackTimeoutID;
this._nextCallbackTimeoutID++;
this._scheduledTimeouts.set(id, setTimeout(
() => {
this._resume();
while (this._scheduledTimeouts.has(id)) {
// for some reason Go failed to register the timeout event, log and try again
// (temporary workaround for https://github.com/golang/go/issues/28975)
console.warn("scheduleTimeoutEvent: missed timeout event");
this._resume();
}
},
getInt64(sp + 8),
));
this.mem.setInt32(sp + 16, id, true);
},
// func clearTimeoutEvent(id int32)
"runtime.clearTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this.mem.getInt32(sp + 8, true);
clearTimeout(this._scheduledTimeouts.get(id));
this._scheduledTimeouts.delete(id);
},
// func getRandomData(r []byte)
"runtime.getRandomData": (sp) => {
sp >>>= 0;
crypto.getRandomValues(loadSlice(sp + 8));
},
// func finalizeRef(v ref)
"syscall/js.finalizeRef": (sp) => {
sp >>>= 0;
const id = this.mem.getUint32(sp + 8, true);
this._goRefCounts[id]--;
if (this._goRefCounts[id] === 0) {
const v = this._values[id];
this._values[id] = null;
this._ids.delete(v);
this._idPool.push(id);
}
},
// func stringVal(value string) ref
"syscall/js.stringVal": (sp) => {
sp >>>= 0;
storeValue(sp + 24, loadString(sp + 8));
},
// func valueGet(v ref, p string) ref
"syscall/js.valueGet": (sp) => {
sp >>>= 0;
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 32, result);
},
// func valueSet(v ref, p string, x ref)
"syscall/js.valueSet": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
},
// func valueDelete(v ref, p string)
"syscall/js.valueDelete": (sp) => {
sp >>>= 0;
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
},
// func valueIndex(v ref, i int) ref
"syscall/js.valueIndex": (sp) => {
sp >>>= 0;
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
},
// valueSetIndex(v ref, i int, x ref)
"syscall/js.valueSetIndex": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
},
// func valueCall(v ref, m string, args []ref) (ref, bool)
"syscall/js.valueCall": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const m = Reflect.get(v, loadString(sp + 16));
const args = loadSliceOfValues(sp + 32);
const result = Reflect.apply(m, v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, result);
this.mem.setUint8(sp + 64, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, err);
this.mem.setUint8(sp + 64, 0);
}
},
// func valueInvoke(v ref, args []ref) (ref, bool)
"syscall/js.valueInvoke": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.apply(v, undefined, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueNew(v ref, args []ref) (ref, bool)
"syscall/js.valueNew": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.construct(v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueLength(v ref) int
"syscall/js.valueLength": (sp) => {
sp >>>= 0;
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
},
// valuePrepareString(v ref) (ref, int)
"syscall/js.valuePrepareString": (sp) => {
sp >>>= 0;
const str = encoder.encode(String(loadValue(sp + 8)));
storeValue(sp + 16, str);
setInt64(sp + 24, str.length);
},
// valueLoadString(v ref, b []byte)
"syscall/js.valueLoadString": (sp) => {
sp >>>= 0;
const str = loadValue(sp + 8);
loadSlice(sp + 16).set(str);
},
// func valueInstanceOf(v ref, t ref) bool
"syscall/js.valueInstanceOf": (sp) => {
sp >>>= 0;
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
},
// func copyBytesToGo(dst []byte, src ref) (int, bool)
"syscall/js.copyBytesToGo": (sp) => {
sp >>>= 0;
const dst = loadSlice(sp + 8);
const src = loadValue(sp + 32);
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
// func copyBytesToJS(dst ref, src []byte) (int, bool)
"syscall/js.copyBytesToJS": (sp) => {
sp >>>= 0;
const dst = loadValue(sp + 8);
const src = loadSlice(sp + 16);
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
"debug": (value) => {
console.log(value);
},
}
};
}
async run(instance) {
if (!(instance instanceof WebAssembly.Instance)) {
throw new Error("Go.run: WebAssembly.Instance expected");
}
this._inst = instance;
this.mem = new DataView(this._inst.exports.mem.buffer);
this._values = [ // JS values that Go currently has references to, indexed by reference id
NaN,
0,
null,
true,
false,
globalThis,
this,
];
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
this._ids = new Map([ // mapping from JS values to reference ids
[0, 1],
[null, 2],
[true, 3],
[false, 4],
[globalThis, 5],
[this, 6],
]);
this._idPool = []; // unused ids that have been garbage collected
this.exited = false; // whether the Go program has exited
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
let offset = 4096;
const strPtr = (str) => {
const ptr = offset;
const bytes = encoder.encode(str + "\0");
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
offset += bytes.length;
if (offset % 8 !== 0) {
offset += 8 - (offset % 8);
}
return ptr;
};
const argc = this.argv.length;
const argvPtrs = [];
this.argv.forEach((arg) => {
argvPtrs.push(strPtr(arg));
});
argvPtrs.push(0);
const keys = Object.keys(this.env).sort();
keys.forEach((key) => {
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
});
argvPtrs.push(0);
const argv = offset;
argvPtrs.forEach((ptr) => {
this.mem.setUint32(offset, ptr, true);
this.mem.setUint32(offset + 4, 0, true);
offset += 8;
});
// The linker guarantees global data starts from at least wasmMinDataAddr.
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
const wasmMinDataAddr = 4096 + 8192;
if (offset >= wasmMinDataAddr) {
throw new Error("total length of command line and environment variables exceeds limit");
}
this._inst.exports.run(argc, argv);
if (this.exited) {
this._resolveExitPromise();
}
await this._exitPromise;
}
_resume() {
if (this.exited) {
throw new Error("Go program has already exited");
}
this._inst.exports.resume();
if (this.exited) {
this._resolveExitPromise();
}
}
_makeFuncWrapper(id) {
const go = this;
return function () {
const event = { id: id, this: this, args: arguments };
go._pendingEvent = event;
go._resume();
return event.result;
};
}
}
})();

View File

@@ -0,0 +1,653 @@
// Mgmt
// Copyright (C) James Shubin and the project contributors
// Written by James Shubin <james@shubin.ca> and the project contributors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// Additional permission under GNU GPL version 3 section 7
//
// If you modify this program, or any covered work, by linking or combining it
// with embedded mcl code and modules (and that the embedded mcl code and
// modules which link with this program, contain a copy of their source code in
// the authoritative form) containing parts covered by the terms of any other
// license, the licensors of this program grant you additional permission to
// convey the resulting work. Furthermore, the licensors of this program grant
// the original author, James Shubin, additional permission to update this
// additional permission if he deems it necessary to achieve the goals of this
// additional permission.
package resources
import (
"context"
"fmt"
"net/url"
"strconv"
"sync"
"github.com/purpleidea/mgmt/engine"
"github.com/purpleidea/mgmt/engine/resources/http_ui/common"
"github.com/purpleidea/mgmt/engine/traits"
"github.com/purpleidea/mgmt/util/errwrap"
)
const (
httpUIInputKind = httpUIKind + ":input"
httpUIInputStoreKey = "key"
httpUIInputStoreSchemeLocal = "local"
httpUIInputStoreSchemeWorld = "world"
httpUIInputTypeText = common.HTTPUIInputTypeText // "text"
httpUIInputTypeRange = common.HTTPUIInputTypeRange // "range"
)
func init() {
engine.RegisterResource(httpUIInputKind, func() engine.Res { return &HTTPUIInputRes{} })
}
// HTTPUIInputRes is a form element that exists within a http:ui resource, which
// exists within an http server. The name is used as the unique id of the field,
// unless the id field is specified, and in that case it is used instead. The
// way this works is that it autogroups at runtime with an existing http:ui
// resource, and in doing so makes the form field associated with this resource
// available as part of that ui which is itself grouped and served from the http
// server resource.
type HTTPUIInputRes struct {
traits.Base // add the base methods without re-implementation
traits.Edgeable // XXX: add autoedge support
traits.Groupable // can be grouped into HTTPUIRes
traits.Sendable
init *engine.Init
// Path is the name of the http ui resource to group this into. If it is
// omitted, and there is only a single http ui resource, then it will
// be grouped into it automatically. If there is more than one main http
// ui resource being used, then the grouping behaviour is *undefined*
// when this is not specified, and it is not recommended to leave this
// blank!
Path string `lang:"path" yaml:"path"`
// ID is the unique id for this element. It is used in form fields and
// should not be a private identifier. It must be unique within a given
// http ui.
ID string `lang:"id" yaml:"id"`
// Value is the default value to use for the form field. If you change
// it, then the resource graph will change and we'll rebuild and have
// the new value visible. You can use either this or the Store field.
// XXX: If we ever add our resource mutate API, we might not need to
// swap to a new resource graph, and maybe Store is not needed?
Value string `lang:"value" yaml:"value"`
// Store the data in this source. It will also read in a default value
// from there if one is present. It will watch it for changes as well,
// and update the displayed value if it's changed from another source.
// This cannot be used at the same time as the Value field.
Store string `lang:"store" yaml:"store"`
// Type specifies the type of input field this is, and some information
// about it.
// XXX: come up with a format such as "multiline://?max=60&style=foo"
Type string `lang:"type" yaml:"type"`
// Sort is a string that you can use to determine the global sorted
// display order of all the elements in a ui.
Sort string `lang:"sort" yaml:"sort"`
scheme string // the scheme we're using with Store, cached for later
key string // the key we're using with Store, cached for later
typeURL *url.URL // the type data, cached for later
typeURLValues url.Values // the type data, cached for later
last *string // the last value we sent
value string // what we've last received from SetValue
storeEvent bool // did a store event happen?
mutex *sync.Mutex // guards storeEvent and value
event chan struct{} // local event that the setValue sends
}
// Default returns some sensible defaults for this resource.
func (obj *HTTPUIInputRes) Default() engine.Res {
return &HTTPUIInputRes{
Type: "text://",
}
}
// Validate checks if the resource data structure was populated correctly.
func (obj *HTTPUIInputRes) Validate() error {
if obj.GetID() == "" {
return fmt.Errorf("empty id")
}
if obj.Value != "" && obj.Store != "" {
return fmt.Errorf("may only use either Value or Store")
}
if obj.Value != "" {
if err := obj.checkValue(obj.Value); err != nil {
return errwrap.Wrapf(err, "the Value field is invalid")
}
}
if obj.Store != "" {
// XXX: check the URI format
}
return nil
}
// Init runs some startup code for this resource.
func (obj *HTTPUIInputRes) Init(init *engine.Init) error {
obj.init = init // save for later
u, err := url.Parse(obj.Type)
if err != nil {
return err
}
if u == nil {
return fmt.Errorf("can't parse Type")
}
if u.Scheme != httpUIInputTypeText && u.Scheme != httpUIInputTypeRange {
return fmt.Errorf("unknown scheme: %s", u.Scheme)
}
values, err := url.ParseQuery(u.RawQuery)
if err != nil {
return err
}
obj.typeURL = u
obj.typeURLValues = values
if obj.Store != "" {
u, err := url.Parse(obj.Store)
if err != nil {
return err
}
if u == nil {
return fmt.Errorf("can't parse Store")
}
if u.Scheme != httpUIInputStoreSchemeLocal && u.Scheme != httpUIInputStoreSchemeWorld {
return fmt.Errorf("unknown scheme: %s", u.Scheme)
}
values, err := url.ParseQuery(u.RawQuery)
if err != nil {
return err
}
obj.scheme = u.Scheme // cache for later
obj.key = obj.Name() // default
x, exists := values[httpUIInputStoreKey]
if exists && len(x) > 0 && x[0] != "" { // ignore absent or broken keys
obj.key = x[0]
}
}
// populate our obj.value cache somehow, so we don't mutate obj.Value
obj.value = obj.Value // copy
obj.mutex = &sync.Mutex{}
obj.event = make(chan struct{}, 1) // buffer to avoid blocks or deadlock
return nil
}
// Cleanup is run by the engine to clean up after the resource is done.
func (obj *HTTPUIInputRes) Cleanup() error {
return nil
}
// getKey returns the key to be used for this resource. If the Store field is
// specified, it will use that parsed part, otherwise it uses the Name.
func (obj *HTTPUIInputRes) getKey() string {
if obj.Store != "" {
return obj.key
}
return obj.Name()
}
// ParentName is used to limit which resources autogroup into this one. If it's
// empty then it's ignored, otherwise it must match the Name of the parent to
// get grouped.
func (obj *HTTPUIInputRes) ParentName() string {
return obj.Path
}
// GetKind returns the kind of this resource.
func (obj *HTTPUIInputRes) GetKind() string {
// NOTE: We don't *need* to return such a specific string, and "input"
// would be enough, but we might as well use this because we have it.
return httpUIInputKind
}
// GetID returns the actual ID we respond to. When ID is not specified, we use
// the Name.
func (obj *HTTPUIInputRes) GetID() string {
if obj.ID != "" {
return obj.ID
}
return obj.Name()
}
// SetValue stores the new value field that was obtained from submitting the
// form. This receives the raw, unsafe value that you must validate first.
func (obj *HTTPUIInputRes) SetValue(ctx context.Context, vs []string) error {
if len(vs) != 1 {
return fmt.Errorf("unexpected length of %d", len(vs))
}
value := vs[0]
if err := obj.checkValue(value); err != nil {
return err
}
obj.mutex.Lock()
obj.setValue(ctx, value) // also sends an event
obj.mutex.Unlock()
return nil
}
// setValue is the helper version where the caller must provide the mutex.
func (obj *HTTPUIInputRes) setValue(ctx context.Context, val string) error {
obj.value = val
select {
case obj.event <- struct{}{}:
default:
}
return nil
}
func (obj *HTTPUIInputRes) checkValue(value string) error {
// XXX: validate based on obj.Type
// XXX: validate what kind of values are allowed, probably no \n, etc...
return nil
}
// GetValue gets a string representation for the form value, that we'll use in
// our html form.
func (obj *HTTPUIInputRes) GetValue(ctx context.Context) (string, error) {
obj.mutex.Lock()
defer obj.mutex.Unlock()
if obj.storeEvent {
val, exists, err := obj.storeGet(ctx, obj.getKey())
if err != nil {
return "", errwrap.Wrapf(err, "error during get")
}
if !exists {
return "", nil // default
}
return val, nil
}
return obj.value, nil
}
// GetType returns a map that you can use to build the input field in the ui.
func (obj *HTTPUIInputRes) GetType() map[string]string {
m := make(map[string]string)
if obj.typeURL.Scheme == httpUIInputTypeRange {
m = obj.rangeGetType()
}
m[common.HTTPUIInputType] = obj.typeURL.Scheme
return m
}
func (obj *HTTPUIInputRes) rangeGetType() map[string]string {
m := make(map[string]string)
base := 10
bits := 64
if sa, exists := obj.typeURLValues[common.HTTPUIInputTypeRangeMin]; exists && len(sa) > 0 {
if x, err := strconv.ParseInt(sa[0], base, bits); err == nil {
m[common.HTTPUIInputTypeRangeMin] = strconv.FormatInt(x, base)
}
}
if sa, exists := obj.typeURLValues[common.HTTPUIInputTypeRangeMax]; exists && len(sa) > 0 {
if x, err := strconv.ParseInt(sa[0], base, bits); err == nil {
m[common.HTTPUIInputTypeRangeMax] = strconv.FormatInt(x, base)
}
}
if sa, exists := obj.typeURLValues[common.HTTPUIInputTypeRangeStep]; exists && len(sa) > 0 {
if x, err := strconv.ParseInt(sa[0], base, bits); err == nil {
m[common.HTTPUIInputTypeRangeStep] = strconv.FormatInt(x, base)
}
}
return m
}
// GetSort returns a string that you can use to determine the global sorted
// display order of all the elements in a ui.
func (obj *HTTPUIInputRes) GetSort() string {
return obj.Sort
}
// Watch is the primary listener for this resource and it outputs events. This
// particular one does absolutely nothing but block until we've received a done
// signal.
func (obj *HTTPUIInputRes) Watch(ctx context.Context) error {
if obj.Store != "" && obj.scheme == httpUIInputStoreSchemeLocal {
return obj.localWatch(ctx)
}
if obj.Store != "" && obj.scheme == httpUIInputStoreSchemeWorld {
return obj.worldWatch(ctx)
}
obj.init.Running() // when started, notify engine that we're running
// XXX: do we need to watch on obj.event for normal .Value stuff?
select {
case <-ctx.Done(): // closed by the engine to signal shutdown
}
//obj.init.Event() // notify engine of an event (this can block)
return nil
}
func (obj *HTTPUIInputRes) localWatch(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
ch, err := obj.init.Local.ValueWatch(ctx, obj.getKey()) // get possible events!
if err != nil {
return errwrap.Wrapf(err, "error during watch")
}
obj.init.Running() // when started, notify engine that we're running
for {
select {
case _, ok := <-ch:
if !ok { // channel shutdown
return nil
}
obj.mutex.Lock()
obj.storeEvent = true
obj.mutex.Unlock()
case <-obj.event:
case <-ctx.Done(): // closed by the engine to signal shutdown
return nil
}
if obj.init.Debug {
obj.init.Logf("event!")
}
obj.init.Event() // notify engine of an event (this can block)
}
}
func (obj *HTTPUIInputRes) worldWatch(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
ch, err := obj.init.World.StrWatch(ctx, obj.getKey()) // get possible events!
if err != nil {
return errwrap.Wrapf(err, "error during watch")
}
obj.init.Running() // when started, notify engine that we're running
for {
select {
case err, ok := <-ch:
if !ok { // channel shutdown
return nil
}
if err != nil {
return errwrap.Wrapf(err, "unknown %s watcher error", obj)
}
obj.mutex.Lock()
obj.storeEvent = true
obj.mutex.Unlock()
case <-obj.event:
case <-ctx.Done(): // closed by the engine to signal shutdown
return nil
}
if obj.init.Debug {
obj.init.Logf("event!")
}
obj.init.Event() // notify engine of an event (this can block)
}
}
// CheckApply performs the send/recv portion of this autogrouped resources. That
// can fail, but only if the send portion fails for some reason. If we're using
// the Store feature, then it also reads and writes to and from that store.
func (obj *HTTPUIInputRes) CheckApply(ctx context.Context, apply bool) (bool, error) {
if obj.init.Debug {
obj.init.Logf("CheckApply")
}
// If we're in ".Value" mode, we want to look at the incoming value, and
// send it onwards. This function mostly exists as a stub in this case.
// The private value gets set by obj.SetValue from the http:ui parent.
// If we're in ".Store" mode, then we're reconciling between the "World"
// and the http:ui "Web".
if obj.Store != "" {
return obj.storeCheckApply(ctx, apply)
}
return obj.valueCheckApply(ctx, apply)
}
func (obj *HTTPUIInputRes) valueCheckApply(ctx context.Context, apply bool) (bool, error) {
obj.mutex.Lock()
value := obj.value // gets set by obj.SetValue
obj.mutex.Unlock()
if obj.last != nil && *obj.last == value {
return true, nil // expected value has already been sent
}
if !apply { // XXX: does this break send/recv if we end early?
return false, nil
}
s := value // copy
obj.last = &s // cache
// XXX: This is getting called twice, what's the bug?
obj.init.Logf("sending: %s", value)
// send
if err := obj.init.Send(&HTTPUIInputSends{
Value: &value,
}); err != nil {
return false, err
}
return false, nil
//return true, nil // always succeeds, with nothing to do!
}
// storeCheckApply is a tricky function where we attempt to reconcile the state
// between a third-party changing the value in the World database, and a recent
// "http:ui" change by and end user. Basically whoever runs last is the "right"
// value that we want to use. We know who sent the event from reading the
// storeEvent variable, and if it was the World, we want to cache it locally,
// and if it was the Web, then we want to push it up to the store.
func (obj *HTTPUIInputRes) storeCheckApply(ctx context.Context, apply bool) (bool, error) {
v1, exists, err := obj.storeGet(ctx, obj.getKey())
if err != nil {
return false, errwrap.Wrapf(err, "error during get")
}
obj.mutex.Lock()
v2 := obj.value // gets set by obj.SetValue
storeEvent := obj.storeEvent
obj.storeEvent = false // reset it
obj.mutex.Unlock()
if exists && v1 == v2 { // both sides are happy
return true, nil
}
if !apply { // XXX: does this break send/recv if we end early?
return false, nil
}
obj.mutex.Lock()
if storeEvent { // event from World, pull down the value
err = obj.setValue(ctx, v1) // also sends an event
}
value := obj.value
obj.mutex.Unlock()
if err != nil {
return false, err
}
if !exists || !storeEvent { // event from web, push up the value
if err := obj.storeSet(ctx, obj.getKey(), value); err != nil {
return false, errwrap.Wrapf(err, "error during set")
}
}
obj.init.Logf("sending: %s", value)
// send
if err := obj.init.Send(&HTTPUIInputSends{
Value: &value,
}); err != nil {
return false, err
}
return false, nil
}
func (obj *HTTPUIInputRes) storeGet(ctx context.Context, key string) (string, bool, error) {
if obj.Store != "" && obj.scheme == httpUIInputStoreSchemeLocal {
val, err := obj.init.Local.ValueGet(ctx, key)
if err != nil {
return "", false, err // real error
}
if val == nil { // if val is nil, and no error then it doesn't exist
return "", false, nil // val doesn't exist
}
s, ok := val.(string)
if !ok {
// TODO: support different types perhaps?
return "", false, fmt.Errorf("not a string") // real error
}
return s, true, nil
}
if obj.Store != "" && obj.scheme == httpUIInputStoreSchemeWorld {
val, err := obj.init.World.StrGet(ctx, key)
if err != nil && obj.init.World.StrIsNotExist(err) {
return "", false, nil // val doesn't exist
}
if err != nil {
return "", false, err // real error
}
return val, true, nil
}
return "", false, nil // something else
}
func (obj *HTTPUIInputRes) storeSet(ctx context.Context, key, val string) error {
if obj.Store != "" && obj.scheme == httpUIInputStoreSchemeLocal {
return obj.init.Local.ValueSet(ctx, key, val)
}
if obj.Store != "" && obj.scheme == httpUIInputStoreSchemeWorld {
return obj.init.World.StrSet(ctx, key, val)
}
return nil // something else
}
// Cmp compares two resources and returns an error if they are not equivalent.
func (obj *HTTPUIInputRes) Cmp(r engine.Res) error {
// we can only compare HTTPUIInputRes to others of the same resource kind
res, ok := r.(*HTTPUIInputRes)
if !ok {
return fmt.Errorf("res is not the same kind")
}
if obj.Path != res.Path {
return fmt.Errorf("the Path differs")
}
if obj.ID != res.ID {
return fmt.Errorf("the ID differs")
}
if obj.Value != res.Value {
return fmt.Errorf("the Value differs")
}
if obj.Store != res.Store {
return fmt.Errorf("the Store differs")
}
if obj.Type != res.Type {
return fmt.Errorf("the Type differs")
}
if obj.Sort != res.Sort {
return fmt.Errorf("the Sort differs")
}
return nil
}
// UnmarshalYAML is the custom unmarshal handler for this struct. It is
// primarily useful for setting the defaults.
func (obj *HTTPUIInputRes) UnmarshalYAML(unmarshal func(interface{}) error) error {
type rawRes HTTPUIInputRes // indirection to avoid infinite recursion
def := obj.Default() // get the default
res, ok := def.(*HTTPUIInputRes) // put in the right format
if !ok {
return fmt.Errorf("could not convert to HTTPUIInputRes")
}
raw := rawRes(*res) // convert; the defaults go here
if err := unmarshal(&raw); err != nil {
return err
}
*obj = HTTPUIInputRes(raw) // restore from indirection with type conversion!
return nil
}
// HTTPUIInputSends is the struct of data which is sent after a successful
// Apply.
type HTTPUIInputSends struct {
// Value is the text element value being sent.
Value *string `lang:"value"`
}
// Sends represents the default struct of values we can send using Send/Recv.
func (obj *HTTPUIInputRes) Sends() interface{} {
return &HTTPUIInputSends{
Value: nil,
}
}

View File

@@ -287,7 +287,11 @@ func (obj *KVRes) CheckApply(ctx context.Context, apply bool) (bool, error) {
if val, exists := obj.init.Recv()["value"]; exists && val.Changed {
// if we received on Value, and it changed, wooo, nothing to do.
obj.init.Logf("`value` was received!")
if obj.Value == nil {
obj.init.Logf("nil `value` was received!")
} else {
obj.init.Logf("`value` (%s) was received!", *obj.Value)
}
}
value, exists, err := obj.kvGet(ctx, obj.getKey())