mirror of
https://github.com/redhat-developer/odo.git
synced 2025-10-19 03:06:19 +03:00
Propagate local Devfile changes to the UI (#6970)
* Add '/notifications' endpoint for subscribing to server-sent events * Generate server and client * Try implementing the notification service endpoint * Revert "Try implementing the notification service endpoint" This does not seem to work because the generated server always responds with application/json, and it is not possible to respond with a different content-type. This reverts commit cf3ce83677649763b8166c4847501c37246dd757. * Revert "Generate server and client" This reverts commit b985c007a0561edbe185adc3b9582e12aa3f072b. * Revert "Add '/notifications' endpoint for subscribing to server-sent events" This reverts commit c5c903329f13dbe4ec096d83b1c8624fd622bef3. * Implement 'GET /notifications' SSE endpoint and logic to detect and notify Devfile changes * Leverage EventSource to subscribe to Server Sent Events Here, this is being used to automatically reload the Devfile in the YAML view whenever the API server notifies of filesystem changes in the Devfile (and related resources). * Add Preference Client to apiserver CLI This is needed to be able to persist Devfiles from the UI to the filesystem * Add E2E test case * fixup! Leverage EventSource to subscribe to Server Sent Events Co-authored-by: Philippe Martin <phmartin@redhat.com> * Limit the round-trips by sending the whole Devfile content in the DevfileUpdated event data Co-authored-by: Philippe Martin <phmartin@redhat.com> * [Cypress] Make sure to wait for APi responses after visiting the home page Co-authored-by: Philippe Martin <phmartin@redhat.com> * Generate static UI * fixup! [Cypress] Make sure to wait for APi responses after visiting the home page --------- Co-authored-by: Philippe Martin <phmartin@redhat.com>
This commit is contained in:
1
.github/workflows/ui-e2e.yaml
vendored
1
.github/workflows/ui-e2e.yaml
vendored
@@ -33,6 +33,7 @@ jobs:
|
|||||||
uses: cypress-io/github-action@v5
|
uses: cypress-io/github-action@v5
|
||||||
env:
|
env:
|
||||||
ODO_EXPERIMENTAL_MODE: "true"
|
ODO_EXPERIMENTAL_MODE: "true"
|
||||||
|
ODO_TRACKING_CONSENT: "no"
|
||||||
with:
|
with:
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
# Run odo against the UI itself
|
# Run odo against the UI itself
|
||||||
|
|||||||
46
pkg/apiserver-impl/sse/event.go
Normal file
46
pkg/apiserver-impl/sse/event.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package sse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EventType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
Heartbeat EventType = iota + 1
|
||||||
|
DevfileUpdated
|
||||||
|
)
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
eventType EventType
|
||||||
|
data interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Event) toSseString() (string, error) {
|
||||||
|
var eventName string
|
||||||
|
switch e.eventType {
|
||||||
|
case Heartbeat:
|
||||||
|
return ": heartbeat\n\n", nil
|
||||||
|
case DevfileUpdated:
|
||||||
|
eventName = "DevfileUpdated"
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unrecognized event type:%v", e.eventType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.data == nil {
|
||||||
|
return fmt.Sprintf("event: %s\n\n", eventName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(e.data)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var dataStr bytes.Buffer
|
||||||
|
err = json.Compact(&dataStr, jsonBytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("event: %s\ndata: %s\n\n", eventName, dataStr.String()), nil
|
||||||
|
}
|
||||||
192
pkg/apiserver-impl/sse/notifications.go
Normal file
192
pkg/apiserver-impl/sse/notifications.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
package sse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"k8s.io/klog"
|
||||||
|
|
||||||
|
openapi "github.com/redhat-developer/odo/pkg/apiserver-gen/go"
|
||||||
|
"github.com/redhat-developer/odo/pkg/testingutil/filesystem"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Notifier struct {
|
||||||
|
fsys filesystem.Filesystem
|
||||||
|
|
||||||
|
devfilePath string
|
||||||
|
|
||||||
|
// eventsChan is a channel for all events that will be broadcast to all subscribers.
|
||||||
|
// Because it is not natively possible to read a same value twice from a Go channel,
|
||||||
|
// we are storing the list of channels to broadcast the event to into the subscribers list.
|
||||||
|
eventsChan chan Event
|
||||||
|
|
||||||
|
// subscribers is a list of all channels where any event from eventsChan will be broadcast to.
|
||||||
|
subscribers []chan Event
|
||||||
|
|
||||||
|
// newSubscriptionChan is a channel where new subscribers can register the channel on which they wish to be notified.
|
||||||
|
// Such channels are stored into the subscribers list.
|
||||||
|
newSubscriptionChan chan chan Event
|
||||||
|
|
||||||
|
// cancelSubscriptionChan is a write-only channel where subscribers can cancel their registration and stop being broadcast new events coming from eventsChan.
|
||||||
|
cancelSubscriptionChan chan (<-chan Event)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNotifier(ctx context.Context, fsys filesystem.Filesystem, devfilePath string, devfileFiles []string) (*Notifier, error) {
|
||||||
|
notifier := Notifier{
|
||||||
|
fsys: fsys,
|
||||||
|
devfilePath: devfilePath,
|
||||||
|
eventsChan: make(chan Event),
|
||||||
|
subscribers: make([]chan Event, 0),
|
||||||
|
newSubscriptionChan: make(chan chan Event),
|
||||||
|
cancelSubscriptionChan: make(chan (<-chan Event)),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := notifier.watchDevfileChanges(ctx, devfileFiles)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
go notifier.manageSubscriptions(ctx)
|
||||||
|
|
||||||
|
// Heartbeat as a keep-alive mechanism to prevent some clients from closing inactive connections (notifications might not be sent regularly).
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(7 * time.Second)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
notifier.eventsChan <- Event{
|
||||||
|
eventType: Heartbeat,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ¬ifier, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Notifier) manageSubscriptions(ctx context.Context) {
|
||||||
|
defer func() {
|
||||||
|
for _, listener := range n.subscribers {
|
||||||
|
if listener != nil {
|
||||||
|
close(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case newSubscriber := <-n.newSubscriptionChan:
|
||||||
|
n.subscribers = append(n.subscribers, newSubscriber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case subscriberToRemove := <-n.cancelSubscriptionChan:
|
||||||
|
for i, ch := range n.subscribers {
|
||||||
|
if ch == subscriberToRemove {
|
||||||
|
n.subscribers[i] = n.subscribers[len(n.subscribers)-1]
|
||||||
|
n.subscribers = n.subscribers[:len(n.subscribers)-1]
|
||||||
|
close(ch)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case val, ok := <-n.eventsChan:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, subscriber := range n.subscribers {
|
||||||
|
subscriber := subscriber
|
||||||
|
if subscriber == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
select {
|
||||||
|
case subscriber <- val:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Notifier) Routes() openapi.Routes {
|
||||||
|
return openapi.Routes{
|
||||||
|
{
|
||||||
|
Name: "ServerSentEvents",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Pattern: "/api/v1/notifications",
|
||||||
|
HandlerFunc: n.handler,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Notifier) handler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newListener := make(chan Event)
|
||||||
|
n.newSubscriptionChan <- newListener
|
||||||
|
defer func() {
|
||||||
|
n.cancelSubscriptionChan <- newListener
|
||||||
|
}()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
// Headers sent back as early as possible to clients
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case ev := <-newListener:
|
||||||
|
func() {
|
||||||
|
defer flusher.Flush()
|
||||||
|
dataToWrite, err := ev.toSseString()
|
||||||
|
if err != nil {
|
||||||
|
klog.V(2).Infof("error writing notification data: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = w.Write([]byte(dataToWrite))
|
||||||
|
if err != nil {
|
||||||
|
klog.V(2).Infof("error writing notification data: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
case <-r.Context().Done():
|
||||||
|
klog.V(8).Infof("Connection closed!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
pkg/apiserver-impl/sse/watcher.go
Normal file
76
pkg/apiserver-impl/sse/watcher.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package sse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
"k8s.io/klog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (n *Notifier) watchDevfileChanges(ctx context.Context, devfileFiles []string) error {
|
||||||
|
devfileWatcher, err := fsnotify.NewWatcher()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
klog.V(2).Infof("context done, reason: %v", ctx.Err())
|
||||||
|
cErr := devfileWatcher.Close()
|
||||||
|
if cErr != nil {
|
||||||
|
klog.V(2).Infof("error closing devfileWater: %v", cErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case ev, ok := <-devfileWatcher.Events:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
klog.V(7).Infof("event: %v", ev)
|
||||||
|
devfileContent, rErr := n.fsys.ReadFile(n.devfilePath)
|
||||||
|
if rErr != nil {
|
||||||
|
klog.V(1).Infof("unable to read Devfile at path %q: %v", n.devfilePath, rErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n.eventsChan <- Event{
|
||||||
|
eventType: DevfileUpdated,
|
||||||
|
data: map[string]string{
|
||||||
|
"path": ev.Name,
|
||||||
|
"operation": ev.Op.String(),
|
||||||
|
"content": string(devfileContent),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if ev.Has(fsnotify.Remove) {
|
||||||
|
// For some reason, depending on the editor used to edit the file, changes would be detected only once.
|
||||||
|
// Workaround recommended is to re-add the path to the watcher.
|
||||||
|
// See https://github.com/fsnotify/fsnotify/issues/363
|
||||||
|
wErr := devfileWatcher.Remove(ev.Name)
|
||||||
|
if wErr != nil {
|
||||||
|
klog.V(7).Infof("error removing file watch: %v", wErr)
|
||||||
|
}
|
||||||
|
wErr = devfileWatcher.Add(ev.Name)
|
||||||
|
if wErr != nil {
|
||||||
|
klog.V(0).Infof("error re-adding file watch: %v", wErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case wErr, ok := <-devfileWatcher.Errors:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
klog.V(0).Infof("error on file watch: %v", wErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for _, f := range devfileFiles {
|
||||||
|
err = devfileWatcher.Add(f)
|
||||||
|
if err != nil {
|
||||||
|
klog.V(0).Infof("error adding watcher for path %q: %v", f, err)
|
||||||
|
} else {
|
||||||
|
klog.V(7).Infof("added watcher for path %q", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -8,13 +8,16 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"k8s.io/klog"
|
||||||
|
|
||||||
openapi "github.com/redhat-developer/odo/pkg/apiserver-gen/go"
|
openapi "github.com/redhat-developer/odo/pkg/apiserver-gen/go"
|
||||||
|
"github.com/redhat-developer/odo/pkg/apiserver-impl/sse"
|
||||||
"github.com/redhat-developer/odo/pkg/kclient"
|
"github.com/redhat-developer/odo/pkg/kclient"
|
||||||
"github.com/redhat-developer/odo/pkg/podman"
|
"github.com/redhat-developer/odo/pkg/podman"
|
||||||
"github.com/redhat-developer/odo/pkg/preference"
|
"github.com/redhat-developer/odo/pkg/preference"
|
||||||
"github.com/redhat-developer/odo/pkg/state"
|
"github.com/redhat-developer/odo/pkg/state"
|
||||||
|
"github.com/redhat-developer/odo/pkg/testingutil/filesystem"
|
||||||
"github.com/redhat-developer/odo/pkg/util"
|
"github.com/redhat-developer/odo/pkg/util"
|
||||||
"k8s.io/klog"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed ui/*
|
//go:embed ui/*
|
||||||
@@ -28,13 +31,14 @@ func StartServer(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
cancelFunc context.CancelFunc,
|
cancelFunc context.CancelFunc,
|
||||||
port int,
|
port int,
|
||||||
|
devfilePath string,
|
||||||
|
devfileFiles []string,
|
||||||
|
fsys filesystem.Filesystem,
|
||||||
kubernetesClient kclient.ClientInterface,
|
kubernetesClient kclient.ClientInterface,
|
||||||
podmanClient podman.Client,
|
podmanClient podman.Client,
|
||||||
stateClient state.Client,
|
stateClient state.Client,
|
||||||
preferenceClient preference.Client,
|
preferenceClient preference.Client,
|
||||||
) ApiServer {
|
) (ApiServer, error) {
|
||||||
var err error
|
|
||||||
|
|
||||||
pushWatcher := make(chan struct{})
|
pushWatcher := make(chan struct{})
|
||||||
defaultApiService := NewDefaultApiService(
|
defaultApiService := NewDefaultApiService(
|
||||||
cancelFunc,
|
cancelFunc,
|
||||||
@@ -46,7 +50,12 @@ func StartServer(
|
|||||||
)
|
)
|
||||||
defaultApiController := openapi.NewDefaultApiController(defaultApiService)
|
defaultApiController := openapi.NewDefaultApiController(defaultApiService)
|
||||||
|
|
||||||
router := openapi.NewRouter(defaultApiController)
|
sseNotifier, err := sse.NewNotifier(ctx, fsys, devfilePath, devfileFiles)
|
||||||
|
if err != nil {
|
||||||
|
return ApiServer{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
router := openapi.NewRouter(sseNotifier, defaultApiController)
|
||||||
|
|
||||||
fSys, err := fs.Sub(staticFiles, "ui")
|
fSys, err := fs.Sub(staticFiles, "ui")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -97,5 +106,5 @@ func StartServer(
|
|||||||
|
|
||||||
return ApiServer{
|
return ApiServer{
|
||||||
PushWatcher: pushWatcher,
|
PushWatcher: pushWatcher,
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
2
pkg/apiserver-impl/ui/index.html
generated
2
pkg/apiserver-impl/ui/index.html
generated
@@ -11,6 +11,6 @@
|
|||||||
<body class="mat-typography">
|
<body class="mat-typography">
|
||||||
<div id="loading">Loading, please wait...</div>
|
<div id="loading">Loading, please wait...</div>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
<script src="runtime.1289ea0acffcdc5e.js" type="module"></script><script src="polyfills.8b3b37cedaf377c3.js" type="module"></script><script src="main.cf0a00d9d9051ab2.js" type="module"></script>
|
<script src="runtime.1289ea0acffcdc5e.js" type="module"></script><script src="polyfills.8b3b37cedaf377c3.js" type="module"></script><script src="main.2ab96df0533a01ab.js" type="module"></script>
|
||||||
|
|
||||||
</body></html>
|
</body></html>
|
||||||
1
pkg/apiserver-impl/ui/main.2ab96df0533a01ab.js
generated
Normal file
1
pkg/apiserver-impl/ui/main.2ab96df0533a01ab.js
generated
Normal file
File diff suppressed because one or more lines are too long
1
pkg/apiserver-impl/ui/main.cf0a00d9d9051ab2.js
generated
1
pkg/apiserver-impl/ui/main.cf0a00d9d9051ab2.js
generated
File diff suppressed because one or more lines are too long
@@ -4,12 +4,15 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
apiserver_impl "github.com/redhat-developer/odo/pkg/apiserver-impl"
|
|
||||||
"github.com/redhat-developer/odo/pkg/odo/cmdline"
|
|
||||||
"github.com/redhat-developer/odo/pkg/odo/genericclioptions"
|
|
||||||
"github.com/redhat-developer/odo/pkg/odo/genericclioptions/clientset"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"k8s.io/klog"
|
"k8s.io/klog"
|
||||||
|
|
||||||
|
apiserver_impl "github.com/redhat-developer/odo/pkg/apiserver-impl"
|
||||||
|
"github.com/redhat-developer/odo/pkg/libdevfile"
|
||||||
|
"github.com/redhat-developer/odo/pkg/odo/cmdline"
|
||||||
|
odocontext "github.com/redhat-developer/odo/pkg/odo/context"
|
||||||
|
"github.com/redhat-developer/odo/pkg/odo/genericclioptions"
|
||||||
|
"github.com/redhat-developer/odo/pkg/odo/genericclioptions/clientset"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -49,15 +52,34 @@ func (o *ApiServerOptions) Run(ctx context.Context) (err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(ctx)
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
_ = apiserver_impl.StartServer(
|
defer cancel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
devfileObj = odocontext.GetEffectiveDevfileObj(ctx)
|
||||||
|
devfilePath = odocontext.GetDevfilePath(ctx)
|
||||||
|
)
|
||||||
|
|
||||||
|
devfileFiles, err := libdevfile.GetReferencedLocalFiles(*devfileObj)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
devfileFiles = append(devfileFiles, devfilePath)
|
||||||
|
_, err = apiserver_impl.StartServer(
|
||||||
ctx,
|
ctx,
|
||||||
cancel,
|
cancel,
|
||||||
o.portFlag,
|
o.portFlag,
|
||||||
|
devfilePath,
|
||||||
|
devfileFiles,
|
||||||
|
o.clientset.FS,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
o.clientset.StateClient,
|
o.clientset.StateClient,
|
||||||
o.clientset.PreferenceClient,
|
o.clientset.PreferenceClient,
|
||||||
)
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -88,6 +110,7 @@ func NewCmdApiServer(ctx context.Context, name, fullName string, testClientset c
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
clientset.Add(apiserverCmd,
|
clientset.Add(apiserverCmd,
|
||||||
|
clientset.FILESYSTEM,
|
||||||
clientset.STATE,
|
clientset.STATE,
|
||||||
clientset.PREFERENCE,
|
clientset.PREFERENCE,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -261,16 +261,28 @@ func (o *DevOptions) Run(ctx context.Context) (err error) {
|
|||||||
|
|
||||||
var apiServer apiserver_impl.ApiServer
|
var apiServer apiserver_impl.ApiServer
|
||||||
if o.apiServerFlag {
|
if o.apiServerFlag {
|
||||||
|
var devfileFiles []string
|
||||||
|
devfileFiles, err = libdevfile.GetReferencedLocalFiles(*devFileObj)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
devfileFiles = append(devfileFiles, devfilePath)
|
||||||
// Start the server here; it will be shutdown when context is cancelled; or if the server encounters an error
|
// Start the server here; it will be shutdown when context is cancelled; or if the server encounters an error
|
||||||
apiServer = apiserver_impl.StartServer(
|
apiServer, err = apiserver_impl.StartServer(
|
||||||
ctx,
|
ctx,
|
||||||
o.cancel,
|
o.cancel,
|
||||||
o.apiServerPortFlag,
|
o.apiServerPortFlag,
|
||||||
|
devfilePath,
|
||||||
|
devfileFiles,
|
||||||
|
o.clientset.FS,
|
||||||
o.clientset.KubernetesClient,
|
o.clientset.KubernetesClient,
|
||||||
o.clientset.PodmanClient,
|
o.clientset.PodmanClient,
|
||||||
o.clientset.StateClient,
|
o.clientset.StateClient,
|
||||||
o.clientset.PreferenceClient,
|
o.clientset.PreferenceClient,
|
||||||
)
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if o.logsFlag {
|
if o.logsFlag {
|
||||||
|
|||||||
@@ -3,15 +3,13 @@ import { TAB_COMMANDS, TAB_CONTAINERS, TAB_IMAGES, TAB_RESOURCES } from "./const
|
|||||||
describe('devfile editor errors handling', () => {
|
describe('devfile editor errors handling', () => {
|
||||||
|
|
||||||
it('fails when YAML is not valid', () => {
|
it('fails when YAML is not valid', () => {
|
||||||
cy.visit('http://localhost:4200');
|
cy.init();
|
||||||
cy.clearDevfile();
|
|
||||||
cy.setDevfile("wrong yaml content");
|
cy.setDevfile("wrong yaml content");
|
||||||
cy.getByDataCy("yaml-error").should('contain.text', 'error parsing devfile YAML');
|
cy.getByDataCy("yaml-error").should('contain.text', 'error parsing devfile YAML');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails when adding a container with an already used name', () => {
|
it('fails when adding a container with an already used name', () => {
|
||||||
cy.visit('http://localhost:4200');
|
cy.init();
|
||||||
cy.clearDevfile();
|
|
||||||
cy.fixture('input/with-container.yaml').then(yaml => {
|
cy.fixture('input/with-container.yaml').then(yaml => {
|
||||||
cy.setDevfile(yaml);
|
cy.setDevfile(yaml);
|
||||||
});
|
});
|
||||||
@@ -26,8 +24,7 @@ describe('devfile editor errors handling', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('fails when adding an image with an already used name', () => {
|
it('fails when adding an image with an already used name', () => {
|
||||||
cy.visit('http://localhost:4200');
|
cy.init();
|
||||||
cy.clearDevfile();
|
|
||||||
cy.fixture('input/with-container.yaml').then(yaml => {
|
cy.fixture('input/with-container.yaml').then(yaml => {
|
||||||
cy.setDevfile(yaml);
|
cy.setDevfile(yaml);
|
||||||
});
|
});
|
||||||
@@ -43,8 +40,7 @@ describe('devfile editor errors handling', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('fails when adding a resource with an already used name', () => {
|
it('fails when adding a resource with an already used name', () => {
|
||||||
cy.visit('http://localhost:4200');
|
cy.init();
|
||||||
cy.clearDevfile();
|
|
||||||
cy.fixture('input/with-container.yaml').then(yaml => {
|
cy.fixture('input/with-container.yaml').then(yaml => {
|
||||||
cy.setDevfile(yaml);
|
cy.setDevfile(yaml);
|
||||||
});
|
});
|
||||||
@@ -59,8 +55,7 @@ describe('devfile editor errors handling', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('fails when adding an exec command with an already used name', () => {
|
it('fails when adding an exec command with an already used name', () => {
|
||||||
cy.visit('http://localhost:4200');
|
cy.init();
|
||||||
cy.clearDevfile();
|
|
||||||
cy.fixture('input/with-exec-command.yaml').then(yaml => {
|
cy.fixture('input/with-exec-command.yaml').then(yaml => {
|
||||||
cy.setDevfile(yaml);
|
cy.setDevfile(yaml);
|
||||||
});
|
});
|
||||||
@@ -79,8 +74,7 @@ describe('devfile editor errors handling', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('fails when adding an apply command with an already used name', () => {
|
it('fails when adding an apply command with an already used name', () => {
|
||||||
cy.visit('http://localhost:4200');
|
cy.init();
|
||||||
cy.clearDevfile();
|
|
||||||
cy.fixture('input/with-apply-command.yaml').then(yaml => {
|
cy.fixture('input/with-apply-command.yaml').then(yaml => {
|
||||||
cy.setDevfile(yaml);
|
cy.setDevfile(yaml);
|
||||||
});
|
});
|
||||||
@@ -97,8 +91,7 @@ describe('devfile editor errors handling', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('fails when adding an image command with an already used name', () => {
|
it('fails when adding an image command with an already used name', () => {
|
||||||
cy.visit('http://localhost:4200');
|
cy.init();
|
||||||
cy.clearDevfile();
|
|
||||||
cy.fixture('input/with-image-command.yaml').then(yaml => {
|
cy.fixture('input/with-image-command.yaml').then(yaml => {
|
||||||
cy.setDevfile(yaml);
|
cy.setDevfile(yaml);
|
||||||
});
|
});
|
||||||
@@ -115,8 +108,7 @@ describe('devfile editor errors handling', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('fails when adding a composite command with an already used name', () => {
|
it('fails when adding a composite command with an already used name', () => {
|
||||||
cy.visit('http://localhost:4200');
|
cy.init();
|
||||||
cy.clearDevfile();
|
|
||||||
cy.fixture('input/with-image-command.yaml').then(yaml => {
|
cy.fixture('input/with-image-command.yaml').then(yaml => {
|
||||||
cy.setDevfile(yaml);
|
cy.setDevfile(yaml);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,9 +2,21 @@ import {TAB_COMMANDS, TAB_CONTAINERS, TAB_IMAGES, TAB_METADATA, TAB_RESOURCES} f
|
|||||||
|
|
||||||
describe('devfile editor spec', () => {
|
describe('devfile editor spec', () => {
|
||||||
|
|
||||||
|
let originalDevfile: string
|
||||||
|
before(() => {
|
||||||
|
cy.readFile('devfile.yaml', null).then(yaml => originalDevfile = (<BufferType> yaml).toString())
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cy.readFile('devfile.yaml', null).then(yaml => {
|
||||||
|
if (originalDevfile !== (<BufferType> yaml).toString()) {
|
||||||
|
cy.writeDevfileFile(originalDevfile)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
it('displays matadata.name set in YAML', () => {
|
it('displays matadata.name set in YAML', () => {
|
||||||
cy.visit('http://localhost:4200');
|
cy.init();
|
||||||
cy.clearDevfile();
|
|
||||||
cy.fixture('input/with-metadata-name.yaml').then(yaml => {
|
cy.fixture('input/with-metadata-name.yaml').then(yaml => {
|
||||||
cy.setDevfile(yaml);
|
cy.setDevfile(yaml);
|
||||||
});
|
});
|
||||||
@@ -14,8 +26,7 @@ describe('devfile editor spec', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('displays container set in YAML', () => {
|
it('displays container set in YAML', () => {
|
||||||
cy.visit('http://localhost:4200');
|
cy.init();
|
||||||
cy.clearDevfile();
|
|
||||||
cy.fixture('input/with-container.yaml').then(yaml => {
|
cy.fixture('input/with-container.yaml').then(yaml => {
|
||||||
cy.setDevfile(yaml);
|
cy.setDevfile(yaml);
|
||||||
});
|
});
|
||||||
@@ -29,8 +40,7 @@ describe('devfile editor spec', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('displays a created container', () => {
|
it('displays a created container', () => {
|
||||||
cy.visit('http://localhost:4200');
|
cy.init();
|
||||||
cy.clearDevfile();
|
|
||||||
|
|
||||||
cy.selectTab(TAB_CONTAINERS);
|
cy.selectTab(TAB_CONTAINERS);
|
||||||
cy.getByDataCy('container-name').type('created-container');
|
cy.getByDataCy('container-name').type('created-container');
|
||||||
@@ -43,8 +53,7 @@ describe('devfile editor spec', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('displays a created image', () => {
|
it('displays a created image', () => {
|
||||||
cy.visit('http://localhost:4200');
|
cy.init();
|
||||||
cy.clearDevfile();
|
|
||||||
|
|
||||||
cy.selectTab(TAB_IMAGES);
|
cy.selectTab(TAB_IMAGES);
|
||||||
cy.getByDataCy('image-name').type('created-image');
|
cy.getByDataCy('image-name').type('created-image');
|
||||||
@@ -61,8 +70,7 @@ describe('devfile editor spec', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('displays a created resource, with manifest', () => {
|
it('displays a created resource, with manifest', () => {
|
||||||
cy.visit('http://localhost:4200');
|
cy.init();
|
||||||
cy.clearDevfile();
|
|
||||||
|
|
||||||
cy.selectTab(TAB_RESOURCES);
|
cy.selectTab(TAB_RESOURCES);
|
||||||
cy.getByDataCy('resource-name').type('created-resource');
|
cy.getByDataCy('resource-name').type('created-resource');
|
||||||
@@ -76,8 +84,7 @@ describe('devfile editor spec', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('displays a created resource, with uri (default)', () => {
|
it('displays a created resource, with uri (default)', () => {
|
||||||
cy.visit('http://localhost:4200');
|
cy.init();
|
||||||
cy.clearDevfile();
|
|
||||||
|
|
||||||
cy.selectTab(TAB_RESOURCES);
|
cy.selectTab(TAB_RESOURCES);
|
||||||
cy.getByDataCy('resource-name').type('created-resource');
|
cy.getByDataCy('resource-name').type('created-resource');
|
||||||
@@ -91,8 +98,7 @@ describe('devfile editor spec', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('creates an exec command with a new container', () => {
|
it('creates an exec command with a new container', () => {
|
||||||
cy.visit('http://localhost:4200');
|
cy.init();
|
||||||
cy.clearDevfile();
|
|
||||||
|
|
||||||
cy.selectTab(TAB_COMMANDS);
|
cy.selectTab(TAB_COMMANDS);
|
||||||
cy.getByDataCy('add').click();
|
cy.getByDataCy('add').click();
|
||||||
@@ -122,8 +128,7 @@ describe('devfile editor spec', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('creates an apply image command with a new image', () => {
|
it('creates an apply image command with a new image', () => {
|
||||||
cy.visit('http://localhost:4200');
|
cy.init();
|
||||||
cy.clearDevfile();
|
|
||||||
|
|
||||||
cy.selectTab(TAB_COMMANDS);
|
cy.selectTab(TAB_COMMANDS);
|
||||||
cy.getByDataCy('add').click();
|
cy.getByDataCy('add').click();
|
||||||
@@ -152,8 +157,7 @@ describe('devfile editor spec', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('creates an apply resource command with a new resource using manifest', () => {
|
it('creates an apply resource command with a new resource using manifest', () => {
|
||||||
cy.visit('http://localhost:4200');
|
cy.init();
|
||||||
cy.clearDevfile();
|
|
||||||
|
|
||||||
cy.selectTab(TAB_COMMANDS);
|
cy.selectTab(TAB_COMMANDS);
|
||||||
cy.getByDataCy('add').click();
|
cy.getByDataCy('add').click();
|
||||||
@@ -179,8 +183,7 @@ describe('devfile editor spec', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('creates an apply resource command with a new resource using uri (default)', () => {
|
it('creates an apply resource command with a new resource using uri (default)', () => {
|
||||||
cy.visit('http://localhost:4200');
|
cy.init();
|
||||||
cy.clearDevfile();
|
|
||||||
|
|
||||||
cy.selectTab(TAB_COMMANDS);
|
cy.selectTab(TAB_COMMANDS);
|
||||||
cy.getByDataCy('add').click();
|
cy.getByDataCy('add').click();
|
||||||
@@ -204,4 +207,21 @@ describe('devfile editor spec', () => {
|
|||||||
.should('contain.text', 'URI')
|
.should('contain.text', 'URI')
|
||||||
.should('contain.text', '/my/manifest.yaml');
|
.should('contain.text', '/my/manifest.yaml');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reloads the Devfile upon changes in the filesystem', () => {
|
||||||
|
cy.init();
|
||||||
|
cy.fixture('input/devfile-new-version.yaml').then(yaml => {
|
||||||
|
cy.writeDevfileFile(yaml);
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.selectTab(TAB_METADATA);
|
||||||
|
cy.getByDataCy("metadata-name").should('have.value', 'my-component');
|
||||||
|
|
||||||
|
cy.selectTab(TAB_CONTAINERS);
|
||||||
|
cy.getByDataCy('container-info').first()
|
||||||
|
.should('contain.text', 'my-cont1')
|
||||||
|
.should('contain.text', 'some-image:latest')
|
||||||
|
.should('contain.text', 'some command')
|
||||||
|
.should('contain.text', 'some arg');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
13
ui/cypress/fixtures/input/devfile-new-version.yaml
Normal file
13
ui/cypress/fixtures/input/devfile-new-version.yaml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
schemaVersion: 2.2.0
|
||||||
|
metadata:
|
||||||
|
name: my-component
|
||||||
|
components:
|
||||||
|
- container:
|
||||||
|
args:
|
||||||
|
- some
|
||||||
|
- arg
|
||||||
|
command:
|
||||||
|
- some
|
||||||
|
- command
|
||||||
|
image: some-image:latest
|
||||||
|
name: my-cont1
|
||||||
@@ -44,28 +44,47 @@ Cypress.Commands.add('selectTab', (n: number) => {
|
|||||||
cy.get('div[role=tab]').eq(n).click();
|
cy.get('div[role=tab]').eq(n).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add('init', () => {
|
||||||
|
cy.intercept('GET', '/api/v1/devfile').as('init.fetchDevfile');
|
||||||
|
cy.intercept('PUT', '/api/v1/devstate/devfile').as('init.applyDevState');
|
||||||
|
cy.visit('http://localhost:4200');
|
||||||
|
cy.wait(['@init.fetchDevfile', '@init.applyDevState']);
|
||||||
|
|
||||||
|
cy.clearDevfile()
|
||||||
|
});
|
||||||
|
|
||||||
Cypress.Commands.add('setDevfile', (devfile: string) => {
|
Cypress.Commands.add('setDevfile', (devfile: string) => {
|
||||||
cy.intercept('GET', '/api/v1/devstate/chart').as('getDevStateChart');
|
cy.intercept('PUT', '/api/v1/devstate/devfile').as('setDevfile.applyDevState');
|
||||||
cy.intercept('PUT', '/api/v1/devstate/devfile').as('applyDevState');
|
|
||||||
cy.get('[data-cy="yaml-input"]').type(devfile);
|
cy.get('[data-cy="yaml-input"]').type(devfile);
|
||||||
cy.get('[data-cy="yaml-save"]').click();
|
cy.get('[data-cy="yaml-save"]').click();
|
||||||
cy.wait(['@applyDevState', '@getDevStateChart']);
|
cy.wait(['@setDevfile.applyDevState']);
|
||||||
});
|
});
|
||||||
|
|
||||||
Cypress.Commands.add('clearDevfile', () => {
|
Cypress.Commands.add('clearDevfile', () => {
|
||||||
cy.intercept('GET', '/api/v1/devstate/chart').as('getDevStateChart');
|
cy.intercept('DELETE', '/api/v1/devstate/devfile').as('clearDevfile.clearDevState');
|
||||||
cy.intercept('DELETE', '/api/v1/devstate/devfile').as('clearDevState');
|
cy.intercept('PUT', '/api/v1/devstate/devfile').as('clearDevfile.applyDevState');
|
||||||
cy.intercept('PUT', '/api/v1/devstate/devfile').as('applyDevState');
|
|
||||||
cy.get('[data-cy="yaml-clear"]', { timeout: 60000 }).click();
|
cy.get('[data-cy="yaml-clear"]', { timeout: 60000 }).click();
|
||||||
cy.wait(['@clearDevState', '@applyDevState', '@getDevStateChart']);
|
cy.wait(['@clearDevfile.clearDevState', '@clearDevfile.applyDevState']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// writeDevfileFile writes the specified content into the local devfile.yaml file on the filesystem.
|
||||||
|
// Since #6902, doing so sends notification from the server to the client, and makes it reload the Devfile.
|
||||||
|
Cypress.Commands.add('writeDevfileFile', (content: string) => {
|
||||||
|
cy.intercept('PUT', '/api/v1/devstate/devfile').as('writeDevfileFile.applyDevState');
|
||||||
|
cy.writeFile('devfile.yaml', content)
|
||||||
|
cy.wait(['@writeDevfileFile.applyDevState']);
|
||||||
});
|
});
|
||||||
|
|
||||||
declare namespace Cypress {
|
declare namespace Cypress {
|
||||||
interface Chainable {
|
interface Chainable {
|
||||||
|
init(): Chainable<void>
|
||||||
|
|
||||||
getByDataCy(value: string): Chainable<void>
|
getByDataCy(value: string): Chainable<void>
|
||||||
selectTab(n: number): Chainable<void>
|
selectTab(n: number): Chainable<void>
|
||||||
|
|
||||||
setDevfile(devfile: string): Chainable<void>
|
setDevfile(devfile: string): Chainable<void>
|
||||||
clearDevfile(): Chainable<void>
|
clearDevfile(): Chainable<void>
|
||||||
|
|
||||||
|
writeDevfileFile(content: string): Chainable<void>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { MermaidService } from './services/mermaid.service';
|
|||||||
import { StateService } from './services/state.service';
|
import { StateService } from './services/state.service';
|
||||||
import { MatIconRegistry } from "@angular/material/icon";
|
import { MatIconRegistry } from "@angular/material/icon";
|
||||||
import { OdoapiService } from './services/odoapi.service';
|
import { OdoapiService } from './services/odoapi.service';
|
||||||
|
import { SseService } from './services/sse.service';
|
||||||
|
import {DevfileContent} from "./api-gen";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -24,6 +26,7 @@ export class AppComponent implements OnInit {
|
|||||||
private odoApi: OdoapiService,
|
private odoApi: OdoapiService,
|
||||||
private mermaid: MermaidService,
|
private mermaid: MermaidService,
|
||||||
private state: StateService,
|
private state: StateService,
|
||||||
|
private sse: SseService,
|
||||||
) {
|
) {
|
||||||
this.matIconRegistry.addSvgIcon(
|
this.matIconRegistry.addSvgIcon(
|
||||||
`github`,
|
`github`,
|
||||||
@@ -64,6 +67,13 @@ export class AppComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.sse.subscribeTo(['DevfileUpdated']).subscribe(event => {
|
||||||
|
let newDevfile: DevfileContent = JSON.parse(event.data)
|
||||||
|
if (newDevfile.content != undefined) {
|
||||||
|
this.onButtonClick(newDevfile.content, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onButtonClick(content: string, save: boolean){
|
onButtonClick(content: string, save: boolean){
|
||||||
|
|||||||
27
ui/src/app/services/sse.service.ts
Normal file
27
ui/src/app/services/sse.service.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import {Observable} from "rxjs";
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class SseService {
|
||||||
|
private base = "/api/v1";
|
||||||
|
private evtSource: EventSource
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.evtSource = new EventSource(this.base + "/notifications");
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeTo(eventTypes: string[]): Observable<any> {
|
||||||
|
return new Observable( (subscriber) => {
|
||||||
|
eventTypes.forEach(eventType => {
|
||||||
|
this.evtSource.addEventListener(eventType, (event) => {
|
||||||
|
subscriber.next(event);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
this.evtSource.onerror = (error) => {
|
||||||
|
subscriber.error(error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user