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:
@@ -165,8 +165,68 @@ func (obj *Engine) Process(ctx context.Context, vertex pgraph.Vertex) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// XXX: maybe we want to receive from someone that was grouped?
|
||||
// XXX: can obj.SendRecv() do that or do we have to modify it?
|
||||
// XXX: it might already *just work* -- test it to double check!
|
||||
}
|
||||
|
||||
// If we contain grouped resources, maybe someone inside wants to recv?
|
||||
// This code is similar to the above and was added for http:ui stuff.
|
||||
// XXX: Maybe this block isn't needed, as mentioned we need to check!
|
||||
if res, ok := vertex.(engine.GroupableRes); ok {
|
||||
process := res.GetGroup() // look through these
|
||||
grouped := []engine.GroupableRes{} // found resources
|
||||
for len(process) > 0 { // recurse through any nesting
|
||||
var x engine.GroupableRes
|
||||
x, process = process[0], process[1:] // pop from front!
|
||||
|
||||
g := x.GetGroup()
|
||||
grouped = append(grouped, g...) // add to the end
|
||||
}
|
||||
|
||||
//for _, g := res.GetGroup() // non-recursive, one-layer method
|
||||
for _, g := range grouped { // recursive method!
|
||||
r, ok := g.(engine.RecvableRes)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// This section looks almost identical to the above one!
|
||||
if updated, err := SendRecv(r, nil); err != nil {
|
||||
return errwrap.Wrapf(err, "could not grouped SendRecv")
|
||||
} else if len(updated) > 0 {
|
||||
for r, m := range updated { // map[engine.RecvableRes]map[string]*engine.Send
|
||||
v, ok := r.(pgraph.Vertex)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
_, stateExists := obj.state[v] // autogrouped children probably don't have a state
|
||||
if !stateExists {
|
||||
continue
|
||||
}
|
||||
for s, send := range m {
|
||||
if !send.Changed {
|
||||
continue
|
||||
}
|
||||
obj.Logf("Send/Recv: %v.%s -> %v.%s", send.Res, send.Key, r, s)
|
||||
// if send.Changed == true, at least one was updated
|
||||
// invalidate cache, mark as dirty
|
||||
obj.state[v].setDirty()
|
||||
//break // we might have more vertices now
|
||||
}
|
||||
|
||||
// re-validate after we change any values
|
||||
if err := engine.Validate(r); err != nil {
|
||||
return errwrap.Wrapf(err, "failed grouped Validate after SendRecv")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// XXX: this might not work with two merged "CompatibleRes" resources...
|
||||
// XXX: fix that so we can have the mappings to do it in lang/interpret.go ?
|
||||
|
||||
var ok = true
|
||||
var applied = false // did we run an apply?
|
||||
var noop = res.MetaParams().Noop // lookup the noop value
|
||||
|
||||
@@ -95,11 +95,19 @@ func (obj *wrappedGrouper) VertexCmp(v1, v2 pgraph.Vertex) error {
|
||||
return fmt.Errorf("one of the autogroup flags is false")
|
||||
}
|
||||
|
||||
if r1.IsGrouped() { // already grouped!
|
||||
return fmt.Errorf("already grouped")
|
||||
}
|
||||
if len(r2.GetGroup()) > 0 { // already has children grouped!
|
||||
return fmt.Errorf("already has groups")
|
||||
// We don't want to bail on these two conditions if the kinds are the
|
||||
// same. This prevents us from having a linear chain of pkg->pkg->pkg,
|
||||
// instead of flattening all of them into one arbitrary choice. But if
|
||||
// we are doing hierarchical grouping, then we want to allow this type
|
||||
// of grouping, or we won't end up building any hierarchies! This change
|
||||
// was added for http:ui stuff. Check this condition is really required.
|
||||
if r1.Kind() == r2.Kind() { // XXX: needed or do we unwrap the contents?
|
||||
if r1.IsGrouped() { // already grouped!
|
||||
return fmt.Errorf("already grouped")
|
||||
}
|
||||
if len(r2.GetGroup()) > 0 { // already has children grouped!
|
||||
return fmt.Errorf("already has groups")
|
||||
}
|
||||
}
|
||||
if err := r1.GroupCmp(r2); err != nil { // resource groupcmp failed!
|
||||
return errwrap.Wrapf(err, "the GroupCmp failed")
|
||||
|
||||
@@ -49,6 +49,13 @@ import (
|
||||
|
||||
func init() {
|
||||
engine.RegisterResource("nooptest", func() engine.Res { return &NoopResTest{} })
|
||||
engine.RegisterResource("nooptestkind:foo", func() engine.Res { return &NoopResTest{} })
|
||||
engine.RegisterResource("nooptestkind:foo:hello", func() engine.Res { return &NoopResTest{} })
|
||||
engine.RegisterResource("nooptestkind:foo:world", func() engine.Res { return &NoopResTest{} })
|
||||
engine.RegisterResource("nooptestkind:foo:world:big", func() engine.Res { return &NoopResTest{} })
|
||||
engine.RegisterResource("nooptestkind:foo:world:bad", func() engine.Res { return &NoopResTest{} })
|
||||
engine.RegisterResource("nooptestkind:foo:world:bazzz", func() engine.Res { return &NoopResTest{} })
|
||||
engine.RegisterResource("nooptestkind:this:is:very:long", func() engine.Res { return &NoopResTest{} })
|
||||
}
|
||||
|
||||
// NoopResTest is a no-op resource that groups strangely.
|
||||
@@ -108,19 +115,35 @@ func (obj *NoopResTest) GroupCmp(r engine.GroupableRes) error {
|
||||
}
|
||||
|
||||
// TODO: implement this in vertexCmp for *testGrouper instead?
|
||||
if strings.Contains(res.Name(), ",") { // HACK
|
||||
return fmt.Errorf("already grouped") // element to be grouped is already grouped!
|
||||
k1 := strings.HasPrefix(obj.Kind(), "nooptestkind:")
|
||||
k2 := strings.HasPrefix(res.Kind(), "nooptestkind:")
|
||||
if !k1 && !k2 { // XXX: compat mode, to skip during "kind" tests
|
||||
if strings.Contains(res.Name(), ",") { // HACK
|
||||
return fmt.Errorf("already grouped") // element to be grouped is already grouped!
|
||||
}
|
||||
}
|
||||
|
||||
// XXX: make a better grouping algorithm for test expression
|
||||
// XXX: this prevents us from re-using the same kind twice in a test...
|
||||
// group different kinds if they're hierarchical (helpful hack for testing)
|
||||
if obj.Kind() != res.Kind() {
|
||||
s1 := strings.Split(obj.Kind(), ":")
|
||||
s2 := strings.Split(res.Kind(), ":")
|
||||
if len(s1) > len(s2) { // let longer get grouped INTO shorter
|
||||
return fmt.Errorf("chunk inversion")
|
||||
}
|
||||
}
|
||||
|
||||
// group if they start with the same letter! (helpful hack for testing)
|
||||
if obj.Name()[0] != res.Name()[0] {
|
||||
return fmt.Errorf("different starting letter")
|
||||
}
|
||||
//fmt.Printf("group of: %+v into: %+v\n", res.Kind(), obj.Kind())
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewNoopResTest(name string) *NoopResTest {
|
||||
n, err := engine.NewNamedResource("nooptest", name)
|
||||
func NewKindNoopResTest(kind, name string) *NoopResTest {
|
||||
n, err := engine.NewNamedResource(kind, name)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("unexpected error: %+v", err))
|
||||
}
|
||||
@@ -138,6 +161,10 @@ func NewNoopResTest(name string) *NoopResTest {
|
||||
return x
|
||||
}
|
||||
|
||||
func NewNoopResTest(name string) *NoopResTest {
|
||||
return NewKindNoopResTest("nooptest", name)
|
||||
}
|
||||
|
||||
func NewNoopResTestSema(name string, semas []string) *NoopResTest {
|
||||
n := NewNoopResTest(name)
|
||||
n.MetaParams().Sema = semas
|
||||
@@ -174,21 +201,29 @@ func (obj *testGrouper) VertexCmp(v1, v2 pgraph.Vertex) error {
|
||||
return fmt.Errorf("v2 is not a GroupableRes")
|
||||
}
|
||||
|
||||
if r1.Kind() != r2.Kind() { // we must group similar kinds
|
||||
// TODO: maybe future resources won't need this limitation?
|
||||
return fmt.Errorf("the two resources aren't the same kind")
|
||||
}
|
||||
//if r1.Kind() != r2.Kind() { // we must group similar kinds
|
||||
// // TODO: maybe future resources won't need this limitation?
|
||||
// return fmt.Errorf("the two resources aren't the same kind")
|
||||
//}
|
||||
// someone doesn't want to group!
|
||||
if r1.AutoGroupMeta().Disabled || r2.AutoGroupMeta().Disabled {
|
||||
return fmt.Errorf("one of the autogroup flags is false")
|
||||
}
|
||||
|
||||
if r1.IsGrouped() { // already grouped!
|
||||
return fmt.Errorf("already grouped")
|
||||
}
|
||||
if len(r2.GetGroup()) > 0 { // already has children grouped!
|
||||
return fmt.Errorf("already has groups")
|
||||
// We don't want to bail on these two conditions if the kinds are the
|
||||
// same. This prevents us from having a linear chain of pkg->pkg->pkg,
|
||||
// instead of flattening all of them into one arbitrary choice. But if
|
||||
// we are doing hierarchical grouping, then we want to allow this type
|
||||
// of grouping, or we won't end up building any hierarchies!
|
||||
if r1.Kind() == r2.Kind() {
|
||||
if r1.IsGrouped() { // already grouped!
|
||||
return fmt.Errorf("already grouped")
|
||||
}
|
||||
if len(r2.GetGroup()) > 0 { // already has children grouped!
|
||||
return fmt.Errorf("already has groups")
|
||||
}
|
||||
}
|
||||
|
||||
if err := r1.GroupCmp(r2); err != nil { // resource groupcmp failed!
|
||||
return errwrap.Wrapf(err, "the GroupCmp failed")
|
||||
}
|
||||
@@ -197,6 +232,8 @@ func (obj *testGrouper) VertexCmp(v1, v2 pgraph.Vertex) error {
|
||||
}
|
||||
|
||||
func (obj *testGrouper) VertexMerge(v1, v2 pgraph.Vertex) (v pgraph.Vertex, err error) {
|
||||
//fmt.Printf("merge of: %s into: %s\n", v2, v1)
|
||||
// NOTE: this doesn't look at kind!
|
||||
r1 := v1.(engine.GroupableRes)
|
||||
r2 := v2.(engine.GroupableRes)
|
||||
if err := r1.GroupRes(r2); err != nil { // group them first
|
||||
@@ -273,8 +310,12 @@ Loop:
|
||||
for v1 := range g1.Adjacency() { // for each vertex in g1
|
||||
r1 := v1.(engine.GroupableRes)
|
||||
l1 := strings.Split(r1.Name(), ",") // make list of everyone's names...
|
||||
for _, x1 := range r1.GetGroup() {
|
||||
l1 = append(l1, x1.Name()) // add my contents
|
||||
// XXX: this should be recursive for hierarchical grouping...
|
||||
// XXX: instead, hack it for now:
|
||||
if !strings.HasPrefix(r1.Kind(), "nooptestkind:") {
|
||||
for _, x1 := range r1.GetGroup() {
|
||||
l1 = append(l1, x1.Name()) // add my contents
|
||||
}
|
||||
}
|
||||
l1 = util.StrRemoveDuplicatesInList(l1) // remove duplicates
|
||||
sort.Strings(l1)
|
||||
@@ -283,8 +324,12 @@ Loop:
|
||||
for v2 := range g2.Adjacency() { // does it match in g2 ?
|
||||
r2 := v2.(engine.GroupableRes)
|
||||
l2 := strings.Split(r2.Name(), ",")
|
||||
for _, x2 := range r2.GetGroup() {
|
||||
l2 = append(l2, x2.Name())
|
||||
// XXX: this should be recursive for hierarchical grouping...
|
||||
// XXX: instead, hack it for now:
|
||||
if !strings.HasPrefix(r2.Kind(), "nooptestkind:") {
|
||||
for _, x2 := range r2.GetGroup() {
|
||||
l2 = append(l2, x2.Name())
|
||||
}
|
||||
}
|
||||
l2 = util.StrRemoveDuplicatesInList(l2) // remove duplicates
|
||||
sort.Strings(l2)
|
||||
@@ -771,9 +816,9 @@ func TestPgraphGrouping16(t *testing.T) {
|
||||
a := NewNoopResTest("a1,a2")
|
||||
b1 := NewNoopResTest("b1")
|
||||
c1 := NewNoopResTest("c1")
|
||||
e1 := NE("e1")
|
||||
e2 := NE("e2")
|
||||
e3 := NE("e3")
|
||||
e1 := NE("e1") // +e3 a bit?
|
||||
e2 := NE("e2") // ok!
|
||||
e3 := NE("e3") // +e1 a bit?
|
||||
g3.AddEdge(a, b1, e1)
|
||||
g3.AddEdge(b1, c1, e2)
|
||||
g3.AddEdge(a, c1, e3)
|
||||
@@ -859,9 +904,9 @@ func TestPgraphGrouping18(t *testing.T) {
|
||||
a := NewNoopResTest("a1,a2")
|
||||
b := NewNoopResTest("b1,b2")
|
||||
c1 := NewNoopResTest("c1")
|
||||
e1 := NE("e1")
|
||||
e2 := NE("e2,e4")
|
||||
e3 := NE("e3")
|
||||
e1 := NE("e1") // +e3 a bit?
|
||||
e2 := NE("e2,e4") // ok!
|
||||
e3 := NE("e3") // +e1 a bit?
|
||||
g3.AddEdge(a, b, e1)
|
||||
g3.AddEdge(b, c1, e2)
|
||||
g3.AddEdge(a, c1, e3)
|
||||
@@ -978,3 +1023,110 @@ func TestPgraphSemaphoreGrouping3(t *testing.T) {
|
||||
}
|
||||
runGraphCmp(t, g1, g2)
|
||||
}
|
||||
|
||||
func TestPgraphGroupingKinds0(t *testing.T) {
|
||||
g1, _ := pgraph.NewGraph("g1") // original graph
|
||||
{
|
||||
a1 := NewKindNoopResTest("nooptestkind:foo", "a1")
|
||||
a2 := NewKindNoopResTest("nooptestkind:foo:hello", "a2")
|
||||
g1.AddVertex(a1, a2)
|
||||
}
|
||||
g2, _ := pgraph.NewGraph("g2") // expected result ?
|
||||
{
|
||||
a := NewNoopResTest("a1,a2")
|
||||
g2.AddVertex(a)
|
||||
}
|
||||
runGraphCmp(t, g1, g2)
|
||||
}
|
||||
|
||||
func TestPgraphGroupingKinds1(t *testing.T) {
|
||||
g1, _ := pgraph.NewGraph("g1") // original graph
|
||||
{
|
||||
a1 := NewKindNoopResTest("nooptestkind:foo", "a1")
|
||||
a2 := NewKindNoopResTest("nooptestkind:foo:world", "a2")
|
||||
a3 := NewKindNoopResTest("nooptestkind:foo:world:big", "a3")
|
||||
g1.AddVertex(a1, a2, a3)
|
||||
}
|
||||
g2, _ := pgraph.NewGraph("g2") // expected result ?
|
||||
{
|
||||
a := NewNoopResTest("a1,a2,a3")
|
||||
g2.AddVertex(a)
|
||||
}
|
||||
runGraphCmp(t, g1, g2)
|
||||
}
|
||||
|
||||
func TestPgraphGroupingKinds2(t *testing.T) {
|
||||
g1, _ := pgraph.NewGraph("g1") // original graph
|
||||
{
|
||||
a1 := NewKindNoopResTest("nooptestkind:foo", "a1")
|
||||
a2 := NewKindNoopResTest("nooptestkind:foo:world", "a2")
|
||||
a3 := NewKindNoopResTest("nooptestkind:foo:world:big", "a3")
|
||||
a4 := NewKindNoopResTest("nooptestkind:foo:world:bad", "a4")
|
||||
g1.AddVertex(a1, a2, a3, a4)
|
||||
}
|
||||
g2, _ := pgraph.NewGraph("g2") // expected result ?
|
||||
{
|
||||
a := NewNoopResTest("a1,a2,a3,a4")
|
||||
g2.AddVertex(a)
|
||||
}
|
||||
runGraphCmp(t, g1, g2)
|
||||
}
|
||||
|
||||
func TestPgraphGroupingKinds3(t *testing.T) {
|
||||
g1, _ := pgraph.NewGraph("g1") // original graph
|
||||
{
|
||||
a1 := NewKindNoopResTest("nooptestkind:foo", "a1")
|
||||
a2 := NewKindNoopResTest("nooptestkind:foo:world", "a2")
|
||||
a3 := NewKindNoopResTest("nooptestkind:foo:world:big", "a3")
|
||||
a4 := NewKindNoopResTest("nooptestkind:foo:world:bad", "a4")
|
||||
a5 := NewKindNoopResTest("nooptestkind:foo:world:bazzz", "a5")
|
||||
g1.AddVertex(a1, a2, a3, a4, a5)
|
||||
}
|
||||
g2, _ := pgraph.NewGraph("g2") // expected result ?
|
||||
{
|
||||
a := NewNoopResTest("a1,a2,a3,a4,a5")
|
||||
g2.AddVertex(a)
|
||||
}
|
||||
runGraphCmp(t, g1, g2)
|
||||
}
|
||||
|
||||
// This test is valid, but our test system doesn't support duplicate kinds atm.
|
||||
//func TestPgraphGroupingKinds4(t *testing.T) {
|
||||
// g1, _ := pgraph.NewGraph("g1") // original graph
|
||||
// {
|
||||
// a1 := NewKindNoopResTest("nooptestkind:foo", "a1")
|
||||
// a2 := NewKindNoopResTest("nooptestkind:foo:world", "a2")
|
||||
// a3 := NewKindNoopResTest("nooptestkind:foo:world:big", "a3")
|
||||
// a4 := NewKindNoopResTest("nooptestkind:foo:world:big", "a4")
|
||||
// g1.AddVertex(a1, a2, a3, a4)
|
||||
// }
|
||||
// g2, _ := pgraph.NewGraph("g2") // expected result ?
|
||||
// {
|
||||
// a := NewNoopResTest("a1,a2,a3,a4")
|
||||
// g2.AddVertex(a)
|
||||
// }
|
||||
// runGraphCmp(t, g1, g2)
|
||||
//}
|
||||
|
||||
func TestPgraphGroupingKinds5(t *testing.T) {
|
||||
g1, _ := pgraph.NewGraph("g1") // original graph
|
||||
{
|
||||
a1 := NewKindNoopResTest("nooptestkind:foo", "a1")
|
||||
a2 := NewKindNoopResTest("nooptestkind:foo:world", "a2")
|
||||
a3 := NewKindNoopResTest("nooptestkind:foo:world:big", "a3")
|
||||
a4 := NewKindNoopResTest("nooptestkind:foo:world:bad", "a4")
|
||||
a5 := NewKindNoopResTest("nooptestkind:foo:world:bazzz", "a5")
|
||||
b1 := NewKindNoopResTest("nooptestkind:foo", "b1")
|
||||
// NOTE: the very long one shouldn't group, but our test doesn't
|
||||
// support detecting this pattern at the moment...
|
||||
b2 := NewKindNoopResTest("nooptestkind:this:is:very:long", "b2")
|
||||
g1.AddVertex(a1, a2, a3, a4, a5, b1, b2)
|
||||
}
|
||||
g2, _ := pgraph.NewGraph("g2") // expected result ?
|
||||
{
|
||||
a := NewNoopResTest("a1,a2,a3,a4,a5")
|
||||
b := NewNoopResTest("b1,b2")
|
||||
g2.AddVertex(a, b)
|
||||
}
|
||||
runGraphCmp(t, g1, g2)
|
||||
}
|
||||
|
||||
43
engine/resources/Makefile
Normal file
43
engine/resources/Makefile
Normal 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
791
engine/resources/http_ui.go
Normal 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
1
engine/resources/http_ui/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
main.wasm
|
||||
8
engine/resources/http_ui/README.md
Normal file
8
engine/resources/http_ui/README.md
Normal 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.
|
||||
82
engine/resources/http_ui/common/common.go
Normal file
82
engine/resources/http_ui/common/common.go
Normal 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"`
|
||||
}
|
||||
163
engine/resources/http_ui/index.html.tmpl
Normal file
163
engine/resources/http_ui/index.html.tmpl
Normal 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>
|
||||
314
engine/resources/http_ui/main.go
Normal file
314
engine/resources/http_ui/main.go
Normal 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
|
||||
}
|
||||
2
engine/resources/http_ui/static/.gitignore
vendored
Normal file
2
engine/resources/http_ui/static/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*.css
|
||||
*.js
|
||||
51
engine/resources/http_ui/static/embed.go
Normal file
51
engine/resources/http_ui/static/embed.go
Normal 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
|
||||
)
|
||||
45
engine/resources/http_ui/static/noembed.go
Normal file
45
engine/resources/http_ui/static/noembed.go
Normal 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
|
||||
)
|
||||
42
engine/resources/http_ui/static/static.go
Normal file
42
engine/resources/http_ui/static/static.go
Normal 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"
|
||||
)
|
||||
577
engine/resources/http_ui/wasm_exec.js
Normal file
577
engine/resources/http_ui/wasm_exec.js
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
653
engine/resources/http_ui_input.go
Normal file
653
engine/resources/http_ui_input.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -78,12 +78,25 @@ func (obj *Groupable) GroupCmp(res engine.GroupableRes) error {
|
||||
// GroupRes groups resource argument (res) into self. Callers of this method
|
||||
// should probably also run SetParent.
|
||||
func (obj *Groupable) GroupRes(res engine.GroupableRes) error {
|
||||
// We can keep this check with hierarchical grouping by adding in the
|
||||
// Kind test which we seen inside... If they're all the same, then we
|
||||
// can't do it. But if they're dissimilar, then it's okay to group!
|
||||
if l := len(res.GetGroup()); l > 0 {
|
||||
return fmt.Errorf("the `%s` resource already contains %d grouped resources", res, l)
|
||||
}
|
||||
if res.IsGrouped() {
|
||||
return fmt.Errorf("the `%s` resource is already grouped", res)
|
||||
kind := res.Kind()
|
||||
ok := true // assume okay for now
|
||||
for _, r := range res.GetGroup() {
|
||||
if r.Kind() == kind {
|
||||
ok = false // non-hierarchical grouping, error!
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("the `%s` resource already contains %d grouped resources", res, l)
|
||||
}
|
||||
}
|
||||
// XXX: Do we need to disable this to support hierarchical grouping?
|
||||
//if res.IsGrouped() {
|
||||
// return fmt.Errorf("the `%s` resource is already grouped", res)
|
||||
//}
|
||||
|
||||
obj.grouped = append(obj.grouped, res)
|
||||
res.SetGrouped(true) // i am contained _in_ a group
|
||||
|
||||
@@ -72,7 +72,12 @@ type Recvable struct {
|
||||
Bug5819 interface{} // XXX: workaround
|
||||
}
|
||||
|
||||
// SetRecv is used to inject incoming values into the resource.
|
||||
// SetRecv is used to inject incoming values into the resource. More
|
||||
// specifically, it stores the mapping of what gets received from what, so that
|
||||
// later on, we know which resources should ask which other resources for the
|
||||
// values that they want to receive. Since this happens when we're building the
|
||||
// graph, and before the autogrouping step, we'll have pointers to the original,
|
||||
// ungrouped resources here, so that it will work even after they're grouped in!
|
||||
func (obj *Recvable) SetRecv(recv map[string]*engine.Send) {
|
||||
//if obj.recv == nil {
|
||||
// obj.recv = make(map[string]*engine.Send)
|
||||
|
||||
Reference in New Issue
Block a user