3k release: massively improve config loading, reduce log spam, etc (#700)

This commit is contained in:
James Read 2025-11-07 00:55:08 +00:00 committed by GitHub
commit 209856eda9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 288 additions and 615 deletions

View File

@ -8,11 +8,11 @@
<template #user-info> <template #user-info>
<div class="flex-row user-info" style="gap: .5em;"> <div class="flex-row user-info" style="gap: .5em;">
<span id="link-login" v-if="!isLoggedIn"><router-link to="/login">Login</router-link></span> <span id="link-login" v-if="!isLoggedIn && showLoginLink"><router-link to="/login">Login</router-link></span>
<router-link v-else to="/user" class="user-link"> <router-link v-else to="/user" class="user-link" v-if="isLoggedIn">
<span id="username-text">{{ username }}</span> <span id="username-text">{{ username }}</span>
</router-link> </router-link>
<HugeiconsIcon :icon="UserCircle02Icon" width = "1.5em" height = "1.5em" /> <HugeiconsIcon :icon="UserCircle02Icon" width = "1.5em" height = "1.5em" v-if="isLoggedIn" />
</div> </div>
</template> </template>
@ -71,7 +71,7 @@ import logoUrl from '../../OliveTinLogo.png';
const router = useRouter(); const router = useRouter();
const sidebar = ref(null); const sidebar = ref(null);
const username = ref('guest'); const username = ref('notset');
const isLoggedIn = ref(false); const isLoggedIn = ref(false);
const serverConnection = ref('Connected'); const serverConnection = ref('Connected');
const currentVersion = ref('?'); const currentVersion = ref('?');
@ -84,6 +84,7 @@ const showLogs = ref(true)
const showDiagnostics = ref(true) const showDiagnostics = ref(true)
const initError = ref(false) const initError = ref(false)
const initErrorMessage = ref('') const initErrorMessage = ref('')
const showLoginLink = ref(true)
function toggleSidebar() { function toggleSidebar() {
if (sidebar.value && showNavigation.value) { if (sidebar.value && showNavigation.value) {
@ -102,6 +103,10 @@ function updateHeaderFromInit() {
showNavigation.value = window.initResponse.showNavigation showNavigation.value = window.initResponse.showNavigation
showLogs.value = window.initResponse.showLogList showLogs.value = window.initResponse.showLogList
showDiagnostics.value = window.initResponse.showDiagnostics showDiagnostics.value = window.initResponse.showDiagnostics
if (!window.initResponse.authLocalLogin && window.initResponse.oAuth2Providers.length === 0) {
showLoginLink.value = false
}
} }
} }
@ -124,15 +129,7 @@ async function requestInit() {
window.initErrorMessage = '' window.initErrorMessage = ''
window.initCompleted = true window.initCompleted = true
username.value = initResponse.authenticatedUser window.updateHeaderFromInit()
isLoggedIn.value = initResponse.authenticatedUser !== '' && initResponse.authenticatedUser !== 'guest'
currentVersion.value = initResponse.currentVersion
bannerMessage.value = initResponse.bannerMessage || '';
bannerCss.value = initResponse.bannerCss || '';
showFooter.value = initResponse.showFooter
showNavigation.value = initResponse.showNavigation
showLogs.value = initResponse.showLogList
showDiagnostics.value = initResponse.showDiagnostics
if (showNavigation.value && sidebar.value) { if (showNavigation.value && sidebar.value) {
for (const rootDashboard of initResponse.rootDashboards) { for (const rootDashboard of initResponse.rootDashboards) {

View File

@ -0,0 +1,2 @@
make:
haproxy -f ./haproxy.conf

View File

@ -1,15 +1,32 @@
global
stats socket /var/run/haproxy/admin.sock mode 660 level admin
log stdout local0
frontend cleartext_frontend frontend cleartext_frontend
bind 0.0.0.0:80 bind 0.0.0.0:8080
timeout client 10s
stats enable
stats uri /stats
stats refresh 30s
log global
option httplog option httplog
option dontlognull
log-format "[%t] %ci:%cp -> BACKEND:%b/%s | %HM %HU | Status:%ST | Bytes:%B"
mode http
use_backend be_olivetin_webs if { hdr(Host) -i olivetin.example.com && path_beg /websocket } use_backend be_olivetin if { hdr(Host) -i -m beg olivetin.example.com }
use_backend be_olivetin_http if { hdr(Host) -i olivetin.example.com }
backend be_olivetin_http backend be_olivetin
server olivetinServer 127.0.0.1:1337 check mode http
timeout connect 10s
backend be_olivetin_webs timeout server 10s
timeout tunnel 1h timeout tunnel 1h
option http-server-close option http-server-close
server olivetinServer 127.0.0.1:1337 http-request set-header X-Forwarded-User "Alice"
http-request set-header X-Forwarded-Group "Group1,Group2"
server olivetinServer 127.0.0.1:1337 check

View File

@ -208,12 +208,19 @@ func UserFromContext[T any](ctx context.Context, req *connect.Request[T], cfg *c
} else { } else {
buildUserAcls(cfg, &user) buildUserAcls(cfg, &user)
} }
path := ""
if req != nil {
path = req.Spec().Procedure
}
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"username": user.Username, "username": user.Username,
"usergroupLine": user.UsergroupLine, "usergroupLine": user.UsergroupLine,
"provider": user.Provider, "provider": user.Provider,
"acls": user.Acls, "acls": user.Acls,
}).Debugf("UserFromContext") "path": path,
}).Debugf("Authenticated API request")
return &user return &user
} }

View File

@ -481,16 +481,20 @@ func (api *oliveTinAPI) GetActionLogs(ctx ctx.Context, req *connect.Request[apiv
ret.StartOffset = page.start ret.StartOffset = page.start
return connect.NewResponse(ret), nil return connect.NewResponse(ret), nil
} }
// Newest-first slicing: compute reversed indices // Newest-first slicing: compute reversed indices
startIdx := page.total - page.end startIdx := page.total - page.end
endIdx := page.total - page.start endIdx := page.total - page.start
if startIdx < 0 { startIdx = 0 } if startIdx < 0 {
if endIdx > int64(len(filtered)) { endIdx = int64(len(filtered)) } startIdx = 0
for _, le := range filtered[startIdx:endIdx] { }
ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user)) if endIdx > int64(len(filtered)) {
} endIdx = int64(len(filtered))
// Entries older than the returned newest page }
ret.CountRemaining = page.start for _, le := range filtered[startIdx:endIdx] {
ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user))
}
// Entries older than the returned newest page
ret.CountRemaining = page.start
ret.PageSize = page.size ret.PageSize = page.size
ret.TotalCount = page.total ret.TotalCount = page.total
ret.StartOffset = page.start ret.StartOffset = page.start
@ -662,7 +666,9 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.
AuthenticatedUser: user, AuthenticatedUser: user,
} }
log.Infof("EventStream: client connected: %v", client.AuthenticatedUser.Username) log.WithFields(log.Fields{
"authenticatedUser": user.Username,
}).Debugf("EventStream: client connected")
api.streamingClientsMutex.Lock() api.streamingClientsMutex.Lock()
api.streamingClients[client] = struct{}{} api.streamingClients[client] = struct{}{}
@ -814,7 +820,7 @@ func (api *oliveTinAPI) buildRootDashboards(user *acl.AuthenticatedUser, dashboa
func (api *oliveTinAPI) addDefaultDashboardIfNeeded(rootDashboards *[]string, rr *DashboardRenderRequest) { func (api *oliveTinAPI) addDefaultDashboardIfNeeded(rootDashboards *[]string, rr *DashboardRenderRequest) {
defaultDashboard := buildDefaultDashboard(rr) defaultDashboard := buildDefaultDashboard(rr)
if defaultDashboard != nil && len(defaultDashboard.Contents) > 0 { if defaultDashboard != nil && len(defaultDashboard.Contents) > 0 {
log.Infof("defaultDashboard: %+v", defaultDashboard.Contents) log.Tracef("defaultDashboard: %+v", defaultDashboard.Contents)
*rootDashboards = append(*rootDashboards, "Actions") *rootDashboards = append(*rootDashboards, "Actions")
} }
} }

View File

@ -7,212 +7,212 @@ import (
// Action represents the core functionality of OliveTin - commands that show up // Action represents the core functionality of OliveTin - commands that show up
// as buttons in the UI. // as buttons in the UI.
type Action struct { type Action struct {
ID string ID string `koanf:"id"`
Title string Title string `koanf:"title"`
Icon string Icon string `koanf:"icon"`
Shell string Shell string `koanf:"shell"`
Exec []string Exec []string `koanf:"exec"`
ShellAfterCompleted string ShellAfterCompleted string `koanf:"shellAfterCompleted"`
Timeout int Timeout int `koanf:"timeout"`
Acls []string Acls []string `koanf:"acls"`
Entity string Entity string `koanf:"entity"`
Hidden bool Hidden bool `koanf:"hidden"`
ExecOnStartup bool ExecOnStartup bool `koanf:"execOnStartup"`
ExecOnCron []string ExecOnCron []string `koanf:"execOnCron"`
ExecOnFileCreatedInDir []string ExecOnFileCreatedInDir []string `koanf:"execOnFileCreatedInDir"`
ExecOnFileChangedInDir []string ExecOnFileChangedInDir []string `koanf:"execOnFileChangedInDir"`
ExecOnCalendarFile string ExecOnCalendarFile string `koanf:"execOnCalendarFile"`
Triggers []string Triggers []string `koanf:"triggers"`
MaxConcurrent int MaxConcurrent int `koanf:"maxConcurrent"`
MaxRate []RateSpec MaxRate []RateSpec `koanf:"maxRate"`
Arguments []ActionArgument Arguments []ActionArgument `koanf:"arguments"`
PopupOnStart string PopupOnStart string `koanf:"popupOnStart"`
SaveLogs SaveLogsConfig SaveLogs SaveLogsConfig `koanf:"saveLogs"`
} }
// ActionArgument objects appear on Actions. // ActionArgument objects appear on Actions.
type ActionArgument struct { type ActionArgument struct {
Name string Name string `koanf:"name"`
Title string Title string `koanf:"title"`
Description string Description string `koanf:"description"`
Type string Type string `koanf:"type"`
Default string Default string `koanf:"default"`
Choices []ActionArgumentChoice Choices []ActionArgumentChoice `koanf:"choices"`
Entity string Entity string `koanf:"entity"`
RejectNull bool RejectNull bool `koanf:"rejectNull"`
Suggestions map[string]string Suggestions map[string]string `koanf:"suggestions"`
} }
// ActionArgumentChoice represents a predefined choice for an argument. // ActionArgumentChoice represents a predefined choice for an argument.
type ActionArgumentChoice struct { type ActionArgumentChoice struct {
Value string Value string `koanf:"value"`
Title string Title string `koanf:"title"`
} }
// RateSpec allows you to set a max frequency for an action. // RateSpec allows you to set a max frequency for an action.
type RateSpec struct { type RateSpec struct {
Limit int Limit int `koanf:"limit"`
Duration string Duration string `koanf:"duration"`
} }
// Entity represents a "thing" that can have multiple actions associated with it. // Entity represents a "thing" that can have multiple actions associated with it.
// for example, a media player with a start and stop action. // for example, a media player with a start and stop action.
type EntityFile struct { type EntityFile struct {
File string File string `koanf:"file"`
Name string Name string `koanf:"name"`
Icon string Icon string `koanf:"icon"`
} }
// PermissionsList defines what users can do with an action. // PermissionsList defines what users can do with an action.
type PermissionsList struct { type PermissionsList struct {
View bool `mapstructure:"view"` View bool `koanf:"view"`
Exec bool `mapstructure:"exec"` Exec bool `koanf:"exec"`
Logs bool `mapstructure:"logs"` Logs bool `koanf:"logs"`
Kill bool `mapstructure:"kill"` Kill bool `koanf:"kill"`
} }
// AccessControlList defines what permissions apply to a user or user group. // AccessControlList defines what permissions apply to a user or user group.
type AccessControlList struct { type AccessControlList struct {
Name string Name string `koanf:"name"`
AddToEveryAction bool AddToEveryAction bool `koanf:"addToEveryAction"`
MatchUsergroups []string MatchUsergroups []string `koanf:"matchUsergroups"`
MatchUsernames []string MatchUsernames []string `koanf:"matchUsernames"`
Permissions PermissionsList Permissions PermissionsList `koanf:"permissions"`
Policy ConfigurationPolicy Policy ConfigurationPolicy `koanf:"policy"`
} }
// ConfigurationPolicy defines global settings which are overridden with an ACL. // ConfigurationPolicy defines global settings which are overridden with an ACL.
type ConfigurationPolicy struct { type ConfigurationPolicy struct {
ShowDiagnostics bool `mapstructure:"showDiagnostics"` ShowDiagnostics bool `koanf:"showDiagnostics"`
ShowLogList bool `mapstructure:"showLogList"` ShowLogList bool `koanf:"showLogList"`
} }
type PrometheusConfig struct { type PrometheusConfig struct {
Enabled bool `mapstructure:"enabled"` Enabled bool `koanf:"enabled"`
DefaultGoMetrics bool `mapstructure:"defaultGoMetrics"` DefaultGoMetrics bool `koanf:"defaultGoMetrics"`
} }
// Config is the global config used through the whole app. // Config is the global config used through the whole app.
type Config struct { type Config struct {
UseSingleHTTPFrontend bool `mapstructure:"useSingleHTTPFrontend"` UseSingleHTTPFrontend bool `koanf:"useSingleHTTPFrontend"`
ThemeName string `mapstructure:"themeName"` ThemeName string `koanf:"themeName"`
ThemeCacheDisabled bool `mapstructure:"themeCacheDisabled"` ThemeCacheDisabled bool `koanf:"themeCacheDisabled"`
ListenAddressSingleHTTPFrontend string `mapstructure:"listenAddressSingleHTTPFrontend"` ListenAddressSingleHTTPFrontend string `koanf:"listenAddressSingleHTTPFrontend"`
ListenAddressWebUI string `mapstructure:"listenAddressWebUI"` ListenAddressWebUI string `koanf:"listenAddressWebUI"`
ListenAddressRestActions string `mapstructure:"listenAddressRestActions"` ListenAddressRestActions string `koanf:"listenAddressRestActions"`
ListenAddressPrometheus string `mapstructure:"listenAddressPrometheus"` ListenAddressPrometheus string `koanf:"listenAddressPrometheus"`
ExternalRestAddress string `mapstructure:"externalRestAddress"` ExternalRestAddress string `koanf:"externalRestAddress"`
LogLevel string `mapstructure:"logLevel"` LogLevel string `koanf:"logLevel"`
LogDebugOptions LogDebugOptions `mapstructure:"logDebugOptions"` LogDebugOptions LogDebugOptions `koanf:"logDebugOptions"`
LogHistoryPageSize int64 `mapstructure:"logHistoryPageSize"` LogHistoryPageSize int64 `koanf:"logHistoryPageSize"`
Actions []*Action `mapstructure:"actions"` Actions []*Action `koanf:"actions"`
Entities []*EntityFile `mapstructure:"entities"` Entities []*EntityFile `koanf:"entities"`
Dashboards []*DashboardComponent `mapstructure:"dashboards"` Dashboards []*DashboardComponent `koanf:"dashboards"`
CheckForUpdates bool `mapstructure:"checkForUpdates"` CheckForUpdates bool `koanf:"checkForUpdates"`
PageTitle string `mapstructure:"pageTitle"` PageTitle string `koanf:"pageTitle"`
ShowFooter bool `mapstructure:"showFooter"` ShowFooter bool `koanf:"showFooter"`
ShowNavigation bool `mapstructure:"showNavigation"` ShowNavigation bool `koanf:"showNavigation"`
ShowNewVersions bool `mapstructure:"showNewVersions"` ShowNewVersions bool `koanf:"showNewVersions"`
EnableCustomJs bool `mapstructure:"enableCustomJs"` EnableCustomJs bool `koanf:"enableCustomJs"`
AuthJwtCookieName string `mapstructure:"authJwtCookieName"` AuthJwtCookieName string `koanf:"authJwtCookieName"`
AuthJwtHeader string `mapstructure:"authJwtHeader"` AuthJwtHeader string `koanf:"authJwtHeader"`
AuthJwtAud string `mapstructure:"authJwtAud"` AuthJwtAud string `koanf:"authJwtAud"`
AuthJwtDomain string `mapstructure:"authJwtDomain"` AuthJwtDomain string `koanf:"authJwtDomain"`
AuthJwtCertsURL string `mapstructure:"authJwtCertsUrl"` AuthJwtCertsURL string `koanf:"authJwtCertsUrl"`
AuthJwtHmacSecret string `mapstructure:"authJwtHmacSecret"` // mutually exclusive with pub key config fields AuthJwtHmacSecret string `koanf:"authJwtHmacSecret"` // mutually exclusive with pub key config fields
AuthJwtClaimUsername string `mapstructure:"authJwtClaimUsername"` AuthJwtClaimUsername string `koanf:"authJwtClaimUsername"`
AuthJwtClaimUserGroup string `mapstructure:"authJwtClaimUserGroup"` AuthJwtClaimUserGroup string `koanf:"authJwtClaimUserGroup"`
AuthJwtPubKeyPath string `mapstructure:"authJwtPubKeyPath"` // will read pub key from file on disk AuthJwtPubKeyPath string `koanf:"authJwtPubKeyPath"` // will read pub key from file on disk
AuthHttpHeaderUsername string `mapstructure:"authHttpHeaderUsername"` AuthHttpHeaderUsername string `koanf:"authHttpHeaderUsername"`
AuthHttpHeaderUserGroup string `mapstructure:"authHttpHeaderUserGroup"` AuthHttpHeaderUserGroup string `koanf:"authHttpHeaderUserGroup"`
AuthHttpHeaderUserGroupSep string `mapstructure:"authHttpHeaderUserGroupSep"` AuthHttpHeaderUserGroupSep string `koanf:"authHttpHeaderUserGroupSep"`
AuthLocalUsers AuthLocalUsersConfig `mapstructure:"authLocalUsers"` AuthLocalUsers AuthLocalUsersConfig `koanf:"authLocalUsers"`
AuthLoginUrl string `mapstructure:"authLoginUrl"` AuthLoginUrl string `koanf:"authLoginUrl"`
AuthRequireGuestsToLogin bool `mapstructure:"authRequireGuestsToLogin"` AuthRequireGuestsToLogin bool `koanf:"authRequireGuestsToLogin"`
AuthOAuth2RedirectURL string `mapstructure:"authOAuth2RedirectUrl"` AuthOAuth2RedirectURL string `koanf:"authOAuth2RedirectUrl"`
AuthOAuth2Providers map[string]*OAuth2Provider `mapstructure:"authOAuth2Providers"` AuthOAuth2Providers map[string]*OAuth2Provider `koanf:"authOAuth2Providers"`
DefaultPermissions PermissionsList `mapstructure:"defaultPermissions"` DefaultPermissions PermissionsList `koanf:"defaultPermissions"`
DefaultPolicy ConfigurationPolicy `mapstructure:"defaultPolicy"` DefaultPolicy ConfigurationPolicy `koanf:"defaultPolicy"`
AccessControlLists []*AccessControlList `mapstructure:"accessControlLists"` AccessControlLists []*AccessControlList `koanf:"accessControlLists"`
WebUIDir string `mapstructure:"webUIDir"` WebUIDir string `koanf:"webUIDir"`
CronSupportForSeconds bool `mapstructure:"cronSupportForSeconds"` CronSupportForSeconds bool `koanf:"cronSupportForSeconds"`
SectionNavigationStyle string `mapstructure:"sectionNavigationStyle"` SectionNavigationStyle string `koanf:"sectionNavigationStyle"`
DefaultPopupOnStart string `mapstructure:"defaultPopupOnStart"` DefaultPopupOnStart string `koanf:"defaultPopupOnStart"`
InsecureAllowDumpOAuth2UserData bool `mapstructure:"insecureAllowDumpOAuth2UserData"` InsecureAllowDumpOAuth2UserData bool `koanf:"insecureAllowDumpOAuth2UserData"`
InsecureAllowDumpVars bool `mapstructure:"insecureAllowDumpVars"` InsecureAllowDumpVars bool `koanf:"insecureAllowDumpVars"`
InsecureAllowDumpSos bool `mapstructure:"insecureAllowDumpSos"` InsecureAllowDumpSos bool `koanf:"insecureAllowDumpSos"`
InsecureAllowDumpActionMap bool `mapstructure:"insecureAllowDumpActionMap"` InsecureAllowDumpActionMap bool `koanf:"insecureAllowDumpActionMap"`
InsecureAllowDumpJwtClaims bool `mapstructure:"insecureAllowDumpJwtClaims"` InsecureAllowDumpJwtClaims bool `koanf:"insecureAllowDumpJwtClaims"`
Prometheus PrometheusConfig `mapstructure:"prometheus"` Prometheus PrometheusConfig `koanf:"prometheus"`
SaveLogs SaveLogsConfig `mapstructure:"saveLogs"` SaveLogs SaveLogsConfig `koanf:"saveLogs"`
DefaultIconForActions string `mapstructure:"defaultIconForActions"` DefaultIconForActions string `koanf:"defaultIconForActions"`
DefaultIconForDirectories string `mapstructure:"defaultIconForDirectories"` DefaultIconForDirectories string `koanf:"defaultIconForDirectories"`
DefaultIconForBack string `mapstructure:"defaultIconForBack"` DefaultIconForBack string `koanf:"defaultIconForBack"`
AdditionalNavigationLinks []*NavigationLink `mapstructure:"additionalNavigationLinks"` AdditionalNavigationLinks []*NavigationLink `koanf:"additionalNavigationLinks"`
ServiceHostMode string `mapstructure:"serviceHostMode"` ServiceHostMode string `koanf:"serviceHostMode"`
StyleMods []string `mapstructure:"styleMods"` StyleMods []string `koanf:"styleMods"`
BannerMessage string `mapstructure:"bannerMessage"` BannerMessage string `koanf:"bannerMessage"`
BannerCSS string `mapstructure:"bannerCss"` BannerCSS string `koanf:"bannerCss"`
Include string `mapstructure:"include"` Include string `koanf:"include"`
sourceFiles []string sourceFiles []string
} }
type AuthLocalUsersConfig struct { type AuthLocalUsersConfig struct {
Enabled bool `mapstructure:"enabled"` Enabled bool `koanf:"enabled"`
Users []*LocalUser `mapstructure:"users"` Users []*LocalUser `koanf:"users"`
} }
type LocalUser struct { type LocalUser struct {
Username string `mapstructure:"username"` Username string `koanf:"username"`
Usergroup string `mapstructure:"usergroup"` Usergroup string `koanf:"usergroup"`
Password string `mapstructure:"password"` Password string `koanf:"password"`
} }
type OAuth2Provider struct { type OAuth2Provider struct {
Name string Name string `koanf:"name"`
Title string Title string `koanf:"title"`
ClientID string ClientID string `koanf:"clientId"`
ClientSecret string ClientSecret string `koanf:"clientSecret"`
Icon string Icon string `koanf:"icon"`
Scopes []string Scopes []string `koanf:"scopes"`
AuthUrl string AuthUrl string `koanf:"authUrl"`
TokenUrl string TokenUrl string `koanf:"tokenUrl"`
WhoamiUrl string WhoamiUrl string `koanf:"whoamiUrl"`
UsernameField string UsernameField string `koanf:"usernameField"`
UserGroupField string UserGroupField string `koanf:"userGroupField"`
InsecureSkipVerify bool InsecureSkipVerify bool `koanf:"insecureSkipVerify"`
CallbackTimeout int CallbackTimeout int `koanf:"callbackTimeout"`
CertBundlePath string CertBundlePath string `koanf:"certBundlePath"`
} }
type NavigationLink struct { type NavigationLink struct {
Title string `mapstructure:"title"` Title string `koanf:"title"`
Url string `mapstructure:"url"` Url string `koanf:"url"`
Target string `mapstructure:"target"` Target string `koanf:"target"`
} }
type SaveLogsConfig struct { type SaveLogsConfig struct {
ResultsDirectory string `mapstructure:"resultsDirectory"` ResultsDirectory string `koanf:"resultsDirectory"`
OutputDirectory string `mapstructure:"outputDirectory"` OutputDirectory string `koanf:"outputDirectory"`
} }
type LogDebugOptions struct { type LogDebugOptions struct {
SingleFrontendRequests bool SingleFrontendRequests bool `koanf:"singleFrontendRequests"`
SingleFrontendRequestHeaders bool SingleFrontendRequestHeaders bool `koanf:"singleFrontendRequestHeaders"`
AclCheckStarted bool AclCheckStarted bool `koanf:"aclCheckStarted"`
AclMatched bool AclMatched bool `koanf:"aclMatched"`
AclNotMatched bool AclNotMatched bool `koanf:"aclNotMatched"`
AclNoneMatched bool AclNoneMatched bool `koanf:"aclNoneMatched"`
} }
type DashboardComponent struct { type DashboardComponent struct {
Title string Title string `koanf:"title"`
Type string Type string `koanf:"type"`
Entity string Entity string `koanf:"entity"`
Icon string Icon string `koanf:"icon"`
CssClass string CssClass string `koanf:"cssClass"`
Contents []*DashboardComponent Contents []*DashboardComponent `koanf:"contents"`
} }
func DefaultConfig() *Config { func DefaultConfig() *Config {

View File

@ -34,90 +34,32 @@ func AddListener(l func()) {
listeners = append(listeners, l) listeners = append(listeners, l)
} }
// AppendSourceWithIncludes loads base config and any included configs
func AppendSourceWithIncludes(cfg *Config, k *koanf.Koanf, configPath string) {
// Load base config first
AppendSource(cfg, k, configPath)
// Load included configs if specified
if cfg.Include != "" {
LoadIncludedConfigs(cfg, k, configPath)
}
}
func AppendSource(cfg *Config, k *koanf.Koanf, configPath string) { func AppendSource(cfg *Config, k *koanf.Koanf, configPath string) {
log.Infof("Appending cfg source: %s", configPath) log.WithFields(log.Fields{
"configPath": configPath,
}).Info("Appending cfg source")
loadIncludedConfigsFromDir(k, configPath)
if !unmarshalRoot(k, cfg) { if !unmarshalRoot(k, cfg) {
return return
} }
loadCollectionsFallbacks(k, cfg)
applyConfigOverrides(k, cfg)
afterLoadFinalize(cfg, configPath) afterLoadFinalize(cfg, configPath)
} }
func unmarshalRoot(k *koanf.Koanf, cfg *Config) bool { func unmarshalRoot(k *koanf.Koanf, cfg *Config) bool {
if err := k.Unmarshal(".", cfg); err != nil { err := k.UnmarshalWithConf("", cfg, koanf.UnmarshalConf{
Tag: "koanf",
})
if err != nil {
log.Errorf("Error unmarshalling config: %v", err) log.Errorf("Error unmarshalling config: %v", err)
return false return false
} }
return true return true
} }
func loadCollectionsFallbacks(k *koanf.Koanf, cfg *Config) {
maybeUnmarshalActions(k, cfg)
maybeUnmarshalDashboards(k, cfg)
maybeUnmarshalEntities(k, cfg)
maybeUnmarshalAuthLocalUsers(k, cfg)
}
func maybeUnmarshalActions(k *koanf.Koanf, cfg *Config) {
if len(cfg.Actions) != 0 || !k.Exists("actions") {
return
}
var actions []*Action
if err := k.Unmarshal("actions", &actions); err == nil {
cfg.Actions = actions
log.Debugf("Manually loaded %d actions", len(actions))
}
}
func maybeUnmarshalDashboards(k *koanf.Koanf, cfg *Config) {
if len(cfg.Dashboards) != 0 || !k.Exists("dashboards") {
return
}
var dashboards []*DashboardComponent
if err := k.Unmarshal("dashboards", &dashboards); err == nil {
cfg.Dashboards = dashboards
log.Debugf("Manually loaded %d dashboards", len(dashboards))
}
}
func maybeUnmarshalEntities(k *koanf.Koanf, cfg *Config) {
if len(cfg.Entities) != 0 || !k.Exists("entities") {
return
}
var entities []*EntityFile
if err := k.Unmarshal("entities", &entities); err == nil {
cfg.Entities = entities
log.Debugf("Manually loaded %d entities", len(entities))
}
}
func maybeUnmarshalAuthLocalUsers(k *koanf.Koanf, cfg *Config) {
if len(cfg.AuthLocalUsers.Users) != 0 || !k.Exists("authLocalUsers") {
return
}
var authLocalUsers AuthLocalUsersConfig
if err := k.Unmarshal("authLocalUsers", &authLocalUsers); err == nil {
cfg.AuthLocalUsers = authLocalUsers
log.Debugf("Manually loaded local auth config")
}
}
func afterLoadFinalize(cfg *Config, configPath string) { func afterLoadFinalize(cfg *Config, configPath string) {
metricConfigReloadedCount.Inc() metricConfigReloadedCount.Inc()
metricConfigActionCount.Set(float64(len(cfg.Actions))) metricConfigActionCount.Set(float64(len(cfg.Actions)))
@ -130,39 +72,19 @@ func afterLoadFinalize(cfg *Config, configPath string) {
} }
} }
func applyConfigOverrides(k *koanf.Koanf, cfg *Config) { // loadIncludedConfigsFromDir loads configuration files from an include directory and merges them
// Override fields that should be read from config func loadIncludedConfigsFromDir(k *koanf.Koanf, baseConfigPath string) {
// mapstructure tags should make most of this unnecessary, but keep for safety relativeIncludePath := k.String("include")
boolVal(k, "showFooter", &cfg.ShowFooter)
boolVal(k, "showNavigation", &cfg.ShowNavigation)
boolVal(k, "checkForUpdates", &cfg.CheckForUpdates)
boolVal(k, "useSingleHTTPFrontend", &cfg.UseSingleHTTPFrontend)
stringVal(k, "logLevel", &cfg.LogLevel)
stringVal(k, "pageTitle", &cfg.PageTitle)
boolVal(k, "authRequireGuestsToLogin", &cfg.AuthRequireGuestsToLogin)
stringVal(k, "include", &cfg.Include)
// Handle nested defaultPolicy struct if relativeIncludePath == "" {
if k.Exists("defaultPolicy") {
boolVal(k, "defaultPolicy.showDiagnostics", &cfg.DefaultPolicy.ShowDiagnostics)
boolVal(k, "defaultPolicy.showLogList", &cfg.DefaultPolicy.ShowLogList)
}
// Handle nested prometheus struct
if k.Exists("prometheus") {
boolVal(k, "prometheus.enabled", &cfg.Prometheus.Enabled)
boolVal(k, "prometheus.defaultGoMetrics", &cfg.Prometheus.DefaultGoMetrics)
}
}
// LoadIncludedConfigs loads configuration files from an include directory and merges them
func LoadIncludedConfigs(cfg *Config, k *koanf.Koanf, baseConfigPath string) {
if cfg.Include == "" {
return return
} }
includePath := filepath.Join(filepath.Dir(baseConfigPath), cfg.Include) includePath := filepath.Join(filepath.Dir(baseConfigPath), relativeIncludePath)
log.Infof("Loading included configs from: %s", includePath)
log.WithFields(log.Fields{
"includePath": includePath,
}).Infof("Loading included configs from dir")
yamlFiles, ok := listYamlFiles(includePath) yamlFiles, ok := listYamlFiles(includePath)
if !ok || len(yamlFiles) == 0 { if !ok || len(yamlFiles) == 0 {
@ -171,11 +93,10 @@ func LoadIncludedConfigs(cfg *Config, k *koanf.Koanf, baseConfigPath string) {
sort.Strings(yamlFiles) sort.Strings(yamlFiles)
for _, filename := range yamlFiles { for _, filename := range yamlFiles {
loadAndMergeIncludedFile(cfg, includePath, filename) loadAndMergeIncludedFile(k, includePath, filename)
} }
log.Infof("Finished loading %d included config file(s)", len(yamlFiles)) log.Infof("Finished loading %d included config file(s)", len(yamlFiles))
cfg.Sanitize()
} }
func listYamlFiles(includePath string) ([]string, bool) { func listYamlFiles(includePath string) ([]string, bool) {
@ -209,152 +130,45 @@ func listYamlFiles(includePath string) ([]string, bool) {
return yamlFiles, true return yamlFiles, true
} }
func loadAndMergeIncludedFile(cfg *Config, includePath, filename string) { func loadAndMergeIncludedFile(k *koanf.Koanf, includePath, filename string) {
filePath := filepath.Join(includePath, filename) filePath := filepath.Join(includePath, filename)
log.Infof("Loading included config file: %s", filePath)
includeK := koanf.New(".") if err := k.Load(file.Provider(filePath), yaml.Parser(), koanf.WithMergeFunc(mergeFunc)); err != nil {
if err := includeK.Load(file.Provider(filePath), yaml.Parser()); err != nil {
log.Errorf("Error loading included config file %s: %v", filePath, err) log.Errorf("Error loading included config file %s: %v", filePath, err)
return return
} }
tempCfg := &Config{} log.WithFields(log.Fields{
if err := includeK.Unmarshal(".", tempCfg); err != nil { "filePath": filePath,
log.Errorf("Error unmarshalling included config file %s: %v", filePath, err) }).Info("Successfully loaded included config file")
return
}
loadCollectionsFallbacks(includeK, tempCfg)
mergeConfig(cfg, tempCfg)
log.Infof("Successfully loaded and merged %s", filename)
} }
func mergeConfig(base *Config, overlay *Config) { func mergeFunc(src map[string]interface{}, dest map[string]interface{}) error {
mergeSlices(base, overlay) // Handle actions merging - koanf provides []interface{} not []*Action
overrideSimple(base, overlay) // Merge src (new) into dest (existing) by appending src's actions to dest's actions
overrideNested(base, overlay) if srcActions, ok := src["actions"]; ok {
overrideStrings(base, overlay) if destActions, ok := dest["actions"]; ok {
} // Both have actions - append src to dest
srcSlice, ok1 := srcActions.([]interface{})
destSlice, ok2 := destActions.([]interface{})
if ok1 && ok2 {
dest["actions"] = append(destSlice, srcSlice...)
} else {
// Fallback: if types don't match, just use src
dest["actions"] = srcActions
}
} else {
// dest doesn't have actions, so use src's actions
dest["actions"] = srcActions
}
}
// If src doesn't have actions, leave dest unchanged
func mergeSlices(base *Config, overlay *Config) { return nil
if len(overlay.Actions) > 0 {
base.Actions = append(base.Actions, overlay.Actions...)
}
if len(overlay.Dashboards) > 0 {
base.Dashboards = append(base.Dashboards, overlay.Dashboards...)
log.Debugf("Merged %d dashboards from include", len(overlay.Dashboards))
}
if len(overlay.Entities) > 0 {
base.Entities = append(base.Entities, overlay.Entities...)
log.Debugf("Merged %d entities from include", len(overlay.Entities))
}
if len(overlay.AccessControlLists) > 0 {
base.AccessControlLists = append(base.AccessControlLists, overlay.AccessControlLists...)
log.Debugf("Merged %d access control lists from include", len(overlay.AccessControlLists))
}
if len(overlay.AuthLocalUsers.Users) > 0 {
base.AuthLocalUsers.Users = append(base.AuthLocalUsers.Users, overlay.AuthLocalUsers.Users...)
log.Debugf("Merged %d local users from include", len(overlay.AuthLocalUsers.Users))
}
if len(overlay.StyleMods) > 0 {
base.StyleMods = append(base.StyleMods, overlay.StyleMods...)
}
if len(overlay.AdditionalNavigationLinks) > 0 {
base.AdditionalNavigationLinks = append(base.AdditionalNavigationLinks, overlay.AdditionalNavigationLinks...)
}
}
func overrideSimple(base *Config, overlay *Config) {
if overlay.LogLevel != "" {
base.LogLevel = overlay.LogLevel
}
if overlay.PageTitle != "" {
base.PageTitle = overlay.PageTitle
}
if overlay.ShowFooter != base.ShowFooter {
base.ShowFooter = overlay.ShowFooter
}
if overlay.ShowNavigation != base.ShowNavigation {
base.ShowNavigation = overlay.ShowNavigation
}
if overlay.CheckForUpdates != base.CheckForUpdates {
base.CheckForUpdates = overlay.CheckForUpdates
}
if overlay.UseSingleHTTPFrontend != base.UseSingleHTTPFrontend {
base.UseSingleHTTPFrontend = overlay.UseSingleHTTPFrontend
}
if overlay.AuthRequireGuestsToLogin != base.AuthRequireGuestsToLogin {
base.AuthRequireGuestsToLogin = overlay.AuthRequireGuestsToLogin
}
if overlay.AuthLocalUsers.Enabled {
base.AuthLocalUsers.Enabled = overlay.AuthLocalUsers.Enabled
}
}
func overrideNested(base *Config, overlay *Config) {
// Only apply overrides when overlay explicitly enables the option.
// This mirrors the presence-check pattern used elsewhere to avoid
// unintentionally disabling an already-enabled base setting with a default false.
if overlay.DefaultPolicy.ShowDiagnostics {
base.DefaultPolicy.ShowDiagnostics = true
}
if overlay.DefaultPolicy.ShowLogList {
base.DefaultPolicy.ShowLogList = true
}
if overlay.Prometheus.Enabled {
base.Prometheus.Enabled = true
}
if overlay.Prometheus.DefaultGoMetrics {
base.Prometheus.DefaultGoMetrics = true
}
}
func overrideStrings(base *Config, overlay *Config) {
overrideString(&base.BannerMessage, overlay.BannerMessage)
overrideString(&base.BannerCSS, overlay.BannerCSS)
overrideString(&base.LogLevel, overlay.LogLevel)
overrideString(&base.PageTitle, overlay.PageTitle)
overrideString(&base.SectionNavigationStyle, overlay.SectionNavigationStyle)
overrideString(&base.DefaultPopupOnStart, overlay.DefaultPopupOnStart)
}
func overrideString(base *string, overlay string) {
if overlay != "" {
*base = overlay
}
}
func getActionTitles(actions []*Action) []string {
titles := make([]string, len(actions))
for i, action := range actions {
titles[i] = action.Title
}
return titles
} }
var envRegex = regexp.MustCompile(`\${{ *?(\S+) *?}}`) var envRegex = regexp.MustCompile(`\${{ *?(\S+) *?}}`)
// Helper functions to reduce repetitive if/set chains
func stringVal(k *koanf.Koanf, key string, dest *string) {
if k.Exists(key) {
*dest = k.String(key)
}
}
func boolVal(k *koanf.Koanf, key string, dest *bool) {
if k.Exists(key) {
*dest = k.Bool(key)
}
}
func int64Val(k *koanf.Koanf, key string, dest *int64) {
if k.Exists(key) {
*dest = k.Int64(key)
}
}
func envDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error) { func envDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error) {
log.Debugf("envDecodeHookFunc called: from=%v, to=%v, data=%v", from, to, data) log.Debugf("envDecodeHookFunc called: from=%v, to=%v, data=%v", from, to, data)
if from.Kind() != reflect.String { if from.Kind() != reflect.String {

View File

@ -53,7 +53,9 @@ func (e *Executor) RebuildActionMap() {
findDashboardActionTitles(req) findDashboardActionTitles(req)
log.Infof("dashboardActionTitles: %v", req.DashboardActionTitles) log.WithFields(log.Fields{
"titles": req.DashboardActionTitles,
}).Trace("dashboardActionTitles")
for configOrder, action := range e.Cfg.Actions { for configOrder, action := range e.Cfg.Actions {
if action.Entity != "" { if action.Entity != "" {

View File

@ -53,7 +53,11 @@ func StartSingleHTTPFrontend(cfg *config.Config, ex *executor.Executor) {
r.URL.Path = apiPath + fn r.URL.Path = apiPath + fn
log.Debugf("SingleFrontend HTTP API Req URL after rewrite: %v", r.URL.Path) log.WithFields(log.Fields{
"path": r.URL.Path,
}).Tracef("SingleFrontend HTTP API Req URL after rewrite")
logDebugRequest(cfg, "api", r)
apiHandler.ServeHTTP(w, r) apiHandler.ServeHTTP(w, r)
})) }))

View File

@ -45,7 +45,7 @@ func (s *webUIServer) handleWebui(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, path.Join(s.webuiDir, "index.html")) http.ServeFile(w, r, path.Join(s.webuiDir, "index.html"))
} else { } else {
log.Infof("Serving webui from %s for %s", s.webuiDir, r.URL.Path) log.Tracef("Serving webui from %s for %s", s.webuiDir, r.URL.Path)
http.ServeFile(w, r, path.Join(s.webuiDir, r.URL.Path)) http.ServeFile(w, r, path.Join(s.webuiDir, r.URL.Path))
// http.StripPrefix(dirName, http.FileServer(http.Dir(s.webuiDir))).ServeHTTP(w, r) // http.StripPrefix(dirName, http.FileServer(http.Dir(s.webuiDir))).ServeHTTP(w, r)
} }

View File

@ -1,185 +0,0 @@
package websocket
import (
"net/http"
"sync"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
"github.com/OliveTin/OliveTin/internal/executor"
ws "github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/reflect/protoreflect"
)
var upgrader = ws.Upgrader{
CheckOrigin: checkOriginPermissive,
}
var (
sendmutex = sync.Mutex{}
)
type WebsocketClient struct {
conn *ws.Conn
}
var clients []*WebsocketClient
var marshalOptions = protojson.MarshalOptions{
UseProtoNames: false, // eg: canExec for js instead of can_exec from protobuf
EmitUnpopulated: true,
}
var ExecutionListener WebsocketExecutionListener
type WebsocketExecutionListener struct{}
func (WebsocketExecutionListener) OnExecutionStarted(ile *executor.InternalLogEntry) {
broadcast(&apiv1.EventExecutionStarted{
LogEntry: internalLogEntryToPb(ile),
})
}
func OnEntityChanged() {
broadcast(&apiv1.EventEntityChanged{})
}
func (WebsocketExecutionListener) OnActionMapRebuilt() {
broadcast(&apiv1.EventConfigChanged{})
}
/*
The default checkOrigin function checks that the origin (browser) matches the
request origin. However in OliveTin we expect many users to deliberately proxy
the connection with reverse proxies.
So, we just permit any origin. After some searching I'm not sure if this exposes
OliveTin to security issues, but it seems probably not. It would be possible to
create a config option like PermitWebsocketConnectionsFrom or something, but
I'd prefer if OliveTin works as much as possible "out of the box".
If this does expose OliveTin to security issues, it will be changed in the
future obviously.
*/
func checkOriginPermissive(r *http.Request) bool {
return true
}
func (WebsocketExecutionListener) OnOutputChunk(chunk []byte, executionTrackingId string) {
log.Tracef("outputchunk: %s", string(chunk))
oc := &apiv1.EventOutputChunk{
Output: string(chunk),
ExecutionTrackingId: executionTrackingId,
}
broadcast(oc)
}
func (WebsocketExecutionListener) OnExecutionFinished(logEntry *executor.InternalLogEntry) {
evt := &apiv1.EventExecutionFinished{
LogEntry: internalLogEntryToPb(logEntry),
}
log.Infof("WS Execution finished: %v", evt.LogEntry)
broadcast(evt)
}
func broadcast(pbmsg protoreflect.ProtoMessage) {
payload, err := marshalOptions.Marshal(pbmsg)
if err != nil {
log.Errorf("websocket marshal error: %v", err)
return
}
messageType := pbmsg.ProtoReflect().Descriptor().FullName()
// <EVIL>
// So, the websocket wants to encode messages using the same protomarshaller
// as the REST API - this gives consistency instead of using encoding/json
// and allows us to set specific marshalOptions.
//
// However, the protomarshaller will marshal the type, but the JavaScript at
// the other end has no idea what type this object is - as we're just sending
// it as JSON over the websocket.
//
// Therefore, we wrap the nicely marsheled bytes in a hacky JSON string
// literal and encode that string just with a byte array cast.
hackyMessageEnvelope := "{\"type\": \"" + messageType + "\", \"payload\": "
hackyMessage := []byte{}
hackyMessage = append(hackyMessage, []byte(hackyMessageEnvelope)...)
hackyMessage = append(hackyMessage, payload...)
hackyMessage = append(hackyMessage, []byte("}")...)
// </EVIL>
sendmutex.Lock()
for _, client := range clients {
err := client.conn.WriteMessage(ws.TextMessage, hackyMessage)
if err != nil {
log.Warnf("websocket send error: %v", err)
}
}
sendmutex.Unlock()
}
func (c *WebsocketClient) messageLoop() {
for {
mt, message, err := c.conn.ReadMessage()
if err != nil {
log.Debugf("err: %v", err)
break
}
log.Tracef("websocket recv: %s %d", message, mt)
}
}
func HandleWebsocket(w http.ResponseWriter, r *http.Request) bool {
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Warnf("Websocket issue: %v", err)
return false
}
// defer c.Close()
wsclient := &WebsocketClient{
conn: c,
}
sendmutex.Lock()
clients = append(clients, wsclient)
sendmutex.Unlock()
go wsclient.messageLoop()
return true
}
func internalLogEntryToPb(logEntry *executor.InternalLogEntry) *apiv1.LogEntry {
return &apiv1.LogEntry{
ActionTitle: logEntry.ActionTitle,
ActionIcon: logEntry.ActionIcon,
ActionId: logEntry.ActionId,
DatetimeStarted: logEntry.DatetimeStarted.Format("2006-01-02 15:04:05"),
DatetimeFinished: logEntry.DatetimeFinished.Format("2006-01-02 15:04:05"),
Output: logEntry.Output,
TimedOut: logEntry.TimedOut,
Blocked: logEntry.Blocked,
ExitCode: logEntry.ExitCode,
Tags: logEntry.Tags,
ExecutionTrackingId: logEntry.ExecutionTrackingID,
ExecutionStarted: logEntry.ExecutionStarted,
ExecutionFinished: logEntry.ExecutionFinished,
User: logEntry.Username,
}
}

View File

@ -18,7 +18,6 @@ import (
"github.com/OliveTin/OliveTin/internal/onstartup" "github.com/OliveTin/OliveTin/internal/onstartup"
"github.com/OliveTin/OliveTin/internal/servicehost" "github.com/OliveTin/OliveTin/internal/servicehost"
updatecheck "github.com/OliveTin/OliveTin/internal/updatecheck" updatecheck "github.com/OliveTin/OliveTin/internal/updatecheck"
"github.com/OliveTin/OliveTin/internal/websocket"
"os" "os"
"strconv" "strconv"
@ -147,23 +146,33 @@ func initConfig(configDir string) {
) )
} }
var firstConfigPath string var baseConfigPath string
for _, directory := range directories { for _, directory := range directories {
configPath := getConfigPath(directory) configPath := getConfigPath(directory)
log.Debugf("Checking config path: %s", configPath) found := true
if _, err := os.Stat(configPath); err != nil { if _, err := os.Stat(configPath); err != nil {
log.Debugf("Config file not found at %s: %v", configPath, err) found = false
}
log.WithFields(log.Fields{
"configPath": configPath,
"found": found,
}).Debug("Checking base config path")
if !found {
continue continue
} }
if firstConfigPath == "" { if baseConfigPath == "" {
firstConfigPath = configPath baseConfigPath = configPath
} }
log.Infof("Loading config from %s", configPath) log.WithFields(log.Fields{
"configPath": configPath,
}).Info("Loading config from path")
f := file.Provider(configPath) f := file.Provider(configPath)
if err := k.Load(f, yaml.Parser()); err != nil { if err := k.Load(f, yaml.Parser()); err != nil {
@ -177,16 +186,18 @@ func initConfig(configDir string) {
k.Load(f, yaml.Parser()) k.Load(f, yaml.Parser())
config.AppendSource(cfg, k, configPath) config.AppendSource(cfg, k, configPath)
}) })
break
} }
cfg = config.DefaultConfigWithBasePort(getBasePort()) cfg = config.DefaultConfigWithBasePort(getBasePort())
if firstConfigPath != "" { if baseConfigPath == "" {
config.AppendSourceWithIncludes(cfg, k, firstConfigPath) log.Fatalf("No base config file found")
} else { os.Exit(1)
config.AppendSource(cfg, k, "base")
} }
config.AppendSource(cfg, k, baseConfigPath)
} }
func initInstallationInfo() { func initInstallationInfo() {
@ -225,7 +236,6 @@ func main() {
executor := executor.DefaultExecutor(cfg) executor := executor.DefaultExecutor(cfg)
executor.RebuildActionMap() executor.RebuildActionMap()
executor.AddListener(websocket.ExecutionListener)
config.AddListener(executor.RebuildActionMap) config.AddListener(executor.RebuildActionMap)
go onstartup.Execute(cfg, executor) go onstartup.Execute(cfg, executor)
@ -233,7 +243,6 @@ func main() {
go onfileindir.WatchFilesInDirectory(cfg, executor) go onfileindir.WatchFilesInDirectory(cfg, executor)
go oncalendarfile.Schedule(cfg, executor) go oncalendarfile.Schedule(cfg, executor)
entities.AddListener(websocket.OnEntityChanged)
entities.AddListener(executor.RebuildActionMap) entities.AddListener(executor.RebuildActionMap)
go entities.SetupEntityFileWatchers(cfg) go entities.SetupEntityFileWatchers(cfg)