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.
This commit is contained in:
Sybren A. Stüvel 2022-07-14 11:09:32 +02:00
parent e4a38f071c
commit aa9837b5f0
22 changed files with 643 additions and 43 deletions

@ -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()

1
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

3
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=

@ -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

@ -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
}

@ -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 {

@ -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
}

@ -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
}

@ -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()

@ -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)

@ -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.

@ -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.

@ -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 {

@ -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 {

@ -0,0 +1,51 @@
<template>
<header>
<router-link :to="{ name: 'index' }" class="navbar-brand">{{ flamencoName }}</router-link>
<nav></nav>
<api-spinner />
<span class="app-version">
<a href="/api/v3/swagger-ui/">API</a>
| version: {{ flamencoVersion }}
</span>
</header>
<router-view></router-view>
</template>
<script>
const DEFAULT_FLAMENCO_NAME = "Flamenco";
const DEFAULT_FLAMENCO_VERSION = "unknown";
import ApiSpinner from '@/components/ApiSpinner.vue'
import { MetaApi } from "@/manager-api";
import { apiClient } from '@/stores/api-query-count';
export default {
name: 'FirstTimeWizard',
components: {
ApiSpinner,
},
data: () => ({
flamencoName: DEFAULT_FLAMENCO_NAME,
flamencoVersion: DEFAULT_FLAMENCO_VERSION,
}),
mounted() {
window.app = this;
this.fetchManagerInfo();
},
methods: {
// TODO: also call this when SocketIO reconnects.
fetchManagerInfo() {
const metaAPI = new MetaApi(apiClient);
metaAPI.getVersion().then((version) => {
this.flamencoName = version.name;
this.flamencoVersion = version.version;
})
},
},
}
</script>
<style>
@import "assets/base.css";
@import "assets/tabulator.css";
</style>

@ -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 ------------ */

@ -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();

@ -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);
})

@ -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;

@ -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();

@ -0,0 +1,82 @@
<template>
<div class="first-time-wizard">
<h1>Welcome to Flamenco 3!</h1>
<section>
<p>Before Flamenco 3 can be used, a few things need to be set up.</p>
<p>This wizard will guide you through the configuration.</p>
</section>
<section>
<h2>Shared Storage</h2>
<p>Flamenco needs some shared storage, to have a central place where the
Manager and Workers exchange files. This could be a NAS in your network,
or some other file sharing server.</p>
<p class="hint">Using a service like Syncthing, ownCloud, or Dropbox for
this is not recommended, as Flamenco does not know when every machine has
received the files.</p>
<form @submit.prevent="checkSharedStoragePath">
<input v-model="sharedStoragePath" type="text">
<button type="submit">Check</button>
</form>
<p v-if="sharedStorageCheckResult != null"
:class="{ 'check-ok': sharedStorageCheckResult.is_usable, 'check-failed': !sharedStorageCheckResult.is_usable }">
{{ sharedStorageCheckResult.cause }}
</p>
</section>
</div>
<footer class="app-footer">
<notification-bar />
</footer>
<update-listener ref="updateListener" @sioReconnected="onSIOReconnected" @sioDisconnected="onSIODisconnected" />
</template>
<script>
import NotificationBar from '@/components/footer/NotificationBar.vue'
import UpdateListener from '@/components/UpdateListener.vue'
import { MetaApi, PathCheckInput } from "@/manager-api";
import { apiClient } from '@/stores/api-query-count';
export default {
name: 'FirstTimeWizardView',
components: {
NotificationBar,
UpdateListener,
},
data: () => ({
sharedStoragePath: "",
sharedStorageCheckResult: null,
metaAPI: new MetaApi(apiClient),
}),
computed: {
cleanSharedStoragePath() {
return this.sharedStoragePath.trim();
},
},
methods: {
// SocketIO connection event handlers:
onSIOReconnected() {
},
onSIODisconnected(reason) {
},
checkSharedStoragePath() {
const pathCheck = new PathCheckInput(this.cleanSharedStoragePath);
console.log("requesting path check:", pathCheck);
this.metaAPI.checkSharedStoragePath({ pathCheckInput: pathCheck })
.then((result) => {
console.log("Storage path check result:", result);
this.sharedStorageCheckResult = result;
})
.catch((error) => {
console.log("Error checking storage path:", error);
})
},
},
}
</script>

@ -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.