From aa9837b5f069680724035fa019211bada0cf51f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sybren=20A=2E=20St=C3=BCvel?= Date: Thu, 14 Jul 2022 11:09:32 +0200 Subject: [PATCH] First incarnation of the first-time wizard This adds a `-wizard` CLI option to the Manager, which opens a webbrowser and shows the First-Time Wizard to aid in configuration of Flamenco. This is work in progress. The wizard is just one page, and doesn't save anything yet to the configuration. --- cmd/flamenco-manager/main.go | 66 +++++++++-- go.mod | 1 + go.sum | 3 + internal/manager/api_impl/dummy/dummy.go | 8 ++ internal/manager/api_impl/dummy/shaman.go | 33 ++++++ internal/manager/api_impl/interfaces.go | 3 + internal/manager/api_impl/meta.go | 107 ++++++++++++++++++ internal/manager/api_impl/meta_test.go | 75 ++++++++++++ .../api_impl/mocks/api_impl_mock.gen.go | 15 +++ internal/manager/api_impl/support_test.go | 24 ++++ internal/manager/config/config.go | 21 ++-- internal/manager/config/defaults.go | 1 + internal/manager/config/service.go | 36 +++++- internal/own_url/own_url.go | 9 ++ web/app/src/FirstTimeWizard.vue | 51 +++++++++ web/app/src/assets/base.css | 39 +++++++ web/app/src/first-time-wizard.js | 26 +++++ web/app/src/main.js | 55 ++++++--- web/app/src/router/first-time-wizard.js | 19 ++++ web/app/src/stores/api-query-count.js | 4 +- web/app/src/views/FirstTimeWizardView.vue | 82 ++++++++++++++ web/web_app.go | 8 +- 22 files changed, 643 insertions(+), 43 deletions(-) create mode 100644 internal/manager/api_impl/dummy/dummy.go create mode 100644 internal/manager/api_impl/dummy/shaman.go create mode 100644 web/app/src/FirstTimeWizard.vue create mode 100644 web/app/src/first-time-wizard.js create mode 100644 web/app/src/router/first-time-wizard.js create mode 100644 web/app/src/views/FirstTimeWizardView.vue diff --git a/cmd/flamenco-manager/main.go b/cmd/flamenco-manager/main.go index c0768c06..a2f56035 100644 --- a/cmd/flamenco-manager/main.go +++ b/cmd/flamenco-manager/main.go @@ -25,12 +25,14 @@ import ( "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/mattn/go-colorable" + "github.com/pkg/browser" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/ziflex/lecho/v3" "git.blender.org/flamenco/internal/appinfo" "git.blender.org/flamenco/internal/manager/api_impl" + "git.blender.org/flamenco/internal/manager/api_impl/dummy" "git.blender.org/flamenco/internal/manager/config" "git.blender.org/flamenco/internal/manager/job_compilers" "git.blender.org/flamenco/internal/manager/last_rendered" @@ -49,12 +51,17 @@ import ( ) var cliArgs struct { - version bool - writeConfig bool - delayResponses bool + version bool + writeConfig bool + delayResponses bool + firstTimeWizard bool } -const developmentWebInterfacePort = 8081 +const ( + developmentWebInterfacePort = 8081 + + webappEntryPoint = "index.html" +) func main() { output := zerolog.ConsoleWriter{Out: colorable.NewColorableStdout(), TimeFormat: time.RFC3339} @@ -82,6 +89,17 @@ func main() { log.Error().Err(err).Msg("loading configuration") } + if cliArgs.firstTimeWizard { + configService.ForceFirstRun() + } + isFirstRun, err := configService.IsFirstRun() + switch { + case err != nil: + log.Fatal().Err(err).Msg("unable to determine whether this is the first run of Flamenco or not") + case isFirstRun: + log.Info().Msg("This seems to be your first run of Flamenco! A webbrowser will open to help you set things up.") + } + if cliArgs.writeConfig { err := configService.Save() if err != nil { @@ -120,8 +138,10 @@ func main() { taskStateMachine := task_state_machine.NewStateMachine(persist, webUpdater, logStorage) lastRender := last_rendered.New(localStorage) + + shamanServer := buildShamanServer(configService, isFirstRun) flamenco := buildFlamencoAPI(timeService, configService, persist, taskStateMachine, - logStorage, webUpdater, lastRender, localStorage) + shamanServer, logStorage, webUpdater, lastRender, localStorage) e := buildWebService(flamenco, persist, ssdp, webUpdater, urls, localStorage) timeoutChecker := timeout_checker.New( @@ -176,6 +196,11 @@ func main() { timeoutChecker.Run(mainCtx) }() + // Open a webbrowser, but give the web service some time to start first. + if isFirstRun { + go openWebbrowser(mainCtx, urls[0]) + } + wg.Wait() log.Info().Msg("shutdown complete") } @@ -185,6 +210,7 @@ func buildFlamencoAPI( configService *config.Service, persist *persistence.DB, taskStateMachine *task_state_machine.StateMachine, + shamanServer api_impl.Shaman, logStorage *task_logs.Storage, webUpdater *webupdates.BiDirComms, lastRender *last_rendered.LastRenderedProcessor, @@ -194,7 +220,6 @@ func buildFlamencoAPI( if err != nil { log.Fatal().Err(err).Msg("error loading job compilers") } - shamanServer := shaman.NewServer(configService.Get().Shaman, nil) flamenco := api_impl.NewFlamenco( compiler, persist, webUpdater, logStorage, configService, taskStateMachine, shamanServer, timeService, lastRender, @@ -297,7 +322,7 @@ func buildWebService( } // Serve static files for the webapp on /app/. - webAppHandler, err := web.WebAppHandler() + webAppHandler, err := web.WebAppHandler(webappEntryPoint) if err != nil { log.Fatal().Err(err).Msg("unable to set up HTTP server for embedded web app") } @@ -382,6 +407,32 @@ func runWebService(ctx context.Context, e *echo.Echo, listen string) error { } } +func buildShamanServer(configService *config.Service, isFirstRun bool) api_impl.Shaman { + if isFirstRun { + log.Info().Msg("Not starting Shaman storage service, as this is the first run of Flamenco. Configure the shared storage location first.") + return &dummy.DummyShaman{} + } + return shaman.NewServer(configService.Get().Shaman, nil) +} + +// openWebbrowser starts a web browser after waiting for 1 second. +// Closing the context aborts the opening of the browser, but doesn't close the +// browser itself if has already started. +func openWebbrowser(ctx context.Context, url url.URL) { + select { + case <-ctx.Done(): + return + case <-time.After(1 * time.Second): + } + + urlToTry := url.String() + if err := browser.OpenURL(urlToTry); err != nil { + log.Fatal().Err(err).Msgf("unable to open a browser to %s", urlToTry) + } + log.Info().Str("url", urlToTry).Msgf("opened browser to the Flamenco interface") + +} + func parseCliArgs() { var quiet, debug, trace bool @@ -392,6 +443,7 @@ func parseCliArgs() { flag.BoolVar(&cliArgs.writeConfig, "write-config", false, "Writes configuration to flamenco-manager.yaml, then exits.") flag.BoolVar(&cliArgs.delayResponses, "delay", false, "Add a random delay to any HTTP responses. This aids in development of Flamenco Manager's web frontend.") + flag.BoolVar(&cliArgs.firstTimeWizard, "wizard", false, "Open a webbrowser with the first-time configuration wizard.") flag.Parse() diff --git a/go.mod b/go.mod index daab0309..e64ff0fc 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,7 @@ require ( github.com/labstack/gommon v0.3.1 // indirect github.com/mailru/easyjson v0.7.0 // indirect github.com/mattn/go-isatty v0.0.14 // indirect + github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect diff --git a/go.sum b/go.sum index e8d65458..42601194 100644 --- a/go.sum +++ b/go.sum @@ -127,6 +127,8 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -207,6 +209,7 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/manager/api_impl/dummy/dummy.go b/internal/manager/api_impl/dummy/dummy.go new file mode 100644 index 00000000..a0262441 --- /dev/null +++ b/internal/manager/api_impl/dummy/dummy.go @@ -0,0 +1,8 @@ +// Package dummy contains non-functional implementations of certain interfaces. +// This allows the Flamenco API to be started with a subset of its +// functionality, so that the API can be served without Shaman file storage, or +// without the persistence layer. +// +// This is used for the first startup of Flamenco, where for example the shared +// storage location isn't configured yet, and thus the Shaman shouldn't start. +package dummy diff --git a/internal/manager/api_impl/dummy/shaman.go b/internal/manager/api_impl/dummy/shaman.go new file mode 100644 index 00000000..b5ac619b --- /dev/null +++ b/internal/manager/api_impl/dummy/shaman.go @@ -0,0 +1,33 @@ +package dummy + +import ( + "context" + "errors" + "io" + + "git.blender.org/flamenco/internal/manager/api_impl" + "git.blender.org/flamenco/pkg/api" +) + +// DummyShaman implements the Shaman interface from `internal/manager/api_impl/interfaces.go` +type DummyShaman struct{} + +var _ api_impl.Shaman = (*DummyShaman)(nil) + +var ErrDummyShaman = errors.New("Shaman storage component is inactive, configure Flamenco first") + +func (ds *DummyShaman) IsEnabled() bool { + return false +} +func (ds *DummyShaman) Checkout(ctx context.Context, checkout api.ShamanCheckout) (string, error) { + return "", ErrDummyShaman +} +func (ds *DummyShaman) Requirements(ctx context.Context, requirements api.ShamanRequirementsRequest) (api.ShamanRequirementsResponse, error) { + return api.ShamanRequirementsResponse{}, ErrDummyShaman +} +func (ds *DummyShaman) FileStoreCheck(ctx context.Context, checksum string, filesize int64) api.ShamanFileStatus { + return api.ShamanFileStatusUnknown +} +func (ds *DummyShaman) FileStore(ctx context.Context, file io.ReadCloser, checksum string, filesize int64, canDefer bool, originalFilename string) error { + return ErrDummyShaman +} diff --git a/internal/manager/api_impl/interfaces.go b/internal/manager/api_impl/interfaces.go index b23eed89..1d5e3935 100644 --- a/internal/manager/api_impl/interfaces.go +++ b/internal/manager/api_impl/interfaces.go @@ -158,6 +158,9 @@ type ConfigService interface { // basically the configured storage path, but can be influenced by other // options (like Shaman). EffectiveStoragePath() string + + // IsFirstRun returns true if this is likely to be the first run of Flamenco. + IsFirstRun() (bool, error) } type Shaman interface { diff --git a/internal/manager/api_impl/meta.go b/internal/manager/api_impl/meta.go index def7512c..0f987039 100644 --- a/internal/manager/api_impl/meta.go +++ b/internal/manager/api_impl/meta.go @@ -4,7 +4,12 @@ package api_impl // SPDX-License-Identifier: GPL-3.0-or-later import ( + "errors" + "fmt" + "io/fs" "net/http" + "os" + "path/filepath" "git.blender.org/flamenco/internal/appinfo" "git.blender.org/flamenco/internal/manager/config" @@ -20,12 +25,25 @@ func (f *Flamenco) GetVersion(e echo.Context) error { } func (f *Flamenco) GetConfiguration(e echo.Context) error { + isFirstRun, err := f.config.IsFirstRun() + if err != nil { + logger := requestLogger(e) + logger.Error().Err(err).Msg("error investigating configuration") + return sendAPIError(e, http.StatusInternalServerError, "error investigating configuration: %v", err) + } + return e.JSON(http.StatusOK, api.ManagerConfiguration{ ShamanEnabled: f.isShamanEnabled(), StorageLocation: f.config.EffectiveStoragePath(), + IsFirstRun: isFirstRun, }) } +func (f *Flamenco) GetConfigurationFile(e echo.Context) error { + config := f.config.Get() + return e.JSON(http.StatusOK, config) +} + func (f *Flamenco) GetVariables(e echo.Context, audience api.ManagerVariableAudience, platform string) error { variables := f.config.ResolveVariables( config.VariableAudience(audience), @@ -44,3 +62,92 @@ func (f *Flamenco) GetVariables(e echo.Context, audience api.ManagerVariableAudi return e.JSON(http.StatusOK, apiVars) } + +func (f *Flamenco) CheckSharedStoragePath(e echo.Context) error { + logger := requestLogger(e) + + var toCheck api.CheckSharedStoragePathJSONBody + if err := e.Bind(&toCheck); err != nil { + logger.Warn().Err(err).Msg("bad request received") + return sendAPIError(e, http.StatusBadRequest, "invalid format") + } + + path := toCheck.Path + logger = logger.With().Str("path", path).Logger() + logger.Info().Msg("checking whether this path is suitable as shared storage") + + mkError := func(cause string, args ...interface{}) error { + if len(args) > 0 { + cause = fmt.Sprintf(cause, args...) + } + + logger.Warn().Str("cause", cause).Msg("shared storage path check failed") + return e.JSON(http.StatusOK, api.PathCheckResult{ + Cause: cause, + IsUsable: false, + Path: path, + }) + } + + // Check for emptyness. + if path == "" { + return mkError("An empty path is never suitable as shared storage") + } + + // Check whether it is actually a directory. + stat, err := os.Stat(path) + switch { + case errors.Is(err, fs.ErrNotExist): + return mkError("This path does not exist. Choose an existing directory.") + case err != nil: + logger.Error().Err(err).Msg("error checking filesystem") + return mkError("Error checking filesystem: %v", err) + case !stat.IsDir(): + return mkError("The given path is not a directory. Choose an existing directory.") + } + + // Check if this is the Flamenco directory itself. + myDir, err := flamencoManagerDir() + if err != nil { + logger.Error().Err(err).Msg("error trying to find my own directory") + } else if path == myDir { + return mkError("Don't pick the installation directory of Flamenco Manager. Choose a directory dedicated to the shared storage of files.") + } + + // See if we can create a file there. + file, err := os.CreateTemp(path, "flamenco-writability-test-*.txt") + if err != nil { + return mkError("Unable to create a file in that directory: %v. "+ + "Pick an existing directory where Flamenco Manager can create files.", err) + } + + defer func() { + // Clean up after the test is done. + file.Close() + os.Remove(file.Name()) + }() + + if _, err := file.Write([]byte("Ünicöde")); err != nil { + return mkError("unable to write to %s: %v", file.Name(), err) + } + if err := file.Close(); err != nil { + // Some write errors only get reported when the file is closed, so just + // report is as a regular write error. + return mkError("unable to write to %s: %v", file.Name(), err) + } + + // There is a directory, and we can create a file there. Should be good to go. + return e.JSON(http.StatusOK, api.PathCheckResult{ + Cause: "Directory checked OK!", + IsUsable: true, + Path: path, + }) +} + +func flamencoManagerDir() (string, error) { + exename, err := os.Executable() + if err != nil { + return "", err + } + return filepath.Dir(exename), nil +} diff --git a/internal/manager/api_impl/meta_test.go b/internal/manager/api_impl/meta_test.go index af59eac7..0ec97ef0 100644 --- a/internal/manager/api_impl/meta_test.go +++ b/internal/manager/api_impl/meta_test.go @@ -3,12 +3,16 @@ package api_impl // SPDX-License-Identifier: GPL-3.0-or-later import ( + "io/fs" "net/http" + "os" + "path/filepath" "testing" "git.blender.org/flamenco/internal/manager/config" "git.blender.org/flamenco/pkg/api" "github.com/golang/mock/gomock" + "github.com/labstack/echo/v4" "github.com/stretchr/testify/assert" ) @@ -58,3 +62,74 @@ func TestGetVariables(t *testing.T) { assertResponseJSON(t, echoCtx, http.StatusOK, api.ManagerVariables{}) } } + +func TestCheckSharedStoragePath(t *testing.T) { + mf, finish := metaTestFixtures(t) + defer finish() + + doTest := func(path string) echo.Context { + echoCtx := mf.prepareMockedJSONRequest( + api.PathCheckInput{Path: path}) + err := mf.flamenco.CheckSharedStoragePath(echoCtx) + if !assert.NoError(t, err) { + t.FailNow() + } + return echoCtx + } + + // Test empty path. + echoCtx := doTest("") + assertResponseJSON(t, echoCtx, http.StatusOK, api.PathCheckResult{ + Path: "", + IsUsable: false, + Cause: "An empty path is never suitable as shared storage", + }) + + // Test usable path (well, at least readable & writable; it may not be shared via Samba/NFS). + echoCtx = doTest(mf.tempdir) + assertResponseJSON(t, echoCtx, http.StatusOK, api.PathCheckResult{ + Path: mf.tempdir, + IsUsable: true, + Cause: "Directory checked OK!", + }) + files, err := filepath.Glob(filepath.Join(mf.tempdir, "*")) + if assert.NoError(t, err) { + assert.Empty(t, files, "After a query, there should not be any leftovers") + } + + // Test inaccessible path. + { + parentPath := filepath.Join(mf.tempdir, "deep") + testPath := filepath.Join(parentPath, "nesting") + if err := os.Mkdir(parentPath, fs.ModePerm); !assert.NoError(t, err) { + t.FailNow() + } + if err := os.Mkdir(testPath, fs.FileMode(0)); !assert.NoError(t, err) { + t.FailNow() + } + echoCtx := doTest(testPath) + result := api.PathCheckResult{} + getResponseJSON(t, echoCtx, http.StatusOK, &result) + assert.Equal(t, testPath, result.Path) + assert.False(t, result.IsUsable) + assert.Contains(t, result.Cause, "Unable to create a file") + } +} + +func metaTestFixtures(t *testing.T) (mockedFlamenco, func()) { + mockCtrl := gomock.NewController(t) + mf := newMockedFlamenco(mockCtrl) + + tempdir, err := os.MkdirTemp("", "test-temp-dir") + if !assert.NoError(t, err) { + t.FailNow() + } + mf.tempdir = tempdir + + finish := func() { + mockCtrl.Finish() + os.RemoveAll(tempdir) + } + + return mf, finish +} diff --git a/internal/manager/api_impl/mocks/api_impl_mock.gen.go b/internal/manager/api_impl/mocks/api_impl_mock.gen.go index c7dde833..4fb68b24 100644 --- a/internal/manager/api_impl/mocks/api_impl_mock.gen.go +++ b/internal/manager/api_impl/mocks/api_impl_mock.gen.go @@ -713,6 +713,21 @@ func (mr *MockConfigServiceMockRecorder) Get() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockConfigService)(nil).Get)) } +// IsFirstRun mocks base method. +func (m *MockConfigService) IsFirstRun() (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsFirstRun") + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IsFirstRun indicates an expected call of IsFirstRun. +func (mr *MockConfigServiceMockRecorder) IsFirstRun() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsFirstRun", reflect.TypeOf((*MockConfigService)(nil).IsFirstRun)) +} + // ResolveVariables mocks base method. func (m *MockConfigService) ResolveVariables(arg0 config.VariableAudience, arg1 config.VariablePlatform) map[string]config.ResolvedVariable { m.ctrl.T.Helper() diff --git a/internal/manager/api_impl/support_test.go b/internal/manager/api_impl/support_test.go index 85643aec..3ae8618f 100644 --- a/internal/manager/api_impl/support_test.go +++ b/internal/manager/api_impl/support_test.go @@ -34,6 +34,9 @@ type mockedFlamenco struct { clock *clock.Mock lastRender *mocks.MockLastRendered localStorage *mocks.MockLocalStorage + + // Place for some tests to store a temporary directory. + tempdir string } func newMockedFlamenco(mockCtrl *gomock.Controller) mockedFlamenco { @@ -108,6 +111,27 @@ func getRecordedResponse(echoCtx echo.Context) *http.Response { return getRecordedResponseRecorder(echoCtx).Result() } +func getResponseJSON(t *testing.T, echoCtx echo.Context, expectStatusCode int, actualPayloadPtr interface{}) { + resp := getRecordedResponse(echoCtx) + assert.Equal(t, expectStatusCode, resp.StatusCode) + contentType := resp.Header.Get(echo.HeaderContentType) + + if !assert.Equal(t, "application/json; charset=UTF-8", contentType) { + t.Fatalf("response not JSON but %q, not going to compare body", contentType) + return + } + + actualJSON, err := io.ReadAll(resp.Body) + if !assert.NoError(t, err) { + t.FailNow() + } + + err = json.Unmarshal(actualJSON, actualPayloadPtr) + if !assert.NoError(t, err) { + t.FailNow() + } +} + // assertResponseJSON asserts that a recorded response is JSON with the given HTTP status code. func assertResponseJSON(t *testing.T, echoCtx echo.Context, expectStatusCode int, expectBody interface{}) { resp := getRecordedResponse(echoCtx) diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 89ccef84..b3b08d4c 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -225,19 +225,20 @@ func (c *Conf) processAfterLoading(override ...func(c *Conf)) { } func (c *Conf) processStorage() { - storagePath, err := filepath.Abs(c.SharedStoragePath) - if err != nil { - log.Error().Err(err). - Str("storage_path", c.SharedStoragePath). - Msg("unable to determine absolute storage path") - } else { - c.SharedStoragePath = storagePath + // The shared storage path should be absolute, but only if it's actually configured. + if c.SharedStoragePath != "" { + storagePath, err := filepath.Abs(c.SharedStoragePath) + if err != nil { + log.Error().Err(err). + Str("storage_path", c.SharedStoragePath). + Msg("unable to determine absolute storage path") + } else { + c.SharedStoragePath = storagePath + } } // Shaman should use the Flamenco storage location. - if c.Shaman.Enabled { - c.Shaman.StoragePath = c.SharedStoragePath - } + c.Shaman.StoragePath = c.SharedStoragePath } // EffectiveStoragePath returns the absolute path of the job storage directory. diff --git a/internal/manager/config/defaults.go b/internal/manager/config/defaults.go index af2d561f..02b711b7 100644 --- a/internal/manager/config/defaults.go +++ b/internal/manager/config/defaults.go @@ -21,6 +21,7 @@ var defaultConfig = Conf{ SSDPDiscovery: true, LocalManagerStoragePath: "./flamenco-manager-storage", SharedStoragePath: "./flamenco-shared-storage", + // SharedStoragePath: "", // Empty string means "first run", and should trigger the config wizard. Shaman: shaman_config.Config{ // Enable Shaman by default, except on Windows where symlinks are still tricky. diff --git a/internal/manager/config/service.go b/internal/manager/config/service.go index 88fbbb6a..6436cfca 100644 --- a/internal/manager/config/service.go +++ b/internal/manager/config/service.go @@ -1,12 +1,19 @@ package config -import "github.com/rs/zerolog/log" - // SPDX-License-Identifier: GPL-3.0-or-later +import ( + "errors" + "fmt" + "io/fs" + + "github.com/rs/zerolog/log" +) + // Service provides access to Flamenco Manager configuration. type Service struct { - config Conf + config Conf + forceFirstRun bool } func NewService() *Service { @@ -15,6 +22,29 @@ func NewService() *Service { } } +// IsFirstRun returns true if this is likely to be the first run of Flamenco. +func (s *Service) IsFirstRun() (bool, error) { + if s.forceFirstRun { + return true, nil + } + + config, err := getConf() + switch { + case errors.Is(err, fs.ErrNotExist): + // No configuration means first run. + return false, nil + case err != nil: + return false, fmt.Errorf("loading %s: %w", configFilename, err) + } + + // No shared storage configured means first run. + return config.SharedStoragePath == "", nil +} + +func (s *Service) ForceFirstRun() { + s.forceFirstRun = true +} + func (s *Service) Load() error { config, err := getConf() if err != nil { diff --git a/internal/own_url/own_url.go b/internal/own_url/own_url.go index 57c036c2..17541c3c 100644 --- a/internal/own_url/own_url.go +++ b/internal/own_url/own_url.go @@ -29,6 +29,15 @@ func AvailableURLs(schema, listen string) ([]url.URL, error) { return urlsForNetworkInterfaces(schema, listen, addrs) } +// ToStringers converts an array of URLs to an array of `fmt.Stringer`. +func ToStringers(urls []url.URL) []fmt.Stringer { + stringers := make([]fmt.Stringer, len(urls)) + for idx := range urls { + stringers[idx] = &urls[idx] + } + return stringers +} + // specificHostURL returns the hosts's URL if the "listen" string is specific enough, otherwise nil. // Examples: "192.168.0.1:8080" is specific enough, "0.0.0.0:8080" and ":8080" are not. func specificHostURL(scheme, listen string) *url.URL { diff --git a/web/app/src/FirstTimeWizard.vue b/web/app/src/FirstTimeWizard.vue new file mode 100644 index 00000000..86f3a653 --- /dev/null +++ b/web/app/src/FirstTimeWizard.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/web/app/src/assets/base.css b/web/app/src/assets/base.css index 59dd2301..e6f44198 100644 --- a/web/app/src/assets/base.css +++ b/web/app/src/assets/base.css @@ -548,3 +548,42 @@ span.state-transition-arrow.lazy { max-height: 100%; max-width: 100%; } + +/* ------------ First Time Wizard ------------ */ + +.first-time-wizard { + --color-check-failed: var(--color-status-failed); + --color-check-ok: var(--color-status-completed); + + /* TODO: this is not always the best layout, as the content will get shifted + * to the right on narrow media. It's probably better to just not use the + * 3-column layout for the first-time wizard, and use a width-limited centered + * div instead. */ + grid-column: col-2; + text-align: left; +} +.first-time-wizard h1 { + border-bottom: thin solid var(--color-accent); +} +.first-time-wizard section { + font-size: larger; + text-align: left; +} +.first-time-wizard p.hint { + color: var(--color-text-hint); + font-size: smaller; +} +.first-time-wizard .check-ok { + color: var(--color-check-ok); +} +.first-time-wizard .check-failed { + color: var(--color-check-failed); +} +.first-time-wizard .check-ok::before { + content: "✔ "; +} +.first-time-wizard .check-failed::before { + content: "❌ "; +} + +/* ------------ /First Time Wizard ------------ */ diff --git a/web/app/src/first-time-wizard.js b/web/app/src/first-time-wizard.js new file mode 100644 index 00000000..40c2b303 --- /dev/null +++ b/web/app/src/first-time-wizard.js @@ -0,0 +1,26 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' + +import FirstTimeWizard from '@/FirstTimeWizard.vue' +import router from '@/router/first-time-wizard' + +// Ensure Tabulator can find `luxon`, which it needs for sorting by +// date/time/datetime. +import { DateTime } from 'luxon'; +window.DateTime = DateTime; + +// plain removes any Vue reactivity. +window.plain = (x) => JSON.parse(JSON.stringify(x)); +// objectEmpty returns whether the object is empty or not. +window.objectEmpty = (o) => !o || Object.entries(o).length == 0; + +const app = createApp(FirstTimeWizard) +const pinia = createPinia() + +app.use(pinia) +app.use(router) +app.mount('#app') + +// Automatically reload the window after a period of inactivity from the user. +import autoreload from '@/autoreloader' +autoreload(); diff --git a/web/app/src/main.js b/web/app/src/main.js index 60ef4264..37782be1 100644 --- a/web/app/src/main.js +++ b/web/app/src/main.js @@ -1,36 +1,55 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' +import { DateTime } from 'luxon'; import App from '@/App.vue' +import FirstTimeWizard from '@/FirstTimeWizard.vue' +import autoreload from '@/autoreloader' import router from '@/router/index' +import wizardRouter from '@/router/first-time-wizard' +import { ApiClient, MetaApi } from "@/manager-api"; +import * as urls from '@/urls' // Ensure Tabulator can find `luxon`, which it needs for sorting by // date/time/datetime. -import { DateTime } from 'luxon'; window.DateTime = DateTime; - // plain removes any Vue reactivity. window.plain = (x) => JSON.parse(JSON.stringify(x)); // objectEmpty returns whether the object is empty or not. window.objectEmpty = (o) => !o || Object.entries(o).length == 0; -const app = createApp(App) +// Automatically reload the window after a period of inactivity from the user. +autoreload(); + const pinia = createPinia() -app.use(pinia) -app.use(router) -app.mount('#app') +function normalMode() { + console.log("Flamenco is starting in normal operation mode"); + const app = createApp(App) + app.use(pinia) + app.use(router) + app.mount('#app') +} -// For debugging. -import { useJobs } from '@/stores/jobs'; -import { useNotifs } from '@/stores/notifications'; -import { useTaskLog } from '@/stores/tasklog'; -import * as API from '@/manager-api'; -window.jobs = useJobs(); -window.notifs = useNotifs(); -window.taskLog = useTaskLog(); -window.API = API; +function firstTimeWizardMode() { + console.log("Flamenco First Time Wizard is starting"); + const app = createApp(FirstTimeWizard) + app.use(pinia) + app.use(wizardRouter) + app.mount('#app') +} -// Automatically reload the window after a period of inactivity from the user. -import autoreload from '@/autoreloader' -autoreload(); +/* This cannot use the client from '@/stores/api-query-count', as that would + * require Pinia, which is unavailable until the app is actually started. And to + * know which app to start, this API call needs to return data. */ +const apiClient = new ApiClient(urls.api());; +const metaAPI = new MetaApi(apiClient); +metaAPI.getConfiguration() + .then((config) => { + console.log("Got config!", config); + if (config.isFirstRun) firstTimeWizardMode(); + else normalMode(); + }) + .catch((error) => { + console.warn("Error getting Manager configuration:", error); + }) diff --git a/web/app/src/router/first-time-wizard.js b/web/app/src/router/first-time-wizard.js new file mode 100644 index 00000000..2d9d1353 --- /dev/null +++ b/web/app/src/router/first-time-wizard.js @@ -0,0 +1,19 @@ +import { createRouter, createWebHistory } from "vue-router"; + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: "/", + name: "index", + component: () => import("../views/FirstTimeWizardView.vue"), + }, + { + path: "/:pathMatch(.*)*", + name: "redirect-to-index", + redirect: '/', + }, + ], +}); + +export default router; diff --git a/web/app/src/stores/api-query-count.js b/web/app/src/stores/api-query-count.js index a6415aa0..3b0c4200 100644 --- a/web/app/src/stores/api-query-count.js +++ b/web/app/src/stores/api-query-count.js @@ -1,5 +1,5 @@ import { defineStore } from "pinia"; -import * as API from "@/manager-api"; +import { ApiClient } from "@/manager-api"; import * as urls from '@/urls' /** @@ -28,7 +28,7 @@ export const useAPIQueryCount = defineStore("apiQueryCount", { }, }); -export class CountingApiClient extends API.ApiClient { +export class CountingApiClient extends ApiClient { callApi(path, httpMethod, pathParams, queryParams, headerParams, formParams, bodyParam, authNames, contentTypes, accepts, returnType, apiBasePath ) { const apiQueryCount = useAPIQueryCount(); diff --git a/web/app/src/views/FirstTimeWizardView.vue b/web/app/src/views/FirstTimeWizardView.vue new file mode 100644 index 00000000..537c28b3 --- /dev/null +++ b/web/app/src/views/FirstTimeWizardView.vue @@ -0,0 +1,82 @@ + + + diff --git a/web/web_app.go b/web/web_app.go index 3cc4dc75..092ec7bc 100644 --- a/web/web_app.go +++ b/web/web_app.go @@ -17,7 +17,8 @@ import ( var webStaticFS embed.FS // WebAppHandler returns a HTTP handler to serve the static files of the Flamenco Manager web app. -func WebAppHandler() (http.Handler, error) { +// `appFilename` is either `index.html` for the main webapp, or `first-time-wizard.html`. +func WebAppHandler(appFilename string) (http.Handler, error) { // Strip the 'static/' directory off of the embedded filesystem. fs, err := fs.Sub(webStaticFS, "static") if err != nil { @@ -25,8 +26,9 @@ func WebAppHandler() (http.Handler, error) { } // Serve `index.html` from the root directory if the requested file cannot be - // found. - wrappedFS := WrapFS(fs, "index.html") + // found. This is necessary for Vue Router, see the docstring of `FSWrapper` + // below. + wrappedFS := WrapFS(fs, appFilename) // Windows doesn't know this mime type. Web browsers won't load the webapp JS // file when it's served as text/plain.