summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.idea/codeStyles/Project.xml5
-rw-r--r--.idea/inspectionProfiles/Project_Default.xml552
-rw-r--r--.idea/inspectionProfiles/profiles_settings.xml7
-rw-r--r--Mustache/Autoloader.php157
-rw-r--r--Mustache/Cache.php43
-rw-r--r--Mustache/Cache/AbstractCache.php60
-rw-r--r--Mustache/Cache/FilesystemCache.php161
-rw-r--r--Mustache/Cache/NoopCache.php47
-rw-r--r--Mustache/Compiler.php1104
-rw-r--r--Mustache/Context.php391
-rw-r--r--Mustache/Engine.php1423
-rw-r--r--Mustache/Exception.php18
-rw-r--r--Mustache/Exception/InvalidArgumentException.php18
-rw-r--r--Mustache/Exception/LogicException.php18
-rw-r--r--Mustache/Exception/RuntimeException.php18
-rw-r--r--Mustache/Exception/SyntaxException.php41
-rw-r--r--Mustache/Exception/UnknownFilterException.php38
-rw-r--r--Mustache/Exception/UnknownHelperException.php38
-rw-r--r--Mustache/Exception/UnknownTemplateException.php38
-rw-r--r--Mustache/HelperCollection.php340
-rw-r--r--Mustache/LICENSE35
-rw-r--r--Mustache/LambdaHelper.php76
-rw-r--r--Mustache/Loader.php53
-rw-r--r--Mustache/Loader/ArrayLoader.php158
-rw-r--r--Mustache/Loader/CascadingLoader.php69
-rw-r--r--Mustache/Loader/FilesystemLoader.php253
-rw-r--r--Mustache/Loader/InlineLoader.php123
-rw-r--r--Mustache/Loader/MutableLoader.php63
-rw-r--r--Mustache/Loader/ProductionFilesystemLoader.php86
-rw-r--r--Mustache/Loader/StringLoader.php81
-rw-r--r--Mustache/Logger.php126
-rw-r--r--Mustache/Logger/AbstractLogger.php121
-rw-r--r--Mustache/Logger/StreamLogger.php194
-rw-r--r--Mustache/Parser.php474
-rw-r--r--Mustache/Source.php40
-rw-r--r--Mustache/Source/FilesystemSource.php77
-rw-r--r--Mustache/Template.php329
-rw-r--r--Mustache/Tokenizer.php694
-rw-r--r--api.php12
-rw-r--r--apis/clientlog.inc.php2
-rw-r--r--apis/cron.inc.php19
-rw-r--r--apis/getconfig.inc.php2
-rw-r--r--config.php.example6
-rw-r--r--inc/arrayutil.inc.php66
-rw-r--r--inc/crypto.inc.php18
-rw-r--r--inc/dashboard.inc.php35
-rw-r--r--inc/database.inc.php155
-rw-r--r--inc/dictionary.inc.php101
-rw-r--r--inc/download.inc.php76
-rw-r--r--inc/errorhandler.inc.php153
-rw-r--r--inc/event.inc.php16
-rw-r--r--inc/eventlog.inc.php61
-rw-r--r--inc/fileutil.inc.php18
-rw-r--r--inc/hook.inc.php19
-rw-r--r--inc/iputil.inc.php41
-rw-r--r--inc/mailer.inc.php186
-rw-r--r--inc/message.inc.php47
-rw-r--r--inc/module.inc.php69
-rw-r--r--inc/paginate.inc.php39
-rw-r--r--inc/permission.inc.php20
-rw-r--r--inc/property.inc.php159
-rw-r--r--inc/render.inc.php63
-rw-r--r--inc/request.inc.php16
-rw-r--r--inc/session.inc.php178
-rw-r--r--inc/taskmanager.inc.php38
-rw-r--r--inc/taskmanagercallback.inc.php61
-rw-r--r--inc/trigger.inc.php56
-rw-r--r--inc/user.inc.php68
-rw-r--r--inc/util.inc.php298
-rw-r--r--index.php17
-rw-r--r--install.php176
-rw-r--r--modules-available/adduser/lang/de/template-tags.json2
-rw-r--r--modules-available/adduser/lang/en/template-tags.json2
-rw-r--r--modules-available/adduser/page.inc.php9
-rw-r--r--modules-available/backup/hooks/cron.inc.php40
-rw-r--r--modules-available/backup/hooks/main-warning.inc.php14
-rw-r--r--modules-available/backup/inc/backuprestore.inc.php13
-rw-r--r--modules-available/backup/lang/de/messages.json1
-rw-r--r--modules-available/backup/lang/de/permissions.json5
-rw-r--r--modules-available/backup/lang/de/template-tags.json22
-rw-r--r--modules-available/backup/lang/en/messages.json1
-rw-r--r--modules-available/backup/lang/en/permissions.json5
-rw-r--r--modules-available/backup/lang/en/template-tags.json20
-rw-r--r--modules-available/backup/page.inc.php92
-rw-r--r--modules-available/backup/permissions/permissions.json3
-rw-r--r--modules-available/backup/templates/_page.html64
-rw-r--r--modules-available/backup/templates/restore.html10
-rw-r--r--modules-available/backup/templates/task-error.html6
-rw-r--r--modules-available/baseconfig/hooks/locations-column.inc.php76
-rw-r--r--modules-available/baseconfig/inc/baseconfig.inc.php27
-rw-r--r--modules-available/baseconfig/inc/baseconfigutil.inc.php14
-rw-r--r--modules-available/baseconfig/inc/configholder.inc.php19
-rw-r--r--modules-available/baseconfig/inc/validator.inc.php18
-rw-r--r--modules-available/baseconfig/lang/de/module.json3
-rw-r--r--modules-available/baseconfig/lang/en/module.json3
-rw-r--r--modules-available/baseconfig/page.inc.php44
-rw-r--r--modules-available/baseconfig/templates/_page.html5
-rw-r--r--modules-available/baseconfig_bwidm/hooks/translation.inc.php8
-rw-r--r--modules-available/baseconfig_bwlp/baseconfig/settings.json28
-rw-r--r--modules-available/baseconfig_bwlp/hooks/translation.inc.php8
-rw-r--r--modules-available/baseconfig_bwlp/lang/de/config-variables.json8
-rw-r--r--modules-available/baseconfig_bwlp/lang/en/config-variables.json12
-rw-r--r--modules-available/dnbd3/baseconfig/getconfig.inc.php13
-rw-r--r--modules-available/dnbd3/hooks/main-warning.inc.php4
-rw-r--r--modules-available/dnbd3/hooks/translation.inc.php4
-rw-r--r--modules-available/dnbd3/inc/dnbd3.inc.php39
-rw-r--r--modules-available/dnbd3/inc/dnbd3rpc.inc.php147
-rw-r--r--modules-available/dnbd3/inc/dnbd3util.inc.php52
-rw-r--r--modules-available/dnbd3/lang/de/config-variables.json4
-rw-r--r--modules-available/dnbd3/lang/de/template-tags.json9
-rw-r--r--modules-available/dnbd3/lang/en/config-variables.json4
-rw-r--r--modules-available/dnbd3/lang/en/template-tags.json7
-rw-r--r--modules-available/dnbd3/page.inc.php94
-rw-r--r--modules-available/dnbd3/templates/fragment-server-settings.html2
-rw-r--r--modules-available/dnbd3/templates/page-proxy-images.html4
-rw-r--r--modules-available/dnbd3/templates/page-proxy-stats.html7
-rw-r--r--modules-available/dnbd3/templates/page-serverlist.html156
-rw-r--r--modules-available/dozmod/api.inc.php107
-rw-r--r--modules-available/dozmod/lang/de/messages.json10
-rw-r--r--modules-available/dozmod/lang/de/template-tags.json7
-rw-r--r--modules-available/dozmod/lang/en/template-tags.json7
-rw-r--r--modules-available/dozmod/page.inc.php6
-rw-r--r--modules-available/dozmod/pages/actionlog.inc.php8
-rw-r--r--modules-available/dozmod/pages/expiredimages.inc.php15
-rw-r--r--modules-available/dozmod/pages/mailconfig.inc.php2
-rw-r--r--modules-available/dozmod/pages/networkrules.inc.php2
-rw-r--r--modules-available/dozmod/pages/networkshares.inc.php2
-rw-r--r--modules-available/dozmod/pages/runscripts.inc.php4
-rw-r--r--modules-available/dozmod/pages/runtimeconfig.inc.php26
-rw-r--r--modules-available/dozmod/pages/special.inc.php4
-rw-r--r--modules-available/dozmod/pages/templates.inc.php2
-rw-r--r--modules-available/dozmod/pages/users.inc.php8
-rw-r--r--modules-available/dozmod/templates/runtimeconfig.html16
-rw-r--r--modules-available/eventlog/hooks/cron.inc.php66
-rw-r--r--modules-available/eventlog/inc/filterruleprocessor.inc.php350
-rw-r--r--modules-available/eventlog/inc/notificationtransport.inc.php279
-rw-r--r--modules-available/eventlog/install.inc.php83
-rw-r--r--modules-available/eventlog/lang/de/messages.json9
-rw-r--r--modules-available/eventlog/lang/de/module.json2
-rw-r--r--modules-available/eventlog/lang/de/permissions.json8
-rw-r--r--modules-available/eventlog/lang/de/template-tags.json60
-rw-r--r--modules-available/eventlog/lang/en/messages.json9
-rw-r--r--modules-available/eventlog/lang/en/module.json2
-rw-r--r--modules-available/eventlog/lang/en/permissions.json8
-rw-r--r--modules-available/eventlog/lang/en/template-tags.json60
-rw-r--r--modules-available/eventlog/page.inc.php85
-rw-r--r--modules-available/eventlog/pages/log.inc.php56
-rw-r--r--modules-available/eventlog/pages/mailconfigs.inc.php99
-rw-r--r--modules-available/eventlog/pages/rules.inc.php187
-rw-r--r--modules-available/eventlog/pages/transports.inc.php179
-rw-r--r--modules-available/eventlog/permissions/permissions.json18
-rw-r--r--modules-available/eventlog/templates/_page.html13
-rw-r--r--modules-available/eventlog/templates/heading.html1
-rw-r--r--modules-available/eventlog/templates/page-filters-edit-mailconfig.html53
-rw-r--r--modules-available/eventlog/templates/page-filters-edit-rule.html219
-rw-r--r--modules-available/eventlog/templates/page-filters-edit-transport.html190
-rw-r--r--modules-available/eventlog/templates/page-filters-mailconfigs.html42
-rw-r--r--modules-available/eventlog/templates/page-filters-rules.html48
-rw-r--r--modules-available/eventlog/templates/page-filters-transports.html45
-rw-r--r--modules-available/eventlog/templates/page-header.html16
-rw-r--r--modules-available/exams/baseconfig/getconfig.inc.php17
-rw-r--r--modules-available/exams/inc/exams.inc.php9
-rw-r--r--modules-available/exams/page.inc.php32
-rw-r--r--modules-available/js_chart/clientscript.js20
-rw-r--r--modules-available/locationinfo/api.inc.php85
-rw-r--r--modules-available/locationinfo/exchange-includes/jamesiarmes/PhpEws/ArrayType/ArrayOfStringsType.php2
-rw-r--r--modules-available/locationinfo/exchange-includes/jamesiarmes/PhpEws/Autodiscover.php2
-rw-r--r--modules-available/locationinfo/exchange-includes/jamesiarmes/PhpNtlm/SoapClient.php5
-rw-r--r--modules-available/locationinfo/inc/coursebackend.inc.php67
-rw-r--r--modules-available/locationinfo/inc/coursebackend/coursebackend_davinci.inc.php20
-rw-r--r--modules-available/locationinfo/inc/coursebackend/coursebackend_dummy.inc.php20
-rwxr-xr-xmodules-available/locationinfo/inc/coursebackend/coursebackend_exchange.inc.php53
-rw-r--r--modules-available/locationinfo/inc/coursebackend/coursebackend_hisinone.inc.php22
-rw-r--r--modules-available/locationinfo/inc/coursebackend/coursebackend_ical.inc.php12
-rw-r--r--modules-available/locationinfo/inc/icalcoursebackend.inc.php33
-rw-r--r--modules-available/locationinfo/inc/icalevent.inc.php444
-rw-r--r--modules-available/locationinfo/inc/icalparser.inc.php151
-rw-r--r--modules-available/locationinfo/inc/infopanel.inc.php30
-rw-r--r--modules-available/locationinfo/inc/locationinfo.inc.php116
-rw-r--r--modules-available/locationinfo/inc/locationinfohooks.inc.php27
-rw-r--r--modules-available/locationinfo/lang/de/template-tags.json6
-rw-r--r--modules-available/locationinfo/lang/en/template-tags.json6
-rw-r--r--modules-available/locationinfo/page.inc.php167
-rwxr-xr-xmodules-available/locationinfo/templates/frontend-default.html14
-rw-r--r--modules-available/locationinfo/templates/page-config-panel-url.html37
-rw-r--r--modules-available/locationinfo/templates/page-locations.html2
-rw-r--r--modules-available/locations/baseconfig/getconfig.inc.php7
-rw-r--r--modules-available/locations/inc/abstractlocationcolumn.inc.php29
-rw-r--r--modules-available/locations/inc/autolocation.inc.php2
-rw-r--r--modules-available/locations/inc/location.inc.php87
-rw-r--r--modules-available/locations/inc/locationhooks.inc.php6
-rw-r--r--modules-available/locations/inc/locationutil.inc.php32
-rw-r--r--modules-available/locations/inc/openingtimes.inc.php62
-rw-r--r--modules-available/locations/install.inc.php7
-rw-r--r--modules-available/locations/lang/de/messages.json4
-rw-r--r--modules-available/locations/lang/de/permissions.json1
-rw-r--r--modules-available/locations/lang/de/template-tags.json19
-rw-r--r--modules-available/locations/lang/en/messages.json4
-rw-r--r--modules-available/locations/lang/en/permissions.json1
-rw-r--r--modules-available/locations/lang/en/template-tags.json15
-rw-r--r--modules-available/locations/pages/cleanup.inc.php9
-rw-r--r--modules-available/locations/pages/details.inc.php131
-rw-r--r--modules-available/locations/pages/locations.inc.php183
-rw-r--r--modules-available/locations/pages/subnets.inc.php10
-rw-r--r--modules-available/locations/permissions/permissions.json3
-rw-r--r--modules-available/locations/style.css12
-rw-r--r--modules-available/locations/templates/ajax-opening-location.html55
-rw-r--r--modules-available/locations/templates/location-subnets.html23
-rw-r--r--modules-available/locations/templates/locations.html107
-rw-r--r--modules-available/locations/templates/mismatch-cleanup.html24
-rw-r--r--modules-available/main/hooks/cron.inc.php4
-rw-r--r--modules-available/main/hooks/translation.inc.php5
-rw-r--r--modules-available/main/install.inc.php74
-rw-r--r--modules-available/main/lang/de/template-tags.json1
-rw-r--r--modules-available/main/lang/en/template-tags.json9
-rw-r--r--modules-available/main/page.inc.php4
-rw-r--r--modules-available/main/templates/main-menu.html4
-rw-r--r--modules-available/minilinux/inc/linuxbootentryhook.inc.php64
-rw-r--r--modules-available/minilinux/inc/minilinux.inc.php186
-rw-r--r--modules-available/minilinux/install.inc.php32
-rw-r--r--modules-available/minilinux/lang/de/messages.json12
-rw-r--r--modules-available/minilinux/lang/de/module.json9
-rw-r--r--modules-available/minilinux/lang/de/permissions.json6
-rw-r--r--modules-available/minilinux/lang/de/template-tags.json2
-rw-r--r--modules-available/minilinux/lang/en/messages.json2
-rw-r--r--modules-available/minilinux/lang/en/module.json1
-rw-r--r--modules-available/minilinux/lang/en/permissions.json6
-rw-r--r--modules-available/minilinux/page.inc.php96
-rw-r--r--modules-available/minilinux/templates/branches.html5
-rw-r--r--modules-available/minilinux/templates/filelist.html7
-rw-r--r--modules-available/minilinux/templates/page-minilinux.html15
-rw-r--r--modules-available/minilinux/templates/versionlist.html15
-rw-r--r--modules-available/news/api.inc.php65
-rw-r--r--modules-available/news/install.inc.php31
-rw-r--r--modules-available/news/lang/de/template-tags.json2
-rw-r--r--modules-available/news/lang/en/template-tags.json2
-rw-r--r--modules-available/news/page.inc.php162
-rw-r--r--modules-available/news/permissions/permissions.json12
-rw-r--r--modules-available/news/templates/page-news.html18
-rw-r--r--modules-available/passthrough/config.json8
-rw-r--r--modules-available/passthrough/hooks/locations-column.inc.php58
-rw-r--r--modules-available/passthrough/inc/passthrough.inc.php53
-rw-r--r--modules-available/passthrough/install.inc.php23
-rw-r--r--modules-available/passthrough/lang/de/messages.json4
-rw-r--r--modules-available/passthrough/lang/de/module.json4
-rw-r--r--modules-available/passthrough/lang/de/permissions.json5
-rw-r--r--modules-available/passthrough/lang/de/template-tags.json15
-rw-r--r--modules-available/passthrough/lang/en/messages.json4
-rw-r--r--modules-available/passthrough/lang/en/module.json4
-rw-r--r--modules-available/passthrough/lang/en/permissions.json5
-rw-r--r--modules-available/passthrough/lang/en/template-tags.json15
-rw-r--r--modules-available/passthrough/page.inc.php195
-rw-r--r--modules-available/passthrough/permissions/permissions.json5
-rw-r--r--modules-available/passthrough/templates/hardware-list.html138
-rw-r--r--modules-available/passthrough/templates/location-assign.html37
-rw-r--r--modules-available/permissionmanager/hooks/translation-global.inc.php5
-rw-r--r--modules-available/permissionmanager/inc/getpermissiondata.inc.php20
-rw-r--r--modules-available/permissionmanager/inc/permissiondbupdate.inc.php21
-rw-r--r--modules-available/permissionmanager/inc/permissionutil.inc.php51
-rw-r--r--modules-available/permissionmanager/install.inc.php33
-rw-r--r--modules-available/permissionmanager/page.inc.php28
-rw-r--r--modules-available/rebootcontrol/api.inc.php36
-rw-r--r--modules-available/rebootcontrol/hooks/client-update.inc.php2
-rw-r--r--modules-available/rebootcontrol/hooks/config-tgz.inc.php6
-rw-r--r--modules-available/rebootcontrol/hooks/cron.inc.php41
-rw-r--r--modules-available/rebootcontrol/inc/rebootcontrol.inc.php433
-rw-r--r--modules-available/rebootcontrol/inc/rebootutils.inc.php15
-rw-r--r--modules-available/rebootcontrol/inc/scheduler.inc.php367
-rw-r--r--modules-available/rebootcontrol/inc/sshkey.inc.php27
-rw-r--r--modules-available/rebootcontrol/install.inc.php23
-rw-r--r--modules-available/rebootcontrol/lang/de/permissions.json3
-rw-r--r--modules-available/rebootcontrol/lang/de/template-tags.json14
-rw-r--r--modules-available/rebootcontrol/lang/en/permissions.json1
-rw-r--r--modules-available/rebootcontrol/lang/en/template-tags.json6
-rw-r--r--modules-available/rebootcontrol/page.inc.php17
-rw-r--r--modules-available/rebootcontrol/pages/exec.inc.php1
-rw-r--r--modules-available/rebootcontrol/pages/jumphost.inc.php5
-rw-r--r--modules-available/rebootcontrol/pages/subnet.inc.php26
-rw-r--r--modules-available/rebootcontrol/pages/task.inc.php23
-rw-r--r--modules-available/rebootcontrol/permissions/permissions.json3
-rw-r--r--modules-available/rebootcontrol/templates/header.html51
-rw-r--r--modules-available/rebootcontrol/templates/status-checkconnection.html2
-rw-r--r--modules-available/rebootcontrol/templates/status-exec.html15
-rw-r--r--modules-available/rebootcontrol/templates/status-reboot.html5
-rw-r--r--modules-available/rebootcontrol/templates/status-wol.html26
-rw-r--r--modules-available/rebootcontrol/templates/subnet-edit.html2
-rw-r--r--modules-available/rebootcontrol/templates/subnet-list.html2
-rw-r--r--modules-available/rebootcontrol/templates/task-list.html4
-rw-r--r--modules-available/remoteaccess/api.inc.php39
-rw-r--r--modules-available/remoteaccess/baseconfig/getconfig.inc.php61
-rw-r--r--modules-available/remoteaccess/inc/remoteaccess.inc.php79
-rw-r--r--modules-available/remoteaccess/install.inc.php20
-rw-r--r--modules-available/remoteaccess/lang/de/template-tags.json12
-rw-r--r--modules-available/remoteaccess/lang/en/template-tags.json14
-rw-r--r--modules-available/remoteaccess/page.inc.php65
-rw-r--r--modules-available/remoteaccess/templates/edit-group.html10
-rw-r--r--modules-available/remoteaccess/templates/edit-settings.html70
-rw-r--r--modules-available/roomplanner/api.inc.php6
-rw-r--r--modules-available/roomplanner/inc/composedroom.inc.php21
-rw-r--r--modules-available/roomplanner/inc/pvsgenerator.inc.php21
-rw-r--r--modules-available/roomplanner/inc/room.inc.php51
-rw-r--r--modules-available/roomplanner/inc/simpleroom.inc.php20
-rw-r--r--modules-available/roomplanner/install.inc.php2
-rw-r--r--modules-available/roomplanner/page.inc.php89
-rw-r--r--modules-available/roomplanner/templates/svg-plan.html26
-rw-r--r--modules-available/runmode/baseconfig/getconfig.inc.php13
-rw-r--r--modules-available/runmode/inc/runmode.inc.php76
-rw-r--r--modules-available/runmode/page.inc.php43
-rw-r--r--modules-available/serversetup-bwlp-ipxe/api.inc.php13
-rw-r--r--modules-available/serversetup-bwlp-ipxe/hooks/ipxe-update.inc.php20
-rw-r--r--modules-available/serversetup-bwlp-ipxe/hooks/locations-column.inc.php57
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/bootentry.inc.php127
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/bootentryhook.inc.php57
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/execdata.inc.php7
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/ipxe.inc.php154
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/ipxebuilder.inc.php80
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/ipxemenu.inc.php113
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/localboot.inc.php37
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/menuentry.inc.php58
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/pxelinux.inc.php174
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/pxemenu.inc.php59
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/pxesection.inc.php117
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderbase.inc.php49
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderbash.inc.php20
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/scriptbuildergrub.inc.php330
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderipxe.inc.php137
-rw-r--r--modules-available/serversetup-bwlp-ipxe/install.inc.php4
-rw-r--r--modules-available/serversetup-bwlp-ipxe/lang/de/messages.json2
-rw-r--r--modules-available/serversetup-bwlp-ipxe/lang/de/module.json3
-rw-r--r--modules-available/serversetup-bwlp-ipxe/lang/de/template-tags.json11
-rw-r--r--modules-available/serversetup-bwlp-ipxe/lang/en/messages.json2
-rw-r--r--modules-available/serversetup-bwlp-ipxe/lang/en/module.json3
-rw-r--r--modules-available/serversetup-bwlp-ipxe/lang/en/template-tags.json15
-rw-r--r--modules-available/serversetup-bwlp-ipxe/page.inc.php183
-rw-r--r--modules-available/serversetup-bwlp-ipxe/templates/download.html11
-rw-r--r--modules-available/serversetup-bwlp-ipxe/templates/git_task.html15
-rw-r--r--modules-available/serversetup-bwlp-ipxe/templates/ipaddress.html114
-rw-r--r--modules-available/serversetup-bwlp-ipxe/templates/ipxe_update.html28
-rw-r--r--modules-available/serversetup-bwlp-pxelinux/config.json3
-rw-r--r--modules-available/serversetup-bwlp-pxelinux/hooks/ipxe-update.inc.php9
-rw-r--r--modules-available/serversetup-bwlp-pxelinux/hooks/main-warning.inc.php6
-rw-r--r--modules-available/serversetup-bwlp-pxelinux/lang/de/messages.json5
-rw-r--r--modules-available/serversetup-bwlp-pxelinux/lang/de/module.json4
-rw-r--r--modules-available/serversetup-bwlp-pxelinux/lang/de/permissions.json6
-rw-r--r--modules-available/serversetup-bwlp-pxelinux/lang/de/template-tags.json33
-rw-r--r--modules-available/serversetup-bwlp-pxelinux/lang/en/messages.json5
-rw-r--r--modules-available/serversetup-bwlp-pxelinux/lang/en/module.json3
-rw-r--r--modules-available/serversetup-bwlp-pxelinux/lang/en/permissions.json6
-rw-r--r--modules-available/serversetup-bwlp-pxelinux/lang/en/template-tags.json33
-rw-r--r--modules-available/serversetup-bwlp-pxelinux/lang/pt/messages.json3
-rw-r--r--modules-available/serversetup-bwlp-pxelinux/lang/pt/module.json3
-rw-r--r--modules-available/serversetup-bwlp-pxelinux/lang/pt/template-tags.json38
-rw-r--r--modules-available/serversetup-bwlp-pxelinux/page.inc.php187
-rw-r--r--modules-available/serversetup-bwlp-pxelinux/permissions/permissions.json14
-rw-r--r--modules-available/serversetup-bwlp-pxelinux/templates/heading.html1
-rw-r--r--modules-available/serversetup-bwlp-pxelinux/templates/ipaddress.html37
-rw-r--r--modules-available/serversetup-bwlp-pxelinux/templates/ipxe.html117
-rw-r--r--modules-available/serversetup-bwlp-pxelinux/templates/ipxe_update.html38
-rw-r--r--modules-available/session/hooks/cron.inc.php6
-rw-r--r--modules-available/session/lang/de/module.json1
-rw-r--r--modules-available/session/lang/de/template-tags.json11
-rw-r--r--modules-available/session/lang/en/module.json1
-rw-r--r--modules-available/session/lang/en/template-tags.json11
-rw-r--r--modules-available/session/page.inc.php33
-rw-r--r--modules-available/session/templates/change-password.html43
-rw-r--r--modules-available/session/templates/page-login.html4
-rw-r--r--modules-available/statistics/api.inc.php166
-rw-r--r--modules-available/statistics/baseconfig/getconfig.inc.php39
-rw-r--r--modules-available/statistics/clientscript.js76
-rw-r--r--modules-available/statistics/config.json1
-rw-r--r--modules-available/statistics/hooks/config-tgz.inc.php4
-rw-r--r--modules-available/statistics/hooks/cron.inc.php58
-rw-r--r--modules-available/statistics/hooks/locations-column.inc.php150
-rw-r--r--modules-available/statistics/hooks/translation.inc.php4
-rw-r--r--modules-available/statistics/inc/devicetype.inc.php6
-rw-r--r--modules-available/statistics/inc/hardwareinfo.inc.php249
-rw-r--r--modules-available/statistics/inc/hardwareparser.inc.php789
-rw-r--r--modules-available/statistics/inc/hardwareparserlegacy.inc.php285
-rw-r--r--modules-available/statistics/inc/hardwarequery.inc.php169
-rw-r--r--modules-available/statistics/inc/hardwarequerycolumn.inc.php94
-rw-r--r--modules-available/statistics/inc/parser.inc.php410
-rw-r--r--modules-available/statistics/inc/pciid.inc.php82
-rw-r--r--modules-available/statistics/inc/statistics.inc.php21
-rw-r--r--modules-available/statistics/inc/statisticsfilter.inc.php382
-rw-r--r--modules-available/statistics/inc/statisticsfilterset.inc.php57
-rw-r--r--modules-available/statistics/inc/statisticshooks.inc.php24
-rw-r--r--modules-available/statistics/inc/statisticsstyling.inc.php30
-rw-r--r--modules-available/statistics/install.inc.php84
-rw-r--r--modules-available/statistics/lang/de/filters.json7
-rw-r--r--modules-available/statistics/lang/de/messages.json2
-rw-r--r--modules-available/statistics/lang/de/module.json5
-rw-r--r--modules-available/statistics/lang/de/permissions.json2
-rw-r--r--modules-available/statistics/lang/de/template-tags.json44
-rw-r--r--modules-available/statistics/lang/en/filters.json7
-rw-r--r--modules-available/statistics/lang/en/messages.json2
-rw-r--r--modules-available/statistics/lang/en/module.json6
-rw-r--r--modules-available/statistics/lang/en/permissions.json2
-rw-r--r--modules-available/statistics/lang/en/template-tags.json44
-rw-r--r--modules-available/statistics/page.inc.php131
-rw-r--r--modules-available/statistics/pages/hints.inc.php221
-rw-r--r--modules-available/statistics/pages/list.inc.php134
-rw-r--r--modules-available/statistics/pages/machine.inc.php420
-rw-r--r--modules-available/statistics/pages/projectors.inc.php6
-rw-r--r--modules-available/statistics/pages/replace.inc.php24
-rw-r--r--modules-available/statistics/pages/summary.inc.php268
-rw-r--r--modules-available/statistics/permissions/permissions.json6
-rw-r--r--modules-available/statistics/style.css22
-rw-r--r--modules-available/statistics/templates/clientlist.html190
-rw-r--r--modules-available/statistics/templates/cpumodels.html25
-rw-r--r--modules-available/statistics/templates/filterbox.html38
-rw-r--r--modules-available/statistics/templates/hints-cpu-legacy.html28
-rw-r--r--modules-available/statistics/templates/hints-hdd-grow.html67
-rw-r--r--modules-available/statistics/templates/hints-nic-speed.html32
-rw-r--r--modules-available/statistics/templates/hints-ram-underclocked.html49
-rw-r--r--modules-available/statistics/templates/hints-ram-upgrade.html32
-rw-r--r--modules-available/statistics/templates/id44.html27
-rw-r--r--modules-available/statistics/templates/js-pciquery.html24
-rw-r--r--modules-available/statistics/templates/kvmstate.html25
-rw-r--r--modules-available/statistics/templates/machine-hdds.html79
-rw-r--r--modules-available/statistics/templates/machine-main.html155
-rw-r--r--modules-available/statistics/templates/memory.html27
-rw-r--r--modules-available/statistics/templates/summary.html50
-rw-r--r--modules-available/statistics_reporting/hooks/cron.inc.php30
-rw-r--r--modules-available/statistics_reporting/inc/getdata.inc.php25
-rw-r--r--modules-available/statistics_reporting/inc/queries.inc.php62
-rw-r--r--modules-available/statistics_reporting/inc/remotereport.inc.php98
-rw-r--r--modules-available/statistics_reporting/page.inc.php25
-rw-r--r--modules-available/sysconfig/addconfig.inc.php54
-rw-r--r--modules-available/sysconfig/addmodule.inc.php58
-rw-r--r--modules-available/sysconfig/addmodule_adauth.inc.php45
-rw-r--r--modules-available/sysconfig/addmodule_branding.inc.php83
-rw-r--r--modules-available/sysconfig/addmodule_custommodule.inc.php32
-rw-r--r--modules-available/sysconfig/addmodule_ldapauth.inc.php32
-rw-r--r--modules-available/sysconfig/addmodule_screensaver.inc.php57
-rw-r--r--modules-available/sysconfig/addmodule_sshconfig.inc.php23
-rw-r--r--modules-available/sysconfig/addmodule_sshkey.inc.php12
-rw-r--r--modules-available/sysconfig/api.inc.php8
-rw-r--r--modules-available/sysconfig/hooks/bootup.inc.php3
-rw-r--r--modules-available/sysconfig/hooks/locations-column.inc.php57
-rw-r--r--modules-available/sysconfig/inc/configmodule.inc.php225
-rw-r--r--modules-available/sysconfig/inc/configmodule/branding.inc.php20
-rw-r--r--modules-available/sysconfig/inc/configmodule/customodule.inc.php20
-rw-r--r--modules-available/sysconfig/inc/configmodule/ldapauth.inc.php2
-rw-r--r--modules-available/sysconfig/inc/configmodule/screensaver.inc.php29
-rw-r--r--modules-available/sysconfig/inc/configmodule/sshconfig.inc.php8
-rw-r--r--modules-available/sysconfig/inc/configmodule/sshkey.inc.php8
-rw-r--r--modules-available/sysconfig/inc/configmodulebaseldap.inc.php26
-rw-r--r--modules-available/sysconfig/inc/configtgz.inc.php188
-rw-r--r--modules-available/sysconfig/inc/ldap.inc.php2
-rw-r--r--modules-available/sysconfig/inc/ppd.inc.php249
-rw-r--r--modules-available/sysconfig/inc/sysconfig.inc.php4
-rw-r--r--modules-available/sysconfig/install.inc.php35
-rw-r--r--modules-available/sysconfig/lang/de/module.json17
-rw-r--r--modules-available/sysconfig/lang/en/module.json17
-rw-r--r--modules-available/sysconfig/page.inc.php21
-rw-r--r--modules-available/sysconfig/templates/ad-start.html2
-rw-r--r--modules-available/sysconfig/templates/branding-check.html2
-rw-r--r--modules-available/sysconfig/templates/branding-start.html2
-rw-r--r--modules-available/sysconfig/templates/cfg-start.html2
-rw-r--r--modules-available/sysconfig/templates/custom-fileselect.html3
-rw-r--r--modules-available/sysconfig/templates/ldap-start.html2
-rw-r--r--modules-available/sysconfig/templates/sshconfig-start.html2
-rw-r--r--modules-available/sysconfig/templates/sshkey-start.html5
-rw-r--r--modules-available/syslog/api.inc.php22
-rw-r--r--modules-available/syslog/inc/clientlog.inc.php47
-rw-r--r--modules-available/syslog/lang/de/template-tags.json1
-rw-r--r--modules-available/syslog/lang/en/template-tags.json1
-rw-r--r--modules-available/syslog/page.inc.php84
-rw-r--r--modules-available/syslog/templates/page-syslog.html18
-rw-r--r--modules-available/systemstatus/hooks/cron.inc.php6
-rw-r--r--modules-available/systemstatus/hooks/main-warning.inc.php18
-rw-r--r--modules-available/systemstatus/inc/systemstatus.inc.php119
-rw-r--r--modules-available/systemstatus/lang/de/messages.json2
-rw-r--r--modules-available/systemstatus/lang/de/module.json1
-rw-r--r--modules-available/systemstatus/lang/de/permissions.json5
-rw-r--r--modules-available/systemstatus/lang/de/template-tags.json19
-rw-r--r--modules-available/systemstatus/lang/en/messages.json2
-rw-r--r--modules-available/systemstatus/lang/en/module.json1
-rw-r--r--modules-available/systemstatus/lang/en/permissions.json5
-rw-r--r--modules-available/systemstatus/lang/en/template-tags.json19
-rw-r--r--modules-available/systemstatus/page.inc.php248
-rw-r--r--modules-available/systemstatus/permissions/permissions.json15
-rw-r--r--modules-available/systemstatus/templates/_page.html6
-rw-r--r--modules-available/systemstatus/templates/diskstat.html2
-rw-r--r--modules-available/systemstatus/templates/sys-update-main.html118
-rw-r--r--modules-available/systemstatus/templates/sys-update-update.html35
-rw-r--r--modules-available/systemstatus/templates/systeminfo.html161
-rw-r--r--modules-available/translation/page.inc.php161
-rw-r--r--modules-available/vmstore/baseconfig/getconfig.inc.php4
-rw-r--r--modules-available/vmstore/hooks/main-warning.inc.php2
-rw-r--r--modules-available/vmstore/inc/vmstorebenchmark.inc.php84
-rw-r--r--modules-available/vmstore/lang/de/messages.json6
-rw-r--r--modules-available/vmstore/lang/de/module.json4
-rw-r--r--modules-available/vmstore/lang/de/permissions.json1
-rw-r--r--modules-available/vmstore/lang/de/template-tags.json10
-rw-r--r--modules-available/vmstore/lang/en/messages.json6
-rw-r--r--modules-available/vmstore/lang/en/module.json4
-rw-r--r--modules-available/vmstore/lang/en/permissions.json1
-rw-r--r--modules-available/vmstore/lang/en/template-tags.json10
-rw-r--r--modules-available/vmstore/page.inc.php270
-rw-r--r--modules-available/vmstore/permissions/permissions.json3
-rw-r--r--modules-available/vmstore/templates/benchmark-imgselect.html59
-rw-r--r--modules-available/vmstore/templates/benchmark-nothing.html7
-rw-r--r--modules-available/vmstore/templates/benchmark-result.html141
-rw-r--r--modules-available/vmstore/templates/page-vmstore.html3
-rw-r--r--modules-available/webinterface/baseconfig/getconfig.inc.php3
-rw-r--r--modules-available/webinterface/lang/en/template-tags.json2
-rwxr-xr-xpack.sh6
-rw-r--r--style/default.css7
-rw-r--r--tools/convert-modules.php3
-rw-r--r--tools/global-candidates.php1
-rw-r--r--tools/jedec.php4
512 files changed, 21466 insertions, 9037 deletions
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 9c263399..d2e00505 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -7,9 +7,6 @@
<option name="LOWER_CASE_BOOLEAN_CONST" value="true" />
<option name="LOWER_CASE_NULL_CONST" value="true" />
</PHPCodeStyleSettings>
- <XML>
- <option name="XML_LEGACY_SETTINGS_IMPORTED" value="true" />
- </XML>
<codeStyleSettings language="HTML">
<indentOptions>
<option name="INDENT_SIZE" value="3" />
@@ -56,4 +53,4 @@
</indentOptions>
</codeStyleSettings>
</code_scheme>
-</component>
+</component> \ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index 3a559fb8..c86bf70f 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -1,7 +1,93 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
- <inspection_tool class="HtmlUnknownTag" enabled="true" level="WARNING" enabled_by_default="true">
+ <inspection_tool class="BadExpressionStatementJS" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="BehatDocStepCanBeConvertedToAttributeInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="CallerJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="CheckEmptyScriptTag" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="CheckImageSize" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="CheckValidXmlInScriptTagBody" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="CommaExpressionJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="ConstantConditionalExpressionJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="ContinueOrBreakFromFinallyBlockJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="CssDeprecatedValue" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="CssInvalidAtRule" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="CssInvalidCharsetRule" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="CssInvalidFunction" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="CssInvalidHtmlTagReference" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="CssInvalidImport" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="CssInvalidMediaFeature" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="CssInvalidNestedSelector" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="CssInvalidPropertyValue" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="CssInvalidPseudoSelector" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="CssMissingComma" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="CssNegativeValue" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="CssNoGenericFontName" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="CssNonIntegerLengthInPixels" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="CssOverwrittenProperties" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="CssRedundantUnit" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="CssReplaceWithShorthandSafely" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="CssReplaceWithShorthandUnsafely" enabled="false" level="INFORMATION" enabled_by_default="false" />
+ <inspection_tool class="CssUnknownProperty" enabled="false" level="WARNING" enabled_by_default="false">
+ <option name="myCustomPropertiesEnabled" value="false" />
+ <option name="myIgnoreVendorSpecificProperties" value="false" />
+ <option name="myCustomPropertiesList">
+ <value>
+ <list size="0" />
+ </value>
+ </option>
+ </inspection_tool>
+ <inspection_tool class="CssUnknownTarget" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="CssUnknownUnit" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="CssUnresolvedClassInComposesRule" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="CssUnresolvedCustomProperty" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="CssUnusedSymbol" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="DuplicateKeyInSection" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="DuplicateSectionInFile" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="ES6BindWithArrowFunction" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="ES6ClassMemberInitializationOrder" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="ES6ConvertIndexedForToForOf" enabled="false" level="INFORMATION" enabled_by_default="false" />
+ <inspection_tool class="ES6ConvertLetToConst" enabled="false" level="INFORMATION" enabled_by_default="false" />
+ <inspection_tool class="ES6ConvertModuleExportToExport" enabled="false" level="INFORMATION" enabled_by_default="false" />
+ <inspection_tool class="ES6ConvertRequireIntoImport" enabled="false" level="INFORMATION" enabled_by_default="false" />
+ <inspection_tool class="ES6ConvertToForOf" enabled="false" level="INFORMATION" enabled_by_default="false" />
+ <inspection_tool class="ES6ConvertVarToLetConst" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="ES6DestructuringVariablesMerge" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="ES6MissingAwait" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="ES6PossiblyAsyncFunction" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="ES6PreferShortImport" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="ES6RedundantAwait" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="ES6RedundantNestingInTemplateLiteral" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="ES6ShorthandObjectProperty" enabled="false" level="INFORMATION" enabled_by_default="false" />
+ <inspection_tool class="ES6UnusedImports" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="EmptyStatementBodyJS" enabled="false" level="WARNING" enabled_by_default="false">
+ <option name="m_reportEmptyBlocks" value="false" />
+ </inspection_tool>
+ <inspection_tool class="ExceptionCaughtLocallyJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="FallThroughInSwitchStatementJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="FileHeaderInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="FlowJSConfig" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="FlowJSFlagCommentPlacement" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="HbEmptyBlock" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="HtmlDeprecatedAttribute" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="HtmlDeprecatedTag" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="HtmlExtraClosingTag" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="HtmlFormInputWithoutLabel" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="HtmlMissingClosingTag" enabled="false" level="INFORMATION" enabled_by_default="false" />
+ <inspection_tool class="HtmlRequiredAltAttribute" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="HtmlRequiredLangAttribute" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="HtmlRequiredTitleElement" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="HtmlUnknownAnchorTarget" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="HtmlUnknownAttribute" enabled="false" level="WARNING" enabled_by_default="false">
+ <option name="myValues">
+ <value>
+ <list size="0" />
+ </value>
+ </option>
+ <option name="myCustomValuesEnabled" value="true" />
+ </inspection_tool>
+ <inspection_tool class="HtmlUnknownBooleanAttribute" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="HtmlUnknownTag" enabled="false" level="WARNING" enabled_by_default="false">
<option name="myValues">
<value>
<list size="7">
@@ -17,15 +103,471 @@
</option>
<option name="myCustomValuesEnabled" value="true" />
</inspection_tool>
+ <inspection_tool class="HtmlUnknownTarget" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="HtmlWrongAttributeValue" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="HttpClientUnresolvedVariable" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="HttpRequestContentLengthIsIgnored" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="HttpRequestPlaceholder" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="HttpRequestWhitespaceInsideRequestTargetPath" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="IncompatibleMaskJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="IncorrectHttpHeaderInspection" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="InfiniteLoopJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="InfiniteRecursionJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSAccessibilityCheck" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSAnnotator" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="JSArrowFunctionBracesCanBeRemoved" enabled="false" level="INFORMATION" enabled_by_default="false" />
+ <inspection_tool class="JSAssignmentUsedAsCondition" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSBitwiseOperatorUsage" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSCheckFunctionSignatures" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSClosureCompilerSyntax" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSCommentMatchesSignature" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSComparisonWithNaN" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSConsecutiveCommasInArrayLiteral" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSConstantReassignment" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="JSDeprecatedSymbols" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSDuplicateCaseLabel" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSDuplicatedDeclaration" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSEqualityComparisonWithCoercion" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSFileReferences" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSFunctionExpressionToArrowFunction" enabled="false" level="INFORMATION" enabled_by_default="false" />
+ <inspection_tool class="JSIgnoredPromiseFromCall" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSIncompatibleTypesComparison" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSJQueryEfficiency" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSJoinVariableDeclarationAndAssignment" enabled="false" level="INFORMATION" enabled_by_default="false" />
+ <inspection_tool class="JSLastCommaInArrayLiteral" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSLastCommaInObjectLiteral" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSMethodCanBeStatic" enabled="false" level="INFORMATION" enabled_by_default="false" />
+ <inspection_tool class="JSMismatchedCollectionQueryUpdate" enabled="false" level="WARNING" enabled_by_default="false">
+ <option name="queries" value="trace,write,forEach,length,size" />
+ <option name="updates" value="pop,push,shift,splice,unshift,add,insert,remove,reverse,copyWithin,fill,sort" />
+ </inspection_tool>
+ <inspection_tool class="JSMissingSwitchBranches" enabled="false" level="INFORMATION" enabled_by_default="false" />
+ <inspection_tool class="JSMissingSwitchDefault" enabled="false" level="INFORMATION" enabled_by_default="false" />
+ <inspection_tool class="JSNonASCIINames" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSObjectNullOrUndefined" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSOctalInteger" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="JSPotentiallyInvalidConstructorUsage" enabled="false" level="WARNING" enabled_by_default="false">
+ <option name="myConsiderUppercaseFunctionsToBeConstructors" value="true" />
+ </inspection_tool>
+ <inspection_tool class="JSPotentiallyInvalidTargetOfIndexedPropertyAccess" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSPotentiallyInvalidUsageOfClassThis" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSPotentiallyInvalidUsageOfThis" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSPrimitiveTypeWrapperUsage" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSRedundantSwitchStatement" enabled="false" level="INFORMATION" enabled_by_default="false" />
+ <inspection_tool class="JSReferencingMutableVariableFromClosure" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSRemoveUnnecessaryParentheses" enabled="false" level="INFORMATION" enabled_by_default="false" />
+ <inspection_tool class="JSStringConcatenationToES6Template" enabled="false" level="INFORMATION" enabled_by_default="false" />
+ <inspection_tool class="JSSuspiciousEqPlus" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSSuspiciousNameCombination" enabled="false" level="WARNING" enabled_by_default="false">
+ <group names="x,width,left,right" />
+ <group names="y,height,top,bottom" />
+ <exclude classes="Math" />
+ </inspection_tool>
+ <inspection_tool class="JSSwitchVariableDeclarationIssue" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSTestFailedLine" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSTypeOfValues" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSUndeclaredVariable" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSUndefinedPropertyAssignment" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSUnnecessarySemicolon" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSUnreachableSwitchBranches" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSUnresolvedExtXType" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSUnresolvedLibraryURL" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSUnresolvedReference" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSUnusedAssignment" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSUnusedGlobalSymbols" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSUnusedLocalSymbols" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSUrlImportUsage" enabled="false" level="INFORMATION" enabled_by_default="false" />
+ <inspection_tool class="JSValidateJSDoc" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSValidateTypes" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSVoidFunctionReturnValueUsed" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSXDomNesting" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JSXNamespaceValidation" enabled="false" level="INFORMATION" enabled_by_default="false" />
+ <inspection_tool class="JSXUnresolvedComponent" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="Json5StandardCompliance" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="JsonDuplicatePropertyKeys" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JsonPathEvaluateUnknownKey" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JsonPathUnknownFunction" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JsonPathUnknownOperator" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JsonSchemaCompliance" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JsonSchemaDeprecation" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="JsonSchemaRefReference" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="JsonStandardCompliance" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="KarmaConfigFile" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="LaravelPintValidationInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="LessResolvedByNameOnly" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="LessUnresolvedMixin" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="LessUnresolvedVariable" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="LoopStatementThatDoesntLoopJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="LossyEncoding" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="MakefileUnresolvedPrerequisite" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="MarkdownIncorrectTableFormatting" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="MarkdownIncorrectlyNumberedListItem" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="MarkdownLinkDestinationWithSpaces" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="MarkdownNoTableBorders" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="MarkdownOutdatedTableOfContents" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="MarkdownUnresolvedFileReference" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="MarkdownUnresolvedHeaderReference" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="MarkdownUnresolvedLinkLabel" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="MessDetectorValidationInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="MissingSinceTagDocInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="MongoJSDeprecationInspection" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="MongoJSExtDeprecationInspection" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="MongoJSExtResolveInspection" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="MongoJSExtSideEffectsInspection" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="MongoJSResolveInspection" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="MongoJSSideEffectsInspection" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="MysqlLoadDataPathInspection" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="MysqlParsingInspection" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="MysqlSpaceAfterFunctionNameInspection" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="NodeCoreCodingAssistance" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="NonAsciiCharacters" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="NonBlockStatementBodyJS" enabled="false" level="TEXT ATTRIBUTES" enabled_by_default="false" editorAttributes="CONSIDERATION_ATTRIBUTES" />
+ <inspection_tool class="NpmUsedModulesInstalled" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="OraMissingBodyInspection" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="OraOverloadInspection" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="OraUnmatchedForwardDeclarationInspection" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="PackageJsonMismatchedDependency" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="PhingDomInspection" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="PhpAccessStaticViaInstanceInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpAccessingStaticMembersOnTraitInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpApplyingEmptyIndexOperatorOnStringInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpArgumentWithoutNamedIdentifierInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpArithmeticTypeCheckInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpArrayAccessCanBeReplacedWithForeachValueInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpArrayAccessOnIllegalTypeInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpArrayAppendUsingCountInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpArrayFillCanBeConvertedToLoopInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpArrayFilterCanBeConvertedToLoopInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpArrayIndexResetIsUnnecessaryInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpArrayIsAlwaysEmptyInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpArrayKeyDoesNotMatchArrayShapeInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpArrayMapCanBeConvertedToLoopInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpArrayModificationWillNotHaveEffectInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpArrayPushWithOneElementInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpArraySearchInBooleanContextInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpArrayShapeCanBeAddedInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpArrayTraversableCanBeReplacedWithIterableInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpArrayUsedOnlyForWriteInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpArrayWriteIsNotUsedInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpAssignmentInConditionInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpAssignmentReplaceableWithOperatorAssignmentInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpAssignmentReplaceableWithPrefixExpressionInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpAttributeCanBeAddedToOverriddenMemberInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpAttributeIsNotRepeatableInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpAutovivificationOnFalseValuesInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpBooleanCanBeSimplifiedInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpCSFixerValidationInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpCSValidationInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpCaseWithValueNotFoundInEnumInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpCastIsEvaluableInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpCastIsUnnecessaryInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpCatchWithInstanceOfCanBeReplacedWithSpecificCatchesInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpClassCanBeReadonlyInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpClassCantBeUsedAsAttributeInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpClassConstantAccessedViaChildClassInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpClassConstantCanBeFinalInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpClassHasTooManyDeclaredMembersInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpClassImplementsSolelyTraversableInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpClassNamingConventionInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpClosureCanBeConvertedToFirstClassCallableInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpClosureCanBeConvertedToShortArrowFunctionInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpCombineMultipleIssetCallsIntoOneInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpCommentCanBeReplacedWithNamedArgumentInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpComplexClassInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpComplexFunctionInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpComposerDuplicatedRequirementInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpCompoundNamespaceDepthInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpConcatenationWithEmptyStringCanBeInlinedInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpConditionAlreadyCheckedInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpConditionCanBeReplacedWithMinMaxCallInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpConditionCheckedByNextConditionInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpConstantNamingConventionInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpContinueTargetingSwitchInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpCoveredCharacterInClassInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpCurlyBraceAccessSyntaxUsageInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpDangerousArrayInitializationInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpDefineCanBeReplacedWithConstInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpDeprecatedAssertDeclarationInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpDeprecatedCastInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpDeprecatedDollarBraceStringInterpolationInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpDeprecationInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpDisabledExtensionStubsInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpDisabledQualityToolComposerInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
<inspection_tool class="PhpDivisionByZeroInspection" enabled="true" level="WARNING" enabled_by_default="true" />
+ <inspection_tool class="PhpDocDuplicateTypeInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpDocFieldTypeMismatchInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpDocFinalChecksInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
<inspection_tool class="PhpDocMissingReturnTagInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
- <inspection_tool class="PhpDocMissingThrowsInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
- <inspection_tool class="PhpDocSignatureInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
- <inspection_tool class="PhpIllegalPsrClassPathInspection" enabled="false" level="INFORMATION" enabled_by_default="false" />
+ <inspection_tool class="PhpDocMissingThrowsInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpDocRedundantThrowsInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpDocSignatureInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpDocSignatureIsNotCompleteInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpDuplicateCatchBodyInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpDuplicateMatchArmBodyInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpDuplicateOperandInComparisonInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpDuplicateSwitchCaseBodyInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpDynamicFieldDeclarationInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpEchoOpenTagInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpElementIsNotAvailableInCurrentPhpVersionInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpEnforceDocCommentInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpExceptionImmediatelyRethrownInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpExpectedValuesShouldBeUsedInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpExpressionAlwaysNullInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpExpressionWithSameOperandsInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpExpressionWithoutClarifyingParenthesesInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpFeatureEnvyLocalInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpFieldCanBePromotedInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpForeachOverSingleElementArrayLiteralInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpForeachVariableOverwritesAlreadyDefinedVariableInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpFormatCallWithSingleArgumentInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpFullyQualifiedNameUsageInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpFuncGetArgCanBeReplacedWithParamInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpFunctionCyclomaticComplexityInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpFunctionNamingConventionInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpGetClassCanBeReplacedWithClassNameLiteralInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpGotoIntoLoopInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpHalsteadMetricInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpHierarchyChecksInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpIdempotentOperationInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpIfCanBeMergedWithSequentialConditionInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpIfCanBeReplacedWithMatchExpressionInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpIfWithCommonPartsInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpIgnoredClassAliasDeclaration" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpIllegalPsrClassPathInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpImmutablePropertyIsWrittenInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpImplicitOctalLiteralUsageInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpInArrayCanBeReplacedWithComparisonInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpInapplicableAttributeTargetDeclarationInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpInappropriateInheritDocUsageInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpIncludeInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpIncorrectMagicMethodSignatureInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpInstanceofIsAlwaysTrueInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpInternalEntityUsedInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpInvalidStringOffsetUsageInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpIssetCanBeReplacedWithCoalesceInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpIssetCanCheckNestedAccessDirectlyInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpLackOfCohesionInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpLanguageLevelInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpLongTypeFormInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpLoopCanBeConvertedToArrayFillInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpLoopCanBeConvertedToArrayFilterInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpLoopCanBeConvertedToArrayMapInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpLoopCanBeReplacedWithImplodeInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpLoopCanBeReplacedWithStdFunctionCallsInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpLoopCanBeReplacedWithStrRepeatInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpLoopNeverIteratesInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpMatchCanBeReplacedWithSwitchStatementInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpMatchExpressionCanBeReplacedWithTernaryInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpMatchExpressionWithOnlyDefaultArmInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpMemberCanBePulledUpInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpMethodMayBeStaticInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpMethodNamingConventionInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpMethodOrClassCallIsNotCaseSensitiveInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpMethodParametersCountMismatchInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpMissingBreakStatementInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpMissingDocCommentInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpMissingFieldTypeInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpMissingParamTypeInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpMissingParentCallCommonInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpMissingParentCallMagicInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpMissingReturnTypeInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpMissingStrictTypesDeclarationInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpMissingVisibilityInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpMixedReturnTypeCanBeReducedInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpMockeryInvalidMockingMethodInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpModifierOrderInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpModuloByOneInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpMultipleClassDeclarationsInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpMultipleClassesDeclarationsInOneFile" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpNModifierCanBeReplacedWithNonCapturingGroupInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpNamedArgumentMightBeUnresolvedInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpNamedArgumentUsageInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpNamedArgumentsWithChangedOrderInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpNestedDirNameCallsCanBeReplacedWithLevelParameterInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpNestedMinMaxCallInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpNestedTernaryExpressionUsageInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpNeverTypedFunctionReturnViolationInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpNewClassMissingParameterListInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpNoReturnAttributeCanBeAddedInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpNonCanonicalElementsOrderInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpNonStrictObjectEqualityInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpNotInstalledPackagesInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpNullIsNotCompatibleWithParameterInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpNullSafeOperatorCanBeUsedInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpObjectShapeCanBeAddedInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpOverridingMethodVisibilityInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpParameterByRefIsNotUsedAsReferenceInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpParameterNameChangedDuringInheritanceInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpPassByRefInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpPluralMixedCanBeReplacedWithArrayInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpPointlessBooleanExpressionInConditionInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpPossiblePolymorphicInvocationInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpPowCallCanBeReplacedWithOperatorInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpPregMatchRedundantClosureInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpPregMatchReplaceWithComparisonInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpPregMatchWithoutEffectiveRegexpInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpPregReplaceWithEmptyReplacementInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpPregSplitWithoutRegExpInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpPrivateFieldCanBeLocalVariableInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpPromotedFieldUsageInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpPropertyCanBeReadonlyInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpPropertyNamingConventionInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpPureAttributeCanBeAddedInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpPureFunctionMayProduceSideEffectsInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpRandArgumentsInReverseOrderInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpRangesInClassCanBeMergedInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpReadonlyPropertyWrittenOutsideDeclarationScopeInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpRedeclarationStdlibFunctionInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpRedundantArrayCallInForeachIteratedValueInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpRedundantAssignmentToPromotedFieldInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpRedundantAttributeParenthesisInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpRedundantCatchClauseInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpRedundantClosingTagInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpRedundantDefaultBreakContinueArgumentInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpRedundantDocCommentInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpRedundantIntersectionTypeInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpRedundantMethodOverrideInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpRedundantOptionalArgumentInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpRedundantReadonlyModifierInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpRedundantTypeInUnionTypeInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpRedundantVariableDocTypeInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpRegExpRedundantModifierInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpReturnDocTypeMismatchInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpReturnValueOfMethodIsNeverUsedInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpSameParameterValueInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpSecondWriteToReadonlyPropertyInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpSeparateElseIfInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpShortOpenEchoTagInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpSingleStatementWithBracesInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpStanGlobal" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpStatementHasEmptyBodyInspection" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="PhpStatementWithoutBracesInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpStaticAsDynamicMethodCallInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpStrFunctionsInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpStrictComparisonWithOperandsOfDifferentTypesInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpStrictTypeCheckingInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES">
+ <option name="ENABLE_WITHOUT_DECLARE_STRICT_DIRECTIVE" value="true" />
+ </inspection_tool>
+ <inspection_tool class="PhpSuperClassIncompatibleWithInterfaceInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpSwitchCanBeReplacedWithMatchExpressionInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpSwitchCaseWithoutDefaultBranchInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpSwitchStatementWitSingleBranchInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpSwitchWithCommonPartsInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpTernaryExpressionCanBeReducedToShortVersionInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpTernaryExpressionCanBeReplacedWithConditionInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpToStringReturnInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpTooLongMemberReferenceChainInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpTooManyParametersInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpTraditionalSyntaxArrayLiteralInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpTraitUsageOutsideUseInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpTraitUseRuleInsideDifferentClassUseListInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpTraitsUseListInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUncoveredEnumCasesInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUndefinedClassConstantInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUndefinedVariableInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnhandledExceptionInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnitAssertArrayHasKeyInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnitAssertCanBeReplacedWithEmptyInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnitAssertCanBeReplacedWithFailInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnitAssertContainsInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnitAssertCountInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnitAssertEqualsCanBeReplacedWithAssertTrueOrFalseInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnitAssertEqualsInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnitAssertFileEqualsInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnitCoversByAccessModifierIsDeprecatedInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnitCoversFunctionWithoutScopeResolutionOperatorInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnitDeprecatedDataProviderSignatureInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnitDeprecatedExpectExceptionInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnitExpectedExceptionDocTagIsDeprecatedInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnitInvalidMockingEntityInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnitMisorderedAssertEqualsArgumentsInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnitMissingTargetForTestInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnnecessaryBoolCastInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnnecessaryCurlyVarSyntaxInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnnecessaryDoubleQuotesInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnnecessaryElseBranchInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnnecessaryFullyQualifiedNameInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnnecessaryLeadingBackslashInUseStatementInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnnecessaryLocalVariableInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnnecessaryParenthesesInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnnecessarySemicolonInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnnecessarySpreadOperatorForFunctionCallArgumentInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnnecessaryStaticReferenceInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnnecessaryStopStatementInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnnecessaryStringCastInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnpackingArraysWithStringKeysInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnused" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnusedAliasInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnusedFieldDefaultValueInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnusedLocalVariableInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnusedMatchConditionInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnusedParameterInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnusedPrivateFieldInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnusedPrivateMethodInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUnusedSwitchBranchInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUsageOfSilenceOperatorInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpUselessTrailingCommaInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpVarExportUsedWithoutReturnArgumentInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpVarTagWithoutVariableNameInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpVarUsageInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpVariableIsUsedOnlyInClosureInspection" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpVariableNamingConventionInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PhpVariableVariableInspection" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="PointlessArithmeticExpressionJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="PointlessBooleanExpressionJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="PsalmGlobal" enabled="false" level="WARNING" enabled_by_default="false" editorAttributes="WARNING_ATTRIBUTES" />
+ <inspection_tool class="RequiredAttributes" enabled="false" level="WARNING" enabled_by_default="false">
+ <option name="myAdditionalRequiredHtmlAttributes" value="" />
+ </inspection_tool>
+ <inspection_tool class="ReservedWordUsedAsNameJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="ReturnFromFinallyBlockJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="ShiftOutOfRangeJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="SillyAssignmentJS" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="SpellCheckingInspection" enabled="true" level="TYPO" enabled_by_default="true">
<option name="processCode" value="false" />
<option name="processLiterals" value="false" />
<option name="processComments" value="true" />
</inspection_tool>
+ <inspection_tool class="SqlRedundantOrderingDirectionInspection" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="SuspiciousTypeOfGuard" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="SwJsonMaybeSpecificationInspection" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="SwJsonUnresolvedReferencesInspection" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="SwYamlMaybeSpecificationInspection" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="SwYamlUnresolvedReferencesInspection" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="ThisExpressionReferencesGlobalObjectJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="ThrowFromFinallyBlockJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="TrivialConditionalJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="TrivialIfJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="TypeScriptAbstractClassConstructorCanBeMadeProtected" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="TypeScriptCheckImport" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="TypeScriptConfig" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="TypeScriptDuplicateUnionOrIntersectionType" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="TypeScriptExplicitMemberType" enabled="false" level="INFORMATION" enabled_by_default="false" />
+ <inspection_tool class="TypeScriptFieldCanBeMadeReadonly" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="TypeScriptJSXUnresolvedComponent" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="TypeScriptLibrary" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="TypeScriptMissingAugmentationImport" enabled="false" level="INFORMATION" enabled_by_default="false" />
+ <inspection_tool class="TypeScriptMissingConfigOption" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="TypeScriptRedundantGenericType" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="TypeScriptSmartCast" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="TypeScriptSuspiciousConstructorParameterAssignment" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="TypeScriptUMDGlobal" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="TypeScriptUnresolvedReference" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="TypeScriptValidateGenericTypes" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="TypeScriptValidateJSTypes" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+ <inspection_tool class="TypeScriptValidateTypes" enabled="false" level="ERROR" enabled_by_default="false" />
+ <inspection_tool class="UnnecessaryContinueJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="UnnecessaryLabelJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="UnnecessaryLabelOnBreakStatementJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="UnnecessaryLabelOnContinueStatementJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="UnnecessaryLocalVariableJS" enabled="false" level="WARNING" enabled_by_default="false">
+ <option name="m_ignoreImmediatelyReturnedVariables" value="false" />
+ <option name="m_ignoreAnnotatedVariables" value="false" />
+ </inspection_tool>
+ <inspection_tool class="UnnecessaryReturnJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="UnreachableCodeJS" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="UpdateDependencyToLatestVersion" enabled="false" level="INFORMATION" enabled_by_default="false" />
+ <inspection_tool class="WebpackConfigHighlighting" enabled="false" level="WARNING" enabled_by_default="false" />
+ <inspection_tool class="WithStatementJS" enabled="false" level="WARNING" enabled_by_default="false" />
</profile>
-</component>
+</component> \ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
deleted file mode 100644
index 3b312839..00000000
--- a/.idea/inspectionProfiles/profiles_settings.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-<component name="InspectionProjectProfileManager">
- <settings>
- <option name="PROJECT_PROFILE" value="Project Default" />
- <option name="USE_PROJECT_PROFILE" value="true" />
- <version value="1.0" />
- </settings>
-</component> \ No newline at end of file
diff --git a/Mustache/Autoloader.php b/Mustache/Autoloader.php
index 707a0ffa..c4cb6d96 100644
--- a/Mustache/Autoloader.php
+++ b/Mustache/Autoloader.php
@@ -1,69 +1,88 @@
-<?php
-
-/*
- * This file is part of Mustache.php.
- *
- * (c) 2012 Justin Hileman
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-/**
- * Mustache class autoloader.
- */
-class Mustache_Autoloader
-{
-
- private $baseDir;
-
- /**
- * Autoloader constructor.
- *
- * @param string $baseDir Mustache library base directory (default: dirname(__FILE__).'/..')
- */
- public function __construct($baseDir = null)
- {
- if ($baseDir === null) {
- $this->baseDir = dirname(__FILE__).'/..';
- } else {
- $this->baseDir = rtrim($baseDir, '/');
- }
- }
-
- /**
- * Register a new instance as an SPL autoloader.
- *
- * @param string $baseDir Mustache library base directory (default: dirname(__FILE__).'/..')
- *
- * @return Mustache_Autoloader Registered Autoloader instance
- */
- public static function register($baseDir = null)
- {
- $loader = new self($baseDir);
- spl_autoload_register(array($loader, 'autoload'));
-
- return $loader;
- }
-
- /**
- * Autoload Mustache classes.
- *
- * @param string $class
- */
- public function autoload($class)
- {
- if ($class[0] === '\\') {
- $class = substr($class, 1);
- }
-
- if (strpos($class, 'Mustache') !== 0) {
- return;
- }
-
- $file = sprintf('%s/%s.php', $this->baseDir, str_replace('_', '/', $class));
- if (is_file($file)) {
- require $file;
- }
- }
-}
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Mustache class autoloader.
+ */
+class Mustache_Autoloader
+{
+ private $baseDir;
+
+ /**
+ * An array where the key is the baseDir and the key is an instance of this
+ * class.
+ *
+ * @var array
+ */
+ private static $instances;
+
+ /**
+ * Autoloader constructor.
+ *
+ * @param string $baseDir Mustache library base directory (default: dirname(__FILE__).'/..')
+ */
+ public function __construct($baseDir = null)
+ {
+ if ($baseDir === null) {
+ $baseDir = dirname(__FILE__) . '/..';
+ }
+
+ // realpath doesn't always work, for example, with stream URIs
+ $realDir = realpath($baseDir);
+ if (is_dir($realDir)) {
+ $this->baseDir = $realDir;
+ } else {
+ $this->baseDir = $baseDir;
+ }
+ }
+
+ /**
+ * Register a new instance as an SPL autoloader.
+ *
+ * @param string $baseDir Mustache library base directory (default: dirname(__FILE__).'/..')
+ *
+ * @return Mustache_Autoloader Registered Autoloader instance
+ */
+ public static function register($baseDir = null)
+ {
+ $key = $baseDir ?: 0;
+
+ if (!isset(self::$instances[$key])) {
+ self::$instances[$key] = new self($baseDir);
+ }
+
+ $loader = self::$instances[$key];
+ spl_autoload_register(array($loader, 'autoload'));
+
+ return $loader;
+ }
+
+ /**
+ * Autoload Mustache classes.
+ *
+ * @param string $class
+ */
+ public function autoload($class)
+ {
+ if ($class[0] === '\\') {
+ $class = substr($class, 1);
+ }
+
+ if (strpos($class, 'Mustache') !== 0) {
+ return;
+ }
+
+ $file = sprintf('%s/%s.php', $this->baseDir, str_replace('_', '/', $class));
+ if (is_file($file)) {
+ require $file;
+ }
+ }
+}
diff --git a/Mustache/Cache.php b/Mustache/Cache.php
new file mode 100644
index 00000000..3292efac
--- /dev/null
+++ b/Mustache/Cache.php
@@ -0,0 +1,43 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Mustache Cache interface.
+ *
+ * Interface for caching and loading Mustache_Template classes
+ * generated by the Mustache_Compiler.
+ */
+interface Mustache_Cache
+{
+ /**
+ * Load a compiled Mustache_Template class from cache.
+ *
+ * @param string $key
+ *
+ * @return bool indicates successfully class load
+ */
+ public function load($key);
+
+ /**
+ * Cache and load a compiled Mustache_Template class.
+ *
+ * @param string $key
+ * @param string $value
+ */
+ public function cache($key, $value);
+
+ /**
+ * Set a logger instance.
+ *
+ * @param Mustache_Logger|Psr\Log\LoggerInterface $logger
+ */
+ public function setLogger($logger = null);
+}
diff --git a/Mustache/Cache/AbstractCache.php b/Mustache/Cache/AbstractCache.php
new file mode 100644
index 00000000..281038fa
--- /dev/null
+++ b/Mustache/Cache/AbstractCache.php
@@ -0,0 +1,60 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Abstract Mustache Cache class.
+ *
+ * Provides logging support to child implementations.
+ *
+ * @abstract
+ */
+abstract class Mustache_Cache_AbstractCache implements Mustache_Cache
+{
+ private $logger = null;
+
+ /**
+ * Get the current logger instance.
+ *
+ * @return Mustache_Logger|Psr\Log\LoggerInterface
+ */
+ public function getLogger()
+ {
+ return $this->logger;
+ }
+
+ /**
+ * Set a logger instance.
+ *
+ * @param Mustache_Logger|Psr\Log\LoggerInterface $logger
+ */
+ public function setLogger($logger = null)
+ {
+ if ($logger !== null && !($logger instanceof Mustache_Logger || is_a($logger, 'Psr\\Log\\LoggerInterface'))) {
+ throw new Mustache_Exception_InvalidArgumentException('Expected an instance of Mustache_Logger or Psr\\Log\\LoggerInterface.');
+ }
+
+ $this->logger = $logger;
+ }
+
+ /**
+ * Add a log record if logging is enabled.
+ *
+ * @param string $level The logging level
+ * @param string $message The log message
+ * @param array $context The log context
+ */
+ protected function log($level, $message, array $context = array())
+ {
+ if (isset($this->logger)) {
+ $this->logger->log($level, $message, $context);
+ }
+ }
+}
diff --git a/Mustache/Cache/FilesystemCache.php b/Mustache/Cache/FilesystemCache.php
new file mode 100644
index 00000000..3e742b70
--- /dev/null
+++ b/Mustache/Cache/FilesystemCache.php
@@ -0,0 +1,161 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Mustache Cache filesystem implementation.
+ *
+ * A FilesystemCache instance caches Mustache Template classes from the filesystem by name:
+ *
+ * $cache = new Mustache_Cache_FilesystemCache(dirname(__FILE__).'/cache');
+ * $cache->cache($className, $compiledSource);
+ *
+ * The FilesystemCache benefits from any opcode caching that may be setup in your environment. So do that, k?
+ */
+class Mustache_Cache_FilesystemCache extends Mustache_Cache_AbstractCache
+{
+ private $baseDir;
+ private $fileMode;
+
+ /**
+ * Filesystem cache constructor.
+ *
+ * @param string $baseDir Directory for compiled templates
+ * @param int $fileMode Override default permissions for cache files. Defaults to using the system-defined umask
+ */
+ public function __construct($baseDir, $fileMode = null)
+ {
+ $this->baseDir = $baseDir;
+ $this->fileMode = $fileMode;
+ }
+
+ /**
+ * Load the class from cache using `require_once`.
+ *
+ * @param string $key
+ *
+ * @return bool
+ */
+ public function load($key)
+ {
+ $fileName = $this->getCacheFilename($key);
+ if (!is_file($fileName)) {
+ return false;
+ }
+
+ require_once $fileName;
+
+ return true;
+ }
+
+ /**
+ * Cache and load the compiled class.
+ *
+ * @param string $key
+ * @param string $value
+ */
+ public function cache($key, $value)
+ {
+ $fileName = $this->getCacheFilename($key);
+
+ $this->log(
+ Mustache_Logger::DEBUG,
+ 'Writing to template cache: "{fileName}"',
+ array('fileName' => $fileName)
+ );
+
+ $this->writeFile($fileName, $value);
+ $this->load($key);
+ }
+
+ /**
+ * Build the cache filename.
+ * Subclasses should override for custom cache directory structures.
+ *
+ * @param string $name
+ *
+ * @return string
+ */
+ protected function getCacheFilename($name)
+ {
+ return sprintf('%s/%s.php', $this->baseDir, $name);
+ }
+
+ /**
+ * Create cache directory.
+ *
+ * @throws Mustache_Exception_RuntimeException If unable to create directory
+ *
+ * @param string $fileName
+ *
+ * @return string
+ */
+ private function buildDirectoryForFilename($fileName)
+ {
+ $dirName = dirname($fileName);
+ if (!is_dir($dirName)) {
+ $this->log(
+ Mustache_Logger::INFO,
+ 'Creating Mustache template cache directory: "{dirName}"',
+ array('dirName' => $dirName)
+ );
+
+ @mkdir($dirName, 0777, true);
+ // @codeCoverageIgnoreStart
+ if (!is_dir($dirName)) {
+ throw new Mustache_Exception_RuntimeException(sprintf('Failed to create cache directory "%s".', $dirName));
+ }
+ // @codeCoverageIgnoreEnd
+ }
+
+ return $dirName;
+ }
+
+ /**
+ * Write cache file.
+ *
+ * @throws Mustache_Exception_RuntimeException If unable to write file
+ *
+ * @param string $fileName
+ * @param string $value
+ */
+ private function writeFile($fileName, $value)
+ {
+ $dirName = $this->buildDirectoryForFilename($fileName);
+
+ $this->log(
+ Mustache_Logger::DEBUG,
+ 'Caching compiled template to "{fileName}"',
+ array('fileName' => $fileName)
+ );
+
+ $tempFile = tempnam($dirName, basename($fileName));
+ if (false !== @file_put_contents($tempFile, $value)) {
+ if (@rename($tempFile, $fileName)) {
+ $mode = isset($this->fileMode) ? $this->fileMode : (0666 & ~umask());
+ @chmod($fileName, $mode);
+
+ return;
+ }
+
+ // @codeCoverageIgnoreStart
+ $this->log(
+ Mustache_Logger::ERROR,
+ 'Unable to rename Mustache temp cache file: "{tempName}" -> "{fileName}"',
+ array('tempName' => $tempFile, 'fileName' => $fileName)
+ );
+ // @codeCoverageIgnoreEnd
+ }
+
+ // @codeCoverageIgnoreStart
+ throw new Mustache_Exception_RuntimeException(sprintf('Failed to write cache file "%s".', $fileName));
+ // @codeCoverageIgnoreEnd
+ }
+}
diff --git a/Mustache/Cache/NoopCache.php b/Mustache/Cache/NoopCache.php
new file mode 100644
index 00000000..ed9eec9d
--- /dev/null
+++ b/Mustache/Cache/NoopCache.php
@@ -0,0 +1,47 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Mustache Cache in-memory implementation.
+ *
+ * The in-memory cache is used for uncached lambda section templates. It's also useful during development, but is not
+ * recommended for production use.
+ */
+class Mustache_Cache_NoopCache extends Mustache_Cache_AbstractCache
+{
+ /**
+ * Loads nothing. Move along.
+ *
+ * @param string $key
+ *
+ * @return bool
+ */
+ public function load($key)
+ {
+ return false;
+ }
+
+ /**
+ * Loads the compiled Mustache Template class without caching.
+ *
+ * @param string $key
+ * @param string $value
+ */
+ public function cache($key, $value)
+ {
+ $this->log(
+ Mustache_Logger::WARNING,
+ 'Template cache disabled, evaluating "{className}" class at runtime',
+ array('className' => $key)
+ );
+ eval('?>' . $value);
+ }
+}
diff --git a/Mustache/Compiler.php b/Mustache/Compiler.php
index dd5307d4..fd93741a 100644
--- a/Mustache/Compiler.php
+++ b/Mustache/Compiler.php
@@ -1,386 +1,718 @@
-<?php
-
-/*
- * This file is part of Mustache.php.
- *
- * (c) 2012 Justin Hileman
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-/**
- * Mustache Compiler class.
- *
- * This class is responsible for turning a Mustache token parse tree into normal PHP source code.
- */
-class Mustache_Compiler
-{
-
- private $sections;
- private $source;
- private $indentNextLine;
- private $customEscape;
- private $charset;
-
- /**
- * Compile a Mustache token parse tree into PHP source code.
- *
- * @param string $source Mustache Template source code
- * @param string $tree Parse tree of Mustache tokens
- * @param string $name Mustache Template class name
- * @param bool $customEscape (default: false)
- * @param string $charset (default: 'UTF-8')
- *
- * @return string Generated PHP source code
- */
- public function compile($source, array $tree, $name, $customEscape = false, $charset = 'UTF-8')
- {
- $this->sections = array();
- $this->source = $source;
- $this->indentNextLine = true;
- $this->customEscape = $customEscape;
- $this->charset = $charset;
-
- return $this->writeCode($tree, $name);
- }
-
- /**
- * Helper function for walking the Mustache token parse tree.
- *
- * @throws InvalidArgumentException upon encountering unknown token types.
- *
- * @param array $tree Parse tree of Mustache tokens
- * @param int $level (default: 0)
- *
- * @return string Generated PHP source code
- */
- private function walk(array $tree, $level = 0)
- {
- $code = '';
- $level++;
- foreach ($tree as $node) {
- switch ($node[Mustache_Tokenizer::TYPE]) {
- case Mustache_Tokenizer::T_SECTION:
- $code .= $this->section(
- $node[Mustache_Tokenizer::NODES],
- $node[Mustache_Tokenizer::NAME],
- $node[Mustache_Tokenizer::INDEX],
- $node[Mustache_Tokenizer::END],
- $node[Mustache_Tokenizer::OTAG],
- $node[Mustache_Tokenizer::CTAG],
- $level
- );
- break;
-
- case Mustache_Tokenizer::T_INVERTED:
- $code .= $this->invertedSection(
- $node[Mustache_Tokenizer::NODES],
- $node[Mustache_Tokenizer::NAME],
- $level
- );
- break;
-
- case Mustache_Tokenizer::T_PARTIAL:
- case Mustache_Tokenizer::T_PARTIAL_2:
- $code .= $this->partial(
- $node[Mustache_Tokenizer::NAME],
- isset($node[Mustache_Tokenizer::INDENT]) ? $node[Mustache_Tokenizer::INDENT] : '',
- $level
- );
- break;
-
- case Mustache_Tokenizer::T_UNESCAPED:
- case Mustache_Tokenizer::T_UNESCAPED_2:
- $code .= $this->variable($node[Mustache_Tokenizer::NAME], false, $level);
- break;
-
- case Mustache_Tokenizer::T_COMMENT:
- break;
-
- case Mustache_Tokenizer::T_ESCAPED:
- $code .= $this->variable($node[Mustache_Tokenizer::NAME], true, $level);
- break;
-
- case Mustache_Tokenizer::T_TEXT:
- $code .= $this->text($node[Mustache_Tokenizer::VALUE], $level);
- break;
-
- default:
- throw new InvalidArgumentException('Unknown node type: '.json_encode($node));
- }
- }
-
- return $code;
- }
-
- const KLASS = '<?php
-
- class %s extends Mustache_Template
- {
- public function renderInternal(Mustache_Context $context, $indent = \'\', $escape = false)
- {
- $buffer = \'\';
- %s
-
- if ($escape) {
- return %s;
- } else {
- return $buffer;
- }
- }
- %s
- }';
-
- /**
- * Generate Mustache Template class PHP source.
- *
- * @param array $tree Parse tree of Mustache tokens
- * @param string $name Mustache Template class name
- *
- * @return string Generated PHP source code
- */
- private function writeCode($tree, $name)
- {
- $code = $this->walk($tree);
- $sections = implode("\n", $this->sections);
-
- return sprintf($this->prepare(self::KLASS, 0, false), $name, $code, $this->getEscape('$buffer'), $sections);
- }
-
- const SECTION_CALL = '
- // %s section
- $buffer .= $this->section%s($context, $indent, $context->%s(%s));
- ';
-
- const SECTION = '
- private function section%s(Mustache_Context $context, $indent, $value) {
- $buffer = \'\';
- if (!is_string($value) && is_callable($value)) {
- $source = %s;
- $buffer .= $this->mustache
- ->loadLambda((string) call_user_func($value, $source)%s)
- ->renderInternal($context, $indent);
- } elseif (!empty($value)) {
- $values = $this->isIterable($value) ? $value : array($value);
- foreach ($values as $value) {
- $context->push($value);%s
- $context->pop();
- }
- }
-
- return $buffer;
- }';
-
- /**
- * Generate Mustache Template section PHP source.
- *
- * @param array $nodes Array of child tokens
- * @param string $id Section name
- * @param int $start Section start offset
- * @param int $end Section end offset
- * @param string $otag Current Mustache opening tag
- * @param string $ctag Current Mustache closing tag
- * @param int $level
- *
- * @return string Generated section PHP source code
- */
- private function section($nodes, $id, $start, $end, $otag, $ctag, $level)
- {
- $method = $this->getFindMethod($id);
- $id = var_export($id, true);
- $source = var_export(substr($this->source, $start, $end - $start), true);
-
- if ($otag !== '{{' || $ctag !== '}}') {
- $delims = ', '.var_export(sprintf('{{= %s %s =}}', $otag, $ctag), true);
- } else {
- $delims = '';
- }
-
- $key = ucfirst(md5($delims."\n".$source));
-
- if (!isset($this->sections[$key])) {
- $this->sections[$key] = sprintf($this->prepare(self::SECTION), $key, $source, $delims, $this->walk($nodes, 2));
- }
-
- return sprintf($this->prepare(self::SECTION_CALL, $level), $id, $key, $method, $id);
- }
-
- const INVERTED_SECTION = '
- // %s inverted section
- $value = $context->%s(%s);
- if (empty($value)) {
- %s
- }';
-
- /**
- * Generate Mustache Template inverted section PHP source.
- *
- * @param array $nodes Array of child tokens
- * @param string $id Section name
- * @param int $level
- *
- * @return string Generated inverted section PHP source code
- */
- private function invertedSection($nodes, $id, $level)
- {
- $method = $this->getFindMethod($id);
- $id = var_export($id, true);
-
- return sprintf($this->prepare(self::INVERTED_SECTION, $level), $id, $method, $id, $this->walk($nodes, $level));
- }
-
- const PARTIAL = '
- if ($partial = $this->mustache->loadPartial(%s)) {
- $buffer .= $partial->renderInternal($context, %s);
- }
- ';
-
- /**
- * Generate Mustache Template partial call PHP source.
- *
- * @param string $id Partial name
- * @param string $indent Whitespace indent to apply to partial
- * @param int $level
- *
- * @return string Generated partial call PHP source code
- */
- private function partial($id, $indent, $level)
- {
- return sprintf(
- $this->prepare(self::PARTIAL, $level),
- var_export($id, true),
- var_export($indent, true)
- );
- }
-
- const VARIABLE = '
- $value = $context->%s(%s);
- if (!is_string($value) && is_callable($value)) {
- $value = $this->mustache
- ->loadLambda((string) call_user_func($value))
- ->renderInternal($context, $indent);
- }
- $buffer .= %s%s;
- ';
-
- /**
- * Generate Mustache Template variable interpolation PHP source.
- *
- * @param string $id Variable name
- * @param boolean $escape Escape the variable value for output?
- * @param int $level
- *
- * @return string Generated variable interpolation PHP source
- */
- private function variable($id, $escape, $level)
- {
- $method = $this->getFindMethod($id);
- $id = ($method !== 'last') ? var_export($id, true) : '';
- $value = $escape ? $this->getEscape() : '$value';
-
- return sprintf($this->prepare(self::VARIABLE, $level), $method, $id, $this->flushIndent(), $value);
- }
-
- const LINE = '$buffer .= "\n";';
- const TEXT = '$buffer .= %s%s;';
-
- /**
- * Generate Mustache Template output Buffer call PHP source.
- *
- * @param string $text
- * @param int $level
- *
- * @return string Generated output Buffer call PHP source
- */
- private function text($text, $level)
- {
- if ($text === "\n") {
- $this->indentNextLine = true;
-
- return $this->prepare(self::LINE, $level);
- } else {
- return sprintf($this->prepare(self::TEXT, $level), $this->flushIndent(), var_export($text, true));
- }
- }
-
- /**
- * Prepare PHP source code snippet for output.
- *
- * @param string $text
- * @param int $bonus Additional indent level (default: 0)
- * @param boolean $prependNewline Prepend a newline to the snippet? (default: true)
- *
- * @return string PHP source code snippet
- */
- private function prepare($text, $bonus = 0, $prependNewline = true)
- {
- $text = ($prependNewline ? "\n" : '').trim($text);
- if ($prependNewline) {
- $bonus++;
- }
-
- return preg_replace("/\n( {8})?/", "\n".str_repeat(" ", $bonus * 4), $text);
- }
-
- const DEFAULT_ESCAPE = 'htmlspecialchars(%s, ENT_COMPAT, %s)';
- const CUSTOM_ESCAPE = 'call_user_func($this->mustache->getEscape(), %s)';
-
- /**
- * Get the current escaper.
- *
- * @param string $value (default: '$value')
- *
- * @return string Either a custom callback, or an inline call to `htmlspecialchars`
- */
- private function getEscape($value = '$value')
- {
- if ($this->customEscape) {
- return sprintf(self::CUSTOM_ESCAPE, $value);
- } else {
- return sprintf(self::DEFAULT_ESCAPE, $value, var_export($this->charset, true));
- }
- }
-
- /**
- * Select the appropriate Context `find` method for a given $id.
- *
- * The return value will be one of `find`, `findDot` or `last`.
- *
- * @see Mustache_Context::find
- * @see Mustache_Context::findDot
- * @see Mustache_Context::last
- *
- * @param string $id Variable name
- *
- * @return string `find` method name
- */
- private function getFindMethod($id)
- {
- if ($id === '.') {
- return 'last';
- } elseif (strpos($id, '.') === false) {
- return 'find';
- } else {
- return 'findDot';
- }
- }
-
- const LINE_INDENT = '$indent . ';
-
- /**
- * Get the current $indent prefix to write to the buffer.
- *
- * @return string "$indent . " or ""
- */
- private function flushIndent()
- {
- if ($this->indentNextLine) {
- $this->indentNextLine = false;
-
- return self::LINE_INDENT;
- } else {
- return '';
- }
- }
-}
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Mustache Compiler class.
+ *
+ * This class is responsible for turning a Mustache token parse tree into normal PHP source code.
+ */
+class Mustache_Compiler
+{
+ private $pragmas;
+ private $defaultPragmas = array();
+ private $sections;
+ private $blocks;
+ private $source;
+ private $indentNextLine;
+ private $customEscape;
+ private $entityFlags;
+ private $charset;
+ private $strictCallables;
+
+ /**
+ * Compile a Mustache token parse tree into PHP source code.
+ *
+ * @param string $source Mustache Template source code
+ * @param string $tree Parse tree of Mustache tokens
+ * @param string $name Mustache Template class name
+ * @param bool $customEscape (default: false)
+ * @param string $charset (default: 'UTF-8')
+ * @param bool $strictCallables (default: false)
+ * @param int $entityFlags (default: ENT_COMPAT)
+ *
+ * @return string Generated PHP source code
+ */
+ public function compile($source, array $tree, $name, $customEscape = false, $charset = 'UTF-8', $strictCallables = false, $entityFlags = ENT_COMPAT)
+ {
+ $this->pragmas = $this->defaultPragmas;
+ $this->sections = array();
+ $this->blocks = array();
+ $this->source = $source;
+ $this->indentNextLine = true;
+ $this->customEscape = $customEscape;
+ $this->entityFlags = $entityFlags;
+ $this->charset = $charset;
+ $this->strictCallables = $strictCallables;
+
+ return $this->writeCode($tree, $name);
+ }
+
+ /**
+ * Enable pragmas across all templates, regardless of the presence of pragma
+ * tags in the individual templates.
+ *
+ * @internal Users should set global pragmas in Mustache_Engine, not here :)
+ *
+ * @param string[] $pragmas
+ */
+ public function setPragmas(array $pragmas)
+ {
+ $this->pragmas = array();
+ foreach ($pragmas as $pragma) {
+ $this->pragmas[$pragma] = true;
+ }
+ $this->defaultPragmas = $this->pragmas;
+ }
+
+ /**
+ * Helper function for walking the Mustache token parse tree.
+ *
+ * @throws Mustache_Exception_SyntaxException upon encountering unknown token types
+ *
+ * @param array $tree Parse tree of Mustache tokens
+ * @param int $level (default: 0)
+ *
+ * @return string Generated PHP source code
+ */
+ private function walk(array $tree, $level = 0)
+ {
+ $code = '';
+ $level++;
+ foreach ($tree as $node) {
+ switch ($node[Mustache_Tokenizer::TYPE]) {
+ case Mustache_Tokenizer::T_PRAGMA:
+ $this->pragmas[$node[Mustache_Tokenizer::NAME]] = true;
+ break;
+
+ case Mustache_Tokenizer::T_SECTION:
+ $code .= $this->section(
+ $node[Mustache_Tokenizer::NODES],
+ $node[Mustache_Tokenizer::NAME],
+ $node[Mustache_Tokenizer::FILTERS] ?? array(),
+ $node[Mustache_Tokenizer::INDEX],
+ $node[Mustache_Tokenizer::END],
+ $node[Mustache_Tokenizer::OTAG],
+ $node[Mustache_Tokenizer::CTAG],
+ $level
+ );
+ break;
+
+ case Mustache_Tokenizer::T_INVERTED:
+ $code .= $this->invertedSection(
+ $node[Mustache_Tokenizer::NODES],
+ $node[Mustache_Tokenizer::NAME],
+ $node[Mustache_Tokenizer::FILTERS] ?? array(),
+ $level
+ );
+ break;
+
+ case Mustache_Tokenizer::T_PARTIAL:
+ $code .= $this->partial(
+ $node[Mustache_Tokenizer::NAME],
+ $node[Mustache_Tokenizer::DYNAMIC] ?? false,
+ $node[Mustache_Tokenizer::INDENT] ?? '',
+ $level
+ );
+ break;
+
+ case Mustache_Tokenizer::T_PARENT:
+ $code .= $this->parent(
+ $node[Mustache_Tokenizer::NAME],
+ $node[Mustache_Tokenizer::DYNAMIC] ?? false,
+ $node[Mustache_Tokenizer::INDENT] ?? '',
+ $node[Mustache_Tokenizer::NODES],
+ $level
+ );
+ break;
+
+ case Mustache_Tokenizer::T_BLOCK_ARG:
+ $code .= $this->blockArg(
+ $node[Mustache_Tokenizer::NODES],
+ $node[Mustache_Tokenizer::NAME],
+ $node[Mustache_Tokenizer::INDEX],
+ $node[Mustache_Tokenizer::END],
+ $node[Mustache_Tokenizer::OTAG],
+ $node[Mustache_Tokenizer::CTAG],
+ $level
+ );
+ break;
+
+ case Mustache_Tokenizer::T_BLOCK_VAR:
+ $code .= $this->blockVar(
+ $node[Mustache_Tokenizer::NODES],
+ $node[Mustache_Tokenizer::NAME],
+ $node[Mustache_Tokenizer::INDEX],
+ $node[Mustache_Tokenizer::END],
+ $node[Mustache_Tokenizer::OTAG],
+ $node[Mustache_Tokenizer::CTAG],
+ $level
+ );
+ break;
+
+ case Mustache_Tokenizer::T_COMMENT:
+ break;
+
+ case Mustache_Tokenizer::T_ESCAPED:
+ case Mustache_Tokenizer::T_UNESCAPED:
+ case Mustache_Tokenizer::T_UNESCAPED_2:
+ $code .= $this->variable(
+ $node[Mustache_Tokenizer::NAME],
+ $node[Mustache_Tokenizer::FILTERS] ?? array(),
+ $node[Mustache_Tokenizer::TYPE] === Mustache_Tokenizer::T_ESCAPED,
+ $level
+ );
+ break;
+
+ case Mustache_Tokenizer::T_TEXT:
+ $code .= $this->text($node[Mustache_Tokenizer::VALUE], $level);
+ break;
+
+ default:
+ throw new Mustache_Exception_SyntaxException(sprintf('Unknown token type: %s', $node[Mustache_Tokenizer::TYPE]), $node);
+ }
+ }
+
+ return $code;
+ }
+
+ const KLASS = '<?php
+
+ class %s extends Mustache_Template
+ {
+ private $lambdaHelper;%s
+
+ public function renderInternal(Mustache_Context $context, $indent = \'\')
+ {
+ $this->lambdaHelper = new Mustache_LambdaHelper($this->mustache, $context);
+ $buffer = \'\';
+ %s
+
+ return $buffer;
+ }
+ %s
+ %s
+ }';
+
+ const KLASS_NO_LAMBDAS = '<?php
+
+ class %s extends Mustache_Template
+ {%s
+ public function renderInternal(Mustache_Context $context, $indent = \'\')
+ {
+ $buffer = \'\';
+ %s
+
+ return $buffer;
+ }
+ }';
+
+ const STRICT_CALLABLE = 'protected $strictCallables = true;';
+
+ /**
+ * Generate Mustache Template class PHP source.
+ *
+ * @param array $tree Parse tree of Mustache tokens
+ * @param string $name Mustache Template class name
+ *
+ * @return string Generated PHP source code
+ */
+ private function writeCode($tree, $name)
+ {
+ $code = $this->walk($tree);
+ $sections = implode("\n", $this->sections);
+ $blocks = implode("\n", $this->blocks);
+ $klass = empty($this->sections) && empty($this->blocks) ? self::KLASS_NO_LAMBDAS : self::KLASS;
+
+ $callable = $this->strictCallables ? $this->prepare(self::STRICT_CALLABLE) : '';
+
+ return sprintf($this->prepare($klass, 0, false, true), $name, $callable, $code, $sections, $blocks);
+ }
+
+ const BLOCK_VAR = '
+ $blockFunction = $context->findInBlock(%s);
+ if (is_callable($blockFunction)) {
+ $buffer .= call_user_func($blockFunction, $context);
+ %s}
+ ';
+
+ const BLOCK_VAR_ELSE = '} else {%s';
+
+ /**
+ * Generate Mustache Template inheritance block variable PHP source.
+ *
+ * @param array $nodes Array of child tokens
+ * @param string $id Section name
+ * @param int $start Section start offset
+ * @param int $end Section end offset
+ * @param string $otag Current Mustache opening tag
+ * @param string $ctag Current Mustache closing tag
+ * @param int $level
+ *
+ * @return string Generated PHP source code
+ */
+ private function blockVar($nodes, $id, $start, $end, $otag, $ctag, $level)
+ {
+ $id = var_export($id, true);
+
+ $else = $this->walk($nodes, $level);
+ if ($else !== '') {
+ $else = sprintf($this->prepare(self::BLOCK_VAR_ELSE, $level + 1, false, true), $else);
+ }
+
+ return sprintf($this->prepare(self::BLOCK_VAR, $level), $id, $else);
+ }
+
+ const BLOCK_ARG = '%s => array($this, \'block%s\'),';
+
+ /**
+ * Generate Mustache Template inheritance block argument PHP source.
+ *
+ * @param array $nodes Array of child tokens
+ * @param string $id Section name
+ * @param int $start Section start offset
+ * @param int $end Section end offset
+ * @param string $otag Current Mustache opening tag
+ * @param string $ctag Current Mustache closing tag
+ * @param int $level
+ *
+ * @return string Generated PHP source code
+ */
+ private function blockArg($nodes, $id, $start, $end, $otag, $ctag, $level)
+ {
+ $key = $this->block($nodes);
+ $id = var_export($id, true);
+
+ return sprintf($this->prepare(self::BLOCK_ARG, $level), $id, $key);
+ }
+
+ const BLOCK_FUNCTION = '
+ public function block%s($context)
+ {
+ $indent = $buffer = \'\';%s
+
+ return $buffer;
+ }
+ ';
+
+ /**
+ * Generate Mustache Template inheritance block function PHP source.
+ *
+ * @param array $nodes Array of child tokens
+ *
+ * @return string key of new block function
+ */
+ private function block($nodes)
+ {
+ $code = $this->walk($nodes, 0);
+ $key = ucfirst(md5($code));
+
+ if (!isset($this->blocks[$key])) {
+ $this->blocks[$key] = sprintf($this->prepare(self::BLOCK_FUNCTION, 0), $key, $code);
+ }
+
+ return $key;
+ }
+
+ const SECTION_CALL = '
+ $value = $context->%s(%s);%s
+ $buffer .= $this->section%s($context, $indent, $value);
+ ';
+
+ const SECTION = '
+ private function section%s(Mustache_Context $context, $indent, $value)
+ {
+ $buffer = \'\';
+
+ if (%s) {
+ $source = %s;
+ $result = (string) call_user_func($value, $source, %s);
+ if (strpos($result, \'{{\') === false) {
+ $buffer .= $result;
+ } else {
+ $buffer .= $this->mustache
+ ->loadLambda($result%s)
+ ->renderInternal($context);
+ }
+ } elseif (!empty($value)) {
+ $values = $this->isIterable($value) ? $value : array($value);
+ foreach ($values as $value) {
+ $context->push($value);
+ %s
+ $context->pop();
+ }
+ }
+
+ return $buffer;
+ }
+ ';
+
+ /**
+ * Generate Mustache Template section PHP source.
+ *
+ * @param array $nodes Array of child tokens
+ * @param string $id Section name
+ * @param string[] $filters Array of filters
+ * @param int $start Section start offset
+ * @param int $end Section end offset
+ * @param string $otag Current Mustache opening tag
+ * @param string $ctag Current Mustache closing tag
+ * @param int $level
+ *
+ * @return string Generated section PHP source code
+ */
+ private function section($nodes, $id, $filters, $start, $end, $otag, $ctag, $level)
+ {
+ $source = var_export(substr($this->source, $start, $end - $start), true);
+ $callable = $this->getCallable();
+
+ if ($otag !== '{{' || $ctag !== '}}') {
+ $delimTag = var_export(sprintf('{{= %s %s =}}', $otag, $ctag), true);
+ $helper = sprintf('$this->lambdaHelper->withDelimiters(%s)', $delimTag);
+ $delims = ', ' . $delimTag;
+ } else {
+ $helper = '$this->lambdaHelper';
+ $delims = '';
+ }
+
+ $key = ucfirst(md5($delims . "\n" . $source));
+
+ if (!isset($this->sections[$key])) {
+ $this->sections[$key] = sprintf($this->prepare(self::SECTION), $key, $callable, $source, $helper, $delims, $this->walk($nodes, 2));
+ }
+
+ $method = $this->getFindMethod($id);
+ $id = var_export($id, true);
+ $filters = $this->getFilters($filters, $level);
+
+ return sprintf($this->prepare(self::SECTION_CALL, $level), $method, $id, $filters, $key);
+ }
+
+ const INVERTED_SECTION = '
+ $value = $context->%s(%s);%s
+ if (empty($value)) {
+ %s
+ }
+ ';
+
+ /**
+ * Generate Mustache Template inverted section PHP source.
+ *
+ * @param array $nodes Array of child tokens
+ * @param string $id Section name
+ * @param string[] $filters Array of filters
+ * @param int $level
+ *
+ * @return string Generated inverted section PHP source code
+ */
+ private function invertedSection($nodes, $id, $filters, $level)
+ {
+ $method = $this->getFindMethod($id);
+ $id = var_export($id, true);
+ $filters = $this->getFilters($filters, $level);
+
+ return sprintf($this->prepare(self::INVERTED_SECTION, $level), $method, $id, $filters, $this->walk($nodes, $level));
+ }
+
+ const DYNAMIC_NAME = '$this->resolveValue($context->%s(%s), $context)';
+
+ /**
+ * Generate Mustache Template dynamic name resolution PHP source.
+ *
+ * @param string $id Tag name
+ * @param bool $dynamic True if the name is dynamic
+ *
+ * @return string Dynamic name resolution PHP source code
+ */
+ private function resolveDynamicName($id, $dynamic)
+ {
+ if (!$dynamic) {
+ return var_export($id, true);
+ }
+
+ $method = $this->getFindMethod($id);
+ $id = ($method !== 'last') ? var_export($id, true) : '';
+
+ // TODO: filters?
+
+ return sprintf(self::DYNAMIC_NAME, $method, $id);
+ }
+
+ const PARTIAL_INDENT = ', $indent . %s';
+ const PARTIAL = '
+ if ($partial = $this->mustache->loadPartial(%s)) {
+ $buffer .= $partial->renderInternal($context%s);
+ }
+ ';
+
+ /**
+ * Generate Mustache Template partial call PHP source.
+ *
+ * @param string $id Partial name
+ * @param bool $dynamic Partial name is dynamic
+ * @param string $indent Whitespace indent to apply to partial
+ * @param int $level
+ *
+ * @return string Generated partial call PHP source code
+ */
+ private function partial($id, $dynamic, $indent, $level)
+ {
+ if ($indent !== '') {
+ $indentParam = sprintf(self::PARTIAL_INDENT, var_export($indent, true));
+ } else {
+ $indentParam = '';
+ }
+
+ return sprintf(
+ $this->prepare(self::PARTIAL, $level),
+ $this->resolveDynamicName($id, $dynamic),
+ $indentParam
+ );
+ }
+
+ const PARENT = '
+ if ($parent = $this->mustache->loadPartial(%s)) {
+ $context->pushBlockContext(array(%s
+ ));
+ $buffer .= $parent->renderInternal($context, $indent);
+ $context->popBlockContext();
+ }
+ ';
+
+ const PARENT_NO_CONTEXT = '
+ if ($parent = $this->mustache->loadPartial(%s)) {
+ $buffer .= $parent->renderInternal($context, $indent);
+ }
+ ';
+
+ /**
+ * Generate Mustache Template inheritance parent call PHP source.
+ *
+ * @param string $id Parent tag name
+ * @param bool $dynamic Tag name is dynamic
+ * @param string $indent Whitespace indent to apply to parent
+ * @param array $children Child nodes
+ * @param int $level
+ *
+ * @return string Generated PHP source code
+ */
+ private function parent($id, $dynamic, $indent, array $children, $level)
+ {
+ $realChildren = array_filter($children, array(__CLASS__, 'onlyBlockArgs'));
+ $partialName = $this->resolveDynamicName($id, $dynamic);
+
+ if (empty($realChildren)) {
+ return sprintf($this->prepare(self::PARENT_NO_CONTEXT, $level), $partialName);
+ }
+
+ return sprintf(
+ $this->prepare(self::PARENT, $level),
+ $partialName,
+ $this->walk($realChildren, $level + 1)
+ );
+ }
+
+ /**
+ * Helper method for filtering out non-block-arg tokens.
+ *
+ * @param array $node
+ *
+ * @return bool True if $node is a block arg token
+ */
+ private static function onlyBlockArgs(array $node)
+ {
+ return $node[Mustache_Tokenizer::TYPE] === Mustache_Tokenizer::T_BLOCK_ARG;
+ }
+
+ const VARIABLE = '
+ $value = $this->resolveValue($context->%s(%s), $context);%s
+ $buffer .= %s($value === null ? \'\' : %s);
+ ';
+
+ /**
+ * Generate Mustache Template variable interpolation PHP source.
+ *
+ * @param string $id Variable name
+ * @param string[] $filters Array of filters
+ * @param bool $escape Escape the variable value for output?
+ * @param int $level
+ *
+ * @return string Generated variable interpolation PHP source
+ */
+ private function variable($id, $filters, $escape, $level)
+ {
+ $method = $this->getFindMethod($id);
+ $id = ($method !== 'last') ? var_export($id, true) : '';
+ $filters = $this->getFilters($filters, $level);
+ $value = $escape ? $this->getEscape() : '$value';
+
+ return sprintf($this->prepare(self::VARIABLE, $level), $method, $id, $filters, $this->flushIndent(), $value);
+ }
+
+ const FILTER = '
+ $filter = $context->%s(%s);
+ if (!(%s)) {
+ throw new Mustache_Exception_UnknownFilterException(%s);
+ }
+ $value = call_user_func($filter, $value);%s
+ ';
+
+ /**
+ * Generate Mustache Template variable filtering PHP source.
+ *
+ * @param string[] $filters Array of filters
+ * @param int $level
+ *
+ * @return string Generated filter PHP source
+ */
+ private function getFilters(array $filters, $level)
+ {
+ if (empty($filters)) {
+ return '';
+ }
+
+ $name = array_shift($filters);
+ $method = $this->getFindMethod($name);
+ $filter = ($method !== 'last') ? var_export($name, true) : '';
+ $callable = $this->getCallable('$filter');
+ $msg = var_export($name, true);
+
+ return sprintf($this->prepare(self::FILTER, $level), $method, $filter, $callable, $msg, $this->getFilters($filters, $level));
+ }
+
+ const LINE = '$buffer .= "\n";';
+ const TEXT = '$buffer .= %s%s;';
+
+ /**
+ * Generate Mustache Template output Buffer call PHP source.
+ *
+ * @param string $text
+ * @param int $level
+ *
+ * @return string Generated output Buffer call PHP source
+ */
+ private function text($text, $level)
+ {
+ $indentNextLine = (substr($text, -1) === "\n");
+ $code = sprintf($this->prepare(self::TEXT, $level), $this->flushIndent(), var_export($text, true));
+ $this->indentNextLine = $indentNextLine;
+
+ return $code;
+ }
+
+ /**
+ * Prepare PHP source code snippet for output.
+ *
+ * @param string $text
+ * @param int $bonus Additional indent level (default: 0)
+ * @param bool $prependNewline Prepend a newline to the snippet? (default: true)
+ * @param bool $appendNewline Append a newline to the snippet? (default: false)
+ *
+ * @return string PHP source code snippet
+ */
+ private function prepare($text, $bonus = 0, $prependNewline = true, $appendNewline = false)
+ {
+ $text = ($prependNewline ? "\n" : '') . trim($text);
+ if ($prependNewline) {
+ $bonus++;
+ }
+ if ($appendNewline) {
+ $text .= "\n";
+ }
+
+ return preg_replace("/\n( {8})?/", "\n" . str_repeat(' ', $bonus * 4), $text);
+ }
+
+ const DEFAULT_ESCAPE = 'htmlspecialchars(%s, %s, %s)';
+ const CUSTOM_ESCAPE = 'call_user_func($this->mustache->getEscape(), %s)';
+
+ /**
+ * Get the current escaper.
+ *
+ * @param string $value (default: '$value')
+ *
+ * @return string Either a custom callback, or an inline call to `htmlspecialchars`
+ */
+ private function getEscape($value = '$value')
+ {
+ if ($this->customEscape) {
+ return sprintf(self::CUSTOM_ESCAPE, $value);
+ }
+
+ return sprintf(self::DEFAULT_ESCAPE, $value, var_export($this->entityFlags, true), var_export($this->charset, true));
+ }
+
+ /**
+ * Select the appropriate Context `find` method for a given $id.
+ *
+ * The return value will be one of `find`, `findDot`, `findAnchoredDot` or `last`.
+ *
+ * @see Mustache_Context::find
+ * @see Mustache_Context::findDot
+ * @see Mustache_Context::last
+ *
+ * @param string $id Variable name
+ *
+ * @return string `find` method name
+ */
+ private function getFindMethod($id)
+ {
+ if ($id === '.') {
+ return 'last';
+ }
+
+ if (isset($this->pragmas[Mustache_Engine::PRAGMA_ANCHORED_DOT]) && $this->pragmas[Mustache_Engine::PRAGMA_ANCHORED_DOT]) {
+ if (substr($id, 0, 1) === '.') {
+ return 'findAnchoredDot';
+ }
+ }
+
+ if (strpos($id, '.') === false) {
+ return 'find';
+ }
+
+ return 'findDot';
+ }
+
+ const IS_CALLABLE = '!is_string(%s) && is_callable(%s)';
+ const STRICT_IS_CALLABLE = 'is_object(%s) && is_callable(%s)';
+
+ /**
+ * Helper function to compile strict vs lax "is callable" logic.
+ *
+ * @param string $variable (default: '$value')
+ *
+ * @return string "is callable" logic
+ */
+ private function getCallable($variable = '$value')
+ {
+ $tpl = $this->strictCallables ? self::STRICT_IS_CALLABLE : self::IS_CALLABLE;
+
+ return sprintf($tpl, $variable, $variable);
+ }
+
+ const LINE_INDENT = '$indent . ';
+
+ /**
+ * Get the current $indent prefix to write to the buffer.
+ *
+ * @return string "$indent . " or ""
+ */
+ private function flushIndent()
+ {
+ if (!$this->indentNextLine) {
+ return '';
+ }
+
+ $this->indentNextLine = false;
+
+ return self::LINE_INDENT;
+ }
+}
diff --git a/Mustache/Context.php b/Mustache/Context.php
index 7bc75719..69c02e01 100644
--- a/Mustache/Context.php
+++ b/Mustache/Context.php
@@ -1,149 +1,242 @@
-<?php
-
-/*
- * This file is part of Mustache.php.
- *
- * (c) 2012 Justin Hileman
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-/**
- * Mustache Template rendering Context.
- */
-class Mustache_Context
-{
- private $stack = array();
-
- /**
- * Mustache rendering Context constructor.
- *
- * @param mixed $context Default rendering context (default: null)
- */
- public function __construct($context = null)
- {
- if ($context !== null) {
- $this->stack = array($context);
- }
- }
-
- /**
- * Push a new Context frame onto the stack.
- *
- * @param mixed $value Object or array to use for context
- */
- public function push($value)
- {
- array_push($this->stack, $value);
- }
-
- /**
- * Pop the last Context frame from the stack.
- *
- * @return mixed Last Context frame (object or array)
- */
- public function pop()
- {
- return array_pop($this->stack);
- }
-
- /**
- * Get the last Context frame.
- *
- * @return mixed Last Context frame (object or array)
- */
- public function last()
- {
- return end($this->stack);
- }
-
- /**
- * Find a variable in the Context stack.
- *
- * Starting with the last Context frame (the context of the innermost section), and working back to the top-level
- * rendering context, look for a variable with the given name:
- *
- * * If the Context frame is an associative array which contains the key $id, returns the value of that element.
- * * If the Context frame is an object, this will check first for a public method, then a public property named
- * $id. Failing both of these, it will try `__isset` and `__get` magic methods.
- * * If a value named $id is not found in any Context frame, returns an empty string.
- *
- * @param string $id Variable name
- *
- * @return mixed Variable value, or '' if not found
- */
- public function find($id)
- {
- return $this->findVariableInStack($id, $this->stack);
- }
-
- /**
- * Find a 'dot notation' variable in the Context stack.
- *
- * Note that dot notation traversal bubbles through scope differently than the regular find method. After finding
- * the initial chunk of the dotted name, each subsequent chunk is searched for only within the value of the previous
- * result. For example, given the following context stack:
- *
- * $data = array(
- * 'name' => 'Fred',
- * 'child' => array(
- * 'name' => 'Bob'
- * ),
- * );
- *
- * ... and the Mustache following template:
- *
- * {{ child.name }}
- *
- * ... the `name` value is only searched for within the `child` value of the global Context, not within parent
- * Context frames.
- *
- * @param string $id Dotted variable selector
- *
- * @return mixed Variable value, or '' if not found
- */
- public function findDot($id)
- {
- $chunks = explode('.', $id);
- $first = array_shift($chunks);
- $value = $this->findVariableInStack($first, $this->stack);
-
- foreach ($chunks as $chunk) {
- if ($value === '') {
- return $value;
- }
-
- $value = $this->findVariableInStack($chunk, array($value));
- }
-
- return $value;
- }
-
- /**
- * Helper function to find a variable in the Context stack.
- *
- * @see Mustache_Context::find
- *
- * @param string $id Variable name
- * @param array $stack Context stack
- *
- * @return mixed Variable value, or '' if not found
- */
- private function findVariableInStack($id, array $stack)
- {
- for ($i = count($stack) - 1; $i >= 0; $i--) {
- if (is_object($stack[$i])) {
- if (method_exists($stack[$i], $id)) {
- return $stack[$i]->$id();
- } elseif (isset($stack[$i]->$id)) {
- return $stack[$i]->$id;
- }
- } elseif (is_array($stack[$i]) && array_key_exists($id, $stack[$i])) {
- return $stack[$i][$id];
- }
- }
-
- return '';
- }
-}
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Mustache Template rendering Context.
+ */
+class Mustache_Context
+{
+ private $stack = array();
+ private $blockStack = array();
+
+ /**
+ * Mustache rendering Context constructor.
+ *
+ * @param mixed $context Default rendering context (default: null)
+ */
+ public function __construct($context = null)
+ {
+ if ($context !== null) {
+ $this->stack = array($context);
+ }
+ }
+
+ /**
+ * Push a new Context frame onto the stack.
+ *
+ * @param mixed $value Object or array to use for context
+ */
+ public function push($value)
+ {
+ array_push($this->stack, $value);
+ }
+
+ /**
+ * Push a new Context frame onto the block context stack.
+ *
+ * @param mixed $value Object or array to use for block context
+ */
+ public function pushBlockContext($value)
+ {
+ array_push($this->blockStack, $value);
+ }
+
+ /**
+ * Pop the last Context frame from the stack.
+ *
+ * @return mixed Last Context frame (object or array)
+ */
+ public function pop()
+ {
+ return array_pop($this->stack);
+ }
+
+ /**
+ * Pop the last block Context frame from the stack.
+ *
+ * @return mixed Last block Context frame (object or array)
+ */
+ public function popBlockContext()
+ {
+ return array_pop($this->blockStack);
+ }
+
+ /**
+ * Get the last Context frame.
+ *
+ * @return mixed Last Context frame (object or array)
+ */
+ public function last()
+ {
+ return end($this->stack);
+ }
+
+ /**
+ * Find a variable in the Context stack.
+ *
+ * Starting with the last Context frame (the context of the innermost section), and working back to the top-level
+ * rendering context, look for a variable with the given name:
+ *
+ * * If the Context frame is an associative array which contains the key $id, returns the value of that element.
+ * * If the Context frame is an object, this will check first for a public method, then a public property named
+ * $id. Failing both of these, it will try `__isset` and `__get` magic methods.
+ * * If a value named $id is not found in any Context frame, returns an empty string.
+ *
+ * @param string $id Variable name
+ *
+ * @return mixed Variable value, or '' if not found
+ */
+ public function find($id)
+ {
+ return $this->findVariableInStack($id, $this->stack);
+ }
+
+ /**
+ * Find a 'dot notation' variable in the Context stack.
+ *
+ * Note that dot notation traversal bubbles through scope differently than the regular find method. After finding
+ * the initial chunk of the dotted name, each subsequent chunk is searched for only within the value of the previous
+ * result. For example, given the following context stack:
+ *
+ * $data = array(
+ * 'name' => 'Fred',
+ * 'child' => array(
+ * 'name' => 'Bob'
+ * ),
+ * );
+ *
+ * ... and the Mustache following template:
+ *
+ * {{ child.name }}
+ *
+ * ... the `name` value is only searched for within the `child` value of the global Context, not within parent
+ * Context frames.
+ *
+ * @param string $id Dotted variable selector
+ *
+ * @return mixed Variable value, or '' if not found
+ */
+ public function findDot($id)
+ {
+ $chunks = explode('.', $id);
+ $first = array_shift($chunks);
+ $value = $this->findVariableInStack($first, $this->stack);
+
+ foreach ($chunks as $chunk) {
+ if ($value === '') {
+ return $value;
+ }
+
+ $value = $this->findVariableInStack($chunk, array($value));
+ }
+
+ return $value;
+ }
+
+ /**
+ * Find an 'anchored dot notation' variable in the Context stack.
+ *
+ * This is the same as findDot(), except it looks in the top of the context
+ * stack for the first value, rather than searching the whole context stack
+ * and starting from there.
+ *
+ * @see Mustache_Context::findDot
+ *
+ * @throws Mustache_Exception_InvalidArgumentException if given an invalid anchored dot $id
+ *
+ * @param string $id Dotted variable selector
+ *
+ * @return mixed Variable value, or '' if not found
+ */
+ public function findAnchoredDot($id)
+ {
+ $chunks = explode('.', $id);
+ $first = array_shift($chunks);
+ if ($first !== '') {
+ throw new Mustache_Exception_InvalidArgumentException(sprintf('Unexpected id for findAnchoredDot: %s', $id));
+ }
+
+ $value = $this->last();
+
+ foreach ($chunks as $chunk) {
+ if ($value === '') {
+ return $value;
+ }
+
+ $value = $this->findVariableInStack($chunk, array($value));
+ }
+
+ return $value;
+ }
+
+ /**
+ * Find an argument in the block context stack.
+ *
+ * @param string $id
+ *
+ * @return mixed Variable value, or '' if not found
+ */
+ public function findInBlock($id)
+ {
+ foreach ($this->blockStack as $context) {
+ if (array_key_exists($id, $context)) {
+ return $context[$id];
+ }
+ }
+
+ return '';
+ }
+
+ /**
+ * Helper function to find a variable in the Context stack.
+ *
+ * @see Mustache_Context::find
+ *
+ * @param string $id Variable name
+ * @param array $stack Context stack
+ *
+ * @return mixed Variable value, or '' if not found
+ */
+ private function findVariableInStack($id, array $stack)
+ {
+ for ($i = count($stack) - 1; $i >= 0; $i--) {
+ $frame = &$stack[$i];
+
+ switch (gettype($frame)) {
+ case 'object':
+ if (!($frame instanceof Closure)) {
+ // Note that is_callable() *will not work here*
+ // See https://github.com/bobthecow/mustache.php/wiki/Magic-Methods
+ if (method_exists($frame, $id)) {
+ return $frame->$id();
+ }
+
+ if (isset($frame->$id)) {
+ return $frame->$id;
+ }
+
+ if ($frame instanceof ArrayAccess && isset($frame[$id])) {
+ return $frame[$id];
+ }
+ }
+ break;
+
+ case 'array':
+ if (array_key_exists($id, $frame)) {
+ return $frame[$id];
+ }
+ break;
+ }
+ }
+
+ return '';
+ }
+}
diff --git a/Mustache/Engine.php b/Mustache/Engine.php
index e1f6b8cf..0ac2a738 100644
--- a/Mustache/Engine.php
+++ b/Mustache/Engine.php
@@ -1,591 +1,832 @@
-<?php
-
-/*
- * This file is part of Mustache.php.
- *
- * (c) 2012 Justin Hileman
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-/**
- * A Mustache implementation in PHP.
- *
- * {@link http://defunkt.github.com/mustache}
- *
- * Mustache is a framework-agnostic logic-less templating language. It enforces separation of view
- * logic from template files. In fact, it is not even possible to embed logic in the template.
- *
- * This is very, very rad.
- *
- * @author Justin Hileman {@link http://justinhileman.com}
- */
-class Mustache_Engine
-{
- const VERSION = '2.0.2';
- const SPEC_VERSION = '1.1.2';
-
- // Template cache
- private $templates = array();
-
- // Environment
- private $templateClassPrefix = '__Mustache_';
- private $cache = null;
- private $loader;
- private $partialsLoader;
- private $helpers;
- private $escape;
- private $charset = 'UTF-8';
-
- /**
- * Mustache class constructor.
- *
- * Passing an $options array allows overriding certain Mustache options during instantiation:
- *
- * $options = array(
- * // The class prefix for compiled templates. Defaults to '__Mustache_'
- * 'template_class_prefix' => '__MyTemplates_',
- *
- * // A cache directory for compiled templates. Mustache will not cache templates unless this is set
- * 'cache' => dirname(__FILE__).'/tmp/cache/mustache',
- *
- * // A Mustache template loader instance. Uses a StringLoader if not specified
- * 'loader' => new Mustache_Loader_FilesystemLoader(dirname(__FILE__).'/views'),
- *
- * // A Mustache loader instance for partials.
- * 'partials_loader' => new Mustache_Loader_FilesystemLoader(dirname(__FILE__).'/views/partials'),
- *
- * // An array of Mustache partials. Useful for quick-and-dirty string template loading, but not as
- * // efficient or lazy as a Filesystem (or database) loader.
- * 'partials' => array('foo' => file_get_contents(dirname(__FILE__).'/views/partials/foo.mustache')),
- *
- * // An array of 'helpers'. Helpers can be global variables or objects, closures (e.g. for higher order
- * // sections), or any other valid Mustache context value. They will be prepended to the context stack,
- * // so they will be available in any template loaded by this Mustache instance.
- * 'helpers' => array('i18n' => function($text) {
- * // do something translatey here...
- * }),
- *
- * // An 'escape' callback, responsible for escaping double-mustache variables.
- * 'escape' => function($value) {
- * return htmlspecialchars($buffer, ENT_COMPAT, 'UTF-8');
- * },
- *
- * // character set for `htmlspecialchars`. Defaults to 'UTF-8'
- * 'charset' => 'ISO-8859-1',
- * );
- *
- * @param array $options (default: array())
- */
- public function __construct(array $options = array())
- {
- if (isset($options['template_class_prefix'])) {
- $this->templateClassPrefix = $options['template_class_prefix'];
- }
-
- if (isset($options['cache'])) {
- $this->cache = $options['cache'];
- }
-
- if (isset($options['loader'])) {
- $this->setLoader($options['loader']);
- }
-
- if (isset($options['partials_loader'])) {
- $this->setPartialsLoader($options['partials_loader']);
- }
-
- if (isset($options['partials'])) {
- $this->setPartials($options['partials']);
- }
-
- if (isset($options['helpers'])) {
- $this->setHelpers($options['helpers']);
- }
-
- if (isset($options['escape'])) {
- if (!is_callable($options['escape'])) {
- throw new InvalidArgumentException('Mustache Constructor "escape" option must be callable');
- }
-
- $this->escape = $options['escape'];
- }
-
- if (isset($options['charset'])) {
- $this->charset = $options['charset'];
- }
- }
-
- /**
- * Shortcut 'render' invocation.
- *
- * Equivalent to calling `$mustache->loadTemplate($template)->render($data);`
- *
- * @see Mustache_Engine::loadTemplate
- * @see Mustache_Template::render
- *
- * @param string $template
- * @param mixed $data
- *
- * @return string Rendered template
- */
- public function render($template, $data)
- {
- return $this->loadTemplate($template)->render($data);
- }
-
- /**
- * Get the current Mustache escape callback.
- *
- * @return mixed Callable or null
- */
- public function getEscape()
- {
- return $this->escape;
- }
-
- /**
- * Get the current Mustache character set.
- *
- * @return string
- */
- public function getCharset()
- {
- return $this->charset;
- }
-
- /**
- * Set the Mustache template Loader instance.
- *
- * @param Mustache_Loader $loader
- */
- public function setLoader(Mustache_Loader $loader)
- {
- $this->loader = $loader;
- }
-
- /**
- * Get the current Mustache template Loader instance.
- *
- * If no Loader instance has been explicitly specified, this method will instantiate and return
- * a StringLoader instance.
- *
- * @return Mustache_Loader
- */
- public function getLoader()
- {
- if (!isset($this->loader)) {
- $this->loader = new Mustache_Loader_StringLoader;
- }
-
- return $this->loader;
- }
-
- /**
- * Set the Mustache partials Loader instance.
- *
- * @param Mustache_Loader $partialsLoader
- */
- public function setPartialsLoader(Mustache_Loader $partialsLoader)
- {
- $this->partialsLoader = $partialsLoader;
- }
-
- /**
- * Get the current Mustache partials Loader instance.
- *
- * If no Loader instance has been explicitly specified, this method will instantiate and return
- * an ArrayLoader instance.
- *
- * @return Mustache_Loader
- */
- public function getPartialsLoader()
- {
- if (!isset($this->partialsLoader)) {
- $this->partialsLoader = new Mustache_Loader_ArrayLoader;
- }
-
- return $this->partialsLoader;
- }
-
- /**
- * Set partials for the current partials Loader instance.
- *
- * @throws RuntimeException If the current Loader instance is immutable
- *
- * @param array $partials (default: array())
- */
- public function setPartials(array $partials = array())
- {
- $loader = $this->getPartialsLoader();
- if (!$loader instanceof Mustache_Loader_MutableLoader) {
- throw new RuntimeException('Unable to set partials on an immutable Mustache Loader instance');
- }
-
- $loader->setTemplates($partials);
- }
-
- /**
- * Set an array of Mustache helpers.
- *
- * An array of 'helpers'. Helpers can be global variables or objects, closures (e.g. for higher order sections), or
- * any other valid Mustache context value. They will be prepended to the context stack, so they will be available in
- * any template loaded by this Mustache instance.
- *
- * @throws InvalidArgumentException if $helpers is not an array or Traversable
- *
- * @param array|Traversable $helpers
- */
- public function setHelpers($helpers)
- {
- if (!is_array($helpers) && !$helpers instanceof Traversable) {
- throw new InvalidArgumentException('setHelpers expects an array of helpers');
- }
-
- $this->getHelpers()->clear();
-
- foreach ($helpers as $name => $helper) {
- $this->addHelper($name, $helper);
- }
- }
-
- /**
- * Get the current set of Mustache helpers.
- *
- * @see Mustache_Engine::setHelpers
- *
- * @return Mustache_HelperCollection
- */
- public function getHelpers()
- {
- if (!isset($this->helpers)) {
- $this->helpers = new Mustache_HelperCollection;
- }
-
- return $this->helpers;
- }
-
- /**
- * Add a new Mustache helper.
- *
- * @see Mustache_Engine::setHelpers
- *
- * @param string $name
- * @param mixed $helper
- */
- public function addHelper($name, $helper)
- {
- $this->getHelpers()->add($name, $helper);
- }
-
- /**
- * Get a Mustache helper by name.
- *
- * @see Mustache_Engine::setHelpers
- *
- * @param string $name
- *
- * @return mixed Helper
- */
- public function getHelper($name)
- {
- return $this->getHelpers()->get($name);
- }
-
- /**
- * Check whether this Mustache instance has a helper.
- *
- * @see Mustache_Engine::setHelpers
- *
- * @param string $name
- *
- * @return boolean True if the helper is present
- */
- public function hasHelper($name)
- {
- return $this->getHelpers()->has($name);
- }
-
- /**
- * Remove a helper by name.
- *
- * @see Mustache_Engine::setHelpers
- *
- * @param string $name
- */
- public function removeHelper($name)
- {
- $this->getHelpers()->remove($name);
- }
-
- /**
- * Set the Mustache Tokenizer instance.
- *
- * @param Mustache_Tokenizer $tokenizer
- */
- public function setTokenizer(Mustache_Tokenizer $tokenizer)
- {
- $this->tokenizer = $tokenizer;
- }
-
- /**
- * Get the current Mustache Tokenizer instance.
- *
- * If no Tokenizer instance has been explicitly specified, this method will instantiate and return a new one.
- *
- * @return Mustache_Tokenizer
- */
- public function getTokenizer()
- {
- if (!isset($this->tokenizer)) {
- $this->tokenizer = new Mustache_Tokenizer;
- }
-
- return $this->tokenizer;
- }
-
- /**
- * Set the Mustache Parser instance.
- *
- * @param Mustache_Parser $parser
- */
- public function setParser(Mustache_Parser $parser)
- {
- $this->parser = $parser;
- }
-
- /**
- * Get the current Mustache Parser instance.
- *
- * If no Parser instance has been explicitly specified, this method will instantiate and return a new one.
- *
- * @return Mustache_Parser
- */
- public function getParser()
- {
- if (!isset($this->parser)) {
- $this->parser = new Mustache_Parser;
- }
-
- return $this->parser;
- }
-
- /**
- * Set the Mustache Compiler instance.
- *
- * @param Mustache_Compiler $compiler
- */
- public function setCompiler(Mustache_Compiler $compiler)
- {
- $this->compiler = $compiler;
- }
-
- /**
- * Get the current Mustache Compiler instance.
- *
- * If no Compiler instance has been explicitly specified, this method will instantiate and return a new one.
- *
- * @return Mustache_Compiler
- */
- public function getCompiler()
- {
- if (!isset($this->compiler)) {
- $this->compiler = new Mustache_Compiler;
- }
-
- return $this->compiler;
- }
-
- /**
- * Helper method to generate a Mustache template class.
- *
- * @param string $source
- *
- * @return string Mustache Template class name
- */
- public function getTemplateClassName($source)
- {
- return $this->templateClassPrefix . md5(sprintf(
- 'version:%s,escape:%s,charset:%s,source:%s',
- self::VERSION,
- isset($this->escape) ? 'custom' : 'default',
- $this->charset,
- $source
- ));
- }
-
- /**
- * Load a Mustache Template by name.
- *
- * @param string $name
- *
- * @return Mustache_Template
- */
- public function loadTemplate($name)
- {
- return $this->loadSource($this->getLoader()->load($name));
- }
-
- /**
- * Load a Mustache partial Template by name.
- *
- * This is a helper method used internally by Template instances for loading partial templates. You can most likely
- * ignore it completely.
- *
- * @param string $name
- *
- * @return Mustache_Template
- */
- public function loadPartial($name)
- {
- try {
- return $this->loadSource($this->getPartialsLoader()->load($name));
- } catch (InvalidArgumentException $e) {
- // If the named partial cannot be found, return null.
- }
- return null;
- }
-
- /**
- * Load a Mustache lambda Template by source.
- *
- * This is a helper method used by Template instances to generate subtemplates for Lambda sections. You can most
- * likely ignore it completely.
- *
- * @param string $source
- * @param string $delims (default: null)
- *
- * @return Mustache_Template
- */
- public function loadLambda($source, $delims = null)
- {
- if ($delims !== null) {
- $source = $delims . "\n" . $source;
- }
-
- return $this->loadSource($source);
- }
-
- /**
- * Instantiate and return a Mustache Template instance by source.
- *
- * @see Mustache_Engine::loadTemplate
- * @see Mustache_Engine::loadPartial
- * @see Mustache_Engine::loadLambda
- *
- * @param string $source
- *
- * @return Mustache_Template
- */
- private function loadSource($source)
- {
- $className = $this->getTemplateClassName($source);
-
- if (!isset($this->templates[$className])) {
- if (!class_exists($className, false)) {
- if ($fileName = $this->getCacheFilename($source)) {
- if (!is_file($fileName)) {
- $this->writeCacheFile($fileName, $this->compile($source));
- }
-
- require_once $fileName;
- } else {
- eval('?>'.$this->compile($source));
- }
- }
-
- $this->templates[$className] = new $className($this);
- }
-
- return $this->templates[$className];
- }
-
- /**
- * Helper method to tokenize a Mustache template.
- *
- * @see Mustache_Tokenizer::scan
- *
- * @param string $source
- *
- * @return array Tokens
- */
- private function tokenize($source)
- {
- return $this->getTokenizer()->scan($source);
- }
-
- /**
- * Helper method to parse a Mustache template.
- *
- * @see Mustache_Parser::parse
- *
- * @param string $source
- *
- * @return array Token tree
- */
- private function parse($source)
- {
- return $this->getParser()->parse($this->tokenize($source));
- }
-
- /**
- * Helper method to compile a Mustache template.
- *
- * @see Mustache_Compiler::compile
- *
- * @param string $source
- *
- * @return string generated Mustache template class code
- */
- private function compile($source)
- {
- $tree = $this->parse($source);
- $name = $this->getTemplateClassName($source);
-
- return $this->getCompiler()->compile($source, $tree, $name, isset($this->escape), $this->charset);
- }
-
- /**
- * Helper method to generate a Mustache Template class cache filename.
- *
- * @param string $source
- *
- * @return string Mustache Template class cache filename
- */
- private function getCacheFilename($source)
- {
- if ($this->cache) {
- return sprintf('%s/%s.php', $this->cache, $this->getTemplateClassName($source));
- }
- return false;
- }
-
- /**
- * Helper method to dump a generated Mustache Template subclass to the file cache.
- *
- * @throws RuntimeException if unable to write to $fileName.
- *
- * @param string $fileName
- * @param string $source
- *
- * @codeCoverageIgnore
- */
- private function writeCacheFile($fileName, $source)
- {
- if (!is_dir(dirname($fileName))) {
- mkdir(dirname($fileName), 0777, true);
- }
-
- $tempFile = tempnam(dirname($fileName), basename($fileName));
- if (false !== @file_put_contents($tempFile, $source)) {
- if (@rename($tempFile, $fileName)) {
- chmod($fileName, 0644);
-
- return;
- }
- }
-
- throw new RuntimeException(sprintf('Failed to write cache file "%s".', $fileName));
- }
-}
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * A Mustache implementation in PHP.
+ *
+ * {@link http://defunkt.github.com/mustache}
+ *
+ * Mustache is a framework-agnostic logic-less templating language. It enforces separation of view
+ * logic from template files. In fact, it is not even possible to embed logic in the template.
+ *
+ * This is very, very rad.
+ *
+ * @author Justin Hileman {@link http://justinhileman.com}
+ */
+class Mustache_Engine
+{
+ const VERSION = '2.14.2';
+ const SPEC_VERSION = '1.3.0';
+
+ const PRAGMA_FILTERS = 'FILTERS';
+ const PRAGMA_BLOCKS = 'BLOCKS';
+ const PRAGMA_ANCHORED_DOT = 'ANCHORED-DOT';
+ const PRAGMA_DYNAMIC_NAMES = 'DYNAMIC-NAMES';
+
+ // Known pragmas
+ private static $knownPragmas = array(
+ self::PRAGMA_FILTERS => true,
+ self::PRAGMA_BLOCKS => true,
+ self::PRAGMA_ANCHORED_DOT => true,
+ self::PRAGMA_DYNAMIC_NAMES => true,
+ );
+
+ // Template cache
+ private $templates = array();
+
+ // Environment
+ private $templateClassPrefix = '__Mustache_';
+ private $cache;
+ private $lambdaCache;
+ private $cacheLambdaTemplates = false;
+ private $loader;
+ private $partialsLoader;
+ private $helpers;
+ private $escape;
+ private $entityFlags = ENT_COMPAT;
+ private $charset = 'UTF-8';
+ private $logger;
+ private $strictCallables = false;
+ private $pragmas = array();
+ private $delimiters;
+
+ // Services
+ private $tokenizer;
+ private $parser;
+ private $compiler;
+
+ /**
+ * Mustache class constructor.
+ *
+ * Passing an $options array allows overriding certain Mustache options during instantiation:
+ *
+ * $options = array(
+ * // The class prefix for compiled templates. Defaults to '__Mustache_'.
+ * 'template_class_prefix' => '__MyTemplates_',
+ *
+ * // A Mustache cache instance or a cache directory string for compiled templates.
+ * // Mustache will not cache templates unless this is set.
+ * 'cache' => dirname(__FILE__).'/tmp/cache/mustache',
+ *
+ * // Override default permissions for cache files. Defaults to using the system-defined umask. It is
+ * // *strongly* recommended that you configure your umask properly rather than overriding permissions here.
+ * 'cache_file_mode' => 0666,
+ *
+ * // Optionally, enable caching for lambda section templates. This is generally not recommended, as lambda
+ * // sections are often too dynamic to benefit from caching.
+ * 'cache_lambda_templates' => true,
+ *
+ * // Customize the tag delimiters used by this engine instance. Note that overriding here changes the
+ * // delimiters used to parse all templates and partials loaded by this instance. To override just for a
+ * // single template, use an inline "change delimiters" tag at the start of the template file:
+ * //
+ * // {{=<% %>=}}
+ * //
+ * 'delimiters' => '<% %>',
+ *
+ * // A Mustache template loader instance. Uses a StringLoader if not specified.
+ * 'loader' => new Mustache_Loader_FilesystemLoader(dirname(__FILE__).'/views'),
+ *
+ * // A Mustache loader instance for partials.
+ * 'partials_loader' => new Mustache_Loader_FilesystemLoader(dirname(__FILE__).'/views/partials'),
+ *
+ * // An array of Mustache partials. Useful for quick-and-dirty string template loading, but not as
+ * // efficient or lazy as a Filesystem (or database) loader.
+ * 'partials' => array('foo' => file_get_contents(dirname(__FILE__).'/views/partials/foo.mustache')),
+ *
+ * // An array of 'helpers'. Helpers can be global variables or objects, closures (e.g. for higher order
+ * // sections), or any other valid Mustache context value. They will be prepended to the context stack,
+ * // so they will be available in any template loaded by this Mustache instance.
+ * 'helpers' => array('i18n' => function ($text) {
+ * // do something translatey here...
+ * }),
+ *
+ * // An 'escape' callback, responsible for escaping double-mustache variables.
+ * 'escape' => function ($value) {
+ * return htmlspecialchars($buffer, ENT_COMPAT, 'UTF-8');
+ * },
+ *
+ * // Type argument for `htmlspecialchars`. Defaults to ENT_COMPAT. You may prefer ENT_QUOTES.
+ * 'entity_flags' => ENT_QUOTES,
+ *
+ * // Character set for `htmlspecialchars`. Defaults to 'UTF-8'. Use 'UTF-8'.
+ * 'charset' => 'ISO-8859-1',
+ *
+ * // A Mustache Logger instance. No logging will occur unless this is set. Using a PSR-3 compatible
+ * // logging library -- such as Monolog -- is highly recommended. A simple stream logger implementation is
+ * // available as well:
+ * 'logger' => new Mustache_Logger_StreamLogger('php://stderr'),
+ *
+ * // Only treat Closure instances and invokable classes as callable. If true, values like
+ * // `array('ClassName', 'methodName')` and `array($classInstance, 'methodName')`, which are traditionally
+ * // "callable" in PHP, are not called to resolve variables for interpolation or section contexts. This
+ * // helps protect against arbitrary code execution when user input is passed directly into the template.
+ * // This currently defaults to false, but will default to true in v3.0.
+ * 'strict_callables' => true,
+ *
+ * // Enable pragmas across all templates, regardless of the presence of pragma tags in the individual
+ * // templates.
+ * 'pragmas' => [Mustache_Engine::PRAGMA_FILTERS],
+ * );
+ *
+ * @throws Mustache_Exception_InvalidArgumentException If `escape` option is not callable
+ *
+ * @param array $options (default: array())
+ */
+ public function __construct(array $options = array())
+ {
+ if (isset($options['template_class_prefix'])) {
+ if ((string) $options['template_class_prefix'] === '') {
+ throw new Mustache_Exception_InvalidArgumentException('Mustache Constructor "template_class_prefix" must not be empty');
+ }
+
+ $this->templateClassPrefix = $options['template_class_prefix'];
+ }
+
+ if (isset($options['cache'])) {
+ $cache = $options['cache'];
+
+ if (is_string($cache)) {
+ $mode = isset($options['cache_file_mode']) ? $options['cache_file_mode'] : null;
+ $cache = new Mustache_Cache_FilesystemCache($cache, $mode);
+ }
+
+ $this->setCache($cache);
+ }
+
+ if (isset($options['cache_lambda_templates'])) {
+ $this->cacheLambdaTemplates = (bool) $options['cache_lambda_templates'];
+ }
+
+ if (isset($options['loader'])) {
+ $this->setLoader($options['loader']);
+ }
+
+ if (isset($options['partials_loader'])) {
+ $this->setPartialsLoader($options['partials_loader']);
+ }
+
+ if (isset($options['partials'])) {
+ $this->setPartials($options['partials']);
+ }
+
+ if (isset($options['helpers'])) {
+ $this->setHelpers($options['helpers']);
+ }
+
+ if (isset($options['escape'])) {
+ if (!is_callable($options['escape'])) {
+ throw new Mustache_Exception_InvalidArgumentException('Mustache Constructor "escape" option must be callable');
+ }
+
+ $this->escape = $options['escape'];
+ }
+
+ if (isset($options['entity_flags'])) {
+ $this->entityFlags = $options['entity_flags'];
+ }
+
+ if (isset($options['charset'])) {
+ $this->charset = $options['charset'];
+ }
+
+ if (isset($options['logger'])) {
+ $this->setLogger($options['logger']);
+ }
+
+ if (isset($options['strict_callables'])) {
+ $this->strictCallables = $options['strict_callables'];
+ }
+
+ if (isset($options['delimiters'])) {
+ $this->delimiters = $options['delimiters'];
+ }
+
+ if (isset($options['pragmas'])) {
+ foreach ($options['pragmas'] as $pragma) {
+ if (!isset(self::$knownPragmas[$pragma])) {
+ throw new Mustache_Exception_InvalidArgumentException(sprintf('Unknown pragma: "%s".', $pragma));
+ }
+ $this->pragmas[$pragma] = true;
+ }
+ }
+ }
+
+ /**
+ * Shortcut 'render' invocation.
+ *
+ * Equivalent to calling `$mustache->loadTemplate($template)->render($context);`
+ *
+ * @see Mustache_Engine::loadTemplate
+ * @see Mustache_Template::render
+ *
+ * @param string $template
+ * @param mixed $context (default: array())
+ *
+ * @return string Rendered template
+ */
+ public function render($template, $context = array())
+ {
+ return $this->loadTemplate($template)->render($context);
+ }
+
+ /**
+ * Get the current Mustache escape callback.
+ *
+ * @return callable|null
+ */
+ public function getEscape()
+ {
+ return $this->escape;
+ }
+
+ /**
+ * Get the current Mustache entitity type to escape.
+ *
+ * @return int
+ */
+ public function getEntityFlags()
+ {
+ return $this->entityFlags;
+ }
+
+ /**
+ * Get the current Mustache character set.
+ *
+ * @return string
+ */
+ public function getCharset()
+ {
+ return $this->charset;
+ }
+
+ /**
+ * Get the current globally enabled pragmas.
+ *
+ * @return array
+ */
+ public function getPragmas()
+ {
+ return array_keys($this->pragmas);
+ }
+
+ /**
+ * Set the Mustache template Loader instance.
+ *
+ * @param Mustache_Loader $loader
+ */
+ public function setLoader(Mustache_Loader $loader)
+ {
+ $this->loader = $loader;
+ }
+
+ /**
+ * Get the current Mustache template Loader instance.
+ *
+ * If no Loader instance has been explicitly specified, this method will instantiate and return
+ * a StringLoader instance.
+ *
+ * @return Mustache_Loader
+ */
+ public function getLoader()
+ {
+ if (!isset($this->loader)) {
+ $this->loader = new Mustache_Loader_StringLoader();
+ }
+
+ return $this->loader;
+ }
+
+ /**
+ * Set the Mustache partials Loader instance.
+ *
+ * @param Mustache_Loader $partialsLoader
+ */
+ public function setPartialsLoader(Mustache_Loader $partialsLoader)
+ {
+ $this->partialsLoader = $partialsLoader;
+ }
+
+ /**
+ * Get the current Mustache partials Loader instance.
+ *
+ * If no Loader instance has been explicitly specified, this method will instantiate and return
+ * an ArrayLoader instance.
+ *
+ * @return Mustache_Loader
+ */
+ public function getPartialsLoader()
+ {
+ if (!isset($this->partialsLoader)) {
+ $this->partialsLoader = new Mustache_Loader_ArrayLoader();
+ }
+
+ return $this->partialsLoader;
+ }
+
+ /**
+ * Set partials for the current partials Loader instance.
+ *
+ * @throws Mustache_Exception_RuntimeException If the current Loader instance is immutable
+ *
+ * @param array $partials (default: array())
+ */
+ public function setPartials(array $partials = array())
+ {
+ if (!isset($this->partialsLoader)) {
+ $this->partialsLoader = new Mustache_Loader_ArrayLoader();
+ }
+
+ if (!$this->partialsLoader instanceof Mustache_Loader_MutableLoader) {
+ throw new Mustache_Exception_RuntimeException('Unable to set partials on an immutable Mustache Loader instance');
+ }
+
+ $this->partialsLoader->setTemplates($partials);
+ }
+
+ /**
+ * Set an array of Mustache helpers.
+ *
+ * An array of 'helpers'. Helpers can be global variables or objects, closures (e.g. for higher order sections), or
+ * any other valid Mustache context value. They will be prepended to the context stack, so they will be available in
+ * any template loaded by this Mustache instance.
+ *
+ * @throws Mustache_Exception_InvalidArgumentException if $helpers is not an array or Traversable
+ *
+ * @param array|Traversable $helpers
+ */
+ public function setHelpers($helpers)
+ {
+ if (!is_array($helpers) && !$helpers instanceof Traversable) {
+ throw new Mustache_Exception_InvalidArgumentException('setHelpers expects an array of helpers');
+ }
+
+ $this->getHelpers()->clear();
+
+ foreach ($helpers as $name => $helper) {
+ $this->addHelper($name, $helper);
+ }
+ }
+
+ /**
+ * Get the current set of Mustache helpers.
+ *
+ * @see Mustache_Engine::setHelpers
+ *
+ * @return Mustache_HelperCollection
+ */
+ public function getHelpers()
+ {
+ if (!isset($this->helpers)) {
+ $this->helpers = new Mustache_HelperCollection();
+ }
+
+ return $this->helpers;
+ }
+
+ /**
+ * Add a new Mustache helper.
+ *
+ * @see Mustache_Engine::setHelpers
+ *
+ * @param string $name
+ * @param mixed $helper
+ */
+ public function addHelper($name, $helper)
+ {
+ $this->getHelpers()->add($name, $helper);
+ }
+
+ /**
+ * Get a Mustache helper by name.
+ *
+ * @see Mustache_Engine::setHelpers
+ *
+ * @param string $name
+ *
+ * @return mixed Helper
+ */
+ public function getHelper($name)
+ {
+ return $this->getHelpers()->get($name);
+ }
+
+ /**
+ * Check whether this Mustache instance has a helper.
+ *
+ * @see Mustache_Engine::setHelpers
+ *
+ * @param string $name
+ *
+ * @return bool True if the helper is present
+ */
+ public function hasHelper($name)
+ {
+ return $this->getHelpers()->has($name);
+ }
+
+ /**
+ * Remove a helper by name.
+ *
+ * @see Mustache_Engine::setHelpers
+ *
+ * @param string $name
+ */
+ public function removeHelper($name)
+ {
+ $this->getHelpers()->remove($name);
+ }
+
+ /**
+ * Set the Mustache Logger instance.
+ *
+ * @throws Mustache_Exception_InvalidArgumentException If logger is not an instance of Mustache_Logger or Psr\Log\LoggerInterface
+ *
+ * @param Mustache_Logger|Psr\Log\LoggerInterface $logger
+ */
+ public function setLogger($logger = null)
+ {
+ if ($logger !== null && !($logger instanceof Mustache_Logger || is_a($logger, 'Psr\\Log\\LoggerInterface'))) {
+ throw new Mustache_Exception_InvalidArgumentException('Expected an instance of Mustache_Logger or Psr\\Log\\LoggerInterface.');
+ }
+
+ if ($this->getCache()->getLogger() === null) {
+ $this->getCache()->setLogger($logger);
+ }
+
+ $this->logger = $logger;
+ }
+
+ /**
+ * Get the current Mustache Logger instance.
+ *
+ * @return Mustache_Logger|Psr\Log\LoggerInterface
+ */
+ public function getLogger()
+ {
+ return $this->logger;
+ }
+
+ /**
+ * Set the Mustache Tokenizer instance.
+ *
+ * @param Mustache_Tokenizer $tokenizer
+ */
+ public function setTokenizer(Mustache_Tokenizer $tokenizer)
+ {
+ $this->tokenizer = $tokenizer;
+ }
+
+ /**
+ * Get the current Mustache Tokenizer instance.
+ *
+ * If no Tokenizer instance has been explicitly specified, this method will instantiate and return a new one.
+ *
+ * @return Mustache_Tokenizer
+ */
+ public function getTokenizer()
+ {
+ if (!isset($this->tokenizer)) {
+ $this->tokenizer = new Mustache_Tokenizer();
+ }
+
+ return $this->tokenizer;
+ }
+
+ /**
+ * Set the Mustache Parser instance.
+ *
+ * @param Mustache_Parser $parser
+ */
+ public function setParser(Mustache_Parser $parser)
+ {
+ $this->parser = $parser;
+ }
+
+ /**
+ * Get the current Mustache Parser instance.
+ *
+ * If no Parser instance has been explicitly specified, this method will instantiate and return a new one.
+ *
+ * @return Mustache_Parser
+ */
+ public function getParser()
+ {
+ if (!isset($this->parser)) {
+ $this->parser = new Mustache_Parser();
+ }
+
+ return $this->parser;
+ }
+
+ /**
+ * Set the Mustache Compiler instance.
+ *
+ * @param Mustache_Compiler $compiler
+ */
+ public function setCompiler(Mustache_Compiler $compiler)
+ {
+ $this->compiler = $compiler;
+ }
+
+ /**
+ * Get the current Mustache Compiler instance.
+ *
+ * If no Compiler instance has been explicitly specified, this method will instantiate and return a new one.
+ *
+ * @return Mustache_Compiler
+ */
+ public function getCompiler()
+ {
+ if (!isset($this->compiler)) {
+ $this->compiler = new Mustache_Compiler();
+ }
+
+ return $this->compiler;
+ }
+
+ /**
+ * Set the Mustache Cache instance.
+ *
+ * @param Mustache_Cache $cache
+ */
+ public function setCache(Mustache_Cache $cache)
+ {
+ if (isset($this->logger) && $cache->getLogger() === null) {
+ $cache->setLogger($this->getLogger());
+ }
+
+ $this->cache = $cache;
+ }
+
+ /**
+ * Get the current Mustache Cache instance.
+ *
+ * If no Cache instance has been explicitly specified, this method will instantiate and return a new one.
+ *
+ * @return Mustache_Cache
+ */
+ public function getCache()
+ {
+ if (!isset($this->cache)) {
+ $this->setCache(new Mustache_Cache_NoopCache());
+ }
+
+ return $this->cache;
+ }
+
+ /**
+ * Get the current Lambda Cache instance.
+ *
+ * If 'cache_lambda_templates' is enabled, this is the default cache instance. Otherwise, it is a NoopCache.
+ *
+ * @see Mustache_Engine::getCache
+ *
+ * @return Mustache_Cache
+ */
+ protected function getLambdaCache()
+ {
+ if ($this->cacheLambdaTemplates) {
+ return $this->getCache();
+ }
+
+ if (!isset($this->lambdaCache)) {
+ $this->lambdaCache = new Mustache_Cache_NoopCache();
+ }
+
+ return $this->lambdaCache;
+ }
+
+ /**
+ * Helper method to generate a Mustache template class.
+ *
+ * This method must be updated any time options are added which make it so
+ * the same template could be parsed and compiled multiple different ways.
+ *
+ * @param string|Mustache_Source $source
+ *
+ * @return string Mustache Template class name
+ */
+ public function getTemplateClassName($source)
+ {
+ // For the most part, adding a new option here should do the trick.
+ //
+ // Pick a value here which is unique for each possible way the template
+ // could be compiled... but not necessarily unique per option value. See
+ // escape below, which only needs to differentiate between 'custom' and
+ // 'default' escapes.
+ //
+ // Keep this list in alphabetical order :)
+ $chunks = array(
+ 'charset' => $this->charset,
+ 'delimiters' => $this->delimiters ? $this->delimiters : '{{ }}',
+ 'entityFlags' => $this->entityFlags,
+ 'escape' => isset($this->escape) ? 'custom' : 'default',
+ 'key' => ($source instanceof Mustache_Source) ? $source->getKey() : 'source',
+ 'pragmas' => $this->getPragmas(),
+ 'strictCallables' => $this->strictCallables,
+ 'version' => self::VERSION,
+ );
+
+ $key = json_encode($chunks);
+
+ // Template Source instances have already provided their own source key. For strings, just include the whole
+ // source string in the md5 hash.
+ if (!$source instanceof Mustache_Source) {
+ $key .= "\n" . $source;
+ }
+
+ return $this->templateClassPrefix . md5($key);
+ }
+
+ /**
+ * Load a Mustache Template by name.
+ *
+ * @param string $name
+ *
+ * @return Mustache_Template
+ */
+ public function loadTemplate($name)
+ {
+ return $this->loadSource($this->getLoader()->load($name));
+ }
+
+ /**
+ * Load a Mustache partial Template by name.
+ *
+ * This is a helper method used internally by Template instances for loading partial templates. You can most likely
+ * ignore it completely.
+ *
+ * @param string $name
+ *
+ * @return Mustache_Template
+ */
+ public function loadPartial($name)
+ {
+ try {
+ if (isset($this->partialsLoader)) {
+ $loader = $this->partialsLoader;
+ } elseif (isset($this->loader) && !$this->loader instanceof Mustache_Loader_StringLoader) {
+ $loader = $this->loader;
+ } else {
+ throw new Mustache_Exception_UnknownTemplateException($name);
+ }
+
+ return $this->loadSource($loader->load($name));
+ } catch (Mustache_Exception_UnknownTemplateException $e) {
+ // If the named partial cannot be found, log then return null.
+ $this->log(
+ Mustache_Logger::WARNING,
+ 'Partial not found: "{name}"',
+ array('name' => $e->getTemplateName())
+ );
+ }
+ return null;
+ }
+
+ /**
+ * Load a Mustache lambda Template by source.
+ *
+ * This is a helper method used by Template instances to generate subtemplates for Lambda sections. You can most
+ * likely ignore it completely.
+ *
+ * @param string $source
+ * @param string $delims (default: null)
+ *
+ * @return Mustache_Template
+ */
+ public function loadLambda($source, $delims = null)
+ {
+ if ($delims !== null) {
+ $source = $delims . "\n" . $source;
+ }
+
+ return $this->loadSource($source, $this->getLambdaCache());
+ }
+
+ /**
+ * Instantiate and return a Mustache Template instance by source.
+ *
+ * Optionally provide a Mustache_Cache instance. This is used internally by Mustache_Engine::loadLambda to respect
+ * the 'cache_lambda_templates' configuration option.
+ *
+ * @see Mustache_Engine::loadTemplate
+ * @see Mustache_Engine::loadPartial
+ * @see Mustache_Engine::loadLambda
+ *
+ * @param string|Mustache_Source $source
+ * @param Mustache_Cache $cache (default: null)
+ *
+ * @return Mustache_Template
+ */
+ private function loadSource($source, Mustache_Cache $cache = null)
+ {
+ $className = $this->getTemplateClassName($source);
+
+ if (!isset($this->templates[$className])) {
+ if ($cache === null) {
+ $cache = $this->getCache();
+ }
+
+ if (!class_exists($className, false)) {
+ if (!$cache->load($className)) {
+ $compiled = $this->compile($source);
+ $cache->cache($className, $compiled);
+ }
+ }
+
+ $this->log(
+ Mustache_Logger::DEBUG,
+ 'Instantiating template: "{className}"',
+ array('className' => $className)
+ );
+
+ $this->templates[$className] = new $className($this);
+ }
+
+ return $this->templates[$className];
+ }
+
+ /**
+ * Helper method to tokenize a Mustache template.
+ *
+ * @see Mustache_Tokenizer::scan
+ *
+ * @param string $source
+ *
+ * @return array Tokens
+ */
+ private function tokenize($source)
+ {
+ return $this->getTokenizer()->scan($source, $this->delimiters);
+ }
+
+ /**
+ * Helper method to parse a Mustache template.
+ *
+ * @see Mustache_Parser::parse
+ *
+ * @param string $source
+ *
+ * @return array Token tree
+ */
+ private function parse($source)
+ {
+ $parser = $this->getParser();
+ $parser->setPragmas($this->getPragmas());
+
+ return $parser->parse($this->tokenize($source));
+ }
+
+ /**
+ * Helper method to compile a Mustache template.
+ *
+ * @see Mustache_Compiler::compile
+ *
+ * @param string|Mustache_Source $source
+ *
+ * @return string generated Mustache template class code
+ */
+ private function compile($source)
+ {
+ $name = $this->getTemplateClassName($source);
+
+ $this->log(
+ Mustache_Logger::INFO,
+ 'Compiling template to "{className}" class',
+ array('className' => $name)
+ );
+
+ if ($source instanceof Mustache_Source) {
+ $source = $source->getSource();
+ }
+ $tree = $this->parse($source);
+
+ $compiler = $this->getCompiler();
+ $compiler->setPragmas($this->getPragmas());
+
+ return $compiler->compile($source, $tree, $name, isset($this->escape), $this->charset, $this->strictCallables, $this->entityFlags);
+ }
+
+ /**
+ * Add a log record if logging is enabled.
+ *
+ * @param int $level The logging level
+ * @param string $message The log message
+ * @param array $context The log context
+ */
+ private function log($level, $message, array $context = array())
+ {
+ if (isset($this->logger)) {
+ $this->logger->log($level, $message, $context);
+ }
+ }
+}
diff --git a/Mustache/Exception.php b/Mustache/Exception.php
new file mode 100644
index 00000000..d4001a9b
--- /dev/null
+++ b/Mustache/Exception.php
@@ -0,0 +1,18 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * A Mustache Exception interface.
+ */
+interface Mustache_Exception
+{
+ // This space intentionally left blank.
+}
diff --git a/Mustache/Exception/InvalidArgumentException.php b/Mustache/Exception/InvalidArgumentException.php
new file mode 100644
index 00000000..becf2ed1
--- /dev/null
+++ b/Mustache/Exception/InvalidArgumentException.php
@@ -0,0 +1,18 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Invalid argument exception.
+ */
+class Mustache_Exception_InvalidArgumentException extends InvalidArgumentException implements Mustache_Exception
+{
+ // This space intentionally left blank.
+}
diff --git a/Mustache/Exception/LogicException.php b/Mustache/Exception/LogicException.php
new file mode 100644
index 00000000..b2424d67
--- /dev/null
+++ b/Mustache/Exception/LogicException.php
@@ -0,0 +1,18 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Logic exception.
+ */
+class Mustache_Exception_LogicException extends LogicException implements Mustache_Exception
+{
+ // This space intentionally left blank.
+}
diff --git a/Mustache/Exception/RuntimeException.php b/Mustache/Exception/RuntimeException.php
new file mode 100644
index 00000000..b6369f4b
--- /dev/null
+++ b/Mustache/Exception/RuntimeException.php
@@ -0,0 +1,18 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Runtime exception.
+ */
+class Mustache_Exception_RuntimeException extends RuntimeException implements Mustache_Exception
+{
+ // This space intentionally left blank.
+}
diff --git a/Mustache/Exception/SyntaxException.php b/Mustache/Exception/SyntaxException.php
new file mode 100644
index 00000000..b1879a3d
--- /dev/null
+++ b/Mustache/Exception/SyntaxException.php
@@ -0,0 +1,41 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Mustache syntax exception.
+ */
+class Mustache_Exception_SyntaxException extends LogicException implements Mustache_Exception
+{
+ protected $token;
+
+ /**
+ * @param string $msg
+ * @param array $token
+ * @param Exception $previous
+ */
+ public function __construct($msg, array $token, Exception $previous = null)
+ {
+ $this->token = $token;
+ if (version_compare(PHP_VERSION, '5.3.0', '>=')) {
+ parent::__construct($msg, 0, $previous);
+ } else {
+ parent::__construct($msg); // @codeCoverageIgnore
+ }
+ }
+
+ /**
+ * @return array
+ */
+ public function getToken()
+ {
+ return $this->token;
+ }
+}
diff --git a/Mustache/Exception/UnknownFilterException.php b/Mustache/Exception/UnknownFilterException.php
new file mode 100644
index 00000000..0651c173
--- /dev/null
+++ b/Mustache/Exception/UnknownFilterException.php
@@ -0,0 +1,38 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Unknown filter exception.
+ */
+class Mustache_Exception_UnknownFilterException extends UnexpectedValueException implements Mustache_Exception
+{
+ protected $filterName;
+
+ /**
+ * @param string $filterName
+ * @param Exception $previous
+ */
+ public function __construct($filterName, Exception $previous = null)
+ {
+ $this->filterName = $filterName;
+ $message = sprintf('Unknown filter: %s', $filterName);
+ if (version_compare(PHP_VERSION, '5.3.0', '>=')) {
+ parent::__construct($message, 0, $previous);
+ } else {
+ parent::__construct($message); // @codeCoverageIgnore
+ }
+ }
+
+ public function getFilterName()
+ {
+ return $this->filterName;
+ }
+}
diff --git a/Mustache/Exception/UnknownHelperException.php b/Mustache/Exception/UnknownHelperException.php
new file mode 100644
index 00000000..193be782
--- /dev/null
+++ b/Mustache/Exception/UnknownHelperException.php
@@ -0,0 +1,38 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Unknown helper exception.
+ */
+class Mustache_Exception_UnknownHelperException extends InvalidArgumentException implements Mustache_Exception
+{
+ protected $helperName;
+
+ /**
+ * @param string $helperName
+ * @param Exception $previous
+ */
+ public function __construct($helperName, Exception $previous = null)
+ {
+ $this->helperName = $helperName;
+ $message = sprintf('Unknown helper: %s', $helperName);
+ if (version_compare(PHP_VERSION, '5.3.0', '>=')) {
+ parent::__construct($message, 0, $previous);
+ } else {
+ parent::__construct($message); // @codeCoverageIgnore
+ }
+ }
+
+ public function getHelperName()
+ {
+ return $this->helperName;
+ }
+}
diff --git a/Mustache/Exception/UnknownTemplateException.php b/Mustache/Exception/UnknownTemplateException.php
new file mode 100644
index 00000000..32a778a5
--- /dev/null
+++ b/Mustache/Exception/UnknownTemplateException.php
@@ -0,0 +1,38 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Unknown template exception.
+ */
+class Mustache_Exception_UnknownTemplateException extends InvalidArgumentException implements Mustache_Exception
+{
+ protected $templateName;
+
+ /**
+ * @param string $templateName
+ * @param Exception $previous
+ */
+ public function __construct($templateName, Exception $previous = null)
+ {
+ $this->templateName = $templateName;
+ $message = sprintf('Unknown template: %s', $templateName);
+ if (version_compare(PHP_VERSION, '5.3.0', '>=')) {
+ parent::__construct($message, 0, $previous);
+ } else {
+ parent::__construct($message); // @codeCoverageIgnore
+ }
+ }
+
+ public function getTemplateName()
+ {
+ return $this->templateName;
+ }
+}
diff --git a/Mustache/HelperCollection.php b/Mustache/HelperCollection.php
index 92bcde4a..5d8f73c1 100644
--- a/Mustache/HelperCollection.php
+++ b/Mustache/HelperCollection.php
@@ -1,168 +1,172 @@
-<?php
-
-/*
- * This file is part of Mustache.php.
- *
- * (c) 2012 Justin Hileman
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-/**
- * A collection of helpers for a Mustache instance.
- */
-class Mustache_HelperCollection
-{
- private $helpers = array();
-
- /**
- * Helper Collection constructor.
- *
- * Optionally accepts an array (or Traversable) of `$name => $helper` pairs.
- *
- * @throws InvalidArgumentException if the $helpers argument isn't an array or Traversable
- *
- * @param array|Traversable $helpers (default: null)
- */
- public function __construct($helpers = null)
- {
- if ($helpers !== null) {
- if (!is_array($helpers) && !$helpers instanceof Traversable) {
- throw new InvalidArgumentException('HelperCollection constructor expects an array of helpers');
- }
-
- foreach ($helpers as $name => $helper) {
- $this->add($name, $helper);
- }
- }
- }
-
- /**
- * Magic mutator.
- *
- * @see Mustache_HelperCollection::add
- *
- * @param string $name
- * @param mixed $helper
- */
- public function __set($name, $helper)
- {
- $this->add($name, $helper);
- }
-
- /**
- * Add a helper to this collection.
- *
- * @param string $name
- * @param mixed $helper
- */
- public function add($name, $helper)
- {
- $this->helpers[$name] = $helper;
- }
-
- /**
- * Magic accessor.
- *
- * @see Mustache_HelperCollection::get
- *
- * @param string $name
- *
- * @return mixed Helper
- */
- public function __get($name)
- {
- return $this->get($name);
- }
-
- /**
- * Get a helper by name.
- *
- * @param string $name
- *
- * @return mixed Helper
- */
- public function get($name)
- {
- if (!$this->has($name)) {
- throw new InvalidArgumentException('Unknown helper: '.$name);
- }
-
- return $this->helpers[$name];
- }
-
- /**
- * Magic isset().
- *
- * @see Mustache_HelperCollection::has
- *
- * @param string $name
- *
- * @return boolean True if helper is present
- */
- public function __isset($name)
- {
- return $this->has($name);
- }
-
- /**
- * Check whether a given helper is present in the collection.
- *
- * @param string $name
- *
- * @return boolean True if helper is present
- */
- public function has($name)
- {
- return array_key_exists($name, $this->helpers);
- }
-
- /**
- * Magic unset().
- *
- * @see Mustache_HelperCollection::remove
- *
- * @param string $name
- */
- public function __unset($name)
- {
- $this->remove($name);
- }
-
- /**
- * Check whether a given helper is present in the collection.
- *
- * @throws InvalidArgumentException if the requested helper is not present.
- *
- * @param string $name
- */
- public function remove($name)
- {
- if (!$this->has($name)) {
- throw new InvalidArgumentException('Unknown helper: '.$name);
- }
-
- unset($this->helpers[$name]);
- }
-
- /**
- * Clear the helper collection.
- *
- * Removes all helpers from this collection
- */
- public function clear()
- {
- $this->helpers = array();
- }
-
- /**
- * Check whether the helper collection is empty.
- *
- * @return boolean True if the collection is empty
- */
- public function isEmpty()
- {
- return empty($this->helpers);
- }
-}
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * A collection of helpers for a Mustache instance.
+ */
+class Mustache_HelperCollection
+{
+ private $helpers = array();
+
+ /**
+ * Helper Collection constructor.
+ *
+ * Optionally accepts an array (or Traversable) of `$name => $helper` pairs.
+ *
+ * @throws Mustache_Exception_InvalidArgumentException if the $helpers argument isn't an array or Traversable
+ *
+ * @param array|Traversable $helpers (default: null)
+ */
+ public function __construct($helpers = null)
+ {
+ if ($helpers === null) {
+ return;
+ }
+
+ if (!is_array($helpers) && !$helpers instanceof Traversable) {
+ throw new Mustache_Exception_InvalidArgumentException('HelperCollection constructor expects an array of helpers');
+ }
+
+ foreach ($helpers as $name => $helper) {
+ $this->add($name, $helper);
+ }
+ }
+
+ /**
+ * Magic mutator.
+ *
+ * @see Mustache_HelperCollection::add
+ *
+ * @param string $name
+ * @param mixed $helper
+ */
+ public function __set($name, $helper)
+ {
+ $this->add($name, $helper);
+ }
+
+ /**
+ * Add a helper to this collection.
+ *
+ * @param string $name
+ * @param mixed $helper
+ */
+ public function add($name, $helper)
+ {
+ $this->helpers[$name] = $helper;
+ }
+
+ /**
+ * Magic accessor.
+ *
+ * @see Mustache_HelperCollection::get
+ *
+ * @param string $name
+ *
+ * @return mixed Helper
+ */
+ public function __get($name)
+ {
+ return $this->get($name);
+ }
+
+ /**
+ * Get a helper by name.
+ *
+ * @throws Mustache_Exception_UnknownHelperException If helper does not exist
+ *
+ * @param string $name
+ *
+ * @return mixed Helper
+ */
+ public function get($name)
+ {
+ if (!$this->has($name)) {
+ throw new Mustache_Exception_UnknownHelperException($name);
+ }
+
+ return $this->helpers[$name];
+ }
+
+ /**
+ * Magic isset().
+ *
+ * @see Mustache_HelperCollection::has
+ *
+ * @param string $name
+ *
+ * @return bool True if helper is present
+ */
+ public function __isset($name)
+ {
+ return $this->has($name);
+ }
+
+ /**
+ * Check whether a given helper is present in the collection.
+ *
+ * @param string $name
+ *
+ * @return bool True if helper is present
+ */
+ public function has($name)
+ {
+ return array_key_exists($name, $this->helpers);
+ }
+
+ /**
+ * Magic unset().
+ *
+ * @see Mustache_HelperCollection::remove
+ *
+ * @param string $name
+ */
+ public function __unset($name)
+ {
+ $this->remove($name);
+ }
+
+ /**
+ * Check whether a given helper is present in the collection.
+ *
+ * @throws Mustache_Exception_UnknownHelperException if the requested helper is not present
+ *
+ * @param string $name
+ */
+ public function remove($name)
+ {
+ if (!$this->has($name)) {
+ throw new Mustache_Exception_UnknownHelperException($name);
+ }
+
+ unset($this->helpers[$name]);
+ }
+
+ /**
+ * Clear the helper collection.
+ *
+ * Removes all helpers from this collection
+ */
+ public function clear()
+ {
+ $this->helpers = array();
+ }
+
+ /**
+ * Check whether the helper collection is empty.
+ *
+ * @return bool True if the collection is empty
+ */
+ public function isEmpty()
+ {
+ return empty($this->helpers);
+ }
+}
diff --git a/Mustache/LICENSE b/Mustache/LICENSE
index 63c95ace..e0aecc94 100644
--- a/Mustache/LICENSE
+++ b/Mustache/LICENSE
@@ -1,22 +1,21 @@
-Copyright (c) 2010 Justin Hileman
+The MIT License (MIT)
-Permission is hereby granted, free of charge, to any person
-obtaining a copy of this software and associated documentation
-files (the "Software"), to deal in the Software without
-restriction, including without limitation the rights to use,
-copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the
-Software is furnished to do so, subject to the following
-conditions:
+Copyright (c) 2010-2015 Justin Hileman
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
-OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
-HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
-OTHER DEALINGS IN THE SOFTWARE.
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
+OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/Mustache/LambdaHelper.php b/Mustache/LambdaHelper.php
new file mode 100644
index 00000000..e93dbfa3
--- /dev/null
+++ b/Mustache/LambdaHelper.php
@@ -0,0 +1,76 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Mustache Lambda Helper.
+ *
+ * Passed as the second argument to section lambdas (higher order sections),
+ * giving them access to a `render` method for rendering a string with the
+ * current context.
+ */
+class Mustache_LambdaHelper
+{
+ private $mustache;
+ private $context;
+ private $delims;
+
+ /**
+ * Mustache Lambda Helper constructor.
+ *
+ * @param Mustache_Engine $mustache Mustache engine instance
+ * @param Mustache_Context $context Rendering context
+ * @param string $delims Optional custom delimiters, in the format `{{= <% %> =}}`. (default: null)
+ */
+ public function __construct(Mustache_Engine $mustache, Mustache_Context $context, $delims = null)
+ {
+ $this->mustache = $mustache;
+ $this->context = $context;
+ $this->delims = $delims;
+ }
+
+ /**
+ * Render a string as a Mustache template with the current rendering context.
+ *
+ * @param string $string
+ *
+ * @return string Rendered template
+ */
+ public function render($string)
+ {
+ return $this->mustache
+ ->loadLambda((string) $string, $this->delims)
+ ->renderInternal($this->context);
+ }
+
+ /**
+ * Render a string as a Mustache template with the current rendering context.
+ *
+ * @param string $string
+ *
+ * @return string Rendered template
+ */
+ public function __invoke($string)
+ {
+ return $this->render($string);
+ }
+
+ /**
+ * Get a Lambda Helper with custom delimiters.
+ *
+ * @param string $delims Custom delimiters, in the format `{{= <% %> =}}`
+ *
+ * @return Mustache_LambdaHelper
+ */
+ public function withDelimiters($delims)
+ {
+ return new self($this->mustache, $this->context, $delims);
+ }
+}
diff --git a/Mustache/Loader.php b/Mustache/Loader.php
index f082cf59..23adba1a 100644
--- a/Mustache/Loader.php
+++ b/Mustache/Loader.php
@@ -1,26 +1,27 @@
-<?php
-
-/*
- * This file is part of Mustache.php.
- *
- * (c) 2012 Justin Hileman
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-/**
- * Mustache Template Loader interface.
- */
-interface Mustache_Loader
-{
-
- /**
- * Load a Template by name.
- *
- * @param string $name
- *
- * @return string Mustache Template source
- */
- public function load($name);
-}
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Mustache Template Loader interface.
+ */
+interface Mustache_Loader
+{
+ /**
+ * Load a Template by name.
+ *
+ * @throws Mustache_Exception_UnknownTemplateException If a template file is not found
+ *
+ * @param string $name
+ *
+ * @return string|Mustache_Source Mustache Template source
+ */
+ public function load($name);
+}
diff --git a/Mustache/Loader/ArrayLoader.php b/Mustache/Loader/ArrayLoader.php
index 0a9ceefb..4276493a 100644
--- a/Mustache/Loader/ArrayLoader.php
+++ b/Mustache/Loader/ArrayLoader.php
@@ -1,79 +1,79 @@
-<?php
-
-/*
- * This file is part of Mustache.php.
- *
- * (c) 2012 Justin Hileman
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-/**
- * Mustache Template array Loader implementation.
- *
- * An ArrayLoader instance loads Mustache Template source by name from an initial array:
- *
- * $loader = new ArrayLoader(
- * 'foo' => '{{ bar }}',
- * 'baz' => 'Hey {{ qux }}!'
- * );
- *
- * $tpl = $loader->load('foo'); // '{{ bar }}'
- *
- * The ArrayLoader is used internally as a partials loader by Mustache_Engine instance when an array of partials
- * is set. It can also be used as a quick-and-dirty Template loader.
- *
- * @implements Loader
- * @implements MutableLoader
- */
-class Mustache_Loader_ArrayLoader implements Mustache_Loader, Mustache_Loader_MutableLoader
-{
-
- /**
- * ArrayLoader constructor.
- *
- * @param array $templates Associative array of Template source (default: array())
- */
- public function __construct(array $templates = array())
- {
- $this->templates = $templates;
- }
-
- /**
- * Load a Template.
- *
- * @param string $name
- *
- * @return string Mustache Template source
- */
- public function load($name)
- {
- if (!isset($this->templates[$name])) {
- throw new InvalidArgumentException('Template '.$name.' not found.');
- }
-
- return $this->templates[$name];
- }
-
- /**
- * Set an associative array of Template sources for this loader.
- *
- * @param array $templates
- */
- public function setTemplates(array $templates)
- {
- $this->templates = $templates;
- }
-
- /**
- * Set a Template source by name.
- *
- * @param string $name
- * @param string $template Mustache Template source
- */
- public function setTemplate($name, $template)
- {
- $this->templates[$name] = $template;
- }
-}
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Mustache Template array Loader implementation.
+ *
+ * An ArrayLoader instance loads Mustache Template source by name from an initial array:
+ *
+ * $loader = new ArrayLoader(
+ * 'foo' => '{{ bar }}',
+ * 'baz' => 'Hey {{ qux }}!'
+ * );
+ *
+ * $tpl = $loader->load('foo'); // '{{ bar }}'
+ *
+ * The ArrayLoader is used internally as a partials loader by Mustache_Engine instance when an array of partials
+ * is set. It can also be used as a quick-and-dirty Template loader.
+ */
+class Mustache_Loader_ArrayLoader implements Mustache_Loader, Mustache_Loader_MutableLoader
+{
+ private $templates;
+
+ /**
+ * ArrayLoader constructor.
+ *
+ * @param array $templates Associative array of Template source (default: array())
+ */
+ public function __construct(array $templates = array())
+ {
+ $this->templates = $templates;
+ }
+
+ /**
+ * Load a Template.
+ *
+ * @throws Mustache_Exception_UnknownTemplateException If a template file is not found
+ *
+ * @param string $name
+ *
+ * @return string Mustache Template source
+ */
+ public function load($name)
+ {
+ if (!isset($this->templates[$name])) {
+ throw new Mustache_Exception_UnknownTemplateException($name);
+ }
+
+ return $this->templates[$name];
+ }
+
+ /**
+ * Set an associative array of Template sources for this loader.
+ *
+ * @param array $templates
+ */
+ public function setTemplates(array $templates)
+ {
+ $this->templates = $templates;
+ }
+
+ /**
+ * Set a Template source by name.
+ *
+ * @param string $name
+ * @param string $template Mustache Template source
+ */
+ public function setTemplate($name, $template)
+ {
+ $this->templates[$name] = $template;
+ }
+}
diff --git a/Mustache/Loader/CascadingLoader.php b/Mustache/Loader/CascadingLoader.php
new file mode 100644
index 00000000..3fb6353c
--- /dev/null
+++ b/Mustache/Loader/CascadingLoader.php
@@ -0,0 +1,69 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * A Mustache Template cascading loader implementation, which delegates to other
+ * Loader instances.
+ */
+class Mustache_Loader_CascadingLoader implements Mustache_Loader
+{
+ private $loaders;
+
+ /**
+ * Construct a CascadingLoader with an array of loaders.
+ *
+ * $loader = new Mustache_Loader_CascadingLoader(array(
+ * new Mustache_Loader_InlineLoader(__FILE__, __COMPILER_HALT_OFFSET__),
+ * new Mustache_Loader_FilesystemLoader(__DIR__.'/templates')
+ * ));
+ *
+ * @param Mustache_Loader[] $loaders
+ */
+ public function __construct(array $loaders = array())
+ {
+ $this->loaders = array();
+ foreach ($loaders as $loader) {
+ $this->addLoader($loader);
+ }
+ }
+
+ /**
+ * Add a Loader instance.
+ *
+ * @param Mustache_Loader $loader
+ */
+ public function addLoader(Mustache_Loader $loader)
+ {
+ $this->loaders[] = $loader;
+ }
+
+ /**
+ * Load a Template by name.
+ *
+ * @throws Mustache_Exception_UnknownTemplateException If a template file is not found
+ *
+ * @param string $name
+ *
+ * @return string Mustache Template source
+ */
+ public function load($name)
+ {
+ foreach ($this->loaders as $loader) {
+ try {
+ return $loader->load($name);
+ } catch (Mustache_Exception_UnknownTemplateException $e) {
+ // do nothing, check the next loader.
+ }
+ }
+
+ throw new Mustache_Exception_UnknownTemplateException($name);
+ }
+}
diff --git a/Mustache/Loader/FilesystemLoader.php b/Mustache/Loader/FilesystemLoader.php
index 34a9ecfd..e366df70 100644
--- a/Mustache/Loader/FilesystemLoader.php
+++ b/Mustache/Loader/FilesystemLoader.php
@@ -1,118 +1,135 @@
-<?php
-
-/*
- * This file is part of Mustache.php.
- *
- * (c) 2012 Justin Hileman
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-/**
- * Mustache Template filesystem Loader implementation.
- *
- * An ArrayLoader instance loads Mustache Template source from the filesystem by name:
- *
- * $loader = new FilesystemLoader(dirname(__FILE__).'/views');
- * $tpl = $loader->load('foo'); // equivalent to `file_get_contents(dirname(__FILE__).'/views/foo.mustache');
- *
- * This is probably the most useful Mustache Loader implementation. It can be used for partials and normal Templates:
- *
- * $m = new Mustache(array(
- * 'loader' => new FilesystemLoader(dirname(__FILE__).'/views'),
- * 'partials_loader' => new FilesystemLoader(dirname(__FILE__).'/views/partials'),
- * ));
- *
- * @implements Loader
- */
-class Mustache_Loader_FilesystemLoader implements Mustache_Loader
-{
- private $baseDir;
- private $extension = '.mustache';
- private $templates = array();
-
- /**
- * Mustache filesystem Loader constructor.
- *
- * Passing an $options array allows overriding certain Loader options during instantiation:
- *
- * $options = array(
- * // The filename extension used for Mustache templates. Defaults to '.mustache'
- * 'extension' => '.ms',
- * );
- *
- * @throws RuntimeException if $baseDir does not exist.
- *
- * @param string $baseDir Base directory containing Mustache template files.
- * @param array $options Array of Loader options (default: array())
- */
- public function __construct($baseDir, array $options = array())
- {
- $this->baseDir = rtrim(realpath($baseDir), '/');
-
- if (!is_dir($this->baseDir)) {
- throw new RuntimeException('FilesystemLoader baseDir must be a directory: '.$baseDir);
- }
-
- if (isset($options['extension'])) {
- $this->extension = '.' . ltrim($options['extension'], '.');
- }
- }
-
- /**
- * Load a Template by name.
- *
- * $loader = new FilesystemLoader(dirname(__FILE__).'/views');
- * $loader->load('admin/dashboard'); // loads "./views/admin/dashboard.mustache";
- *
- * @param string $name
- *
- * @return string Mustache Template source
- */
- public function load($name)
- {
- if (!isset($this->templates[$name])) {
- $this->templates[$name] = $this->loadFile($name);
- }
-
- return $this->templates[$name];
- }
-
- /**
- * Helper function for loading a Mustache file by name.
- *
- * @throws InvalidArgumentException if a template file is not found.
- *
- * @param string $name
- *
- * @return string Mustache Template source
- */
- protected function loadFile($name)
- {
- $fileName = $this->getFileName($name);
-
- if (!file_exists($fileName)) {
- throw new InvalidArgumentException('Template '.$name.' not found.');
- }
-
- return file_get_contents($fileName);
- }
-
- /**
- * Helper function for getting a Mustache template file name.
- *
- * @param string $name
- *
- * @return string Template file name
- */
- protected function getFileName($name)
- {
- $fileName = $this->baseDir . '/' . $name;
- if (substr($fileName, 0 - strlen($this->extension)) !== $this->extension) {
- $fileName .= $this->extension;
- }
-
- return $fileName;
- }
-}
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Mustache Template filesystem Loader implementation.
+ *
+ * A FilesystemLoader instance loads Mustache Template source from the filesystem by name:
+ *
+ * $loader = new Mustache_Loader_FilesystemLoader(dirname(__FILE__).'/views');
+ * $tpl = $loader->load('foo'); // equivalent to `file_get_contents(dirname(__FILE__).'/views/foo.mustache');
+ *
+ * This is probably the most useful Mustache Loader implementation. It can be used for partials and normal Templates:
+ *
+ * $m = new Mustache(array(
+ * 'loader' => new Mustache_Loader_FilesystemLoader(dirname(__FILE__).'/views'),
+ * 'partials_loader' => new Mustache_Loader_FilesystemLoader(dirname(__FILE__).'/views/partials'),
+ * ));
+ */
+class Mustache_Loader_FilesystemLoader implements Mustache_Loader
+{
+ private $baseDir;
+ private $extension = '.mustache';
+ private $templates = array();
+
+ /**
+ * Mustache filesystem Loader constructor.
+ *
+ * Passing an $options array allows overriding certain Loader options during instantiation:
+ *
+ * $options = array(
+ * // The filename extension used for Mustache templates. Defaults to '.mustache'
+ * 'extension' => '.ms',
+ * );
+ *
+ * @throws Mustache_Exception_RuntimeException if $baseDir does not exist
+ *
+ * @param string $baseDir Base directory containing Mustache template files
+ * @param array $options Array of Loader options (default: array())
+ */
+ public function __construct($baseDir, array $options = array())
+ {
+ $this->baseDir = $baseDir;
+
+ if (strpos($this->baseDir, '://') === false) {
+ $this->baseDir = realpath($this->baseDir);
+ }
+
+ if ($this->shouldCheckPath() && !is_dir($this->baseDir)) {
+ throw new Mustache_Exception_RuntimeException(sprintf('FilesystemLoader baseDir must be a directory: %s', $baseDir));
+ }
+
+ if (array_key_exists('extension', $options)) {
+ if (empty($options['extension'])) {
+ $this->extension = '';
+ } else {
+ $this->extension = '.' . ltrim($options['extension'], '.');
+ }
+ }
+ }
+
+ /**
+ * Load a Template by name.
+ *
+ * $loader = new Mustache_Loader_FilesystemLoader(dirname(__FILE__).'/views');
+ * $loader->load('admin/dashboard'); // loads "./views/admin/dashboard.mustache";
+ *
+ * @param string $name
+ *
+ * @return string Mustache Template source
+ */
+ public function load($name)
+ {
+ if (!isset($this->templates[$name])) {
+ $this->templates[$name] = $this->loadFile($name);
+ }
+
+ return $this->templates[$name];
+ }
+
+ /**
+ * Helper function for loading a Mustache file by name.
+ *
+ * @throws Mustache_Exception_UnknownTemplateException If a template file is not found
+ *
+ * @param string $name
+ *
+ * @return string Mustache Template source
+ */
+ protected function loadFile($name)
+ {
+ $fileName = $this->getFileName($name);
+
+ if ($this->shouldCheckPath() && !file_exists($fileName)) {
+ throw new Mustache_Exception_UnknownTemplateException($name);
+ }
+
+ return file_get_contents($fileName);
+ }
+
+ /**
+ * Helper function for getting a Mustache template file name.
+ *
+ * @param string $name
+ *
+ * @return string Template file name
+ */
+ protected function getFileName($name)
+ {
+ $fileName = $this->baseDir . '/' . $name;
+ if (substr($fileName, 0 - strlen($this->extension)) !== $this->extension) {
+ $fileName .= $this->extension;
+ }
+
+ return $fileName;
+ }
+
+ /**
+ * Only check if baseDir is a directory and requested templates are files if
+ * baseDir is using the filesystem stream wrapper.
+ *
+ * @return bool Whether to check `is_dir` and `file_exists`
+ */
+ protected function shouldCheckPath()
+ {
+ return strpos($this->baseDir, '://') === false || strpos($this->baseDir, 'file://') === 0;
+ }
+}
diff --git a/Mustache/Loader/InlineLoader.php b/Mustache/Loader/InlineLoader.php
new file mode 100644
index 00000000..ae297fec
--- /dev/null
+++ b/Mustache/Loader/InlineLoader.php
@@ -0,0 +1,123 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * A Mustache Template loader for inline templates.
+ *
+ * With the InlineLoader, templates can be defined at the end of any PHP source
+ * file:
+ *
+ * $loader = new Mustache_Loader_InlineLoader(__FILE__, __COMPILER_HALT_OFFSET__);
+ * $hello = $loader->load('hello');
+ * $goodbye = $loader->load('goodbye');
+ *
+ * __halt_compiler();
+ *
+ * @@ hello
+ * Hello, {{ planet }}!
+ *
+ * @@ goodbye
+ * Goodbye, cruel {{ planet }}
+ *
+ * Templates are deliniated by lines containing only `@@ name`.
+ *
+ * The InlineLoader is well-suited to micro-frameworks such as Silex:
+ *
+ * $app->register(new MustacheServiceProvider, array(
+ * 'mustache.loader' => new Mustache_Loader_InlineLoader(__FILE__, __COMPILER_HALT_OFFSET__)
+ * ));
+ *
+ * $app->get('/{name}', function ($name) use ($app) {
+ * return $app['mustache']->render('hello', compact('name'));
+ * })
+ * ->value('name', 'world');
+ *
+ * // ...
+ *
+ * __halt_compiler();
+ *
+ * @@ hello
+ * Hello, {{ name }}!
+ */
+class Mustache_Loader_InlineLoader implements Mustache_Loader
+{
+ protected $fileName;
+ protected $offset;
+ protected $templates;
+
+ /**
+ * The InlineLoader requires a filename and offset to process templates.
+ *
+ * The magic constants `__FILE__` and `__COMPILER_HALT_OFFSET__` are usually
+ * perfectly suited to the job:
+ *
+ * $loader = new Mustache_Loader_InlineLoader(__FILE__, __COMPILER_HALT_OFFSET__);
+ *
+ * Note that this only works if the loader is instantiated inside the same
+ * file as the inline templates. If the templates are located in another
+ * file, it would be necessary to manually specify the filename and offset.
+ *
+ * @param string $fileName The file to parse for inline templates
+ * @param int $offset A string offset for the start of the templates.
+ * This usually coincides with the `__halt_compiler`
+ * call, and the `__COMPILER_HALT_OFFSET__`
+ */
+ public function __construct($fileName, $offset)
+ {
+ if (!is_file($fileName)) {
+ throw new Mustache_Exception_InvalidArgumentException('InlineLoader expects a valid filename.');
+ }
+
+ if (!is_int($offset) || $offset < 0) {
+ throw new Mustache_Exception_InvalidArgumentException('InlineLoader expects a valid file offset.');
+ }
+
+ $this->fileName = $fileName;
+ $this->offset = $offset;
+ }
+
+ /**
+ * Load a Template by name.
+ *
+ * @throws Mustache_Exception_UnknownTemplateException If a template file is not found
+ *
+ * @param string $name
+ *
+ * @return string Mustache Template source
+ */
+ public function load($name)
+ {
+ $this->loadTemplates();
+
+ if (!array_key_exists($name, $this->templates)) {
+ throw new Mustache_Exception_UnknownTemplateException($name);
+ }
+
+ return $this->templates[$name];
+ }
+
+ /**
+ * Parse and load templates from the end of a source file.
+ */
+ protected function loadTemplates()
+ {
+ if ($this->templates === null) {
+ $this->templates = array();
+ $data = file_get_contents($this->fileName, false, null, $this->offset);
+ foreach (preg_split("/^@@(?= [\w\d\.]+$)/m", $data, -1) as $chunk) {
+ if (trim($chunk)) {
+ list($name, $content) = explode("\n", $chunk, 2);
+ $this->templates[trim($name)] = trim($content);
+ }
+ }
+ }
+ }
+}
diff --git a/Mustache/Loader/MutableLoader.php b/Mustache/Loader/MutableLoader.php
index 952db2f0..57fe5be3 100644
--- a/Mustache/Loader/MutableLoader.php
+++ b/Mustache/Loader/MutableLoader.php
@@ -1,32 +1,31 @@
-<?php
-
-/*
- * This file is part of Mustache.php.
- *
- * (c) 2012 Justin Hileman
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-/**
- * Mustache Template mutable Loader interface.
- */
-interface Mustache_Loader_MutableLoader
-{
-
- /**
- * Set an associative array of Template sources for this loader.
- *
- * @param array $templates
- */
- public function setTemplates(array $templates);
-
- /**
- * Set a Template source by name.
- *
- * @param string $name
- * @param string $template Mustache Template source
- */
- public function setTemplate($name, $template);
-}
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Mustache Template mutable Loader interface.
+ */
+interface Mustache_Loader_MutableLoader
+{
+ /**
+ * Set an associative array of Template sources for this loader.
+ *
+ * @param array $templates
+ */
+ public function setTemplates(array $templates);
+
+ /**
+ * Set a Template source by name.
+ *
+ * @param string $name
+ * @param string $template Mustache Template source
+ */
+ public function setTemplate($name, $template);
+}
diff --git a/Mustache/Loader/ProductionFilesystemLoader.php b/Mustache/Loader/ProductionFilesystemLoader.php
new file mode 100644
index 00000000..e7353327
--- /dev/null
+++ b/Mustache/Loader/ProductionFilesystemLoader.php
@@ -0,0 +1,86 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Mustache Template production filesystem Loader implementation.
+ *
+ * A production-ready FilesystemLoader, which doesn't require reading a file if it already exists in the template cache.
+ *
+ * {@inheritdoc}
+ */
+class Mustache_Loader_ProductionFilesystemLoader extends Mustache_Loader_FilesystemLoader
+{
+ private $statProps;
+
+ /**
+ * Mustache production filesystem Loader constructor.
+ *
+ * Passing an $options array allows overriding certain Loader options during instantiation:
+ *
+ * $options = array(
+ * // The filename extension used for Mustache templates. Defaults to '.mustache'
+ * 'extension' => '.ms',
+ * 'stat_props' => array('size', 'mtime'),
+ * );
+ *
+ * Specifying 'stat_props' overrides the stat properties used to invalidate the template cache. By default, this
+ * uses 'mtime' and 'size', but this can be set to any of the properties supported by stat():
+ *
+ * http://php.net/manual/en/function.stat.php
+ *
+ * You can also disable filesystem stat entirely:
+ *
+ * $options = array('stat_props' => null);
+ *
+ * But with great power comes great responsibility. Namely, if you disable stat-based cache invalidation,
+ * YOU MUST CLEAR THE TEMPLATE CACHE YOURSELF when your templates change. Make it part of your build or deploy
+ * process so you don't forget!
+ *
+ * @throws Mustache_Exception_RuntimeException if $baseDir does not exist.
+ *
+ * @param string $baseDir Base directory containing Mustache template files.
+ * @param array $options Array of Loader options (default: array())
+ */
+ public function __construct($baseDir, array $options = array())
+ {
+ parent::__construct($baseDir, $options);
+
+ if (array_key_exists('stat_props', $options)) {
+ if (empty($options['stat_props'])) {
+ $this->statProps = array();
+ } else {
+ $this->statProps = $options['stat_props'];
+ }
+ } else {
+ $this->statProps = array('size', 'mtime');
+ }
+ }
+
+ /**
+ * Helper function for loading a Mustache file by name.
+ *
+ * @throws Mustache_Exception_UnknownTemplateException If a template file is not found.
+ *
+ * @param string $name
+ *
+ * @return Mustache_Source Mustache Template source
+ */
+ protected function loadFile($name)
+ {
+ $fileName = $this->getFileName($name);
+
+ if (!file_exists($fileName)) {
+ throw new Mustache_Exception_UnknownTemplateException($name);
+ }
+
+ return new Mustache_Source_FilesystemSource($fileName, $this->statProps);
+ }
+}
diff --git a/Mustache/Loader/StringLoader.php b/Mustache/Loader/StringLoader.php
index 8f18062f..7012c03b 100644
--- a/Mustache/Loader/StringLoader.php
+++ b/Mustache/Loader/StringLoader.php
@@ -1,42 +1,39 @@
-<?php
-
-/*
- * This file is part of Mustache.php.
- *
- * (c) 2012 Justin Hileman
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-/**
- * Mustache Template string Loader implementation.
- *
- * A StringLoader instance is essentially a noop. It simply passes the 'name' argument straight through:
- *
- * $loader = new StringLoader;
- * $tpl = $loader->load('{{ foo }}'); // '{{ foo }}'
- *
- * This is the default Template Loader instance used by Mustache:
- *
- * $m = new Mustache;
- * $tpl = $m->loadTemplate('{{ foo }}');
- * echo $tpl->render(array('foo' => 'bar')); // "bar"
- *
- * @implements Loader
- */
-class Mustache_Loader_StringLoader implements Mustache_Loader
-{
-
- /**
- * Load a Template by source.
- *
- * @param string $name Mustache Template source
- *
- * @return string Mustache Template source
- */
- public function load($name)
- {
- return $name;
- }
-}
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Mustache Template string Loader implementation.
+ *
+ * A StringLoader instance is essentially a noop. It simply passes the 'name' argument straight through:
+ *
+ * $loader = new StringLoader;
+ * $tpl = $loader->load('{{ foo }}'); // '{{ foo }}'
+ *
+ * This is the default Template Loader instance used by Mustache:
+ *
+ * $m = new Mustache;
+ * $tpl = $m->loadTemplate('{{ foo }}');
+ * echo $tpl->render(array('foo' => 'bar')); // "bar"
+ */
+class Mustache_Loader_StringLoader implements Mustache_Loader
+{
+ /**
+ * Load a Template by source.
+ *
+ * @param string $name Mustache Template source
+ *
+ * @return string Mustache Template source
+ */
+ public function load($name)
+ {
+ return $name;
+ }
+}
diff --git a/Mustache/Logger.php b/Mustache/Logger.php
new file mode 100644
index 00000000..cb4037a2
--- /dev/null
+++ b/Mustache/Logger.php
@@ -0,0 +1,126 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Describes a Mustache logger instance.
+ *
+ * This is identical to the Psr\Log\LoggerInterface.
+ *
+ * The message MUST be a string or object implementing __toString().
+ *
+ * The message MAY contain placeholders in the form: {foo} where foo
+ * will be replaced by the context data in key "foo".
+ *
+ * The context array can contain arbitrary data, the only assumption that
+ * can be made by implementors is that if an Exception instance is given
+ * to produce a stack trace, it MUST be in a key named "exception".
+ *
+ * See https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md
+ * for the full interface specification.
+ */
+interface Mustache_Logger
+{
+ /**
+ * Psr\Log compatible log levels.
+ */
+ const EMERGENCY = 'emergency';
+ const ALERT = 'alert';
+ const CRITICAL = 'critical';
+ const ERROR = 'error';
+ const WARNING = 'warning';
+ const NOTICE = 'notice';
+ const INFO = 'info';
+ const DEBUG = 'debug';
+
+ /**
+ * System is unusable.
+ *
+ * @param string $message
+ * @param array $context
+ */
+ public function emergency($message, array $context = array());
+
+ /**
+ * Action must be taken immediately.
+ *
+ * Example: Entire website down, database unavailable, etc. This should
+ * trigger the SMS alerts and wake you up.
+ *
+ * @param string $message
+ * @param array $context
+ */
+ public function alert($message, array $context = array());
+
+ /**
+ * Critical conditions.
+ *
+ * Example: Application component unavailable, unexpected exception.
+ *
+ * @param string $message
+ * @param array $context
+ */
+ public function critical($message, array $context = array());
+
+ /**
+ * Runtime errors that do not require immediate action but should typically
+ * be logged and monitored.
+ *
+ * @param string $message
+ * @param array $context
+ */
+ public function error($message, array $context = array());
+
+ /**
+ * Exceptional occurrences that are not errors.
+ *
+ * Example: Use of deprecated APIs, poor use of an API, undesirable things
+ * that are not necessarily wrong.
+ *
+ * @param string $message
+ * @param array $context
+ */
+ public function warning($message, array $context = array());
+
+ /**
+ * Normal but significant events.
+ *
+ * @param string $message
+ * @param array $context
+ */
+ public function notice($message, array $context = array());
+
+ /**
+ * Interesting events.
+ *
+ * Example: User logs in, SQL logs.
+ *
+ * @param string $message
+ * @param array $context
+ */
+ public function info($message, array $context = array());
+
+ /**
+ * Detailed debug information.
+ *
+ * @param string $message
+ * @param array $context
+ */
+ public function debug($message, array $context = array());
+
+ /**
+ * Logs with an arbitrary level.
+ *
+ * @param mixed $level
+ * @param string $message
+ * @param array $context
+ */
+ public function log($level, $message, array $context = array());
+}
diff --git a/Mustache/Logger/AbstractLogger.php b/Mustache/Logger/AbstractLogger.php
new file mode 100644
index 00000000..a169f9c6
--- /dev/null
+++ b/Mustache/Logger/AbstractLogger.php
@@ -0,0 +1,121 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * This is a simple Logger implementation that other Loggers can inherit from.
+ *
+ * This is identical to the Psr\Log\AbstractLogger.
+ *
+ * It simply delegates all log-level-specific methods to the `log` method to
+ * reduce boilerplate code that a simple Logger that does the same thing with
+ * messages regardless of the error level has to implement.
+ */
+abstract class Mustache_Logger_AbstractLogger implements Mustache_Logger
+{
+ /**
+ * System is unusable.
+ *
+ * @param string $message
+ * @param array $context
+ */
+ public function emergency($message, array $context = array())
+ {
+ $this->log(Mustache_Logger::EMERGENCY, $message, $context);
+ }
+
+ /**
+ * Action must be taken immediately.
+ *
+ * Example: Entire website down, database unavailable, etc. This should
+ * trigger the SMS alerts and wake you up.
+ *
+ * @param string $message
+ * @param array $context
+ */
+ public function alert($message, array $context = array())
+ {
+ $this->log(Mustache_Logger::ALERT, $message, $context);
+ }
+
+ /**
+ * Critical conditions.
+ *
+ * Example: Application component unavailable, unexpected exception.
+ *
+ * @param string $message
+ * @param array $context
+ */
+ public function critical($message, array $context = array())
+ {
+ $this->log(Mustache_Logger::CRITICAL, $message, $context);
+ }
+
+ /**
+ * Runtime errors that do not require immediate action but should typically
+ * be logged and monitored.
+ *
+ * @param string $message
+ * @param array $context
+ */
+ public function error($message, array $context = array())
+ {
+ $this->log(Mustache_Logger::ERROR, $message, $context);
+ }
+
+ /**
+ * Exceptional occurrences that are not errors.
+ *
+ * Example: Use of deprecated APIs, poor use of an API, undesirable things
+ * that are not necessarily wrong.
+ *
+ * @param string $message
+ * @param array $context
+ */
+ public function warning($message, array $context = array())
+ {
+ $this->log(Mustache_Logger::WARNING, $message, $context);
+ }
+
+ /**
+ * Normal but significant events.
+ *
+ * @param string $message
+ * @param array $context
+ */
+ public function notice($message, array $context = array())
+ {
+ $this->log(Mustache_Logger::NOTICE, $message, $context);
+ }
+
+ /**
+ * Interesting events.
+ *
+ * Example: User logs in, SQL logs.
+ *
+ * @param string $message
+ * @param array $context
+ */
+ public function info($message, array $context = array())
+ {
+ $this->log(Mustache_Logger::INFO, $message, $context);
+ }
+
+ /**
+ * Detailed debug information.
+ *
+ * @param string $message
+ * @param array $context
+ */
+ public function debug($message, array $context = array())
+ {
+ $this->log(Mustache_Logger::DEBUG, $message, $context);
+ }
+}
diff --git a/Mustache/Logger/StreamLogger.php b/Mustache/Logger/StreamLogger.php
new file mode 100644
index 00000000..402a148e
--- /dev/null
+++ b/Mustache/Logger/StreamLogger.php
@@ -0,0 +1,194 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * A Mustache Stream Logger.
+ *
+ * The Stream Logger wraps a file resource instance (such as a stream) or a
+ * stream URL. All log messages over the threshold level will be appended to
+ * this stream.
+ *
+ * Hint: Try `php://stderr` for your stream URL.
+ */
+class Mustache_Logger_StreamLogger extends Mustache_Logger_AbstractLogger
+{
+ protected static $levels = array(
+ self::DEBUG => 100,
+ self::INFO => 200,
+ self::NOTICE => 250,
+ self::WARNING => 300,
+ self::ERROR => 400,
+ self::CRITICAL => 500,
+ self::ALERT => 550,
+ self::EMERGENCY => 600,
+ );
+
+ protected $level;
+ protected $stream = null;
+ protected $url = null;
+
+ /**
+ * @throws InvalidArgumentException if the logging level is unknown
+ *
+ * @param resource|string $stream Resource instance or URL
+ * @param int $level The minimum logging level at which this handler will be triggered
+ */
+ public function __construct($stream, $level = Mustache_Logger::ERROR)
+ {
+ $this->setLevel($level);
+
+ if (is_resource($stream)) {
+ $this->stream = $stream;
+ } else {
+ $this->url = $stream;
+ }
+ }
+
+ /**
+ * Close stream resources.
+ */
+ public function __destruct()
+ {
+ if (is_resource($this->stream)) {
+ fclose($this->stream);
+ }
+ }
+
+ /**
+ * Set the minimum logging level.
+ *
+ * @throws Mustache_Exception_InvalidArgumentException if the logging level is unknown
+ *
+ * @param int $level The minimum logging level which will be written
+ */
+ public function setLevel($level)
+ {
+ if (!array_key_exists($level, self::$levels)) {
+ throw new Mustache_Exception_InvalidArgumentException(sprintf('Unexpected logging level: %s', $level));
+ }
+
+ $this->level = $level;
+ }
+
+ /**
+ * Get the current minimum logging level.
+ *
+ * @return int
+ */
+ public function getLevel()
+ {
+ return $this->level;
+ }
+
+ /**
+ * Logs with an arbitrary level.
+ *
+ * @throws Mustache_Exception_InvalidArgumentException if the logging level is unknown
+ *
+ * @param mixed $level
+ * @param string $message
+ * @param array $context
+ */
+ public function log($level, $message, array $context = array())
+ {
+ if (!array_key_exists($level, self::$levels)) {
+ throw new Mustache_Exception_InvalidArgumentException(sprintf('Unexpected logging level: %s', $level));
+ }
+
+ if (self::$levels[$level] >= self::$levels[$this->level]) {
+ $this->writeLog($level, $message, $context);
+ }
+ }
+
+ /**
+ * Write a record to the log.
+ *
+ * @throws Mustache_Exception_LogicException If neither a stream resource nor url is present
+ * @throws Mustache_Exception_RuntimeException If the stream url cannot be opened
+ *
+ * @param int $level The logging level
+ * @param string $message The log message
+ * @param array $context The log context
+ */
+ protected function writeLog($level, $message, array $context = array())
+ {
+ if (!is_resource($this->stream)) {
+ if (!isset($this->url)) {
+ throw new Mustache_Exception_LogicException('Missing stream url, the stream can not be opened. This may be caused by a premature call to close().');
+ }
+
+ $this->stream = fopen($this->url, 'a');
+ if (!is_resource($this->stream)) {
+ // @codeCoverageIgnoreStart
+ throw new Mustache_Exception_RuntimeException(sprintf('The stream or file "%s" could not be opened.', $this->url));
+ // @codeCoverageIgnoreEnd
+ }
+ }
+
+ fwrite($this->stream, self::formatLine($level, $message, $context));
+ }
+
+ /**
+ * Gets the name of the logging level.
+ *
+ * @throws InvalidArgumentException if the logging level is unknown
+ *
+ * @param int $level
+ *
+ * @return string
+ */
+ protected static function getLevelName($level)
+ {
+ return strtoupper($level);
+ }
+
+ /**
+ * Format a log line for output.
+ *
+ * @param int $level The logging level
+ * @param string $message The log message
+ * @param array $context The log context
+ *
+ * @return string
+ */
+ protected static function formatLine($level, $message, array $context = array())
+ {
+ return sprintf(
+ "%s: %s\n",
+ self::getLevelName($level),
+ self::interpolateMessage($message, $context)
+ );
+ }
+
+ /**
+ * Interpolate context values into the message placeholders.
+ *
+ * @param string $message
+ * @param array $context
+ *
+ * @return string
+ */
+ protected static function interpolateMessage($message, array $context = array())
+ {
+ if (strpos($message, '{') === false) {
+ return $message;
+ }
+
+ // build a replacement array with braces around the context keys
+ $replace = array();
+ foreach ($context as $key => $val) {
+ $replace['{' . $key . '}'] = $val;
+ }
+
+ // interpolate replacement values into the the message and return
+ return strtr($message, $replace);
+ }
+}
diff --git a/Mustache/Parser.php b/Mustache/Parser.php
index 39911d6b..e5523b24 100644
--- a/Mustache/Parser.php
+++ b/Mustache/Parser.php
@@ -1,88 +1,386 @@
-<?php
-
-/*
- * This file is part of Mustache.php.
- *
- * (c) 2012 Justin Hileman
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-/**
- * Mustache Parser class.
- *
- * This class is responsible for turning a set of Mustache tokens into a parse tree.
- */
-class Mustache_Parser
-{
-
- /**
- * Process an array of Mustache tokens and convert them into a parse tree.
- *
- * @param array $tokens Set of Mustache tokens
- *
- * @return array Mustache token parse tree
- */
- public function parse(array $tokens = array())
- {
- return $this->buildTree(new ArrayIterator($tokens));
- }
-
- /**
- * Helper method for recursively building a parse tree.
- *
- * @param ArrayIterator $tokens Stream of Mustache tokens
- * @param array $parent Parent token (default: null)
- *
- * @return array Mustache Token parse tree
- *
- * @throws LogicException when nesting errors or mismatched section tags are encountered.
- */
- private function buildTree(ArrayIterator $tokens, array $parent = null)
- {
- $nodes = array();
-
- do {
- $token = $tokens->current();
- $tokens->next();
-
- if ($token === null) {
- continue;
- } else {
- switch ($token[Mustache_Tokenizer::TYPE]) {
- case Mustache_Tokenizer::T_SECTION:
- case Mustache_Tokenizer::T_INVERTED:
- $nodes[] = $this->buildTree($tokens, $token);
- break;
-
- case Mustache_Tokenizer::T_END_SECTION:
- if (!isset($parent)) {
- throw new LogicException('Unexpected closing tag: /'. $token[Mustache_Tokenizer::NAME]);
- }
-
- if ($token[Mustache_Tokenizer::NAME] !== $parent[Mustache_Tokenizer::NAME]) {
- throw new LogicException('Nesting error: ' . $parent[Mustache_Tokenizer::NAME] . ' vs. ' . $token[Mustache_Tokenizer::NAME]);
- }
-
- $parent[Mustache_Tokenizer::END] = $token[Mustache_Tokenizer::INDEX];
- $parent[Mustache_Tokenizer::NODES] = $nodes;
-
- return $parent;
- break;
-
- default:
- $nodes[] = $token;
- break;
- }
- }
-
- } while ($tokens->valid());
-
- if (isset($parent)) {
- throw new LogicException('Missing closing tag: ' . $parent[Mustache_Tokenizer::NAME]);
- }
-
- return $nodes;
- }
-}
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Mustache Parser class.
+ *
+ * This class is responsible for turning a set of Mustache tokens into a parse tree.
+ */
+class Mustache_Parser
+{
+ private $lineNum;
+ private $lineTokens;
+ private $pragmas;
+ private $defaultPragmas = array();
+
+ private $pragmaFilters;
+ private $pragmaBlocks;
+ private $pragmaDynamicNames;
+
+ /**
+ * Process an array of Mustache tokens and convert them into a parse tree.
+ *
+ * @param array $tokens Set of Mustache tokens
+ *
+ * @return array Mustache token parse tree
+ */
+ public function parse(array $tokens = array())
+ {
+ $this->lineNum = -1;
+ $this->lineTokens = 0;
+ $this->pragmas = $this->defaultPragmas;
+
+ $this->pragmaFilters = isset($this->pragmas[Mustache_Engine::PRAGMA_FILTERS]);
+ $this->pragmaBlocks = isset($this->pragmas[Mustache_Engine::PRAGMA_BLOCKS]);
+ $this->pragmaDynamicNames = isset($this->pragmas[Mustache_Engine::PRAGMA_DYNAMIC_NAMES]);
+
+ return $this->buildTree($tokens);
+ }
+
+ /**
+ * Enable pragmas across all templates, regardless of the presence of pragma
+ * tags in the individual templates.
+ *
+ * @internal Users should set global pragmas in Mustache_Engine, not here :)
+ *
+ * @param string[] $pragmas
+ */
+ public function setPragmas(array $pragmas)
+ {
+ $this->pragmas = array();
+ foreach ($pragmas as $pragma) {
+ $this->enablePragma($pragma);
+ }
+ $this->defaultPragmas = $this->pragmas;
+ }
+
+ /**
+ * Helper method for recursively building a parse tree.
+ *
+ * @throws Mustache_Exception_SyntaxException when nesting errors or mismatched section tags are encountered
+ *
+ * @param array &$tokens Set of Mustache tokens
+ * @param array $parent Parent token (default: null)
+ *
+ * @return array Mustache Token parse tree
+ */
+ private function buildTree(array &$tokens, array $parent = null)
+ {
+ $nodes = array();
+
+ while (!empty($tokens)) {
+ $token = array_shift($tokens);
+
+ if ($token[Mustache_Tokenizer::LINE] === $this->lineNum) {
+ $this->lineTokens++;
+ } else {
+ $this->lineNum = $token[Mustache_Tokenizer::LINE];
+ $this->lineTokens = 0;
+ }
+
+ if ($token[Mustache_Tokenizer::TYPE] !== Mustache_Tokenizer::T_COMMENT) {
+ if ($this->pragmaDynamicNames && isset($token[Mustache_Tokenizer::NAME])) {
+ list($name, $isDynamic) = $this->getDynamicName($token);
+ if ($isDynamic) {
+ $token[Mustache_Tokenizer::NAME] = $name;
+ $token[Mustache_Tokenizer::DYNAMIC] = true;
+ }
+ }
+
+ if ($this->pragmaFilters && isset($token[Mustache_Tokenizer::NAME])) {
+ list($name, $filters) = $this->getNameAndFilters($token[Mustache_Tokenizer::NAME]);
+ if (!empty($filters)) {
+ $token[Mustache_Tokenizer::NAME] = $name;
+ $token[Mustache_Tokenizer::FILTERS] = $filters;
+ }
+ }
+ }
+
+ switch ($token[Mustache_Tokenizer::TYPE]) {
+ case Mustache_Tokenizer::T_DELIM_CHANGE:
+ $this->checkIfTokenIsAllowedInParent($parent, $token);
+ $this->clearStandaloneLines($nodes, $tokens);
+ break;
+
+ case Mustache_Tokenizer::T_SECTION:
+ case Mustache_Tokenizer::T_INVERTED:
+ $this->checkIfTokenIsAllowedInParent($parent, $token);
+ $this->clearStandaloneLines($nodes, $tokens);
+ $nodes[] = $this->buildTree($tokens, $token);
+ break;
+
+ case Mustache_Tokenizer::T_END_SECTION:
+ if (!isset($parent)) {
+ $msg = sprintf(
+ 'Unexpected closing tag: /%s on line %d',
+ $token[Mustache_Tokenizer::NAME],
+ $token[Mustache_Tokenizer::LINE]
+ );
+ throw new Mustache_Exception_SyntaxException($msg, $token);
+ }
+
+ $sameName = $token[Mustache_Tokenizer::NAME] !== $parent[Mustache_Tokenizer::NAME];
+ $tokenDynamic = isset($token[Mustache_Tokenizer::DYNAMIC]) && $token[Mustache_Tokenizer::DYNAMIC];
+ $parentDynamic = isset($parent[Mustache_Tokenizer::DYNAMIC]) && $parent[Mustache_Tokenizer::DYNAMIC];
+
+ if ($sameName || ($tokenDynamic !== $parentDynamic)) {
+ $msg = sprintf(
+ 'Nesting error: %s%s (on line %d) vs. %s%s (on line %d)',
+ $parentDynamic ? '*' : '',
+ $parent[Mustache_Tokenizer::NAME],
+ $parent[Mustache_Tokenizer::LINE],
+ $tokenDynamic ? '*' : '',
+ $token[Mustache_Tokenizer::NAME],
+ $token[Mustache_Tokenizer::LINE]
+ );
+ throw new Mustache_Exception_SyntaxException($msg, $token);
+ }
+
+ $this->clearStandaloneLines($nodes, $tokens);
+ $parent[Mustache_Tokenizer::END] = $token[Mustache_Tokenizer::INDEX];
+ $parent[Mustache_Tokenizer::NODES] = $nodes;
+
+ return $parent;
+
+ case Mustache_Tokenizer::T_PARTIAL:
+ $this->checkIfTokenIsAllowedInParent($parent, $token);
+ //store the whitespace prefix for laters!
+ if ($indent = $this->clearStandaloneLines($nodes, $tokens)) {
+ $token[Mustache_Tokenizer::INDENT] = $indent[Mustache_Tokenizer::VALUE];
+ }
+ $nodes[] = $token;
+ break;
+
+ case Mustache_Tokenizer::T_PARENT:
+ $this->checkIfTokenIsAllowedInParent($parent, $token);
+ $nodes[] = $this->buildTree($tokens, $token);
+ break;
+
+ case Mustache_Tokenizer::T_BLOCK_VAR:
+ if ($this->pragmaBlocks) {
+ // BLOCKS pragma is enabled, let's do this!
+ if (isset($parent) && $parent[Mustache_Tokenizer::TYPE] === Mustache_Tokenizer::T_PARENT) {
+ $token[Mustache_Tokenizer::TYPE] = Mustache_Tokenizer::T_BLOCK_ARG;
+ }
+ $this->clearStandaloneLines($nodes, $tokens);
+ $nodes[] = $this->buildTree($tokens, $token);
+ } else {
+ // pretend this was just a normal "escaped" token...
+ $token[Mustache_Tokenizer::TYPE] = Mustache_Tokenizer::T_ESCAPED;
+ // TODO: figure out how to figure out if there was a space after this dollar:
+ $token[Mustache_Tokenizer::NAME] = '$' . $token[Mustache_Tokenizer::NAME];
+ $nodes[] = $token;
+ }
+ break;
+
+ case Mustache_Tokenizer::T_PRAGMA:
+ $this->enablePragma($token[Mustache_Tokenizer::NAME]);
+ // no break
+
+ case Mustache_Tokenizer::T_COMMENT:
+ $this->clearStandaloneLines($nodes, $tokens);
+ $nodes[] = $token;
+ break;
+
+ default:
+ $nodes[] = $token;
+ break;
+ }
+ }
+
+ if (isset($parent)) {
+ $msg = sprintf(
+ 'Missing closing tag: %s opened on line %d',
+ $parent[Mustache_Tokenizer::NAME],
+ $parent[Mustache_Tokenizer::LINE]
+ );
+ throw new Mustache_Exception_SyntaxException($msg, $parent);
+ }
+
+ return $nodes;
+ }
+
+ /**
+ * Clear standalone line tokens.
+ *
+ * Returns a whitespace token for indenting partials, if applicable.
+ *
+ * @param array $nodes Parsed nodes
+ * @param array $tokens Tokens to be parsed
+ *
+ * @return array|null Resulting indent token, if any
+ */
+ private function clearStandaloneLines(array &$nodes, array &$tokens)
+ {
+ if ($this->lineTokens > 1) {
+ // this is the third or later node on this line, so it can't be standalone
+ return null;
+ }
+
+ $prev = null;
+ if ($this->lineTokens === 1) {
+ // this is the second node on this line, so it can't be standalone
+ // unless the previous node is whitespace.
+ if ($prev = end($nodes)) {
+ if (!$this->tokenIsWhitespace($prev)) {
+ return null;
+ }
+ }
+ }
+
+ if ($next = reset($tokens)) {
+ // If we're on a new line, bail.
+ if ($next[Mustache_Tokenizer::LINE] !== $this->lineNum) {
+ return null;
+ }
+
+ // If the next token isn't whitespace, bail.
+ if (!$this->tokenIsWhitespace($next)) {
+ return null;
+ }
+
+ if (count($tokens) !== 1) {
+ // Unless it's the last token in the template, the next token
+ // must end in newline for this to be standalone.
+ if (substr($next[Mustache_Tokenizer::VALUE], -1) !== "\n") {
+ return null;
+ }
+ }
+
+ // Discard the whitespace suffix
+ array_shift($tokens);
+ }
+
+ if ($prev) {
+ // Return the whitespace prefix, if any
+ return array_pop($nodes);
+ }
+ return null;
+ }
+
+ /**
+ * Check whether token is a whitespace token.
+ *
+ * True if token type is T_TEXT and value is all whitespace characters.
+ *
+ * @param array $token
+ *
+ * @return bool True if token is a whitespace token
+ */
+ private function tokenIsWhitespace(array $token)
+ {
+ if ($token[Mustache_Tokenizer::TYPE] === Mustache_Tokenizer::T_TEXT) {
+ return preg_match('/^\s*$/', $token[Mustache_Tokenizer::VALUE]);
+ }
+
+ return false;
+ }
+
+ /**
+ * Check whether a token is allowed inside a parent tag.
+ *
+ * @throws Mustache_Exception_SyntaxException if an invalid token is found inside a parent tag
+ *
+ * @param array|null $parent
+ * @param array $token
+ */
+ private function checkIfTokenIsAllowedInParent($parent, array $token)
+ {
+ if (isset($parent) && $parent[Mustache_Tokenizer::TYPE] === Mustache_Tokenizer::T_PARENT) {
+ throw new Mustache_Exception_SyntaxException('Illegal content in < parent tag', $token);
+ }
+ }
+
+ /**
+ * Parse dynamic names.
+ *
+ * @throws Mustache_Exception_SyntaxException when a tag does not allow *
+ * @throws Mustache_Exception_SyntaxException on multiple *s, or dots or filters with *
+ */
+ private function getDynamicName(array $token)
+ {
+ $name = $token[Mustache_Tokenizer::NAME];
+ $isDynamic = false;
+
+ if (preg_match('/^\s*\*\s*/', $name)) {
+ $this->ensureTagAllowsDynamicNames($token);
+ $name = preg_replace('/^\s*\*\s*/', '', $name);
+ $isDynamic = true;
+ }
+
+ return array($name, $isDynamic);
+ }
+
+ /**
+ * Check whether the given token supports dynamic tag names.
+ *
+ * @throws Mustache_Exception_SyntaxException when a tag does not allow *
+ *
+ * @param array $token
+ */
+ private function ensureTagAllowsDynamicNames(array $token)
+ {
+ switch ($token[Mustache_Tokenizer::TYPE]) {
+ case Mustache_Tokenizer::T_PARTIAL:
+ case Mustache_Tokenizer::T_PARENT:
+ case Mustache_Tokenizer::T_END_SECTION:
+ return;
+ }
+
+ $msg = sprintf(
+ 'Invalid dynamic name: %s in %s tag',
+ $token[Mustache_Tokenizer::NAME],
+ Mustache_Tokenizer::getTagName($token[Mustache_Tokenizer::TYPE])
+ );
+
+ throw new Mustache_Exception_SyntaxException($msg, $token);
+ }
+
+
+ /**
+ * Split a tag name into name and filters.
+ *
+ * @param string $name
+ *
+ * @return array [Tag name, Array of filters]
+ */
+ private function getNameAndFilters($name)
+ {
+ $filters = array_map('trim', explode('|', $name));
+ $name = array_shift($filters);
+
+ return array($name, $filters);
+ }
+
+ /**
+ * Enable a pragma.
+ *
+ * @param string $name
+ */
+ private function enablePragma($name)
+ {
+ $this->pragmas[$name] = true;
+
+ switch ($name) {
+ case Mustache_Engine::PRAGMA_BLOCKS:
+ $this->pragmaBlocks = true;
+ break;
+
+ case Mustache_Engine::PRAGMA_FILTERS:
+ $this->pragmaFilters = true;
+ break;
+
+ case Mustache_Engine::PRAGMA_DYNAMIC_NAMES:
+ $this->pragmaDynamicNames = true;
+ break;
+ }
+ }
+}
diff --git a/Mustache/Source.php b/Mustache/Source.php
new file mode 100644
index 00000000..278c2cb3
--- /dev/null
+++ b/Mustache/Source.php
@@ -0,0 +1,40 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Mustache template Source interface.
+ */
+interface Mustache_Source
+{
+ /**
+ * Get the Source key (used to generate the compiled class name).
+ *
+ * This must return a distinct key for each template source. For example, an
+ * MD5 hash of the template contents would probably do the trick. The
+ * ProductionFilesystemLoader uses mtime and file path. If your production
+ * source directory is under version control, you could use the current Git
+ * rev and the file path...
+ *
+ * @throws RuntimeException when a source file cannot be read
+ *
+ * @return string
+ */
+ public function getKey();
+
+ /**
+ * Get the template Source.
+ *
+ * @throws RuntimeException when a source file cannot be read
+ *
+ * @return string
+ */
+ public function getSource();
+}
diff --git a/Mustache/Source/FilesystemSource.php b/Mustache/Source/FilesystemSource.php
new file mode 100644
index 00000000..270f584e
--- /dev/null
+++ b/Mustache/Source/FilesystemSource.php
@@ -0,0 +1,77 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Mustache template Filesystem Source.
+ *
+ * This template Source uses stat() to generate the Source key, so that using
+ * pre-compiled templates doesn't require hitting the disk to read the source.
+ * It is more suitable for production use, and is used by default in the
+ * ProductionFilesystemLoader.
+ */
+class Mustache_Source_FilesystemSource implements Mustache_Source
+{
+ private $fileName;
+ private $statProps;
+ private $stat;
+
+ /**
+ * Filesystem Source constructor.
+ *
+ * @param string $fileName
+ * @param array $statProps
+ */
+ public function __construct($fileName, array $statProps)
+ {
+ $this->fileName = $fileName;
+ $this->statProps = $statProps;
+ }
+
+ /**
+ * Get the Source key (used to generate the compiled class name).
+ *
+ * @throws Mustache_Exception_RuntimeException when a source file cannot be read
+ *
+ * @return string
+ */
+ public function getKey()
+ {
+ $chunks = array(
+ 'fileName' => $this->fileName,
+ );
+
+ if (!empty($this->statProps)) {
+ if (!isset($this->stat)) {
+ $this->stat = @stat($this->fileName);
+ }
+
+ if ($this->stat === false) {
+ throw new Mustache_Exception_RuntimeException(sprintf('Failed to read source file "%s".', $this->fileName));
+ }
+
+ foreach ($this->statProps as $prop) {
+ $chunks[$prop] = $this->stat[$prop];
+ }
+ }
+
+ return json_encode($chunks);
+ }
+
+ /**
+ * Get the template Source.
+ *
+ * @return string
+ */
+ public function getSource()
+ {
+ return file_get_contents($this->fileName);
+ }
+}
diff --git a/Mustache/Template.php b/Mustache/Template.php
index ebb9df8c..4de82393 100644
--- a/Mustache/Template.php
+++ b/Mustache/Template.php
@@ -1,149 +1,180 @@
-<?php
-
-/*
- * This file is part of Mustache.php.
- *
- * (c) 2012 Justin Hileman
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-/**
- * Abstract Mustache Template class.
- *
- * @abstract
- */
-abstract class Mustache_Template
-{
-
- /**
- * @var Mustache_Engine
- */
- protected $mustache;
-
- /**
- * Mustache Template constructor.
- *
- * @param Mustache_Engine $mustache
- */
- public function __construct(Mustache_Engine $mustache)
- {
- $this->mustache = $mustache;
- }
-
- /**
- * Mustache Template instances can be treated as a function and rendered by simply calling them:
- *
- * $m = new Mustache_Engine;
- * $tpl = $m->loadTemplate('Hello, {{ name }}!');
- * echo $tpl(array('name' => 'World')); // "Hello, World!"
- *
- * @see Mustache_Template::render
- *
- * @param mixed $context Array or object rendering context (default: array())
- *
- * @return string Rendered template
- */
- public function __invoke($context = array())
- {
- return $this->render($context);
- }
-
- /**
- * Render this template given the rendering context.
- *
- * @param mixed $context Array or object rendering context (default: array())
- *
- * @return string Rendered template
- */
- public function render($context = array())
- {
- return $this->renderInternal($this->prepareContextStack($context));
- }
-
- /**
- * Internal rendering method implemented by Mustache Template concrete subclasses.
- *
- * This is where the magic happens :)
- *
- * @param Mustache_Context $context
- * @param string $indent (default: '')
- * @param bool $escape (default: false)
- *
- * @return string Rendered template
- */
- abstract public function renderInternal(Mustache_Context $context, $indent = '', $escape = false);
-
- /**
- * Tests whether a value should be iterated over (e.g. in a section context).
- *
- * In most languages there are two distinct array types: list and hash (or whatever you want to call them). Lists
- * should be iterated, hashes should be treated as objects. Mustache follows this paradigm for Ruby, Javascript,
- * Java, Python, etc.
- *
- * PHP, however, treats lists and hashes as one primitive type: array. So Mustache.php needs a way to distinguish
- * between between a list of things (numeric, normalized array) and a set of variables to be used as section context
- * (associative array). In other words, this will be iterated over:
- *
- * $items = array(
- * array('name' => 'foo'),
- * array('name' => 'bar'),
- * array('name' => 'baz'),
- * );
- *
- * ... but this will be used as a section context block:
- *
- * $items = array(
- * 1 => array('name' => 'foo'),
- * 'banana' => array('name' => 'bar'),
- * 42 => array('name' => 'baz'),
- * );
- *
- * @param mixed $value
- *
- * @return boolean True if the value is 'iterable'
- */
- protected function isIterable($value)
- {
- if (is_object($value)) {
- return $value instanceof Traversable;
- } elseif (is_array($value)) {
- $i = 0;
- foreach ($value as $k => $v) {
- if ($k !== $i++) {
- return false;
- }
- }
-
- return true;
- } else {
- return false;
- }
- }
-
- /**
- * Helper method to prepare the Context stack.
- *
- * Adds the Mustache HelperCollection to the stack's top context frame if helpers are present.
- *
- * @param mixed $context Optional first context frame (default: null)
- *
- * @return Mustache_Context
- */
- protected function prepareContextStack($context = null)
- {
- $stack = new Mustache_Context;
-
- $helpers = $this->mustache->getHelpers();
- if (!$helpers->isEmpty()) {
- $stack->push($helpers);
- }
-
- if (!empty($context)) {
- $stack->push($context);
- }
-
- return $stack;
- }
-}
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Abstract Mustache Template class.
+ *
+ * @abstract
+ */
+abstract class Mustache_Template
+{
+ /**
+ * @var Mustache_Engine
+ */
+ protected $mustache;
+
+ /**
+ * @var bool
+ */
+ protected $strictCallables = false;
+
+ /**
+ * Mustache Template constructor.
+ *
+ * @param Mustache_Engine $mustache
+ */
+ public function __construct(Mustache_Engine $mustache)
+ {
+ $this->mustache = $mustache;
+ }
+
+ /**
+ * Mustache Template instances can be treated as a function and rendered by simply calling them.
+ *
+ * $m = new Mustache_Engine;
+ * $tpl = $m->loadTemplate('Hello, {{ name }}!');
+ * echo $tpl(array('name' => 'World')); // "Hello, World!"
+ *
+ * @see Mustache_Template::render
+ *
+ * @param mixed $context Array or object rendering context (default: array())
+ *
+ * @return string Rendered template
+ */
+ public function __invoke($context = array())
+ {
+ return $this->render($context);
+ }
+
+ /**
+ * Render this template given the rendering context.
+ *
+ * @param mixed $context Array or object rendering context (default: array())
+ *
+ * @return string Rendered template
+ */
+ public function render($context = array())
+ {
+ return $this->renderInternal(
+ $this->prepareContextStack($context)
+ );
+ }
+
+ /**
+ * Internal rendering method implemented by Mustache Template concrete subclasses.
+ *
+ * This is where the magic happens :)
+ *
+ * NOTE: This method is not part of the Mustache.php public API.
+ *
+ * @param Mustache_Context $context
+ * @param string $indent (default: '')
+ *
+ * @return string Rendered template
+ */
+ abstract public function renderInternal(Mustache_Context $context, $indent = '');
+
+ /**
+ * Tests whether a value should be iterated over (e.g. in a section context).
+ *
+ * In most languages there are two distinct array types: list and hash (or whatever you want to call them). Lists
+ * should be iterated, hashes should be treated as objects. Mustache follows this paradigm for Ruby, Javascript,
+ * Java, Python, etc.
+ *
+ * PHP, however, treats lists and hashes as one primitive type: array. So Mustache.php needs a way to distinguish
+ * between between a list of things (numeric, normalized array) and a set of variables to be used as section context
+ * (associative array). In other words, this will be iterated over:
+ *
+ * $items = array(
+ * array('name' => 'foo'),
+ * array('name' => 'bar'),
+ * array('name' => 'baz'),
+ * );
+ *
+ * ... but this will be used as a section context block:
+ *
+ * $items = array(
+ * 1 => array('name' => 'foo'),
+ * 'banana' => array('name' => 'bar'),
+ * 42 => array('name' => 'baz'),
+ * );
+ *
+ * @param mixed $value
+ *
+ * @return bool True if the value is 'iterable'
+ */
+ protected function isIterable($value)
+ {
+ switch (gettype($value)) {
+ case 'object':
+ return $value instanceof Traversable;
+
+ case 'array':
+ $i = 0;
+ foreach ($value as $k => $v) {
+ if ($k !== $i++) {
+ return false;
+ }
+ }
+
+ return true;
+
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Helper method to prepare the Context stack.
+ *
+ * Adds the Mustache HelperCollection to the stack's top context frame if helpers are present.
+ *
+ * @param mixed $context Optional first context frame (default: null)
+ *
+ * @return Mustache_Context
+ */
+ protected function prepareContextStack($context = null)
+ {
+ $stack = new Mustache_Context();
+
+ $helpers = $this->mustache->getHelpers();
+ if (!$helpers->isEmpty()) {
+ $stack->push($helpers);
+ }
+
+ if (!empty($context)) {
+ $stack->push($context);
+ }
+
+ return $stack;
+ }
+
+ /**
+ * Resolve a context value.
+ *
+ * Invoke the value if it is callable, otherwise return the value.
+ *
+ * @param mixed $value
+ * @param Mustache_Context $context
+ *
+ * @return string
+ */
+ protected function resolveValue($value, Mustache_Context $context)
+ {
+ if (($this->strictCallables ? is_object($value) : !is_string($value)) && is_callable($value)) {
+ return $this->mustache
+ ->loadLambda((string) call_user_func($value))
+ ->renderInternal($context);
+ }
+
+ return $value;
+ }
+}
diff --git a/Mustache/Tokenizer.php b/Mustache/Tokenizer.php
index fd866e30..d96f1298 100644
--- a/Mustache/Tokenizer.php
+++ b/Mustache/Tokenizer.php
@@ -1,286 +1,408 @@
-<?php
-
-/*
- * This file is part of Mustache.php.
- *
- * (c) 2012 Justin Hileman
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-/**
- * Mustache Tokenizer class.
- *
- * This class is responsible for turning raw template source into a set of Mustache tokens.
- */
-class Mustache_Tokenizer
-{
-
- // Finite state machine states
- const IN_TEXT = 0;
- const IN_TAG_TYPE = 1;
- const IN_TAG = 2;
-
- // Token types
- const T_SECTION = '#';
- const T_INVERTED = '^';
- const T_END_SECTION = '/';
- const T_COMMENT = '!';
- const T_PARTIAL = '>';
- const T_PARTIAL_2 = '<';
- const T_DELIM_CHANGE = '=';
- const T_ESCAPED = '_v';
- const T_UNESCAPED = '{';
- const T_UNESCAPED_2 = '&';
- const T_TEXT = '_t';
-
- // Valid token types
- private static $tagTypes = array(
- self::T_SECTION => true,
- self::T_INVERTED => true,
- self::T_END_SECTION => true,
- self::T_COMMENT => true,
- self::T_PARTIAL => true,
- self::T_PARTIAL_2 => true,
- self::T_DELIM_CHANGE => true,
- self::T_ESCAPED => true,
- self::T_UNESCAPED => true,
- self::T_UNESCAPED_2 => true,
- );
-
- // Interpolated tags
- private static $interpolatedTags = array(
- self::T_ESCAPED => true,
- self::T_UNESCAPED => true,
- self::T_UNESCAPED_2 => true,
- );
-
- // Token properties
- const TYPE = 'type';
- const NAME = 'name';
- const OTAG = 'otag';
- const CTAG = 'ctag';
- const INDEX = 'index';
- const END = 'end';
- const INDENT = 'indent';
- const NODES = 'nodes';
- const VALUE = 'value';
-
- private $state;
- private $tagType;
- private $tag;
- private $buffer;
- private $tokens;
- private $seenTag;
- private $lineStart;
- private $otag;
- private $ctag;
-
- /**
- * Scan and tokenize template source.
- *
- * @param string $text Mustache template source to tokenize
- * @param string $delimiters Optionally, pass initial opening and closing delimiters (default: null)
- *
- * @return array Set of Mustache tokens
- */
- public function scan($text, $delimiters = null)
- {
- $this->reset();
-
- if ($delimiters = trim($delimiters)) {
- list($otag, $ctag) = explode(' ', $delimiters);
- $this->otag = $otag;
- $this->ctag = $ctag;
- }
-
- $len = strlen($text);
- for ($i = 0; $i < $len; $i++) {
- switch ($this->state) {
- case self::IN_TEXT:
- if ($this->tagChange($this->otag, $text, $i)) {
- $i--;
- $this->flushBuffer();
- $this->state = self::IN_TAG_TYPE;
- } else {
- if ($text[$i] == "\n") {
- $this->filterLine();
- } else {
- $this->buffer .= $text[$i];
- }
- }
- break;
-
- case self::IN_TAG_TYPE:
-
- $i += strlen($this->otag) - 1;
- if (isset(self::$tagTypes[$text[$i + 1]])) {
- $tag = $text[$i + 1];
- $this->tagType = $tag;
- } else {
- $tag = null;
- $this->tagType = self::T_ESCAPED;
- }
-
- if ($this->tagType === self::T_DELIM_CHANGE) {
- $i = $this->changeDelimiters($text, $i);
- $this->state = self::IN_TEXT;
- } else {
- if ($tag !== null) {
- $i++;
- }
- $this->state = self::IN_TAG;
- }
- $this->seenTag = $i;
- break;
-
- default:
- if ($this->tagChange($this->ctag, $text, $i)) {
- $this->tokens[] = array(
- self::TYPE => $this->tagType,
- self::NAME => trim($this->buffer),
- self::OTAG => $this->otag,
- self::CTAG => $this->ctag,
- self::INDEX => ($this->tagType == self::T_END_SECTION) ? $this->seenTag - strlen($this->otag) : $i + strlen($this->ctag)
- );
-
- $this->buffer = '';
- $i += strlen($this->ctag) - 1;
- $this->state = self::IN_TEXT;
- if ($this->tagType == self::T_UNESCAPED) {
- if ($this->ctag == '}}') {
- $i++;
- } else {
- // Clean up `{{{ tripleStache }}}` style tokens.
- $lastName = $this->tokens[count($this->tokens) - 1][self::NAME];
- if (substr($lastName, -1) === '}') {
- $this->tokens[count($this->tokens) - 1][self::NAME] = trim(substr($lastName, 0, -1));
- }
- }
- }
- } else {
- $this->buffer .= $text[$i];
- }
- break;
- }
- }
-
- $this->filterLine(true);
-
- return $this->tokens;
- }
-
- /**
- * Helper function to reset tokenizer internal state.
- */
- private function reset()
- {
- $this->state = self::IN_TEXT;
- $this->tagType = null;
- $this->tag = null;
- $this->buffer = '';
- $this->tokens = array();
- $this->seenTag = false;
- $this->lineStart = 0;
- $this->otag = '{{';
- $this->ctag = '}}';
- }
-
- /**
- * Flush the current buffer to a token.
- */
- private function flushBuffer()
- {
- if (!empty($this->buffer)) {
- $this->tokens[] = array(self::TYPE => self::T_TEXT, self::VALUE => $this->buffer);
- $this->buffer = '';
- }
- }
-
- /**
- * Test whether the current line is entirely made up of whitespace.
- *
- * @return boolean True if the current line is all whitespace
- */
- private function lineIsWhitespace()
- {
- $tokensCount = count($this->tokens);
- for ($j = $this->lineStart; $j < $tokensCount; $j++) {
- $token = $this->tokens[$j];
- if (isset(self::$tagTypes[$token[self::TYPE]])) {
- if (isset(self::$interpolatedTags[$token[self::TYPE]])) {
- return false;
- }
- } elseif ($token[self::TYPE] == self::T_TEXT) {
- if (preg_match('/\S/', $token[self::VALUE])) {
- return false;
- }
- }
- }
-
- return true;
- }
-
- /**
- * Filter out whitespace-only lines and store indent levels for partials.
- *
- * @param bool $noNewLine Suppress the newline? (default: false)
- */
- private function filterLine($noNewLine = false)
- {
- $this->flushBuffer();
- if ($this->seenTag && $this->lineIsWhitespace()) {
- $tokensCount = count($this->tokens);
- for ($j = $this->lineStart; $j < $tokensCount; $j++) {
- if ($this->tokens[$j][self::TYPE] == self::T_TEXT) {
- if (isset($this->tokens[$j+1]) && $this->tokens[$j+1][self::TYPE] == self::T_PARTIAL) {
- $this->tokens[$j+1][self::INDENT] = $this->tokens[$j][self::VALUE];
- }
-
- $this->tokens[$j] = null;
- }
- }
- } elseif (!$noNewLine) {
- $this->tokens[] = array(self::TYPE => self::T_TEXT, self::VALUE => "\n");
- }
-
- $this->seenTag = false;
- $this->lineStart = count($this->tokens);
- }
-
- /**
- * Change the current Mustache delimiters. Set new `otag` and `ctag` values.
- *
- * @param string $text Mustache template source
- * @param int $index Current tokenizer index
- *
- * @return int New index value
- */
- private function changeDelimiters($text, $index)
- {
- $startIndex = strpos($text, '=', $index) + 1;
- $close = '='.$this->ctag;
- $closeIndex = strpos($text, $close, $index);
-
- list($otag, $ctag) = explode(' ', trim(substr($text, $startIndex, $closeIndex - $startIndex)));
- $this->otag = $otag;
- $this->ctag = $ctag;
-
- return $closeIndex + strlen($close) - 1;
- }
-
- /**
- * Test whether it's time to change tags.
- *
- * @param string $tag Current tag name
- * @param string $text Mustache template source
- * @param int $index Current tokenizer index
- *
- * @return boolean True if this is a closing section tag
- */
- private function tagChange($tag, $text, $index)
- {
- return substr($text, $index, strlen($tag)) === $tag;
- }
-}
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2010-2017 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Mustache Tokenizer class.
+ *
+ * This class is responsible for turning raw template source into a set of Mustache tokens.
+ */
+class Mustache_Tokenizer
+{
+ // Finite state machine states
+ const IN_TEXT = 0;
+ const IN_TAG_TYPE = 1;
+ const IN_TAG = 2;
+
+ // Token types
+ const T_SECTION = '#';
+ const T_INVERTED = '^';
+ const T_END_SECTION = '/';
+ const T_COMMENT = '!';
+ const T_PARTIAL = '>';
+ const T_PARENT = '<';
+ const T_DELIM_CHANGE = '=';
+ const T_ESCAPED = '_v';
+ const T_UNESCAPED = '{';
+ const T_UNESCAPED_2 = '&';
+ const T_TEXT = '_t';
+ const T_PRAGMA = '%';
+ const T_BLOCK_VAR = '$';
+ const T_BLOCK_ARG = '$arg';
+
+ // Valid token types
+ private static $tagTypes = array(
+ self::T_SECTION => true,
+ self::T_INVERTED => true,
+ self::T_END_SECTION => true,
+ self::T_COMMENT => true,
+ self::T_PARTIAL => true,
+ self::T_PARENT => true,
+ self::T_DELIM_CHANGE => true,
+ self::T_ESCAPED => true,
+ self::T_UNESCAPED => true,
+ self::T_UNESCAPED_2 => true,
+ self::T_PRAGMA => true,
+ self::T_BLOCK_VAR => true,
+ );
+
+ private static $tagNames = array(
+ self::T_SECTION => 'section',
+ self::T_INVERTED => 'inverted section',
+ self::T_END_SECTION => 'section end',
+ self::T_COMMENT => 'comment',
+ self::T_PARTIAL => 'partial',
+ self::T_PARENT => 'parent',
+ self::T_DELIM_CHANGE => 'set delimiter',
+ self::T_ESCAPED => 'variable',
+ self::T_UNESCAPED => 'unescaped variable',
+ self::T_UNESCAPED_2 => 'unescaped variable',
+ self::T_PRAGMA => 'pragma',
+ self::T_BLOCK_VAR => 'block variable',
+ self::T_BLOCK_ARG => 'block variable',
+ );
+
+ // Token properties
+ const TYPE = 'type';
+ const NAME = 'name';
+ const DYNAMIC = 'dynamic';
+ const OTAG = 'otag';
+ const CTAG = 'ctag';
+ const LINE = 'line';
+ const INDEX = 'index';
+ const END = 'end';
+ const INDENT = 'indent';
+ const NODES = 'nodes';
+ const VALUE = 'value';
+ const FILTERS = 'filters';
+
+ private $state;
+ private $tagType;
+ private $buffer;
+ private $tokens;
+ private $seenTag;
+ private $line;
+
+ private $otag;
+ private $otagChar;
+ private $otagLen;
+
+ private $ctag;
+ private $ctagChar;
+ private $ctagLen;
+
+ /**
+ * Scan and tokenize template source.
+ *
+ * @throws Mustache_Exception_SyntaxException when mismatched section tags are encountered
+ * @throws Mustache_Exception_InvalidArgumentException when $delimiters string is invalid
+ *
+ * @param string $text Mustache template source to tokenize
+ * @param string $delimiters Optionally, pass initial opening and closing delimiters (default: empty string)
+ *
+ * @return array Set of Mustache tokens
+ */
+ public function scan($text, $delimiters = '')
+ {
+ // Setting mbstring.func_overload makes things *really* slow.
+ // Let's do everyone a favor and scan this string as ASCII instead.
+ //
+ // The INI directive was removed in PHP 8.0 so we don't need to check there (and can drop it
+ // when we remove support for older versions of PHP).
+ //
+ // @codeCoverageIgnoreStart
+ $encoding = null;
+ if (version_compare(PHP_VERSION, '8.0.0', '<')) {
+ if (function_exists('mb_internal_encoding') && ini_get('mbstring.func_overload') & 2) {
+ $encoding = mb_internal_encoding();
+ mb_internal_encoding('ASCII');
+ }
+ }
+ // @codeCoverageIgnoreEnd
+
+ $this->reset();
+
+ if (is_string($delimiters) && $delimiters = trim($delimiters)) {
+ $this->setDelimiters($delimiters);
+ }
+
+ $len = strlen($text);
+ for ($i = 0; $i < $len; $i++) {
+ switch ($this->state) {
+ case self::IN_TEXT:
+ $char = $text[$i];
+ // Test whether it's time to change tags.
+ if ($char === $this->otagChar && substr($text, $i, $this->otagLen) === $this->otag) {
+ $i--;
+ $this->flushBuffer();
+ $this->state = self::IN_TAG_TYPE;
+ } else {
+ $this->buffer .= $char;
+ if ($char === "\n") {
+ $this->flushBuffer();
+ $this->line++;
+ }
+ }
+ break;
+
+ case self::IN_TAG_TYPE:
+ $i += $this->otagLen - 1;
+ $char = $text[$i + 1];
+ if (isset(self::$tagTypes[$char])) {
+ $tag = $char;
+ $this->tagType = $tag;
+ } else {
+ $tag = null;
+ $this->tagType = self::T_ESCAPED;
+ }
+
+ if ($this->tagType === self::T_DELIM_CHANGE) {
+ $i = $this->changeDelimiters($text, $i);
+ $this->state = self::IN_TEXT;
+ } elseif ($this->tagType === self::T_PRAGMA) {
+ $i = $this->addPragma($text, $i);
+ $this->state = self::IN_TEXT;
+ } else {
+ if ($tag !== null) {
+ $i++;
+ }
+ $this->state = self::IN_TAG;
+ }
+ $this->seenTag = $i;
+ break;
+
+ default:
+ $char = $text[$i];
+ // Test whether it's time to change tags.
+ if ($char === $this->ctagChar && substr($text, $i, $this->ctagLen) === $this->ctag) {
+ $token = array(
+ self::TYPE => $this->tagType,
+ self::NAME => trim($this->buffer),
+ self::OTAG => $this->otag,
+ self::CTAG => $this->ctag,
+ self::LINE => $this->line,
+ self::INDEX => ($this->tagType === self::T_END_SECTION) ? $this->seenTag - $this->otagLen : $i + $this->ctagLen,
+ );
+
+ if ($this->tagType === self::T_UNESCAPED) {
+ // Clean up `{{{ tripleStache }}}` style tokens.
+ if ($this->ctag === '}}') {
+ if (($i + 2 < $len) && $text[$i + 2] === '}') {
+ $i++;
+ } else {
+ $msg = sprintf(
+ 'Mismatched tag delimiters: %s on line %d',
+ $token[self::NAME],
+ $token[self::LINE]
+ );
+
+ throw new Mustache_Exception_SyntaxException($msg, $token);
+ }
+ } else {
+ $lastName = $token[self::NAME];
+ if (substr($lastName, -1) === '}') {
+ $token[self::NAME] = trim(substr($lastName, 0, -1));
+ } else {
+ $msg = sprintf(
+ 'Mismatched tag delimiters: %s on line %d',
+ $token[self::NAME],
+ $token[self::LINE]
+ );
+
+ throw new Mustache_Exception_SyntaxException($msg, $token);
+ }
+ }
+ }
+
+ $this->buffer = '';
+ $i += $this->ctagLen - 1;
+ $this->state = self::IN_TEXT;
+ $this->tokens[] = $token;
+ } else {
+ $this->buffer .= $char;
+ }
+ break;
+ }
+ }
+
+ if ($this->state !== self::IN_TEXT) {
+ $this->throwUnclosedTagException();
+ }
+
+ $this->flushBuffer();
+
+ // Restore the user's encoding...
+ // @codeCoverageIgnoreStart
+ if ($encoding) {
+ mb_internal_encoding($encoding);
+ }
+ // @codeCoverageIgnoreEnd
+
+ return $this->tokens;
+ }
+
+ /**
+ * Helper function to reset tokenizer internal state.
+ */
+ private function reset()
+ {
+ $this->state = self::IN_TEXT;
+ $this->tagType = null;
+ $this->buffer = '';
+ $this->tokens = array();
+ $this->seenTag = false;
+ $this->line = 0;
+
+ $this->otag = '{{';
+ $this->otagChar = '{';
+ $this->otagLen = 2;
+
+ $this->ctag = '}}';
+ $this->ctagChar = '}';
+ $this->ctagLen = 2;
+ }
+
+ /**
+ * Flush the current buffer to a token.
+ */
+ private function flushBuffer()
+ {
+ if (strlen($this->buffer) > 0) {
+ $this->tokens[] = array(
+ self::TYPE => self::T_TEXT,
+ self::LINE => $this->line,
+ self::VALUE => $this->buffer,
+ );
+ $this->buffer = '';
+ }
+ }
+
+ /**
+ * Change the current Mustache delimiters. Set new `otag` and `ctag` values.
+ *
+ * @throws Mustache_Exception_SyntaxException when delimiter string is invalid
+ *
+ * @param string $text Mustache template source
+ * @param int $index Current tokenizer index
+ *
+ * @return int New index value
+ */
+ private function changeDelimiters($text, $index)
+ {
+ $startIndex = strpos($text, '=', $index) + 1;
+ $close = '=' . $this->ctag;
+ $closeIndex = strpos($text, $close, $index);
+
+ if ($closeIndex === false) {
+ $this->throwUnclosedTagException();
+ }
+
+ $token = array(
+ self::TYPE => self::T_DELIM_CHANGE,
+ self::LINE => $this->line,
+ );
+
+ try {
+ $this->setDelimiters(trim(substr($text, $startIndex, $closeIndex - $startIndex)));
+ } catch (Mustache_Exception_InvalidArgumentException $e) {
+ throw new Mustache_Exception_SyntaxException($e->getMessage(), $token);
+ }
+
+ $this->tokens[] = $token;
+
+ return $closeIndex + strlen($close) - 1;
+ }
+
+ /**
+ * Set the current Mustache `otag` and `ctag` delimiters.
+ *
+ * @throws Mustache_Exception_InvalidArgumentException when delimiter string is invalid
+ *
+ * @param string $delimiters
+ */
+ private function setDelimiters($delimiters)
+ {
+ if (!preg_match('/^\s*(\S+)\s+(\S+)\s*$/', $delimiters, $matches)) {
+ throw new Mustache_Exception_InvalidArgumentException(sprintf('Invalid delimiters: %s', $delimiters));
+ }
+
+ list($_, $otag, $ctag) = $matches;
+
+ $this->otag = $otag;
+ $this->otagChar = $otag[0];
+ $this->otagLen = strlen($otag);
+
+ $this->ctag = $ctag;
+ $this->ctagChar = $ctag[0];
+ $this->ctagLen = strlen($ctag);
+ }
+
+ /**
+ * Add pragma token.
+ *
+ * Pragmas are hoisted to the front of the template, so all pragma tokens
+ * will appear at the front of the token list.
+ *
+ * @param string $text
+ * @param int $index
+ *
+ * @return int New index value
+ */
+ private function addPragma($text, $index)
+ {
+ $end = strpos($text, $this->ctag, $index);
+ if ($end === false) {
+ $this->throwUnclosedTagException();
+ }
+
+ $pragma = trim(substr($text, $index + 2, $end - $index - 2));
+
+ // Pragmas are hoisted to the front of the template.
+ array_unshift($this->tokens, array(
+ self::TYPE => self::T_PRAGMA,
+ self::NAME => $pragma,
+ self::LINE => 0,
+ ));
+
+ return $end + $this->ctagLen - 1;
+ }
+
+
+ private function throwUnclosedTagException()
+ {
+ $name = trim($this->buffer);
+ if ($name !== '') {
+ $msg = sprintf('Unclosed tag: %s on line %d', $name, $this->line);
+ } else {
+ $msg = sprintf('Unclosed tag on line %d', $this->line);
+ }
+
+ throw new Mustache_Exception_SyntaxException($msg, array(
+ self::TYPE => $this->tagType,
+ self::NAME => $name,
+ self::OTAG => $this->otag,
+ self::CTAG => $this->ctag,
+ self::LINE => $this->line,
+ self::INDEX => $this->seenTag - $this->otagLen,
+ ));
+ }
+
+ /**
+ * Get the human readable name for a tag type.
+ *
+ * @param string $tagType One of the tokenizer T_* constants
+ *
+ * @return string
+ */
+ static function getTagName($tagType)
+ {
+ return isset(self::$tagNames[$tagType]) ? self::$tagNames[$tagType] : 'unknown';
+ }
+}
diff --git a/api.php b/api.php
index 9e43a42a..5a451f3a 100644
--- a/api.php
+++ b/api.php
@@ -1,9 +1,13 @@
<?php
-error_reporting(E_ALL);
chdir(dirname($_SERVER['SCRIPT_FILENAME']));
require_once 'config.php';
+if (CONFIG_DEBUG) {
+ error_reporting(E_ALL);
+} else {
+ error_reporting(E_ALL & ~E_DEPRECATED);
+}
define('API', true);
define('AJAX', false);
@@ -13,13 +17,13 @@ if (CONFIG_SQL_PASS === '%MYSQL_OPENSLX_PASS%')
// Autoload classes from ./inc which adhere to naming scheme <lowercasename>.inc.php
spl_autoload_register(function ($class) {
- $file = 'inc/' . preg_replace('/[^a-z0-9]/', '', mb_strtolower($class)) . '.inc.php';
+ $file = 'inc/' . strtolower(preg_replace('/[^A-Za-z0-9]/', '', $class)) . '.inc.php';
if (!file_exists($file))
return;
require_once $file;
});
-function isLocalExecution()
+function isLocalExecution(): bool
{
return !isset($_SERVER['REMOTE_ADDR']) || $_SERVER['REMOTE_ADDR'] === '127.0.0.1';
}
@@ -55,7 +59,7 @@ if (Module::isAvailable($module)) {
}
if (!file_exists($module)) {
- Util::traceError('Invalid module, or module without API: ' . $module);
+ ErrorHandler::traceError('Invalid module, or module without API: ' . $module);
}
if (php_sapi_name() === 'cli') {
register_shutdown_function(function() {
diff --git a/apis/clientlog.inc.php b/apis/clientlog.inc.php
index b68e4632..29838dfc 100644
--- a/apis/clientlog.inc.php
+++ b/apis/clientlog.inc.php
@@ -9,7 +9,7 @@
if (empty($_POST['type'])) die('Missing options.');
$type = mb_strtolower($_POST['type']);
-if ($type{0} === '~' || $type{0} === '.') {
+if ($type[0] === '~' || $type[0] === '.') {
if (Module::isAvailable('statistics')) {
require 'modules/statistics/api.inc.php';
}
diff --git a/apis/cron.inc.php b/apis/cron.inc.php
index d00c179c..03b6201f 100644
--- a/apis/cron.inc.php
+++ b/apis/cron.inc.php
@@ -14,15 +14,15 @@ define('CRON_KEY_STATUS', 'cron.key.status');
define('CRON_KEY_BLOCKED', 'cron.key.blocked');
// Crash report mode - used by system crontab entry
-if (($report = Request::get('crashreport', false, 'string'))) {
+if (($report = Request::get('crashreport', false, 'string')) !== false) {
$list = Property::getList(CRON_KEY_STATUS);
if (empty($list)) {
error_log('Cron crash report triggered but no cronjob marked active.');
exit(0);
}
$str = array();
- foreach ($list as $item) {
- Property::removeFromList(CRON_KEY_STATUS, $item);
+ foreach ($list as $subkey => $item) {
+ Property::removeFromListByKey(CRON_KEY_STATUS, $subkey);
$entry = explode('|', $item, 2);
if (count($entry) !== 2)
continue;
@@ -35,7 +35,7 @@ if (($report = Request::get('crashreport', false, 'string'))) {
if (empty($str)) {
$str = 'an unknown module';
}
- $message = 'Conjob failed. No reply by ' . implode(', ', $str);
+ $message = 'Cronjob failed. No reply by ' . implode(', ', $str);
$details = '';
if (is_readable($report)) {
$details = file_get_contents($report);
@@ -62,10 +62,7 @@ function getJobStatus($id)
}
// Hooks by other modules
-/**
- * @param Hook $hook
- */
-function handleModule($hook)
+function handleModule(Hook $hook): void
{
global $cron_log_text;
$cron_log_text = '';
@@ -91,13 +88,13 @@ foreach (Hook::load('cron') as $hook) {
$runtime = (time() - $status['start']);
if ($runtime < 0) {
// Clock skew
- Property::removeFromList(CRON_KEY_STATUS, $status['string']);
+ Property::removeFromListByVal(CRON_KEY_STATUS, $status['string']);
} elseif ($runtime < 900) {
// Allow up to 15 minutes for a job to complete before we complain...
continue;
} else {
// Consider job crashed
- Property::removeFromList(CRON_KEY_STATUS, $status['string']);
+ Property::removeFromListByVal(CRON_KEY_STATUS, $status['string']);
EventLog::failure('Cronjob for module ' . $hook->moduleId . ' seems to be stuck or has crashed.');
continue;
}
@@ -114,5 +111,5 @@ foreach (Hook::load('cron') as $hook) {
// Logging
EventLog::failure('Cronjob for module ' . $hook->moduleId . ' has crashed. Check the php or web server error log.', $e->getMessage());
}
- Property::removeFromList(CRON_KEY_STATUS, $value);
+ Property::removeFromListByVal(CRON_KEY_STATUS, $value);
}
diff --git a/apis/getconfig.inc.php b/apis/getconfig.inc.php
index 3fe05ed1..a5d5254d 100644
--- a/apis/getconfig.inc.php
+++ b/apis/getconfig.inc.php
@@ -6,7 +6,7 @@
*/
if (!Module::isAvailable('baseconfig')) {
- Util::traceError('Module baseconfig not available');
+ ErrorHandler::traceError('Module baseconfig not available');
}
require 'modules/baseconfig/api.inc.php'; \ No newline at end of file
diff --git a/config.php.example b/config.php.example
index df361ff9..6b291cd1 100644
--- a/config.php.example
+++ b/config.php.example
@@ -7,11 +7,9 @@ define('CONFIG_SESSION_DIR', '/tmp/openslx');
define('CONFIG_SESSION_TIMEOUT', 86400 * 3);
// Put your mysql credentials here
-define('CONFIG_SQL_DSN', 'mysql:dbname=openslx;host=localhost');
+define('CONFIG_SQL_DSN', 'mysql:dbname=openslx;host=localhost;charset=utf8mb4');
define('CONFIG_SQL_USER', 'openslx');
define('CONFIG_SQL_PASS', '%MYSQL_OPENSLX_PASS%');
-// Set this to true if you mysql server doesn't default to UTF-8 on new connections
-define('CONFIG_SQL_FORCE_UTF8', true);
define('CONFIG_TM_PASSWORD', '%TM_OPENSLX_PASS%');
@@ -35,6 +33,8 @@ define('CONFIG_BIOS_URL', 'https://bwlp-masterserver.ruf.uni-freiburg.de/bios/li
define('CONFIG_PRODUCT_NAME', 'OpenSLX');
define('CONFIG_PRODUCT_NAME_LONG', 'OpenSLX Admin');
+date_default_timezone_set('Europe/Berlin');
+
// Sort order for menu
// Optional - if missing, will be sorted by module id (internal name)
// Here it is also possible to assign a module to a different category,
diff --git a/inc/arrayutil.inc.php b/inc/arrayutil.inc.php
index 490b5a4f..3d93d7d5 100644
--- a/inc/arrayutil.inc.php
+++ b/inc/arrayutil.inc.php
@@ -1,33 +1,24 @@
<?php
+declare(strict_types=1);
+
class ArrayUtil
{
/**
* Take an array of arrays, take given key from each sub-array and return
* new array with just those corresponding values.
- * @param array $list
- * @param string $key
- * @return array
*/
- public static function flattenByKey(array $list, string $key)
+ public static function flattenByKey(array $list, string $key): array
{
- $ret = [];
- foreach ($list as $item) {
- if (array_key_exists($key, $item)) {
- $ret[] = $item[$key];
- }
- }
- return $ret;
+ return array_column($list, $key);
}
/**
* Pass an array of arrays you want to merge. The keys of the outer array will become
* the inner keys of the resulting array, and vice versa.
- * @param array $arrays
- * @return array
*/
- public static function mergeByKey(array $arrays)
+ public static function mergeByKey(array $arrays): array
{
$empty = array_combine(array_keys($arrays), array_fill(0, count($arrays), false));
$out = [];
@@ -42,4 +33,51 @@ class ArrayUtil
return $out;
}
+ /**
+ * Sort array by given column.
+ */
+ public static function sortByColumn(array &$array, string $column, int $sortOrder = SORT_ASC, int $sortFlags = SORT_REGULAR): void
+ {
+ $sorter = array_column($array, $column);
+ array_multisort($sorter, $sortOrder, $sortFlags, $array);
+ }
+
+ /**
+ * Check whether $array contains all keys given in $keyList
+ *
+ * @param array $array An array
+ * @param array $keyList A list of strings which must all be valid keys in $array
+ */
+ public static function hasAllKeys(array $array, array $keyList): bool
+ {
+ foreach ($keyList as $key) {
+ if (!isset($array[$key]))
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Check if all elements in given array are primitive types,
+ * i.e. not object, array or resource.
+ */
+ public static function isOnlyPrimitiveTypes(array $array): bool
+ {
+ foreach ($array as $item) {
+ if (is_array($item) || is_object($item) || is_resource($item))
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Force each element of given array to be of type $type.
+ */
+ public static function forceType(array &$array, string $type): void
+ {
+ foreach ($array as &$elem) {
+ settype($elem, $type);
+ }
+ }
+
} \ No newline at end of file
diff --git a/inc/crypto.inc.php b/inc/crypto.inc.php
index 56f5073c..acefcf67 100644
--- a/inc/crypto.inc.php
+++ b/inc/crypto.inc.php
@@ -1,5 +1,7 @@
<?php
+declare(strict_types=1);
+
class Crypto
{
@@ -8,19 +10,25 @@ class Crypto
* which translates to ~130 bit salt
* and 5000 rounds of hashing with SHA-512.
*/
- public static function hash6($password)
+ public static function hash6(string $password): string
{
- $salt = substr(str_replace('+', '.', base64_encode(pack('N4', mt_rand(), mt_rand(), mt_rand(), mt_rand()))), 0, 16);
+ $bytes = Util::randomBytes(16);
+ if ($bytes === null)
+ ErrorHandler::traceError('Could not get random bytes');
+ $salt = substr(str_replace('+', '.',
+ base64_encode($bytes)), 0, 16);
$hash = crypt($password, '$6$' . $salt);
- if (strlen($hash) < 60) Util::traceError('Error hashing password using SHA-512');
+ if ($hash === null || strlen($hash) < 60) {
+ ErrorHandler::traceError('Error hashing password using SHA-512');
+ }
return $hash;
}
/**
- * Check if the given password matches the given cryp hash.
+ * Check if the given password matches the given crypt hash.
* Useful for checking a hashed password.
*/
- public static function verify($password, $hash)
+ public static function verify(string $password, string $hash): bool
{
return crypt($password, $hash) === $hash;
}
diff --git a/inc/dashboard.inc.php b/inc/dashboard.inc.php
index 0b0f69e3..449fe7c0 100644
--- a/inc/dashboard.inc.php
+++ b/inc/dashboard.inc.php
@@ -1,5 +1,7 @@
<?php
+declare(strict_types=1);
+
class Dashboard
{
@@ -7,12 +9,12 @@ class Dashboard
private static $subMenu = array();
private static $disabled = false;
- public static function disable()
+ public static function disable(): void
{
self::$disabled = true;
}
- public static function createMenu()
+ public static function createMenu(): void
{
if (self::$disabled)
return;
@@ -22,7 +24,7 @@ class Dashboard
if (isset($MENU_CAT_OVERRIDE)) {
foreach ($MENU_CAT_OVERRIDE as $cat => $list) {
foreach ($list as $mod) {
- $modByCategory[$cat][$mod] = false;
+ $modByCategory[$cat][$mod] = null;
$modById[$mod] =& $modByCategory[$cat][$mod];
}
}
@@ -30,10 +32,10 @@ class Dashboard
$all = Module::getEnabled(true);
foreach ($all as $module) {
$cat = $module->getCategory();
- if ($cat === false)
+ if (empty($cat))
continue;
$modId = $module->getIdentifier();
- if (isset($modById[$modId])) {
+ if (array_key_exists($modId, $modById)) {
$modById[$modId] = $module;
} else {
$modByCategory[$cat][$modId] = $module;
@@ -43,10 +45,10 @@ class Dashboard
$categories = array();
foreach ($modByCategory as $catId => $modList) {
$collapse = true;
- /* @var Module[] $modList */
+ /* @var (?Module)[] $modList */
$modules = array();
- foreach ($modList as $modId => $module) {
- if ($module === false)
+ foreach ($modList as $module) {
+ if ($module === null)
continue; // Was set in $MENU_CAT_OVERRIDE, but is not enabled
$newEntry = array(
'displayName' => $module->getDisplayName(),
@@ -62,28 +64,25 @@ class Dashboard
}
$modules[] = $newEntry;
}
- $categories[] = array(
+ $categories[] = [
'icon' => self::getCategoryIcon($catId),
'displayName' => Dictionary::getCategoryName($catId),
'modules' => $modules,
'collapse' => $collapse,
- );
+ ];
}
- Render::setDashboard(array(
+ Render::setDashboard([
'categories' => $categories,
'url' => urlencode($_SERVER['REQUEST_URI']),
'langs' => Dictionary::getLanguages(true),
'user' => User::getName(),
'warning' => User::getName() !== false && User::hasPermission('.eventlog.*') && User::getLastSeenEvent() < Property::getLastWarningId(),
'needsSetup' => User::getName() !== false && Property::getNeedsSetup()
- ));
+ ]);
}
- public static function getCategoryIcon($category)
+ public static function getCategoryIcon(string $category): string
{
- if ($category === false) {
- return '';
- }
if (!preg_match('/^(\w+)\.(.*)$/', $category, $out)) {
error_log('Requested category icon for invalid category "' . $category . '"');
return '';
@@ -105,12 +104,12 @@ class Dashboard
return 'glyphicon glyphicon-' . self::$iconCache[$module][$icon];
}
- public static function addSubmenu($url, $name)
+ public static function addSubmenu(string $url, string $name): void
{
self::$subMenu[] = array('url' => $url, 'name' => $name);
}
- public static function getSubmenus()
+ public static function getSubmenus(): array
{
return self::$subMenu;
}
diff --git a/inc/database.inc.php b/inc/database.inc.php
index eddd4faf..83720baa 100644
--- a/inc/database.inc.php
+++ b/inc/database.inc.php
@@ -1,5 +1,7 @@
<?php
+declare(strict_types=1);
+
/**
* Handle communication with the database
* This is a very thin layer between you and PDO.
@@ -11,37 +13,38 @@ class Database
* @var \PDO Database handle
*/
private static $dbh = false;
- /*
- * @var \PDOStatement[]
- */
- private static $statements = array();
- private static $returnErrors;
- private static $lastError = false;
+
+ /** @var bool */
+ private static $returnErrors = false;
+ /** @var ?string */
+ private static $lastError = null;
private static $explainList = array();
private static $queryCount = 0;
+ /** @var float */
private static $queryTime = 0;
/**
* Connect to the DB if not already connected.
*/
- public static function init($returnErrors = false)
+ public static function init(bool $returnErrors = false): bool
{
if (self::$dbh !== false)
return true;
self::$returnErrors = $returnErrors;
try {
- if (CONFIG_SQL_FORCE_UTF8) {
- self::$dbh = new PDO(CONFIG_SQL_DSN, CONFIG_SQL_USER, CONFIG_SQL_PASS, array(PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8"));
- } else {
- self::$dbh = new PDO(CONFIG_SQL_DSN, CONFIG_SQL_USER, CONFIG_SQL_PASS);
- }
+ self::$dbh = new PDO(CONFIG_SQL_DSN, CONFIG_SQL_USER, CONFIG_SQL_PASS, [
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ PDO::ATTR_EMULATE_PREPARES => true,
+ PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4', // Somehow needed, even if charset=utf8mb4 is in DSN?
+ ]);
} catch (PDOException $e) {
if (self::$returnErrors)
return false;
- Util::traceError('Connecting to the local database failed: ' . $e->getMessage());
+ ErrorHandler::traceError('Connecting to the local database failed: ' . $e->getMessage());
}
if (CONFIG_DEBUG) {
- Database::exec("SET sql_mode='STRICT_TRANS_TABLES'");
+ Database::exec("SET SESSION sql_mode='STRICT_ALL_TABLES,NO_ENGINE_SUBSTITUTION,ERROR_FOR_DIVISION_BY_ZERO'");
+ Database::exec("SET SESSION innodb_strict_mode=ON");
register_shutdown_function(function() {
self::examineLoggedQueries();
});
@@ -54,12 +57,12 @@ class Database
*
* @return array|boolean Associative array representing row, or false if no row matches the query
*/
- public static function queryFirst($query, $args = array(), $ignoreError = null)
+ public static function queryFirst(string $query, array $args = [], bool $ignoreError = null)
{
$res = self::simpleQuery($query, $args, $ignoreError);
if ($res === false)
return false;
- return $res->fetch(PDO::FETCH_ASSOC);
+ return $res->fetch();
}
/**
@@ -69,12 +72,12 @@ class Database
*
* @return array|bool List of associative arrays representing rows, or false on error
*/
- public static function queryAll($query, $args = array(), $ignoreError = null)
+ public static function queryAll(string $query, array $args = [], bool $ignoreError = null)
{
$res = self::simpleQuery($query, $args, $ignoreError);
if ($res === false)
return false;
- return $res->fetchAll(PDO::FETCH_ASSOC);
+ return $res->fetchAll();
}
/**
@@ -82,7 +85,7 @@ class Database
*
* @return array|bool List of values representing first column of query
*/
- public static function queryColumnArray($query, $args = array(), $ignoreError = null)
+ public static function queryColumnArray(string $query, array $args = [], bool $ignoreError = null)
{
$res = self::simpleQuery($query, $args, $ignoreError);
if ($res === false)
@@ -91,15 +94,63 @@ class Database
}
/**
+ * Fetch two columns as key => value list.
+ *
+ * @return array|bool Associative array, first column is key, second column is value
+ */
+ public static function queryKeyValueList(string $query, array $args = [], bool $ignoreError = null)
+ {
+ $res = self::simpleQuery($query, $args, $ignoreError);
+ if ($res === false)
+ return false;
+ return $res->fetchAll(PDO::FETCH_KEY_PAIR);
+ }
+
+ /**
+ * Fetch and group by first column. First column is key, value is a list of rows with remaining columns.
+ * [
+ * col1 => [
+ * [col2, col3],
+ * [col2, col3],
+ * ],
+ * ...,
+ * ]
+ *
+ * @return array|bool Associative array, first column is key, remaining columns are array values
+ */
+ public static function queryGroupList(string $query, array $args = [], bool $ignoreError = null)
+ {
+ $res = self::simpleQuery($query, $args, $ignoreError);
+ if ($res === false)
+ return false;
+ return $res->fetchAll(PDO::FETCH_GROUP);
+ }
+
+ /**
+ * Fetch and use first column as key of returned array.
+ * This is like queryGroup list, but it is assumed that the first column is unique, so
+ * the remaining columns won't be wrapped in another array.
+ *
+ * @return array|bool Associative array, first column is key, remaining columns are array values
+ */
+ public static function queryIndexedList(string $query, array $args = [], bool $ignoreError = null)
+ {
+ $res = self::simpleQuery($query, $args, $ignoreError);
+ if ($res === false)
+ return false;
+ return $res->fetchAll(PDO::FETCH_GROUP | PDO::FETCH_UNIQUE);
+ }
+
+ /**
* Execute the given query and return the number of rows affected.
* Mostly useful for UPDATEs or INSERTs
*
* @param string $query Query to run
* @param array $args Arguments to query
- * @param boolean $ignoreError Ignore query errors and just return false
+ * @param ?bool $ignoreError Ignore query errors and just return false
* @return int|boolean Number of rows affected, or false on error
*/
- public static function exec($query, $args = array(), $ignoreError = null)
+ public static function exec(string $query, array $args = [], bool $ignoreError = null)
{
$res = self::simpleQuery($query, $args, $ignoreError);
if ($res === false)
@@ -108,19 +159,19 @@ class Database
}
/**
- * Get id (promary key) of last row inserted.
+ * Get id (primary key) of last row inserted.
*
* @return int the id
*/
- public static function lastInsertId()
+ public static function lastInsertId(): int
{
- return self::$dbh->lastInsertId();
+ return (int)self::$dbh->lastInsertId();
}
/**
- * @return string|bool return last error returned by query
+ * @return ?string return last error returned by query
*/
- public static function lastError()
+ public static function lastError(): ?string
{
return self::$lastError;
}
@@ -133,10 +184,10 @@ class Database
*
* @return \PDOStatement|false The query result object
*/
- public static function simpleQuery($query, $args = array(), $ignoreError = null)
+ public static function simpleQuery(string $query, array $args = [], bool $ignoreError = null)
{
self::init();
- if (CONFIG_DEBUG && !isset(self::$explainList[$query]) && preg_match('/^\s*SELECT/is', $query)) {
+ if (CONFIG_DEBUG && !isset(self::$explainList[$query]) && preg_match('/^\s*SELECT/i', $query)) {
self::$explainList[$query] = [$args];
}
// Support passing nested arrays for IN statements, automagically refactor
@@ -152,17 +203,13 @@ class Database
}
}
try {
- if (!isset(self::$statements[$query])) {
- self::$statements[$query] = self::$dbh->prepare($query);
- } else {
- //self::$statements[$query]->closeCursor();
- }
+ $stmt = self::$dbh->prepare($query);
$start = microtime(true);
- if (self::$statements[$query]->execute($args) === false) {
- self::$lastError = implode("\n", self::$statements[$query]->errorInfo());
+ if ($stmt->execute($args) === false) {
+ self::$lastError = implode("\n", $stmt->errorInfo());
if ($ignoreError === true || ($ignoreError === null && self::$returnErrors))
return false;
- Util::traceError("Database Error: \n" . self::$lastError);
+ ErrorHandler::traceError("Database Error: \n" . self::$lastError);
}
if (CONFIG_DEBUG) {
$duration = microtime(true) - $start;
@@ -175,14 +222,13 @@ class Database
}
self::$queryCount += 1;
}
- return self::$statements[$query];
+ return $stmt;
} catch (Exception $e) {
self::$lastError = '(' . $e->getCode() . ') ' . $e->getMessage();
if ($ignoreError === true || ($ignoreError === null && self::$returnErrors))
return false;
- Util::traceError("Database Error: \n" . self::$lastError);
+ ErrorHandler::traceError("Database Error: \n" . self::$lastError);
}
- return false;
}
public static function examineLoggedQueries()
@@ -192,7 +238,7 @@ class Database
}
}
- private static function explainQuery($query, $data)
+ private static function explainQuery(string $query, array $data)
{
$args = array_shift($data);
$slow = false;
@@ -212,7 +258,7 @@ class Database
$res = self::simpleQuery('EXPLAIN ' . $query, $args, true);
if ($res === false)
return;
- $rows = $res->fetchAll(PDO::FETCH_ASSOC);
+ $rows = $res->fetchAll();
if (empty($rows))
return;
$log = $veryslow;
@@ -225,7 +271,7 @@ class Database
$log = true;
}
foreach ($row as $key => $col) {
- $l = strlen($col);
+ $l = strlen((string)($col ?? 'NULL'));
if ($l > $lens[$key]) {
$lens[$key] = $l;
}
@@ -246,7 +292,7 @@ class Database
foreach ($rows as $row) {
$line = '';
foreach ($lens as $key => $len) {
- $line .= '| '. str_pad($row[$key], $len) . ' ';
+ $line .= '| '. str_pad((string)($row[$key] ?? 'NULL'), $len) . ' ';
}
error_log($line . "|");
}
@@ -264,8 +310,9 @@ class Database
*
* @param string $query sql query string
* @param array $args query arguments
+ * @return void
*/
- private static function handleArrayArgument(&$query, &$args)
+ private static function handleArrayArgument(string &$query, array &$args)
{
$again = false;
foreach (array_keys($args) as $key) {
@@ -279,7 +326,7 @@ class Database
continue;
}
$newkey = $key;
- if ($newkey{0} !== ':') {
+ if ($newkey[0] !== ':') {
$newkey = ":$newkey";
}
$new = array();
@@ -306,7 +353,7 @@ class Database
* Simply calls PDO::prepare and returns the PDOStatement.
* You must call PDOStatement::execute manually on it.
*/
- public static function prepare($query)
+ public static function prepare(string $query)
{
self::init();
self::$queryCount += 1; // Cannot know actual count
@@ -334,17 +381,17 @@ class Database
* @param string $table table to insert into
* @param string $aiKey name of the AUTO_INCREMENT column
* @param array $uniqueValues assoc array containing columnName => value mapping
- * @param array $additionalValues assoc array containing columnName => value mapping
- * @return int[] list of AUTO_INCREMENT values matching the list of $values
+ * @param ?array $additionalValues assoc array containing columnName => value mapping
+ * @return int AUTO_INCREMENT value matching the given unique values entry
*/
- public static function insertIgnore($table, $aiKey, $uniqueValues, $additionalValues = false)
+ public static function insertIgnore(string $table, string $aiKey, array $uniqueValues, array $additionalValues = null): int
{
// Sanity checks
if (array_key_exists($aiKey, $uniqueValues)) {
- Util::traceError("$aiKey must not be in \$uniqueValues");
+ ErrorHandler::traceError("$aiKey must not be in \$uniqueValues");
}
if (is_array($additionalValues) && array_key_exists($aiKey, $additionalValues)) {
- Util::traceError("$aiKey must not be in \$additionalValues");
+ ErrorHandler::traceError("$aiKey must not be in \$additionalValues");
}
// Simple SELECT first
$selectSql = 'SELECT ' . $aiKey . ' FROM ' . $table . ' WHERE 1';
@@ -402,17 +449,17 @@ class Database
// Insert done, retrieve key again
$res = self::queryFirst($selectSql, $uniqueValues);
if ($res === false) {
- Util::traceError('Could not find value in table ' . $table . ' that was just inserted');
+ ErrorHandler::traceError('Could not find value in table ' . $table . ' that was just inserted');
}
return $res[$aiKey];
}
- public static function getQueryCount()
+ public static function getQueryCount(): int
{
return self::$queryCount;
}
- public static function getQueryTime()
+ public static function getQueryTime(): float
{
return self::$queryTime;
}
diff --git a/inc/dictionary.inc.php b/inc/dictionary.inc.php
index e366207f..3a2f9c2b 100644
--- a/inc/dictionary.inc.php
+++ b/inc/dictionary.inc.php
@@ -1,21 +1,22 @@
<?php
+declare(strict_types=1);
+
class Dictionary
{
/**
* @var string[] Array of languages, numeric index, two letter CC as values
*/
- private static $languages = false;
+ private static $languages = [];
/**
- * @var array Array of languages, numeric index, values are ['name' => 'Language Name', 'cc' => 'xx']
+ * @var array{'name': string, 'cc': string}|null Long name of language, and CC
*/
- private static $languagesLong = false;
- private static $stringCache = array();
+ private static $languagesLong = null;
+ private static $stringCache = [];
- public static function init()
+ public static function init(): void
{
- self::$languages = array();
foreach (glob('lang/??', GLOB_ONLYDIR) as $lang) {
if (!file_exists($lang . '/name.txt') && !file_exists($lang . '/flag.png'))
continue;
@@ -64,29 +65,56 @@ class Dictionary
}
/**
+ * Format given number using country-specific decimal point and thousands
+ * separator.
+ * @param float $num Number to format
+ * @param int $decimals How many decimals to display
+ */
+ public static function number(float $num, int $decimals = 0): string
+ {
+ static $dec = null, $tho = null;
+ if ($dec === null) {
+ if (LANG === 'de') {
+ $dec = ',';
+ $tho = '.';
+ } elseif (LANG !== 'en' && file_exists("lang/" . LANG . "/format.txt")) {
+ $tmp = file_get_contents("lang/" . LANG . "/format.txt");
+ $dec = $tmp[0];
+ $tho = $tmp[1];
+ } else {
+ $dec = '.';
+ $tho = ',';
+ }
+ }
+ return number_format($num, $decimals, $dec, $tho);
+ }
+
+ /**
* Get complete key=>value list for given module, file, language
*
* @param string $module Module name
* @param string $file Dictionary name
- * @param string|false $lang Language CC, false === current language
+ * @param ?string $lang Language CC, false === current language
* @return array assoc array mapping language tags to the translated strings
*/
- public static function getArray($module, $file, $lang = false)
+ public static function getArray(string $module, string $file, ?string $lang = null): array
{
- if ($lang === false)
+ if ($lang === null)
$lang = LANG;
$path = Util::safePath("modules/{$module}/lang/{$lang}/{$file}.json");
+ if ($path === null)
+ ErrorHandler::traceError("Invalid path");
if (isset(self::$stringCache[$path]))
return self::$stringCache[$path];
if (!file_exists($path))
- return array();
+ return [];
$content = file_get_contents($path);
if ($content === false) { // File does not exist for language
$content = '[]';
}
$json = json_decode($content, true);
if (!is_array($json)) {
- $json = array();
+ $json = [];
}
return self::$stringCache[$path] = $json;
}
@@ -101,7 +129,7 @@ class Dictionary
* @param bool $returnTagOnMissing If true, the tag name enclosed in {{}} will be returned if the tag does not exist
* @return string|false The requested tag's translation, or false if not found and $returnTagOnMissing === false
*/
- public static function translateFileModule($moduleId, $file, $tag, $returnTagOnMissing = false)
+ public static function translateFileModule(string $moduleId, string $file, string $tag, bool $returnTagOnMissing = true)
{
$strings = self::getArray($moduleId, $file);
if (!isset($strings[$tag])) {
@@ -121,7 +149,7 @@ class Dictionary
* @param bool $returnTagOnMissing If true, the tag name enclosed in {{}} will be returned if the tag does not exist
* @return string|false The requested tag's translation, or false if not found and $returnTagOnMissing === false
*/
- public static function translateFile($file, $tag, $returnTagOnMissing = false)
+ public static function translateFile(string $file, string $tag, bool $returnTagOnMissing = true)
{
if (!class_exists('Page') || Page::getModule() === false)
return false; // We have no page - return false for now, as we're most likely running in api or install mode
@@ -135,9 +163,9 @@ class Dictionary
* @param bool $returnTagOnMissing If true, the tag name enclosed in {{}} will be returned if the tag does not exist
* @return string|false The requested tag's translation, or false if not found and $returnTagOnMissing === false
*/
- public static function translate($tag, $returnTagOnMissing = false)
+ public static function translate(string $tag, bool $returnTagOnMissing = true)
{
- $string = self::translateFile('module', $tag);
+ $string = self::translateFile('module', $tag, false);
if ($string !== false)
return $string;
$string = self::translateFileModule('main', 'global-tags', $tag);
@@ -151,9 +179,8 @@ class Dictionary
*
* @param string $module Module the message belongs to
* @param string $id Message id
- * @return string|false
*/
- public static function getMessage($module, $id)
+ public static function getMessage(string $module, string $id): string
{
$string = self::translateFileModule($module, 'messages', $id);
if ($string === false) {
@@ -165,19 +192,18 @@ class Dictionary
/**
* Get translation of the given category.
*
- * @param string $category
+ * @param string $category Menu category to get localized name for
* @return string Category name, or some generic fallback to the given category id
*/
- public static function getCategoryName($category)
+ public static function getCategoryName(string $category): string
{
- if ($category === false) {
- return 'No Category';
- }
- if (!preg_match('/^(\w+)\.(.*)$/', $category, $out)) {
- return 'Invalid Category ID format: ' . $category;
+ if (!empty($category)) {
+ if (!preg_match('/^(\w+)\.(.*)$/', $category, $out)) {
+ return 'Invalid Category ID format: ' . $category;
+ }
+ $string = self::translateFileModule($out[1], 'categories', $out[2]);
}
- $string = self::translateFileModule($out[1], 'categories', $out[2]);
- if ($string === false) {
+ if (empty($category) || $string === false) {
return "!!{$category}!!";
}
return $string;
@@ -190,12 +216,12 @@ class Dictionary
* false = regular array containing only the ccs
* @return array List of languages
*/
- public static function getLanguages($withName = false)
+ public static function getLanguages(bool $withName = false): ?array
{
if (!$withName)
return self::$languages;
- if (self::$languagesLong === false) {
- self::$languagesLong = array();
+ if (self::$languagesLong === null) {
+ self::$languagesLong = [];
foreach (self::$languages as $lang) {
if (file_exists("lang/$lang/name.txt")) {
$name = file_get_contents("lang/$lang/name.txt");
@@ -205,10 +231,10 @@ class Dictionary
if (!isset($name) || $name === false) {
$name = $lang;
}
- self::$languagesLong[] = array(
+ self::$languagesLong[] = [
'cc' => $lang,
- 'name' => $name
- );
+ 'name' => $name,
+ ];
}
}
return self::$languagesLong;
@@ -217,11 +243,8 @@ class Dictionary
/**
* Get name of language matching given language CC.
* Default to the CC if the language isn't known.
- *
- * @param string $langCC
- * @return string
*/
- public static function getLanguageName($langCC)
+ public static function getLanguageName(string $langCC): string
{
if (file_exists("lang/$langCC/name.txt")) {
$name = file_get_contents("lang/$langCC/name.txt");
@@ -239,12 +262,12 @@ class Dictionary
* to the image, otherwise, it is just added as the title attribute.
*
* @param $caption bool with caption next to <img>
- * @param $langCC string Language cc to get flag code for - defaults to current language
+ * @param $langCC ?string Language cc to get flag code for - defaults to current language
* @return string html code of img tag for language
*/
- public static function getFlagHtml($caption = false, $langCC = false)
+ public static function getFlagHtml(bool $caption = false, string $langCC = null): string
{
- if ($langCC === false) {
+ if ($langCC === null) {
$langCC = LANG;
}
$flag = "lang/$langCC/flag.png";
diff --git a/inc/download.inc.php b/inc/download.inc.php
index 39f8e2e2..8358eaa3 100644
--- a/inc/download.inc.php
+++ b/inc/download.inc.php
@@ -1,67 +1,53 @@
<?php
+declare(strict_types=1);
+
class Download
{
+ /**
+ * @var false|resource
+ */
private static $curlHandle = false;
/**
* Common initialization for download and downloadToFile
* Return file handle to header file
+ * @return false|resource
*/
- private static function initCurl($url, $timeout, &$head)
+ private static function initCurl(string $url, int $timeout)
{
if (self::$curlHandle === false) {
self::$curlHandle = curl_init();
if (self::$curlHandle === false) {
- Util::traceError('Could not initialize cURL');
+ ErrorHandler::traceError('Could not initialize cURL');
}
curl_setopt(self::$curlHandle, CURLOPT_CONNECTTIMEOUT, ceil($timeout / 2));
curl_setopt(self::$curlHandle, CURLOPT_TIMEOUT, $timeout);
curl_setopt(self::$curlHandle, CURLOPT_FOLLOWLOCATION, true);
curl_setopt(self::$curlHandle, CURLOPT_AUTOREFERER, true);
- curl_setopt(self::$curlHandle, CURLOPT_BINARYTRANSFER, true);
curl_setopt(self::$curlHandle, CURLOPT_MAXREDIRS, 6);
+ curl_setopt(self::$curlHandle, CURLOPT_ACCEPT_ENCODING, '');
+ curl_setopt(self::$curlHandle, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) SLX-Admin/1.0');
+ curl_setopt(self::$curlHandle, CURLOPT_PROTOCOLS, CURLPROTO_FTP | CURLPROTO_FTPS | CURLPROTO_HTTP | CURLPROTO_HTTPS);
}
curl_setopt(self::$curlHandle, CURLOPT_URL, $url);
- $tmpfile = tempnam('/tmp/', 'bwlp-');
- $head = fopen($tmpfile, 'w+b');
- unlink($tmpfile);
- if ($head === false)
- Util::traceError("Could not open temporary head file $tmpfile for writing.");
- curl_setopt(self::$curlHandle, CURLOPT_WRITEHEADER, $head);
return self::$curlHandle;
}
/**
- * Read 10kb from the given file handle, seek to 0 first,
- * close the file after reading. Returns data read
- */
- private static function getContents($fh)
- {
- fseek($fh, 0, SEEK_SET);
- $data = fread($fh, 10000);
- fclose($fh);
- return $data;
- }
-
- /**
* Download file, obey given timeout in seconds
* Return data on success, false on failure
*/
- public static function asString($url, $timeout, &$code)
+ public static function asString(string $url, int $timeout, ?int &$code)
{
- $ch = self::initCurl($url, $timeout, $head);
+ $ch = self::initCurl($url, $timeout);
+ curl_setopt($ch, CURLOPT_FILE, null);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$data = curl_exec($ch);
- $head = self::getContents($head);
- if (preg_match_all('#^HTTP/\d+\.\d+ (\d+) #m', $head, $out)) {
- $code = (int) array_pop($out[1]);
- } else {
- $code = 999;
- }
+ $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
return $data;
}
@@ -71,9 +57,10 @@ class Download
* @param string $url URL to fetch
* @param array|false $params POST params to set in body, list of key-value-pairs
* @param int $timeout timeout in seconds
- * @param int $code HTTP response code, or 999 on error
+ * @param ?int $code HTTP response code, or 999 on error
+ * @return string|false
*/
- public static function asStringPost($url, $params, $timeout, &$code)
+ public static function asStringPost(string $url, $params, int $timeout, ?int &$code)
{
$string = '';
if (is_array($params)) {
@@ -84,17 +71,13 @@ class Download
$string .= $k . '=' . urlencode($v);
}
}
- $ch = self::initCurl($url, $timeout, $head);
+ $ch = self::initCurl($url, $timeout);
+ curl_setopt($ch, CURLOPT_FILE, null);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $string);
$data = curl_exec($ch);
- $head = self::getContents($head);
- if (preg_match_all('#^HTTP/\d+\.\d+ (\d+) #m', $head, $out)) {
- $code = (int) array_pop($out[1]);
- } else {
- $code = 999;
- }
+ $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
return $data;
}
@@ -104,28 +87,23 @@ class Download
* @param string $target destination path to download file to
* @param string $url URL of file to download
* @param int $timeout timeout in seconds
- * @param int $code HTTP status code passed out by reference
- * @return boolean
+ * @param ?int $code HTTP status code passed out by reference
*/
- public static function toFile($target, $url, $timeout, &$code)
+ public static function toFile(string $target, string $url, int $timeout, ?int &$code): bool
{
$fh = fopen($target, 'wb');
if ($fh === false)
- Util::traceError("Could not open $target for writing.");
- $ch = self::initCurl($url, $timeout, $head);
+ ErrorHandler::traceError("Could not open $target for writing.");
+ $ch = self::initCurl($url, $timeout);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
curl_setopt($ch, CURLOPT_FILE, $fh);
$res = curl_exec($ch);
- $head = self::getContents($head);
+ $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
fclose($fh);
if ($res === false) {
@unlink($target);
return false;
}
- if (preg_match_all('#^HTTP/\d+\.\d+ (\d+) #m', $head, $out)) {
- $code = (int) array_pop($out[1]);
- } else {
- $code = '999 ' . curl_error($ch);
- }
return true;
}
diff --git a/inc/errorhandler.inc.php b/inc/errorhandler.inc.php
new file mode 100644
index 00000000..ce969966
--- /dev/null
+++ b/inc/errorhandler.inc.php
@@ -0,0 +1,153 @@
+<?php
+
+declare(strict_types=1);
+
+use JetBrains\PhpStorm\NoReturn;
+
+class ErrorHandler
+{
+
+
+ /**
+ * Displays an error message and stops script execution.
+ * If CONFIG_DEBUG is true, it will also dump a stack trace
+ * and all globally defined variables.
+ * (As this might reveal sensitive data you should never enable it in production)
+ */
+ #[NoReturn]
+ public static function traceError(string $message): void
+ {
+ if ((defined('API') && API) || (defined('AJAX') && AJAX) || php_sapi_name() === 'cli') {
+ error_log('API ERROR: ' . $message);
+ error_log(self::formatBacktracePlain(debug_backtrace()));
+ }
+ if (php_sapi_name() === 'cli') {
+ // Don't spam HTML when invoked via cli, above error_log should have gone to stdout/stderr
+ exit(1);
+ }
+ Header('HTTP/1.1 500 Internal Server Error');
+ if (isset($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'html') === false) {
+ Header('Content-Type: text/plain; charset=utf-8');
+ echo 'API ERROR: ', $message, "\n", self::formatBacktracePlain(debug_backtrace());
+ exit(0);
+ }
+ Header('Content-Type: text/html; charset=utf-8');
+ echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><style>', "\n",
+ ".arg { color: red; background: white; }\n",
+ "h1 a { color: inherit; text-decoration: inherit; font-weight: inherit; }\n",
+ '</style><title>Fatal Error</title></head><body>';
+ echo '<h1>Flagrant <a href="https://www.youtube.com/watch?v=7rrZ-sA4FQc&t=2m2s" target="_blank">S</a>ystem error</h1>';
+ echo "<h2>Message</h2><pre>$message</pre>";
+ if (strpos($message, 'Database') !== false) {
+ echo '<div><a href="install.php">Try running database setup</a></div>';
+ }
+ echo "<br><br>";
+ if (defined('CONFIG_DEBUG') && CONFIG_DEBUG) {
+ global $SLX_ERRORS;
+ if (!empty($SLX_ERRORS)) {
+ echo '<h2>PHP Errors</h2><pre>';
+ foreach ($SLX_ERRORS as $error) {
+ echo htmlspecialchars("{$error['errstr']} ({$error['errfile']}:{$error['errline']}\n");
+ }
+ echo '</pre>';
+ }
+ echo "<h2>Stack Trace</h2>";
+ echo '<pre>', self::formatBacktraceHtml(debug_backtrace()), '</pre>';
+ echo "<h2>Globals</h2><pre>";
+ echo htmlspecialchars(print_r($GLOBALS, true));
+ echo '</pre>';
+ } else {
+ echo <<<SADFACE
+<pre>
+________________________¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶________
+____________________¶¶¶___________________¶¶¶¶_____
+________________¶¶¶_________________________¶¶¶¶___
+______________¶¶______________________________¶¶¶__
+___________¶¶¶_________________________________¶¶¶_
+_________¶¶_____________________________________¶¶¶
+________¶¶_________¶¶¶¶¶___________¶¶¶¶¶_________¶¶
+______¶¶__________¶¶¶¶¶¶__________¶¶¶¶¶¶_________¶¶
+_____¶¶___________¶¶¶¶____________¶¶¶¶___________¶¶
+____¶¶___________________________________________¶¶
+___¶¶___________________________________________¶¶_
+__¶¶____________________¶¶¶¶____________________¶¶_
+_¶¶_______________¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶______________¶¶__
+_¶¶____________¶¶¶¶___________¶¶¶¶¶___________¶¶___
+¶¶¶_________¶¶¶__________________¶¶__________¶¶____
+¶¶_________¶______________________¶¶________¶¶_____
+¶¶¶______¶________________________¶¶_______¶¶______
+¶¶¶_____¶_________________________¶¶_____¶¶________
+_¶¶¶___________________________________¶¶__________
+__¶¶¶________________________________¶¶____________
+___¶¶¶____________________________¶¶_______________
+____¶¶¶¶______________________¶¶¶__________________
+_______¶¶¶¶¶_____________¶¶¶¶¶_____________________
+</pre>
+SADFACE;
+ }
+ echo '</body></html>';
+ exit(0);
+ }
+
+ public static function formatBacktraceHtml(array $trace): string
+ {
+ $output = '';
+ foreach ($trace as $idx => $line) {
+ $args = array();
+ foreach ($line['args'] as $arg) {
+ $arg = self::formatArgument($arg);
+ $args[] = '<span class="arg">' . htmlspecialchars($arg) . '</span>';
+ }
+ $frame = str_pad('#' . $idx, 3, ' ', STR_PAD_LEFT);
+ $function = htmlspecialchars($line['function']);
+ $args = implode(', ', $args);
+ $file = preg_replace('~(/[^/]+)$~', '<b>$1</b>', htmlspecialchars($line['file']));
+ // Add line
+ $output .= $frame . ' ' . $function . '<b>(</b>'
+ . $args . '<b>)</b>' . ' @ <i>' . $file . '</i>:' . $line['line'] . "\n";
+ }
+ return $output;
+ }
+
+ public static function formatBacktracePlain(array $trace): string
+ {
+ $output = '';
+ foreach ($trace as $idx => $line) {
+ $args = array();
+ foreach ($line['args'] as $arg) {
+ $args[] = self::formatArgument($arg);
+ }
+ $frame = str_pad('#' . $idx, 3, ' ', STR_PAD_LEFT);
+ $args = implode(', ', $args);
+ // Add line
+ $output .= "\n" . $frame . ' ' . $line['function'] . '('
+ . $args . ')' . ' @ ' . $line['file'] . ':' . $line['line'];
+ }
+ return $output;
+ }
+
+ private static function formatArgument($arg, bool $expandArray = true): string
+ {
+ if (is_string($arg)) {
+ $arg = "'$arg'";
+ } elseif (is_object($arg)) {
+ $arg = 'instanceof ' . get_class($arg);
+ } elseif (is_array($arg)) {
+ if ($expandArray && count($arg) < 20) {
+ $expanded = '';
+ foreach ($arg as $key => $value) {
+ if (!empty($expanded)) {
+ $expanded .= ', ';
+ }
+ $expanded .= $key . ': ' . self::formatArgument($value, false);
+ if (strlen($expanded) > 200)
+ break;
+ }
+ if (strlen($expanded) <= 200)
+ return '[' . $expanded . ']';
+ }
+ $arg = 'Array(' . count($arg) . ')';
+ }
+ return (string)$arg;
+ }
+} \ No newline at end of file
diff --git a/inc/event.inc.php b/inc/event.inc.php
index 2d916b48..4d02b580 100644
--- a/inc/event.inc.php
+++ b/inc/event.inc.php
@@ -1,5 +1,7 @@
<?php
+declare(strict_types=1);
+
/**
* Class with static functions that are called when a specific event
* took place, like the server has been booted, or the interface address
@@ -15,7 +17,7 @@ class Event
* Called when the system (re)booted. Could be implemented
* by a @reboot entry in crontab (running as the same user php does)
*/
- public static function systemBooted()
+ public static function systemBooted(): void
{
EventLog::info('System boot...');
$everythingFine = true;
@@ -45,7 +47,7 @@ class Event
// Check status of all tasks
// Mount vm store
- if ($mountId === false) {
+ if ($mountId === null) {
EventLog::info('No VM store type defined.');
$everythingFine = false;
} else {
@@ -58,13 +60,13 @@ class Event
$everythingFine = false;
}
// iPXE generation
- if ($ipxeId === false) {
+ if ($ipxeId === null) {
EventLog::failure('Cannot generate PXE menu: Taskmanager unreachable!');
$everythingFine = false;
} else {
$res = Taskmanager::waitComplete($ipxeId, 5000);
if (Taskmanager::isFailed($res)) {
- EventLog::failure('Update PXE Menu failed', $res['data']['error'] ?? $res['data']['error'] ?? '');
+ EventLog::failure('Update PXE Menu failed', $res['data']['error'] ?? $res['statusCode'] ?? '');
$everythingFine = false;
}
}
@@ -78,10 +80,10 @@ class Event
$mountId = Trigger::mount();
$mountStatus = Taskmanager::waitComplete($mountId, 10000);
}
- if ($mountId !== false && Taskmanager::isFailed($mountStatus)) {
+ if ($mountId !== null && Taskmanager::isFailed($mountStatus)) {
EventLog::failure('Mounting VM store failed', $mountStatus['data']['messages'] ?? '');
$everythingFine = false;
- } elseif ($mountId !== false && !Taskmanager::isFinished($mountStatus)) {
+ } elseif ($mountId !== null && !Taskmanager::isFinished($mountStatus)) {
// TODO: Still running - create callback
}
@@ -96,7 +98,7 @@ class Event
/**
* Server's primary IP address changed.
*/
- public static function serverIpChanged()
+ public static function serverIpChanged(): void
{
Trigger::ipxe();
if (Module::isAvailable('sysconfig')) { // TODO: Modularize events
diff --git a/inc/eventlog.inc.php b/inc/eventlog.inc.php
index 3ebb82a4..99585abd 100644
--- a/inc/eventlog.inc.php
+++ b/inc/eventlog.inc.php
@@ -1,5 +1,7 @@
<?php
+declare(strict_types=1);
+
/**
* Class to add entries to the event log. Technically this class belongs to the
* eventlog module, but since it is used in so many places, this helper resides
@@ -10,46 +12,71 @@
class EventLog
{
- private static function log($type, $message, $details)
+ private static function log(string $type, string $message, string $details, bool $markWarning): void
{
if (!Module::isAvailable('eventlog')) {
// Eventlog module does not exist; the eventlog table might not exist, so bail out
error_log($message);
return;
}
- Database::exec("INSERT INTO eventlog (dateline, logtypeid, description, extra)"
- . " VALUES (UNIX_TIMESTAMP(), :type, :message, :details)", array(
- 'type' => $type,
- 'message' => $message,
- 'details' => $details
- ), true);
+ if (mb_strlen($message) > 255) {
+ $message = mb_substr($message, 0, 255);
+ }
+ if (mb_strlen($details) > 65535) {
+ $details = mb_substr($details, 0, 65535);
+ }
+ $data = [
+ 'type' => $type,
+ 'message' => $message,
+ 'details' => $details,
+ ];
+ if (Database::exec("INSERT INTO eventlog (dateline, logtypeid, description, extra)"
+ . " VALUES (UNIX_TIMESTAMP(), :type, :message, :details)", $data, true) === false) {
+ error_log($message);
+ } else {
+ // Insert ok, see if we should update the "latest warning" id
+ $id = Database::lastInsertId();
+ if ($id !== 0 && $markWarning) {
+ Property::setLastWarningId($id);
+ }
+ }
+ self::applyFilterRules('#serverlog', $data);
}
- public static function failure($message, $details = '')
+ public static function failure(string $message, string $details = ''): void
{
- self::log('failure', $message, $details);
- Property::setLastWarningId(Database::lastInsertId());
+ self::log('failure', $message, $details, true);
}
- public static function warning($message, $details = '')
+ public static function warning(string $message, string $details = ''): void
{
- self::log('warning', $message, $details);
- Property::setLastWarningId(Database::lastInsertId());
+ self::log('warning', $message, $details, true);
}
- public static function info($message, $details = '')
+ public static function info(string $message, string $details = ''): void
{
- self::log('info', $message, $details);
+ self::log('info', $message, $details, false);
}
/**
* DELETE ENTIRE EVENT LOG!
*/
- public static function clear()
+ public static function clear(): void
{
if (!Module::isAvailable('eventlog'))
return;
Database::exec("TRUNCATE eventlog");
}
-
+
+ /**
+ * @param string $type the event. Will either be client state like ~poweron, ~runstate etc. or a client log type
+ * @param array $data A structured array containing event specific data that can be matched.
+ */
+ public static function applyFilterRules(string $type, array $data): void
+ {
+ if (!Module::isAvailable('eventlog'))
+ return;
+ FilterRuleProcessor::applyFilterRules($type, $data);
+ }
+
}
diff --git a/inc/fileutil.inc.php b/inc/fileutil.inc.php
index f35f987e..e986b2de 100644
--- a/inc/fileutil.inc.php
+++ b/inc/fileutil.inc.php
@@ -1,5 +1,7 @@
<?php
+declare(strict_types=1);
+
class FileUtil
{
@@ -8,9 +10,9 @@ class FileUtil
*
* @param string $file file to read
* @param int $maxBytes maximum length to read
- * @return boolean|string data, false on error
+ * @return false|string data, false on error
*/
- public static function readFile($file, $maxBytes = 1000)
+ public static function readFile(string $file, int $maxBytes = 1000)
{
$fh = @fopen($file, 'rb');
if ($fh === false)
@@ -19,18 +21,18 @@ class FileUtil
fclose($fh);
return $data;
}
-
+
/**
* Read a file of key=value lines to assoc array.
*
* @param string $file Filename
- * @return boolean|array assoc array, false on error
+ * @return ?array assoc array, null on error
*/
- public static function fileToArray($file)
+ public static function fileToArray(string $file): ?array
{
$data = self::readFile($file, 2000);
if ($data === false)
- return false;
+ return null;
$data = explode("\n", str_replace("\r", "\n", $data));
$ret = array();
foreach ($data as $line) {
@@ -40,7 +42,7 @@ class FileUtil
}
return $ret;
}
-
+
/**
* Write given associative array to file as key=value pairs.
*
@@ -48,7 +50,7 @@ class FileUtil
* @param array $array Associative array to write
* @return boolean success of operation
*/
- public static function arrayToFile($file, $array)
+ public static function arrayToFile(string $file, array $array): bool
{
$fh = fopen($file, 'wb');
if ($fh === false)
diff --git a/inc/hook.inc.php b/inc/hook.inc.php
index 05078f72..f7ca617d 100644
--- a/inc/hook.inc.php
+++ b/inc/hook.inc.php
@@ -1,5 +1,7 @@
<?php
+declare(strict_types=1);
+
/**
* Generic helper for getting and executing hooks.
*/
@@ -11,11 +13,12 @@ class Hook
* Internally, this scans for "modules/<*>/hooks/$hookName.inc.php"
* and optionally checks if the module's dependencies are fulfilled,
* then returns a list of all matching modules.
+ *
* @param string $hookName Name of hook to search for.
* @param bool $filterBroken if true, modules that have a hook but have missing deps will not be returned
- * @return \Hook[] list of modules with requested hooks
+ * @return Hook[] list of modules with requested hooks
*/
- public static function load($hookName, $filterBroken = true)
+ public static function load(string $hookName, bool $filterBroken = true): array
{
$retval = array();
foreach (glob('modules/*/hooks/' . $hookName . '.inc.php', GLOB_NOSORT) as $file) {
@@ -33,17 +36,17 @@ class Hook
* @param string $moduleName Module
* @param string $hookName Hook
* @param bool $filterBroken return false if the module has missing deps
- * @return Hook|false hook instance, false on error or if module doesn't have given hook
+ * @return ?Hook hook instance, false on error or if module doesn't have given hook
*/
- public static function loadSingle($moduleName, $hookName, $filterBroken = true)
+ public static function loadSingle(string $moduleName, string $hookName, bool $filterBroken = true): ?Hook
{
if (Module::get($moduleName) === false) // No such module
- return false;
+ return null;
if ($filterBroken && !Module::isAvailable($moduleName)) // Broken
- return false;
+ return null;
$file = 'modules/' . $moduleName . '/hooks/' . $hookName . '.inc.php';
if (!file_exists($file)) // No hook
- return false;
+ return null;
return new Hook($moduleName, $file);
}
@@ -72,7 +75,7 @@ class Hook
try {
return (include $this->file);
} catch (Exception $e) {
- error_log($e);
+ error_log($e->getMessage());
return false;
}
}
diff --git a/inc/iputil.inc.php b/inc/iputil.inc.php
index 69311d7f..a50f22eb 100644
--- a/inc/iputil.inc.php
+++ b/inc/iputil.inc.php
@@ -1,40 +1,43 @@
<?php
+declare(strict_types=1);
+
class IpUtil
{
- public static function rangeToCidr($start, $end)
+ public static function rangeToCidr(int $start, int $end): string
{
- $value = (int)$start ^ (int)$end;
+ $value = $start ^ $end;
if (!self::isAllOnes($value))
return 'NOT SUBNET: ' . long2ip($start) . '-' . long2ip($end);
- $ones = self::countOnes($value);
+ $ones = self::bitLength($value);
return long2ip($start) . '/' . (32 - $ones);
}
- public static function isValidSubnetRange($start, $end)
+ public static function isValidSubnetRange(int $start, int $end): bool
{
- return self::isAllOnes((int)$start ^ (int)$end);
+ return self::isAllOnes($start ^ $end);
}
/**
- * Return number of one bits required to represent
- * this number. Assumes given number is 2^n - 1.
+ * Return number of bits required to represent
+ * this number.
+ * !! Assumes given number is 2^n - 1 !!
*/
- private static function countOnes($value)
+ private static function bitLength(int $value): int
{
// This is log(value) / log(2)
// It should actually be $value + 1, but floating point errors
// start to happen either way at higher values, so with
// the round() thrown in, it doesn't matter...
- return round(log($value) / 0.69314718055995);
+ return (int)round(log($value) / 0.69314718055995);
}
/**
* Is the given number just ones if converted to
* binary (ignoring leading zeros)?
*/
- private static function isAllOnes($value)
+ private static function isAllOnes(int $value): bool
{
return ($value & ($value + 1)) === 0;
}
@@ -44,35 +47,31 @@ class IpUtil
* ['start' => (int), 'end' => (int)] representing
* the according start and end addresses as integer
* values. Returns false on malformed input.
+ *
* @param string $cidr 192.168.101/24, 1.2.3.4/16, ...
- * @return array|false start and end address, false on error
+ * @return array{start: int, end: int}|null start and end address, false on error
*/
- public static function parseCidr($cidr)
+ public static function parseCidr(string $cidr): ?array
{
$parts = explode('/', $cidr);
if (count($parts) !== 2) {
$ip = ip2long($cidr);
if ($ip === false)
- return false;
- if (PHP_INT_SIZE === 4) {
- $ip = sprintf('%u', $ip);
- }
+ return null;
return ['start' => $ip, 'end' => $ip];
}
$ip = $parts[0];
$bits = $parts[1];
if (!is_numeric($bits) || $bits < 0 || $bits > 32)
- return false;
+ return null;
$dots = substr_count($ip, '.');
if ($dots < 3) {
$ip .= str_repeat('.0', 3 - $dots);
}
$ip = ip2long($ip);
if ($ip === false)
- return false;
- $bits = pow(2, 32 - $bits) - 1;
- if (PHP_INT_SIZE === 4)
- return ['start' => sprintf('%u', $ip & ~$bits), 'end' => sprintf('%u', $ip | $bits)];
+ return null;
+ $bits = (int)((2 ** (32 - $bits)) - 1);
return ['start' => $ip & ~$bits, 'end' => $ip | $bits];
}
diff --git a/inc/mailer.inc.php b/inc/mailer.inc.php
new file mode 100644
index 00000000..bfdcd320
--- /dev/null
+++ b/inc/mailer.inc.php
@@ -0,0 +1,186 @@
+<?php
+
+declare(strict_types=1);
+
+class Mailer
+{
+
+ /** @var array|null */
+ private static $configs = null;
+
+ /** @var array */
+ private $curlOptions;
+
+ /** @var string|null */
+ private $replyTo;
+
+ /** @var string */
+ private $errlog = '';
+
+ /** @var string */
+ private $from;
+
+ // $keys = array('host', 'port', 'ssl', 'senderAddress', 'replyTo', 'username', 'password', 'serverName');
+ public function __construct(string $hosturi, bool $startTls, string $from, string $user, string $pass, string $replyTo = null)
+ {
+ $this->from = $from;
+ if (preg_match('/[^<>"\'\s]+@[^<>"\'\s]+/i', $from, $out)) {
+ $from = $out[0];
+ }
+ $this->curlOptions = [
+ CURLOPT_URL => $hosturi,
+ CURLOPT_USE_SSL => $startTls ? CURLUSESSL_ALL : CURLUSESSL_TRY,
+ CURLOPT_MAIL_FROM => $from,
+ CURLOPT_UPLOAD => 1,
+ CURLOPT_VERBOSE => 1, // XXX
+ ];
+ if (!empty($user)) {
+ $this->curlOptions += [
+ CURLOPT_LOGIN_OPTIONS => 'AUTH=NTLM;AUTH=DIGEST-MD5;AUTH=CRAM-MD5;AUTH=PLAIN;AUTH=LOGIN',
+ CURLOPT_USERNAME => $user,
+ CURLOPT_PASSWORD => $pass,
+ ];
+ }
+ $this->replyTo = $replyTo;
+ }
+
+ /**
+ * Send a mail to given list of recipients.
+ * @param string[] $rcpts Recipients
+ * @param string $subject Mail subject
+ * @param string $text Mail body
+ * @return int curl error code, CURLE_OK on success
+ */
+ public function send(array $rcpts, string $subject, string $text): int
+ {
+ // Convert all line breaks to CRLF while trying to avoid introducing additional ones
+ $text = str_replace(["\r\n", "\r"], "\n", $text);
+ $text = str_replace("\n", "\r\n", $text);
+ $mail = ["Date: " . date('r')];
+ foreach ($rcpts as $r) {
+ $mail[] = self::mimeEncode('To', $r);
+ }
+ $mail[] = self::mimeEncode('Content-Type', 'text/plain; charset="utf-8"');
+ $mail[] = self::mimeEncode('Subject', $subject);
+ $mail[] = self::mimeEncode('From', $this->from);
+ $mail[] = self::mimeEncode('Message-ID', '<' . Util::randomUuid() . '@rfcpedant.example.org>');
+ if (!empty($this->replyTo)) {
+ $mail[] = self::mimeEncode('Reply-To', $this->replyTo);
+ }
+
+ $mail = implode("\r\n", $mail) . "\r\n\r\n" . $text;
+ $c = curl_init();
+ $pos = 0;
+ $this->curlOptions[CURLOPT_MAIL_RCPT] = array_map(function ($mail) {
+ return preg_replace('/\s.*$/', '', $mail);
+ }, $rcpts);
+ $this->curlOptions[CURLOPT_READFUNCTION] = function($ch, $fp, $len) use (&$pos, $mail) {
+ $txt = substr($mail, $pos, $len);
+ $pos += strlen($txt);
+ return $txt;
+ };
+ $err = fopen('php://temp', 'w+');
+ $this->curlOptions[CURLOPT_STDERR] = $err;
+ curl_setopt_array($c, $this->curlOptions);
+ curl_exec($c);
+ rewind($err);
+ $this->errlog = stream_get_contents($err);
+ fclose($err);
+ return curl_errno($c);
+ }
+
+ public function getLog(): string
+ {
+ // Remove repeated "Expire" messages, keep only last one
+ return preg_replace('/^\* Expire in \d+ ms for.*[\\r\\n]+(?=\* Expire)/m','$1', $this->errlog);
+ }
+
+ public static function queue(int $configid, array $rcpts, string $subject, string $text): void
+ {
+ foreach ($rcpts as $rcpt) {
+ Database::exec("INSERT INTO mail_queue (rcpt, subject, body, dateline, configid)
+ VALUES (:rcpt, :subject, :body, UNIX_TIMESTAMP(), :config)",
+ ['rcpt' => $rcpt, 'subject' => $subject, 'body' => $text, 'config' => $configid]);
+ }
+ }
+
+ public static function flushQueue(): void
+ {
+ $list = Database::queryGroupList("SELECT Concat(configid, rcpt, subject) AS keyval,
+ mailid, configid, rcpt, subject, body, dateline FROM mail_queue
+ WHERE nexttry <= UNIX_TIMESTAMP() LIMIT 20");
+ $cutoff = time() - 43200; // 12h
+ $mailers = [];
+ // Loop over mails, grouped by configid-rcpt-subject
+ foreach ($list as $mails) {
+ $delete = [];
+ $body = [];
+ // Loop over individual mails in current group
+ foreach ($mails as $mail) {
+ $delete[] = $mail['mailid'];
+ if ($mail['dateline'] < $cutoff) {
+ EventLog::info("Dropping queued mail '{$mail['subject']}' for {$mail['rcpt']} as it's too old.");
+ continue; // Ignore, too old
+ }
+ $body[] = ' *** ' . date('d.m.Y H:i:s', $mail['dateline']) . "\r\n"
+ . $mail['body'];
+ }
+ if (!empty($body) && isset($mail)) {
+ $body = implode("\r\n\r\n - - - - -\r\n\r\n", $body);
+ if (!isset($mailers[$mail['configid']])) {
+ $mailers[$mail['configid']] = self::instanceFromConfig($mail['configid']);
+ if ($mailers[$mail['configid']] === null) {
+ EventLog::failure("Invalid mailer config id: " . $mail['configid']);
+ }
+ }
+ if (($mailer = $mailers[$mail['configid']]) !== null) {
+ $ret = $mailer->send([$mail['rcpt']], $mail['subject'], $body);
+ if ($ret !== CURLE_OK) {
+ Database::exec("UPDATE mail_queue SET nexttry = UNIX_TIMESTAMP() + 7200
+ WHERE mailid IN (:ids)", ['ids' => $delete]);
+ $delete = [];
+ EventLog::info("Error sending mail '{$mail['subject']}' for {$mail['rcpt']}.",
+ $mailer->getLog());
+ }
+ }
+ }
+ // Now delete these, either sending succeeded or it's too old
+ if (!empty($delete)) {
+ Database::exec("DELETE FROM mail_queue WHERE mailid IN (:ids)", ['ids' => $delete]);
+ }
+ }
+ }
+
+ private static function mimeEncode(string $field, string $string): string
+ {
+ if (preg_match('/[\r\n\x7f-\xff]/', $string)) {
+ return iconv_mime_encode($field, $string);
+ }
+ return "$field: $string";
+ }
+
+ private static function getConfig(int $configId): array
+ {
+ if (!is_array(self::$configs)) {
+ self::$configs = Database::queryIndexedList("SELECT configid, host, port, `ssl`, senderaddress, replyto,
+ username, password
+ FROM mail_config");
+ }
+ return self::$configs[$configId] ?? [];
+ }
+
+ public static function instanceFromConfig(int $configId): ?Mailer
+ {
+ $config = self::getConfig($configId);
+ if (empty($config))
+ return null;
+ if ($config['ssl'] === 'IMPLICIT') {
+ $uri = "smtps://{$config['host']}:{$config['port']}";
+ } else {
+ $uri = "smtp://{$config['host']}:{$config['port']}";
+ }
+ return new Mailer($uri, $config['ssl'] === 'EXPLICIT', $config['senderaddress'], $config['username'],
+ $config['password'], $config['replyto']);
+ }
+
+} \ No newline at end of file
diff --git a/inc/message.inc.php b/inc/message.inc.php
index 9197e4c2..119bb2ba 100644
--- a/inc/message.inc.php
+++ b/inc/message.inc.php
@@ -1,5 +1,7 @@
<?php
+declare(strict_types=1);
+
class Message
{
private static $list = array();
@@ -11,40 +13,39 @@ class Message
* yet, it will be added to the queue, otherwise it will be added
* in place during rendering.
*/
- public static function addError($id)
+ public static function addError(string $id, ...$params): void
{
- self::add('danger', $id, func_get_args());
+ self::add('danger', $id, $params);
}
- public static function addWarning($id)
+ public static function addWarning(string $id, ...$params): void
{
- self::add('warning', $id, func_get_args());
+ self::add('warning', $id, $params);
}
- public static function addInfo($id)
+ public static function addInfo(string $id, ...$params): void
{
- self::add('info', $id, func_get_args());
+ self::add('info', $id, $params);
}
- public static function addSuccess($id)
+ public static function addSuccess(string $id, ...$params): void
{
- self::add('success', $id, func_get_args());
+ self::add('success', $id, $params);
}
/**
* Internal function that adds a message. Used by
* addError/Success/Info/... above.
*/
- private static function add($type, $id, $params)
+ private static function add(string $type, string $id, array $params): void
{
if (strstr($id, '.') === false) {
$id = Page::getModule()->getIdentifier() . '.' . $id;
}
- if (count($params) > 1 && $params[1] === true) {
- $params = array_slice($params, 2);
+ if (!empty($params) && $params[0] === true) {
+ $params = array_slice($params, 1);
$linkModule = true;
} else {
- $params = array_slice($params, 1);
$linkModule = false;
}
switch ($type) {
@@ -63,6 +64,7 @@ class Message
default:
$icon = '';
}
+ ArrayUtil::forceType($params, 'string');
self::$list[] = array(
'type' => $type,
'icon' => $icon,
@@ -70,7 +72,9 @@ class Message
'params' => $params,
'link' => $linkModule
);
- if (self::$flushed) self::renderList();
+ if (self::$flushed) {
+ self::renderList();
+ }
}
/**
@@ -78,7 +82,7 @@ class Message
* After calling this, any further calls to add* will be rendered in
* place in the current page output.
*/
- public static function renderList()
+ public static function renderList(): void
{
self::$flushed = true;
if (empty(self::$list))
@@ -122,7 +126,7 @@ class Message
* Get all queued messages, flushing the queue.
* Useful in api/ajax mode.
*/
- public static function asString()
+ public static function asString(): string
{
$return = '';
foreach (self::$list as $item) {
@@ -145,17 +149,20 @@ class Message
* Deserialize any messages from the current HTTP request and
* place them in the message queue.
*/
- public static function fromRequest()
+ public static function fromRequest(): void
{
$messages = is_array($_REQUEST['message']) ? $_REQUEST['message'] : array($_REQUEST['message']);
foreach ($messages as $message) {
$data = explode('|', $message);
+ if (count($data) < 2)
+ continue;
if (substr($data[0], -1) === '@') {
$data[0] = substr($data[0], 0, -1);
- array_splice($data, 1, 0, true);
+ array_splice($data, 2, 0, true);
}
- if (count($data) < 2 || !preg_match('/^(danger|warning|info|success)$/', $data[0])) continue;
- self::add($data[0], $data[1], array_slice($data, 1));
+ if (!preg_match('/^(danger|warning|info|success)$/', $data[0]))
+ continue;
+ self::add($data[0], $data[1], array_slice($data, 2));
}
}
@@ -163,7 +170,7 @@ class Message
* Turn the current message queue into a serialized version,
* suitable for appending to a GET or POST request
*/
- public static function toRequest()
+ public static function toRequest(): string
{
$parts = array();
foreach (array_merge(self::$list, self::$alreadyDisplayed) as $item) {
diff --git a/inc/module.inc.php b/inc/module.inc.php
index 55713cd0..b072c4a2 100644
--- a/inc/module.inc.php
+++ b/inc/module.inc.php
@@ -1,5 +1,7 @@
<?php
+declare(strict_types=1);
+
class Module
{
/*
@@ -7,16 +9,16 @@ class Module
*/
/**
- * @var \Module[]
+ * @var ?Module[]
*/
- private static $modules = false;
+ private static $modules = null;
/**
* @param string $name ID/Internal name of module
- * @param false $ignoreDepFail whether to return the module even if some of its dependencies failed
+ * @param bool $ignoreDepFail whether to return the module even if some of its dependencies failed
* @return false|Module
*/
- public static function get($name, $ignoreDepFail = false)
+ public static function get(string $name, bool $ignoreDepFail = false)
{
if (!isset(self::$modules[$name]))
return false;
@@ -28,13 +30,13 @@ class Module
/**
* Check whether given module is available, that is, all dependencies are
* met. If the module is available, it will be activated, so all its classes
- * are available through the auto-loader, and any js or css is added to the
+ * are available through the autoloader, and any js or css is added to the
* final page output.
*
* @param string $moduleId module to check
* @return bool true if module is available and activated
*/
- public static function isAvailable($moduleId, $activate = true)
+ public static function isAvailable(string $moduleId, bool $activate = true): bool
{
$module = self::get($moduleId);
if ($module === false)
@@ -45,7 +47,7 @@ class Module
return !$module->hasMissingDependencies();
}
- private static function resolveDepsByName($name)
+ private static function resolveDepsByName(string $name): bool
{
if (!isset(self::$modules[$name]))
return false;
@@ -57,7 +59,7 @@ class Module
* @param \Module $mod the module to check
* @return boolean true iff module deps are all found and enabled
*/
- private static function resolveDeps($mod)
+ private static function resolveDeps(Module $mod): bool
{
if (!$mod->depsChecked) {
$mod->depsChecked = true;
@@ -75,7 +77,7 @@ class Module
/**
* @return \Module[] List of valid, enabled modules
*/
- public static function getEnabled($sortById = false)
+ public static function getEnabled(bool $sortById = false): array
{
$ret = array();
$sort = array();
@@ -96,7 +98,7 @@ class Module
/**
* @return \Module[] List of all modules, including with missing deps
*/
- public static function getAll()
+ public static function getAll(): array
{
foreach (self::$modules as $module) {
self::resolveDeps($module);
@@ -107,7 +109,7 @@ class Module
/**
* @return \Module[] List of modules that have been activated
*/
- public static function getActivated()
+ public static function getActivated(): array
{
$ret = array();
$i = 0;
@@ -120,9 +122,9 @@ class Module
return $ret;
}
- public static function init()
+ public static function init(): void
{
- if (self::$modules !== false)
+ if (self::$modules !== null)
return;
$dh = opendir('modules');
if ($dh === false)
@@ -143,7 +145,8 @@ class Module
* Non-static
*/
- private $category = false;
+ /** @var ?string category id */
+ private $category = null;
private $clientPlugin = false;
private $depsMissing = false;
private $depsChecked = false;
@@ -161,7 +164,7 @@ class Module
*/
private $scripts = array();
- private function __construct($name)
+ private function __construct(string $name)
{
$file = 'modules/' . $name . '/config.json';
$json = @json_decode(@file_get_contents($file), true);
@@ -173,30 +176,30 @@ class Module
if (isset($json['category']) && is_string($json['category'])) {
$this->category = $json['category'];
}
- $this->collapse = isset($json['collapse']) && (bool)$json['collapse'];
+ $this->collapse = isset($json['collapse']) && $json['collapse'];
if (isset($json['client-plugin'])) {
$this->clientPlugin = (bool)$json['client-plugin'];
}
$this->name = $name;
}
- public function hasMissingDependencies()
+ public function hasMissingDependencies(): bool
{
return $this->depsMissing;
}
- public function newPage()
+ public function newPage(): Page
{
$modulePath = 'modules/' . $this->name . '/page.inc.php';
if (!file_exists($modulePath)) {
- Util::traceError("Module doesn't have a page: " . $modulePath);
+ ErrorHandler::traceError("Module doesn't have a page: " . $modulePath);
}
require_once $modulePath;
$class = 'Page_' . $this->name;
return new $class();
}
- public function activate($depth, $direct)
+ public function activate(?int $depth, ?bool $direct): bool
{
if ($this->depsMissing)
return false;
@@ -228,14 +231,14 @@ class Module
return true;
}
- public function getDependencies()
+ public function getDependencies(): array
{
$deps = array();
$this->getDepsInternal($deps);
return array_keys($deps);
}
- private function getDepsInternal(&$deps)
+ private function getDepsInternal(array &$deps): void
{
if (!is_array($this->dependencies))
return;
@@ -250,49 +253,49 @@ class Module
}
}
- public function getIdentifier()
+ public function getIdentifier(): string
{
return $this->name;
}
- public function getDisplayName()
+ public function getDisplayName(): string
{
- $string = Dictionary::translateFileModule($this->name, 'module', 'module_name');
+ $string = Dictionary::translateFileModule($this->name, 'module', 'module_name', false);
if ($string === false) {
return '!!' . $this->name . '!!';
}
return $string;
}
- public function getPageTitle()
+ public function getPageTitle(): string
{
- $val = Dictionary::translateFileModule($this->name, 'module', 'page_title');
+ $val = Dictionary::translateFileModule($this->name, 'module', 'page_title', false);
if ($val !== false)
return $val;
return $this->getDisplayName();
}
- public function getCategory()
+ public function getCategory(): ?string
{
return $this->category;
}
- public function getCategoryName()
+ public function getCategoryName(): string
{
return Dictionary::getCategoryName($this->category);
}
- public function doCollapse()
+ public function doCollapse(): bool
{
return $this->collapse;
}
- public function getDir()
+ public function getDir(): string
{
return 'modules/' . $this->name;
}
- public function getScripts()
+ public function getScripts(): array
{
if ($this->directActivation && $this->clientPlugin) {
if (!in_array('clientscript.js', $this->scripts) && file_exists($this->getDir() . '/clientscript.js')) {
@@ -303,7 +306,7 @@ class Module
return [];
}
- public function getCss()
+ public function getCss(): array
{
if ($this->directActivation && $this->clientPlugin) {
if (!in_array('style.css', $this->css) && file_exists($this->getDir() . '/style.css')) {
diff --git a/inc/paginate.inc.php b/inc/paginate.inc.php
index b212e252..7757228a 100644
--- a/inc/paginate.inc.php
+++ b/inc/paginate.inc.php
@@ -1,5 +1,7 @@
<?php
+declare(strict_types=1);
+
class Paginate
{
private $query;
@@ -9,43 +11,44 @@ class Paginate
private $totalRows = false;
/**
- * @query - The query that will return lines to show
- * @currentPage - 0based index of currently viewed page
- * @perPage - Number of items to show per page
- * @url - URL of current wegpage
+ * @param string $query - The query that will return lines to show
+ * @param int $perPage - Number of items to show per page
+ * @param ?string $url - URL of current wegpage
*/
- public function __construct($query, $perPage, $url = false)
+ public function __construct(string $query, int $perPage, string $url = null)
{
$this->currentPage = (isset($_GET['page']) ? (int)$_GET['page'] : 0);
- $this->perPage = (int)$perPage;
+ $this->perPage = $perPage;
if ($this->currentPage < 0) {
- Util::traceError('Current page < 0');
+ ErrorHandler::traceError('Current page < 0');
}
if ($this->perPage < 1) {
- Util::traceError('Per page < 1');
+ ErrorHandler::traceError('Per page < 1');
}
// Query
- if (!preg_match('/\s*SELECT\s/is', $query)) {
- Util::traceError('Query has to start with SELECT!');
+ if (!preg_match('/\s*SELECT\s/i', $query)) {
+ ErrorHandler::traceError('Query has to start with SELECT!');
}
// XXX: MySQL only
- if (preg_match('/^mysql/i', CONFIG_SQL_DSN)) {
+ if (preg_match('/^(mysql|mariadb)/i', CONFIG_SQL_DSN)) {
// Sanity: Check for LIMIT specification at the end
if (preg_match('/LIMIT\s+(\d+|\:\w+|\?)\s*,\s*(\d+|\:\w+|\?)(\s|;)*(\-\-.*)?$/is', $query)) {
- Util::traceError("You cannot pass a query containing a LIMIT to the Paginator class!");
+ ErrorHandler::traceError("You cannot pass a query containing a LIMIT to the Paginator class!");
}
// Sanity: no comment or semi-colon at end (sloppy, might lead to false negatives)
- if (preg_match('/(\-\-|;)(\s|[^\'"`])*$/is', $query)) {
- Util::traceError("Your query must not end in a comment or semi-colon!");
+ if (preg_match('/(\-\-|;)(\s|[^\'"`])*$/i', $query)) {
+ ErrorHandler::traceError("Your query must not end in a comment or semi-colon!");
}
// Don't use SQL_CALC_FOUND_ROWS as it leads to filesort frequently thus being slower than two queries
// See https://www.percona.com/blog/2007/08/28/to-sql_calc_found_rows-or-not-to-sql_calc_found_rows/
} else {
- Util::traceError('Unsupported database engine');
+ ErrorHandler::traceError('Unsupported database engine');
}
// Mangle URL
- if ($url === false) $url = $_SERVER['REQUEST_URI'];
+ if ($url === null) {
+ $url = $_SERVER['REQUEST_URI'];
+ }
if (strpos($url, '?') === false) {
$url .= '?';
} else {
@@ -60,7 +63,7 @@ class Paginate
/**
* Execute the query, returning the PDO query object
*/
- public function exec($args = array())
+ public function exec(array $args = [])
{
$countQuery = preg_replace('/ORDER\s+BY\s.*?(\sASC|\sDESC|$)/is', '', $this->query);
$countQuery = preg_replace('/SELECT\s.*?\sFROM\s/is', 'SELECT Count(*) AS rowcount FROM ', $countQuery);
@@ -71,7 +74,7 @@ class Paginate
return $retval;
}
- public function render($template, $data)
+ public function render(string $template, array $data): void
{
if ($this->totalRows == 0) {
// Shortcut for no content
diff --git a/inc/permission.inc.php b/inc/permission.inc.php
index cd9cc43c..f346f1da 100644
--- a/inc/permission.inc.php
+++ b/inc/permission.inc.php
@@ -1,5 +1,7 @@
<?php
+declare(strict_types=1);
+
class Permission
{
private static $permissions = array(
@@ -9,18 +11,21 @@ class Permission
'translation' => 8, // Can edit translations
);
- public static function get($permission)
+ public static function get(string $permission): int
{
- if (!isset(self::$permissions[$permission])) Util::traceError('Invalid permission: ' . $permission);
+ if (!isset(self::$permissions[$permission])) ErrorHandler::traceError('Invalid permission: ' . $permission);
return self::$permissions[$permission];
}
// TODO: Doc/Refactor
- public static function addGlobalTags(&$array, $locationid, $disabled, $noneAvailDisabled = null)
+ public static function addGlobalTags(?array &$array, ?int $locationid, array $disabled, ?string $noneAvailDisabled = null): void
{
if (Module::get('permissionmanager') === false)
return;
+ if ($array === null) {
+ $array = [];
+ }
$one = false;
foreach ($disabled as $perm) {
if (User::hasPermission($perm, $locationid)) {
@@ -47,7 +52,7 @@ class Permission
}
}
- public static function moduleHasPermissions($moduleId)
+ public static function moduleHasPermissions(string $moduleId): bool
{
if (Module::get('permissionmanager') === false)
return true;
@@ -58,13 +63,8 @@ class Permission
* Takes a list of locations, removes any locations from it where the user doesn't have permission,
* and then re-adds locations resulting from the given query. The given query should return only
* one column per row, which is a location id.
- * @param $passedLocations
- * @param $permission
- * @param $query
- * @param $params
- * @return array
*/
- public static function mergeWithDisallowed($passedLocations, $permission, $query, $params)
+ public static function mergeWithDisallowed(array $passedLocations, string $permission, string $query, array $params): array
{
$allowed = User::getAllowedLocations($permission);
if (in_array(0, $allowed))
diff --git a/inc/property.inc.php b/inc/property.inc.php
index 3911b0d4..aaf03254 100644
--- a/inc/property.inc.php
+++ b/inc/property.inc.php
@@ -1,5 +1,7 @@
<?php
+declare(strict_types=1);
+
/**
* Get or set simple key-value-pairs, backed by the database
* to make them persistent.
@@ -16,12 +18,13 @@ class Property
* @param mixed $default value to return if $key does not exist in the property store
* @return mixed the value attached to $key, or $default if $key does not exist
*/
- public static function get($key, $default = false)
+ public static function get(string $key, $default = false)
{
if (self::$cache === false) {
+ self::$cache = [];
$NOW = time();
$res = Database::simpleQuery("SELECT name, dateline, value FROM property");
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if ($row['dateline'] != 0 && $row['dateline'] < $NOW)
continue;
self::$cache[$row['name']] = $row['value'];
@@ -33,24 +36,30 @@ class Property
}
/**
- * Set value in property store.
+ * Set value in property store. Passing null or false as the value deletes the
+ * entry from the property table.
*
* @param string $key key of value to set
- * @param string $value the value to store for $key
+ * @param string|null|false $value the value to store for $key
* @param int $maxAgeMinutes how long to keep this entry around at least, in minutes. 0 for infinite
*/
- public static function set($key, $value, $maxAgeMinutes = 0)
+ public static function set(string $key, $value, int $maxAgeMinutes = 0): void
{
- if (self::$cache === false || self::get($key) != $value) { // Simple compare, so it works for numbers accidentally casted to string somewhere
+ if ($value === false || $value === null) {
+ Database::exec("DELETE FROM property WHERE name = :key", ['key' => $key]);
+ if (self::$cache !== false) {
+ unset(self::$cache[$key]);
+ }
+ } else {
Database::exec("INSERT INTO property (name, value, dateline) VALUES (:key, :value, :dateline)"
- . " ON DUPLICATE KEY UPDATE value = VALUES(value), dateline = VALUES(dateline)", array(
+ . " ON DUPLICATE KEY UPDATE value = VALUES(value), dateline = VALUES(dateline)", [
'key' => $key,
'value' => $value,
'dateline' => ($maxAgeMinutes === 0 ? 0 : time() + ($maxAgeMinutes * 60))
- ));
- }
- if (self::$cache !== false) {
- self::$cache[$key] = $value;
+ ]);
+ if (self::$cache !== false) {
+ self::$cache[$key] = $value;
+ }
}
}
@@ -60,33 +69,76 @@ class Property
* @param string $key Key of list to get all items for
* @return array All the items matching the key
*/
- public static function getList($key)
+ public static function getList(string $key): array
{
- $res = Database::simpleQuery("SELECT dateline, value FROM property_list WHERE name = :key", compact('key'));
+ $res = Database::simpleQuery("SELECT subkey, dateline, value FROM property_list
+ WHERE `name` = :key", compact('key'));
$NOW = time();
- $return = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ $return = [];
+ foreach ($res as $row) {
if ($row['dateline'] != 0 && $row['dateline'] < $NOW)
continue;
- $return[] = $row['value'];
+ $return[$row['subkey']] = $row['value'];
}
return $return;
}
/**
+ * @return ?string entry from property list
+ */
+ public static function getListEntry(string $key, int $subkey): ?string
+ {
+ $row = Database::queryFirst("SELECT dateline, `value` FROM property_list
+ WHERE `name` = :key AND subkey = :subkey", ['key' => $key, 'subkey' => $subkey]);
+ if ($row === false || ($row['dateline'] != 0 && $row['dateline'] < time()))
+ return null;
+ return $row['value'];
+ }
+
+ /**
* Add item to property list.
*
* @param string $key key of value to set
* @param string $value the value to add for $key
* @param int $maxAgeMinutes how long to keep this entry around at least, in minutes. 0 for infinite
+ * @return int The auto generated sub-key
*/
- public static function addToList($key, $value, $maxAgeMinutes = 0)
+ public static function addToList(string $key, string $value, int $maxAgeMinutes = 0): int
{
Database::exec("INSERT INTO property_list (name, value, dateline) VALUES (:key, :value, :dateline)", array(
'key' => $key,
'value' => $value,
'dateline' => ($maxAgeMinutes === 0 ? 0 : time() + ($maxAgeMinutes * 60))
));
+ return Database::lastInsertId();
+ }
+
+ /**
+ * Update existing entry in property list.
+ *
+ * @param string $key key of list
+ * @param int $subkey subkey of entry in list
+ * @param string $value new value to set entry to
+ * @param int $maxAgeMinutes the new lifetime of that entry
+ * @param ?string $expectedValue if not null, the value will only be updated if it currently has this value
+ * @return bool whether the entry existed and has been updated
+ */
+ public static function updateListEntry(string $key, int $subkey, string $value,
+ int $maxAgeMinutes = 0, string $expectedValue = null): bool
+ {
+ $args = [
+ 'name' => $key,
+ 'subkey' => $subkey,
+ 'newvalue' => $value,
+ 'dateline' => ($maxAgeMinutes === 0 ? 0 : time() + ($maxAgeMinutes * 60)),
+ ];
+ if ($expectedValue !== null) {
+ $args['oldvalue'] = $expectedValue;
+ return Database::exec("UPDATE property_list SET `value` = :newvalue, dateline = :dateline
+ WHERE `name` = :name AND subkey = :subkey AND `value` = :oldvalue", $args) > 0;
+ }
+ return Database::exec("UPDATE property_list SET `value` = :newvalue, dateline = :dateline
+ WHERE `name` = :name AND subkey = :subkey", $args) > 0;
}
/**
@@ -97,7 +149,7 @@ class Property
* @param string $value item to remove
* @return int number of items removed
*/
- public static function removeFromList($key, $value)
+ public static function removeFromListByVal(string $key, string $value): int
{
return Database::exec("DELETE FROM property_list WHERE name = :key AND value = :value", array(
'key' => $key,
@@ -106,12 +158,28 @@ class Property
}
/**
+ * Remove given item from property list. If the list contains this item
+ * multiple times, they will all be removed.
+ *
+ * @param string $key Key of list
+ * @param int $value item to remove
+ * @return bool whether item was found and removed
+ */
+ public static function removeFromListByKey(string $key, int $subkey): bool
+ {
+ return Database::exec("DELETE FROM property_list WHERE name = :key AND subkey = :subkey", array(
+ 'key' => $key,
+ 'subkey' => $subkey,
+ )) > 0;
+ }
+
+ /**
* Delete entire list with given key.
*
* @param string $key Key of list
* @return int number of items removed
*/
- public static function clearList($key)
+ public static function clearList(string $key): int
{
return Database::exec("DELETE FROM property_list WHERE name = :key", compact('key'));
}
@@ -120,12 +188,12 @@ class Property
* Legacy getters/setters
*/
- public static function getServerIp()
+ public static function getServerIp(): string
{
- return self::get('server-ip', 'none');
+ return self::get('server-ip', 'invalid');
}
- public static function setServerIp($value, $automatic = false)
+ public static function setServerIp(string $value, bool $automatic = false): bool
{
if ($value === self::getServerIp())
return false;
@@ -135,19 +203,15 @@ class Property
return true;
}
- public static function getBootMenu()
- {
- return json_decode(self::get('ipxe-menu'), true);
- }
-
- public static function setBootMenu($value)
- {
- self::set('ipxe-menu', json_encode($value));
- }
-
- public static function getVmStoreConfig()
+ public static function getVmStoreConfig(): array
{
- return json_decode(self::get('vmstore-config'), true);
+ $data = self::get('vmstore-config');
+ if (!is_string($data))
+ return [];
+ $data = json_decode($data, true);
+ if (!is_array($data))
+ return [];
+ return $data;
}
public static function getVmStoreUrl()
@@ -169,21 +233,6 @@ class Property
self::set('vmstore-config', json_encode($value));
}
- public static function getDownloadTask($name)
- {
- return self::get('dl-' . $name);
- }
-
- public static function setDownloadTask($name, $taskId)
- {
- self::set('dl-' . $name, $taskId, 5);
- }
-
- public static function getCurrentSchemaVersion()
- {
- return self::get('webif-version');
- }
-
public static function setLastWarningId($id)
{
self::set('last-warn-event-id', $id);
@@ -194,22 +243,22 @@ class Property
return self::get('last-warn-event-id', 0);
}
- public static function setNeedsSetup($value)
+ public static function setNeedsSetup(bool $value)
{
- self::set('needs-setup', $value);
+ self::set('needs-setup', (int)$value);
}
- public static function getNeedsSetup()
+ public static function getNeedsSetup(): bool
{
- return self::get('needs-setup');
+ return self::get('needs-setup') != 0;
}
- public static function setPasswordFieldType($value)
+ public static function setPasswordFieldType(string $value)
{
self::set('password-type', $value);
}
- public static function getPasswordFieldType()
+ public static function getPasswordFieldType(): string
{
return self::get('password-type', 'password');
}
diff --git a/inc/render.inc.php b/inc/render.inc.php
index 2c3a1da7..a636382e 100644
--- a/inc/render.inc.php
+++ b/inc/render.inc.php
@@ -1,5 +1,7 @@
<?php
+declare(strict_types=1);
+
require_once('inc/util.inc.php');
require_once('Mustache/Autoloader.php');
@@ -14,21 +16,22 @@ class Render
{
/**
- * @var Mustache_Engine
+ * @var ?Mustache_Engine
*/
- private static $mustache = false;
+ private static $mustache = null;
private static $body = '';
private static $header = '';
- private static $dashboard = false;
+ /** @var ?array */
+ private static $dashboard = null;
private static $footer = '';
private static $title = '';
private static $templateCache = array();
private static $tags = array();
- public static function init()
+ public static function init(): void
{
- if (self::$mustache !== false)
- Util::traceError('Called Render::init() twice!');
+ if (self::$mustache !== null)
+ ErrorHandler::traceError('Called Render::init() twice!');
$options = array();
$tmp = '/tmp/bwlp-cache';
$dir = is_dir($tmp);
@@ -41,7 +44,7 @@ class Render
self::$mustache = new Mustache_Engine($options);
}
- private static function cssEsc($str)
+ private static function cssEsc(string $str): string
{
return str_replace(array('"', '&', '<', '>'), array('\\000022', '\\000026', '\\00003c', '\\00003e'), $str);
}
@@ -49,12 +52,10 @@ class Render
/**
* Output the buffered, generated page
*/
- public static function output()
+ public static function output(): void
{
Header('Content-Type: text/html; charset=utf-8');
- /* @var $modules Module[] */
$modules = array_reverse(Module::getActivated());
- $pageModule = Page::getModule();
$title = Property::get('page-title-prefix', '');
$bgcolor = Property::get('logo-background', '');
if (!empty($bgcolor) || !empty($title)) {
@@ -99,7 +100,7 @@ class Render
' </head>
<body>
',
- (self::$dashboard !== false ? self::parse('main-menu', self::$dashboard, 'main') : ''),
+ (self::$dashboard !== null ? self::parse('main-menu', self::$dashboard, 'main') : ''),
'<div class="main" id="mainpage"><div class="container-fluid">
',
self::$body
@@ -128,7 +129,7 @@ class Render
/**
* Set the page title (title-tag)
*/
- public static function setTitle($title, $override = true)
+ public static function setTitle(string $title, bool $override = true): void
{
if (!$override && !empty(self::$title))
return;
@@ -138,7 +139,7 @@ class Render
/**
* Add raw html data to the header-section of the generated page
*/
- public static function addHeader($html)
+ public static function addHeader(string $html): void
{
self::$header .= $html . "\n";
}
@@ -146,7 +147,7 @@ class Render
/**
* Add raw html data to the footer-section of the generated page (right before the closing body tag)
*/
- public static function addFooter($html)
+ public static function addFooter(string $html): void
{
self::$footer .= $html . "\n";
}
@@ -154,7 +155,7 @@ class Render
/**
* Add the given template to the output, using the given params for placeholders in the template
*/
- public static function addTemplate($template, $params = false, $module = false)
+ public static function addTemplate(string $template, array $params = [], ?string $module = null)
{
self::$body .= self::parse($template, $params, $module);
}
@@ -167,7 +168,7 @@ class Render
* @param string $template template used to fill the dialog body
* @param array $params parameters for rendering the body template
*/
- public static function addDialog($title, $next, $template, $params = false)
+ public static function addDialog(string $title, bool $next, string $template, array $params = []): void
{
self::addTemplate('dialog-generic', array(
'title' => $title,
@@ -179,7 +180,7 @@ class Render
/**
* Add error message to page
*/
- public static function addError($message)
+ public static function addError($message): void
{
self::addTemplate('messagebox-error', array('message' => $message));
}
@@ -188,12 +189,13 @@ class Render
* Parse template with given params and return; do not add to body
* @param string $template name of template, relative to templates/, without .html extension
* @param array $params tags to render into template
- * @param string $module name of module to load template from; defaults to currently active module
+ * @param ?string $module name of module to load template from; defaults to currently active module
+ * @param ?string $lang override language if not null
* @return string Rendered template
*/
- public static function parse($template, $params = false, $module = false, $lang = false)
+ public static function parse(string $template, array $params = [], ?string $module = null, ?string $lang = null): string
{
- if ($module === false && class_exists('Page')) {
+ if ($module === null && class_exists('Page', false)) {
$module = Page::getModule()->getIdentifier();
}
// Load html snippet
@@ -201,9 +203,6 @@ class Render
if ($html === false) {
return '<h3>Template ' . htmlspecialchars($template) . '</h3>' . nl2br(htmlspecialchars(print_r($params, true))) . '<hr>';
}
- if (!is_array($params)) {
- $params = array();
- }
// Now find all language tags in this array
if (preg_match_all('/{{\s*(lang_.+?)\s*}}/', $html, $out) > 0) {
$dictionary = Dictionary::getArray($module, 'template-tags', $lang);
@@ -211,14 +210,14 @@ class Render
foreach ($out[1] as $tag) {
if ($fallback === false && empty($dictionary[$tag])) {
$fallback = true; // Fallback to general dictionary of main module
- $dictionary = $dictionary + Dictionary::getArray('main', 'global-tags');
+ $dictionary += Dictionary::getArray('main', 'global-tags');
}
// Add untranslated strings to the dictionary, so their tag is seen in the rendered page
if (empty($dictionary[$tag])) {
$dictionary[$tag] = '{{' . $tag . '}}';
}
}
- $params = $params + $dictionary;
+ $params += $dictionary;
}
// Always add token to parameter list
$params['token'] = Session::get('token');
@@ -244,7 +243,7 @@ class Render
*/
public static function openTag($tag, $params = false)
{
- array_push(self::$tags, $tag);
+ self::$tags[] = $tag;
if (!is_array($params)) {
self::$body .= '<' . $tag . '>';
} else {
@@ -262,17 +261,18 @@ class Render
public static function closeTag($tag)
{
if (empty(self::$tags))
- Util::traceError('Tried to close tag ' . $tag . ' when no open tags exist.');
+ ErrorHandler::traceError('Tried to close tag ' . $tag . ' when no open tags exist.');
$last = array_pop(self::$tags);
if ($last !== $tag)
- Util::traceError('Tried to close tag ' . $tag . ' when last opened tag was ' . $last);
+ ErrorHandler::traceError('Tried to close tag ' . $tag . ' when last opened tag was ' . $last);
self::$body .= '</' . $tag . '>';
}
/**
* Private helper: Load the given template and return it
+ * @return false|string
*/
- private static function getTemplate($template, $module)
+ private static function getTemplate(string $template, string $module)
{
$id = "$template/$module";
if (isset(self::$templateCache[$id])) {
@@ -287,12 +287,13 @@ class Render
/**
* Create the dashboard menu
*/
- public static function setDashboard($params)
+ public static function setDashboard(array $params): void
{
self::$dashboard = $params;
}
- public static function readableColor($hex) {
+ public static function readableColor(string $hex): string
+ {
if (strlen($hex) <= 4) {
$cnt = 1;
} else {
diff --git a/inc/request.inc.php b/inc/request.inc.php
index 7e9ed97e..cd782d99 100644
--- a/inc/request.inc.php
+++ b/inc/request.inc.php
@@ -1,5 +1,7 @@
<?php
+declare(strict_types=1);
+
/**
* Wrapper for getting fields from the request (GET, POST, ...)
*/
@@ -23,7 +25,7 @@ class Request
* @param string $type if the parameter exists, cast it to given type
* @return mixed Field from $_GET, or $default if not set
*/
- public static function get($key, $default = false, $type = false)
+ public static function get(string $key, $default = false, $type = false)
{
return self::handle($_GET, $key, $default, $type);
}
@@ -34,7 +36,7 @@ class Request
* @param string $default Value to return if $_POST does not contain $key
* @return mixed Field from $_POST, or $default if not set
*/
- public static function post($key, $default = false, $type = false)
+ public static function post(string $key, $default = false, $type = false)
{
return self::handle($_POST, $key, $default, $type);
}
@@ -45,7 +47,7 @@ class Request
* @param string $default Value to return if $_REQUEST does not contain $key
* @return mixed Field from $_REQUEST, or $default if not set
*/
- public static function any($key, $default = false, $type = false)
+ public static function any(string $key, $default = false, $type = false)
{
return self::handle($_REQUEST, $key, $default, $type);
}
@@ -53,7 +55,7 @@ class Request
/**
* @return true iff the request is a POST request
*/
- public static function isPost()
+ public static function isPost(): bool
{
return $_SERVER['REQUEST_METHOD'] === 'POST';
}
@@ -61,21 +63,21 @@ class Request
/**
* @return true iff the request is a GET request
*/
- public static function isGet()
+ public static function isGet(): bool
{
return $_SERVER['REQUEST_METHOD'] === 'GET';
}
private static function handle(&$array, $key, $default, $type)
{
- if (!isset($array[$key])) {
+ if (!array_key_exists($key, $array)) {
if ($default === self::REQUIRED || $default === self::REQUIRED_EMPTY) {
Message::addError('main.parameter-missing', $key);
Util::redirect('?do=' . $_REQUEST['do']);
}
return $default;
}
- if ($default === self::REQUIRED && (string)$array[$key] === '') {
+ if ($default === self::REQUIRED && $array[$key] === '') {
Message::addError('main.parameter-empty', $key);
Util::redirect('?do=' . $_REQUEST['do']);
}
diff --git a/inc/session.inc.php b/inc/session.inc.php
index cb52cd38..ccb878cd 100644
--- a/inc/session.inc.php
+++ b/inc/session.inc.php
@@ -1,19 +1,21 @@
<?php
-require_once('config.php');
+declare(strict_types=1);
-@mkdir(CONFIG_SESSION_DIR, 0700, true);
-@chmod(CONFIG_SESSION_DIR, 0700);
-if (!is_writable(CONFIG_SESSION_DIR)) die('Config error: Session Path not writable!');
+require_once('config.php');
class Session
{
private static $sid = false;
private static $data = false;
+ private static $dataChanged = false;
+ private static $userId = 0;
+ private static $updateSessionDateline = false;
- private static function generateSessionId($salt)
+ private static function generateSessionId(string $salt): void
{
- if (self::$sid !== false) Util::traceError('Error: Asked to generate session id when already set.');
+ if (self::$sid !== false)
+ ErrorHandler::traceError('Error: Asked to generate session id when already set.');
self::$sid = sha1($salt . ','
. mt_rand(0, 65535)
. $_SERVER['REMOTE_ADDR']
@@ -27,26 +29,42 @@ class Session
);
}
- public static function create($salt = '')
+ public static function create(string $salt, int $userId, bool $fixedAddress): void
{
self::generateSessionId($salt);
- self::$data = array();
+ self::$data = [];
+ self::$userId = $userId;
+ Database::exec("INSERT INTO session (sid, userid, dateline, lastip, fixedip, data)
+ VALUES (:sid, :userid, 0, '', :fixedip, '')", [
+ 'sid' => self::$sid,
+ 'userid' => $userId,
+ 'fixedip' => $fixedAddress ? 1 : 0,
+ ]);
+ self::setupSessionAccounting(true);
}
- public static function load()
+ public static function load(): bool
{
// Try to load session id from cookie
- if (!self::loadSessionId()) return false;
+ if (!self::loadSessionId())
+ return false;
// Succeeded, now try to load session data. If successful, job is done
- if (self::readSessionData()) return true;
+ if (self::readSessionData())
+ return true;
// Loading session data failed
- self::delete();
+ self::$sid = false;
return false;
}
- public static function get($key)
+ public static function getUserId(): int
{
- if (!isset(self::$data[$key]) || !is_array(self::$data[$key])) return false;
+ return self::$userId;
+ }
+
+ public static function get(string $key)
+ {
+ if (!isset(self::$data[$key]) || !is_array(self::$data[$key]))
+ return false;
return self::$data[$key][0];
}
@@ -55,80 +73,132 @@ class Session
* @param mixed $value data to store for key, false = delete
* @param int|false $validMinutes validity in minutes, or false = forever
*/
- public static function set($key, $value, $validMinutes = false)
+ public static function set(string $key, $value, $validMinutes = 60): void
{
- if (self::$data === false) Util::traceError('Tried to set session data with no active session');
+ if (self::$data === false)
+ ErrorHandler::traceError('Tried to set session data with no active session');
if ($value === false) {
unset(self::$data[$key]);
} else {
self::$data[$key] = [$value, $validMinutes === false ? false : time() + $validMinutes * 60];
}
+ self::$dataChanged = true;
}
- private static function loadSessionId()
+ private static function loadSessionId(): bool
{
- if (self::$sid !== false) die('Error: Asked to load session id when already set.');
- if (empty($_COOKIE['sid'])) return false;
+ if (self::$sid !== false)
+ ErrorHandler::traceError('Error: Asked to load session id when already set.');
+ if (empty($_COOKIE['sid']))
+ return false;
$id = preg_replace('/[^a-zA-Z0-9]/', '', $_COOKIE['sid']);
- if (empty($id)) return false;
+ if (empty($id))
+ return false;
self::$sid = $id;
return true;
}
- public static function delete()
+ public static function delete(): void
{
- if (self::$sid === false) return;
- @unlink(self::getSessionFile());
+ if (self::$sid === false)
+ return;
+ Database::exec("DELETE FROM session WHERE sid = :sid",
+ ['sid' => self::$sid]);
self::deleteCookie();
self::$sid = false;
self::$data = false;
}
- public static function deleteCookie()
+ /**
+ * Kill all sessions of currently logged-in user. This can be used as
+ * a security measure if the user suspects that a session left open on
+ * another device could be/is being abused.
+ */
+ public static function deleteAllButCurrent(): void
{
- Util::clearCookie('sid');
+ if (self::$sid === false)
+ return;
+ Database::exec("DELETE FROM session WHERE sid <> :sid AND userid = :uid",
+ ['sid' => self::$sid, 'uid' => self::$userId]);
}
-
- private static function getSessionFile()
+
+ public static function deleteCookie(): void
{
- if (self::$sid === false) Util::traceError('Error: Tried to access session file when no session id was set.');
- return CONFIG_SESSION_DIR . '/' . self::$sid;
+ Util::clearCookie('sid');
}
- private static function readSessionData()
+ private static function readSessionData(): bool
{
- if (self::$data !== false) Util::traceError('Tried to call read session data twice');
- $sessionfile = self::getSessionFile();
- if (!is_readable($sessionfile) || filemtime($sessionfile) + CONFIG_SESSION_TIMEOUT < time()) {
- @unlink($sessionfile);
- return false;
- }
- self::$data = @unserialize(@file_get_contents($sessionfile));
- if (self::$data === false)
- return false;
+ if (self::$data !== false)
+ ErrorHandler::traceError('Tried to call read session data twice');
+ $row = Database::queryFirst("SELECT userid, dateline, lastip, fixedip, data FROM session WHERE sid = :sid",
+ ['sid' => self::$sid]);
$now = time();
- $save = false;
+ if ($row === false || $row['dateline'] < $now) {
+ self::delete();
+ return false;
+ }
+ if ($row['fixedip'] && $row['lastip'] !== $_SERVER['REMOTE_ADDR']) {
+ return false; // Ignore but don't invalidate
+ }
+ // Refresh cookie if appropriate
+ self::setupSessionAccounting(Request::isGet() && $row['dateline'] + 86400 < $now + CONFIG_SESSION_TIMEOUT);
+ self::$userId = (int)$row['userid'];
+ self::$data = @json_decode($row['data'], true);
+ if (!is_array(self::$data)) {
+ self::$data = [];
+ }
foreach (array_keys(self::$data) as $key) {
if (self::$data[$key][1] !== false && self::$data[$key][1] < $now) {
unset(self::$data[$key]);
- $save = true;
+ self::$dataChanged = true;
}
}
- if ($save) {
- self::save();
- }
return true;
}
-
- public static function save()
+
+ private static function setupSessionAccounting(bool $cookie): void
{
- if (self::$sid === false || self::$data === false) return; //Util::traceError('Called saveSession with no active session');
- $sessionfile = self::getSessionFile();
- $ret = @file_put_contents($sessionfile, @serialize(self::$data));
- if (!$ret) Util::traceError('Storing session data in ' . $sessionfile . ' failed.');
- Util::clearCookie('sid');
- $ret = setcookie('sid', self::$sid, time() + CONFIG_SESSION_TIMEOUT, null, null, !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off', true);
- if (!$ret) Util::traceError('Error: Could not set Cookie for Client (headers already sent)');
+ if ($cookie) {
+ self::$updateSessionDateline = true;
+ $ret = setcookie('sid', self::$sid, time() + CONFIG_SESSION_TIMEOUT,
+ '', '', !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off', true);
+ if (!$ret)
+ ErrorHandler::traceError('Error: Could not set Cookie for Client (headers already sent)');
+ }
+ register_shutdown_function(function () {
+ self::saveOnShutdown();
+ });
}
-}
+ private static function saveOnShutdown(): void
+ {
+ $now = time();
+ $args = ['lastip' => $_SERVER['REMOTE_ADDR']];
+ if (self::$updateSessionDateline) {
+ $args['dateline'] = $now + CONFIG_SESSION_TIMEOUT;
+ }
+ if (self::$dataChanged) {
+ $args['data'] = json_encode(self::$data);
+ }
+ self::saveData($args);
+ }
+
+ public static function saveExtraData(): void
+ {
+ if (!self::$dataChanged)
+ return;
+ self::saveData(['data' => json_encode(self::$data)]);
+ self::$dataChanged = false;
+ }
+
+ private static function saveData(array $args): void
+ {
+ $query = "UPDATE session SET " . implode(', ', array_map(function ($key) {
+ return "$key = :$key";
+ }, array_keys($args))) . " WHERE sid = :sid";
+ $args['sid'] = self::$sid;
+ Database::exec($query, $args);
+ }
+
+}
diff --git a/inc/taskmanager.inc.php b/inc/taskmanager.inc.php
index f7c72e04..d9396901 100644
--- a/inc/taskmanager.inc.php
+++ b/inc/taskmanager.inc.php
@@ -1,5 +1,7 @@
<?php
+declare(strict_types=1);
+
/**
* Interface to the external task manager.
*/
@@ -19,7 +21,7 @@ class Taskmanager
*/
private static $sock = false;
- private static function init()
+ private static function init(): void
{
if (self::$sock !== false)
return;
@@ -32,7 +34,7 @@ class Taskmanager
self::send(CONFIG_TM_PASSWORD);
}
- private static function send($message)
+ private static function send(string $message): bool
{
$len = strlen($message);
$sent = socket_send(self::$sock, pack('N', $len) . $message, $len + 4, 0);
@@ -49,9 +51,9 @@ class Taskmanager
* @param array $data data to pass to the task. the structure depends on the task.
* @param boolean $async if true, the function will not wait for the reply of the taskmanager, which means
* the return value is just true (and you won't know if the task could actually be started)
- * @return array|false struct representing the task status (as a result of submit); false on communication error
+ * @return array{id: string, statusCode: string, data: array}|bool struct representing the task status (as a result of submit); false on communication error
*/
- public static function submit($task, $data = false, $async = false)
+ public static function submit(string $task, array $data = null, bool $async = false)
{
self::init();
$seq = (string) mt_rand();
@@ -109,7 +111,7 @@ class Taskmanager
* @param string|array $taskid a task id or a task array returned by ::status or ::submit
* @return boolean true if taskid exists in taskmanager
*/
- public static function isTask($task)
+ public static function isTask($task): bool
{
if ($task === false)
return false;
@@ -127,7 +129,7 @@ class Taskmanager
* @param int $timeout maximum time in ms to wait for completion of task
* @return array|false result/status of task, or false if it couldn't be queried
*/
- public static function waitComplete($task, $timeout = 2500)
+ public static function waitComplete($task, int $timeout = 2500)
{
if (is_array($task) && isset($task['id'])) {
if ($task['statusCode'] !== Taskmanager::TASK_PROCESSING && $task['statusCode'] !== Taskmanager::TASK_WAITING) {
@@ -140,8 +142,9 @@ class Taskmanager
return false;
$done = false;
$deadline = microtime(true) + $timeout / 1000;
+ $status = false;
while (($remaining = $deadline - microtime(true)) > 0) {
- usleep(min(100000, $remaining * 100000));
+ usleep((int)min(100000, $remaining * 100000));
$status = self::status($task);
if (!isset($status['statusCode']))
break;
@@ -163,7 +166,7 @@ class Taskmanager
* @param array|false $task struct representing task, obtained by ::status
* @return boolean true if task failed, false if finished successfully or still waiting/running
*/
- public static function isFailed($task)
+ public static function isFailed($task): bool
{
if (!is_array($task) || !isset($task['statusCode']) || !isset($task['id']))
return true;
@@ -176,10 +179,10 @@ class Taskmanager
* Check whether the given task is finished, i.e. either failed or succeeded,
* but is not running, still waiting for execution or simply unknown.
*
- * @param array $task struct representing task, obtained by ::status
+ * @param mixed $task struct representing task, obtained by ::status
* @return boolean true if task failed or finished, false if waiting for execution or currently executing, no valid task, etc.
*/
- public static function isFinished($task)
+ public static function isFinished($task): bool
{
if (!is_array($task) || !isset($task['statusCode']) || !isset($task['id']))
return false;
@@ -192,10 +195,10 @@ class Taskmanager
* Check whether the given task is running, that is either waiting for execution
* or currently executing.
*
- * @param array $task struct representing task, obtained by ::status
+ * @param mixed $task struct representing task, obtained by ::status
* @return boolean true if task is waiting or executing, false if waiting for execution or currently executing, no valid task, etc.
*/
- public static function isRunning($task)
+ public static function isRunning($task): bool
{
if (!is_array($task) || !isset($task['statusCode']) || !isset($task['id']))
return false;
@@ -204,7 +207,7 @@ class Taskmanager
return false;
}
- public static function addErrorMessage($task)
+ public static function addErrorMessage($task): void
{
static $failure = false;
if ($task === false) {
@@ -231,7 +234,7 @@ class Taskmanager
* @param string|array $task task to release. can either be its id, or a struct representing the task, as returned
* by ::submit() or ::status()
*/
- public static function release($task)
+ public static function release($task): void
{
if (is_array($task) && isset($task['id'])) {
$task = $task['id'];
@@ -247,7 +250,6 @@ class Taskmanager
/**
* Read reply from socket for given sequence number.
*
- * @param string $seq
* @return mixed the decoded json data for that message as an array, or false on error
*/
private static function readReply(string $seq)
@@ -288,7 +290,7 @@ class Taskmanager
if (count($parts) !== 2) {
error_log('TM: Invalid reply, no "," in payload');
} elseif ($parts[0] === 'ERROR') {
- Util::traceError('Taskmanager remote error: ' . $parts[1]);
+ ErrorHandler::traceError('Taskmanager remote error: ' . $parts[1]);
} elseif ($parts[0] === 'WARNING') {
Message::addWarning('main.taskmanager-warning', $parts[1]);
} else {
@@ -324,7 +326,7 @@ class Taskmanager
* sending or receiving and the send or receive
* buffer might be in an undefined state.
*/
- private static function reset()
+ private static function reset(): void
{
if (self::$sock === false)
return;
@@ -335,7 +337,7 @@ class Taskmanager
/**
* @param float $deadline end time
*/
- private static function updateRecvTimeout($deadline)
+ private static function updateRecvTimeout(float $deadline): void
{
$to = $deadline - microtime(true);
if ($to <= 0) {
diff --git a/inc/taskmanagercallback.inc.php b/inc/taskmanagercallback.inc.php
index 5f153baa..c6b447c9 100644
--- a/inc/taskmanagercallback.inc.php
+++ b/inc/taskmanagercallback.inc.php
@@ -1,5 +1,7 @@
<?php
+declare(strict_types=1);
+
/**
* Contains all callbacks for detached taskmanager tasks.
*/
@@ -13,7 +15,7 @@ class TaskmanagerCallback
* @param string|array $task Task or Task ID to define callback for
* @param string $callback name of callback function, must be a static method in this class
*/
- public static function addCallback($task, $callback, $args = NULL)
+ public static function addCallback($task, string $callback, $args = NULL): void
{
if (!call_user_func_array('method_exists', array('TaskmanagerCallback', $callback))) {
EventLog::warning("addCallback: Invalid callback function: $callback");
@@ -50,13 +52,13 @@ class TaskmanagerCallback
*
* @return array list of array(taskid => list of callbacks)
*/
- public static function getPendingCallbacks()
+ public static function getPendingCallbacks(): array
{
$res = Database::simpleQuery("SELECT taskid, cbfunction, args FROM callback", array(), true);
if ($res === false)
return array();
$retval = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$retval[$row['taskid']][] = $row;
}
return $retval;
@@ -67,9 +69,9 @@ class TaskmanagerCallback
* table if appropriate.
*
* @param array $callback entry from the callback table (cbfunction + taskid + args)
- * @param array $status status of the task as returned by the taskmanager. If NULL it will be queried.
+ * @param ?array $status status of the task as returned by the taskmanager. If NULL it will be queried.
*/
- public static function handleCallback($callback, $status = NULL)
+ public static function handleCallback(array $callback, array $status = NULL): void
{
if (is_null($status))
$status = Taskmanager::status($callback['taskid']);
@@ -82,12 +84,13 @@ class TaskmanagerCallback
}
}
if (Taskmanager::isFinished($status)) {
+ Taskmanager::release($status);
$func = array('TaskmanagerCallback', preg_replace('/\W/', '', $callback['cbfunction']));
if (!call_user_func_array('method_exists', $func)) {
Eventlog::warning("handleCallback: Callback {$callback['cbfunction']} doesn't exist.");
} else {
if (empty($callback['args']))
- call_user_func($func, $status);
+ call_user_func($func, $status, null);
else
call_user_func($func, $status, unserialize($callback['args']));
}
@@ -99,7 +102,7 @@ class TaskmanagerCallback
/**
* Result of trying to (re)launch ldadp.
*/
- public static function ldadpStartup($task)
+ public static function ldadpStartup(array $task)
{
if (Taskmanager::isFailed($task)) {
if (!isset($task['data']['messages'])) {
@@ -112,14 +115,14 @@ class TaskmanagerCallback
/**
* Result of restoring the server configuration
*/
- public static function dbRestored($task)
+ public static function dbRestored(array $task)
{
if (!Taskmanager::isFailed($task)) {
EventLog::info('Configuration backup restored.');
}
}
- public static function adConfigCreate($task)
+ public static function adConfigCreate(array $task)
{
if (Taskmanager::isFailed($task))
EventLog::warning("Could not generate Active Directory configuration", $task['data']['error']);
@@ -131,7 +134,7 @@ class TaskmanagerCallback
* @param array $task task obj
* @param array $args has keys 'moduleid' and optionally 'deleteOnError' and 'tmpTgz'
*/
- public static function cbConfModCreated($task, $args)
+ public static function cbConfModCreated(array $task, array $args)
{
$mod = Module::get('sysconfig');
if ($mod === false)
@@ -150,7 +153,7 @@ class TaskmanagerCallback
* @param array $task task obj
* @param array $args has keys 'configid' and optionally 'deleteOnError'
*/
- public static function cbConfTgzCreated($task, $args)
+ public static function cbConfTgzCreated(array $task, array $args)
{
$mod = Module::get('sysconfig');
if ($mod === false)
@@ -163,7 +166,7 @@ class TaskmanagerCallback
}
}
- public static function manualMount($task, $args)
+ public static function manualMount(array $task, $args)
{
if (!isset($task['data']['exitCode']))
return;
@@ -180,11 +183,10 @@ class TaskmanagerCallback
unset($data['storetype']);
Property::setVmStoreConfig($data);
}
- return;
}
}
- public static function mlGotList($task, $args)
+ public static function mlGotList(array $task, $args)
{
$mod = Module::get('minilinux');
if ($mod === false)
@@ -193,7 +195,7 @@ class TaskmanagerCallback
MiniLinux::listDownloadCallback($task, $args);
}
- public static function mlGotLinux($task, $args)
+ public static function mlGotLinux(array $task, $args)
{
$mod = Module::get('minilinux');
if ($mod === false)
@@ -202,7 +204,7 @@ class TaskmanagerCallback
MiniLinux::linuxDownloadCallback($task, $args);
}
- public static function rbcConnCheck($task, $args)
+ public static function rbcConnCheck(array $task, $args)
{
$mod = Module::get('rebootcontrol');
if ($mod === false)
@@ -211,4 +213,31 @@ class TaskmanagerCallback
RebootControl::connectionCheckCallback($task, $args);
}
+ public static function ipxeVersionSet(array $task)
+ {
+ $mod = Module::get('serversetup');
+ if ($mod === false)
+ return;
+ $mod->activate(1, false);
+ IPxeBuilder::setIPxeVersionCallback($task);
+ }
+
+ public static function ipxeCompileDone(array $task)
+ {
+ $mod = Module::get('serversetup');
+ if ($mod === false)
+ return;
+ $mod->activate(1, false);
+ IPxeBuilder::compileCompleteCallback($task);
+ }
+
+ public static function ssUpgradable(array $task): void
+ {
+ $mod = Module::get('systemstatus');
+ if ($mod === false)
+ return;
+ $mod->activate(1, false);
+ SystemStatus::setUpgradableData($task);
+ }
+
}
diff --git a/inc/trigger.inc.php b/inc/trigger.inc.php
index 5024b907..d0d5d365 100644
--- a/inc/trigger.inc.php
+++ b/inc/trigger.inc.php
@@ -1,5 +1,7 @@
<?php
+declare(strict_types=1);
+
/**
* This is one giant class containing various functions that will generate
* required config files, daemon instances and more, mostly through the Taskmanager.
@@ -14,20 +16,21 @@ class Trigger
/**
* Compile iPXE pxelinux menu. Needs to be done whenever the server's IP
* address changes.
- *
- * @param boolean $force force recompilation even if it seems up to date
- * @return boolean|string false if launching task failed, task-id otherwise
+ *
+ * @return ?string null if launching task failed, task-id otherwise
*/
- public static function ipxe()
+ public static function ipxe(string $taskId = null): ?string
{
+ static $lastResult = null;
+ if ($lastResult !== null)
+ return $lastResult;
$hooks = Hook::load('ipxe-update');
- static $taskId = false;
foreach ($hooks as $hook) {
$ret = function($taskId) use ($hook) {
$ret = include_once($hook->file);
if (is_string($ret))
return $ret;
- return isset($taskId) ? $taskId : false;
+ return $taskId;
};
$ret = $ret($taskId);
if (is_string($ret)) {
@@ -36,7 +39,7 @@ class Trigger
$taskId = $ret['id'];
}
}
- Property::set('ipxe-task-id', $taskId, 15);
+ $lastResult = $taskId;
return $taskId;
}
@@ -48,7 +51,7 @@ class Trigger
* @return boolean true if current configured IP address is still valid, or if a new address could
* successfully be determined, false otherwise
*/
- public static function autoUpdateServerIp()
+ public static function autoUpdateServerIp(): bool
{
for ($i = 0; $i < 5; ++$i) {
$task = Taskmanager::submit('LocalAddressesList');
@@ -59,7 +62,7 @@ class Trigger
if ($task === false)
return false;
$task = Taskmanager::waitComplete($task, 10000);
- if (!isset($task['data']['addresses']) || empty($task['data']['addresses']))
+ if (empty($task['data']['addresses']))
return false;
$serverIp = Property::getServerIp();
@@ -96,22 +99,18 @@ class Trigger
/**
* Mount the VM store into the server.
*
- * @param array $vmstore VM Store configuration to use. If false, read from properties
+ * @param array|false $vmstore VM Store configuration to use. If false, read from properties
* @param bool $ifLocalOnly Only execute task if the storage type is local (used for DNBD3)
- * @return string|false task id of mount procedure, or false on error
+ * @return ?string task id of mount procedure, or false on error
*/
- public static function mount($vmstore = false, $ifLocalOnly = false)
+ public static function mount($vmstore = false, bool $ifLocalOnly = false): ?string
{
if ($vmstore === false) {
$vmstore = Property::getVmStoreConfig();
}
if (!is_array($vmstore))
- return false;
- if (isset($vmstore['storetype'])) {
- $storetype = $vmstore['storetype'];
- } else {
- $storetype = 'unknown';
- }
+ return null;
+ $storetype = $vmstore['storetype'] ?? 'unknown';
if ($storetype === 'nfs') {
$addr = $vmstore['nfsaddr'];
$opts = 'nfsopts';
@@ -124,12 +123,8 @@ class Trigger
}
// Bail out if storage is not local, and we only want to run it in that case
if ($ifLocalOnly && $addr !== 'null')
- return false;
- if (isset($vmstore[$opts])) {
- $opts = $vmstore[$opts];
- }else {
- $opts = null;
- }
+ return null;
+ $opts = $vmstore[$opts] ?? null;
$status = Taskmanager::submit('MountVmStore', array(
'address' => $addr,
'type' => 'images',
@@ -143,7 +138,7 @@ class Trigger
// for the taskmanager to give us the existing id
$status = Taskmanager::waitComplete($status, 100);
}
- return $status['data']['existingTask'] ?? $status['id'] ?? false;
+ return $status['data']['existingTask'] ?? $status['id'] ?? null;
}
/**
@@ -151,7 +146,7 @@ class Trigger
*
* @return boolean Whether there are still callbacks pending
*/
- public static function checkCallbacks()
+ public static function checkCallbacks(): bool
{
$tasksLeft = false;
$callbackList = TaskmanagerCallback::getPendingCallbacks();
@@ -162,15 +157,16 @@ class Trigger
foreach ($callbacks as $callback) {
TaskmanagerCallback::handleCallback($callback, $status);
}
- if (Taskmanager::isFailed($status) || Taskmanager::isFinished($status))
+ if (Taskmanager::isFailed($status) || Taskmanager::isFinished($status)) {
Taskmanager::release($status);
- else
+ } else {
$tasksLeft = true;
+ }
}
return $tasksLeft;
}
- private static function triggerDaemons($action, $parent, &$taskids)
+ private static function triggerDaemons(string $action, ?string $parent, array &$taskids): ?string
{
$task = Taskmanager::submit('Systemctl', array(
'operation' => $action,
@@ -198,7 +194,7 @@ class Trigger
/**
* Stop any daemons that might be sitting on the VMstore, or database.
*/
- public static function stopDaemons($parent, &$taskids)
+ public static function stopDaemons(?string $parent, array &$taskids): ?string
{
$parent = self::triggerDaemons('stop', $parent, $taskids);
$task = Taskmanager::submit('LdadpLauncher', array(
diff --git a/inc/user.inc.php b/inc/user.inc.php
index 20e8cd3d..9ef27cd0 100644
--- a/inc/user.inc.php
+++ b/inc/user.inc.php
@@ -1,5 +1,9 @@
<?php
+declare(strict_types=1);
+
+use JetBrains\PhpStorm\NoReturn;
+
require_once('inc/session.inc.php');
class User
@@ -7,7 +11,7 @@ class User
private static $user = false;
- public static function isLoggedIn()
+ public static function isLoggedIn(): bool
{
return self::$user !== false;
}
@@ -26,12 +30,12 @@ class User
return self::$user['fullname'];
}
- public static function hasPermission($permission, $locationid = NULL)
+ public static function hasPermission(string $permission, ?int $locationid = NULL): bool
{
if (!self::isLoggedIn())
return false;
if (Module::isAvailable("permissionmanager")) {
- if ($permission{0} === '.') {
+ if ($permission[0] === '.') {
$permission = substr($permission, 1);
} else {
if (class_exists('Page')) {
@@ -54,11 +58,12 @@ class User
/**
* Confirm current user has the given permission, stop execution and show error message
* otherwise.
+ *
* @param string $permission Permission to check for
* @param null|int $locationid location this permission has to apply to, NULL if any location is sufficient
* @param null|string $redirect page to redirect to if permission is not given, NULL defaults to main page
*/
- public static function assertPermission($permission, $locationid = NULL, $redirect = NULL)
+ public static function assertPermission(string $permission, ?int $locationid = NULL, ?string $redirect = NULL): void
{
if (User::hasPermission($permission, $locationid))
return;
@@ -70,7 +75,7 @@ class User
Message::addError('main.no-permission');
Util::redirect($redirect);
} elseif (Module::isAvailable('permissionmanager')) {
- if ($permission{0} !== '.') {
+ if ($permission[0] !== '.') {
$module = Page::getModule();
if ($module !== false) {
$permission = '.' . $module->getIdentifier() . '.' . $permission;
@@ -83,12 +88,12 @@ class User
}
}
- public static function getAllowedLocations($permission)
+ public static function getAllowedLocations(string $permission): array
{
if (!self::isLoggedIn())
return [];
if (Module::isAvailable("permissionmanager")) {
- if ($permission{0} === '.') {
+ if ($permission[0] === '.') {
$permission = substr($permission, 1);
} else {
$module = Page::getModule();
@@ -105,16 +110,19 @@ class User
}
return $a;
}
- return array();
+ return [];
}
- public static function load()
+ public static function load(): bool
{
if (self::isLoggedIn())
return true;
if (Session::load()) {
- $uid = Session::get('uid');
- if ($uid === false || $uid < 1)
+ if (empty(Session::get('token'))) {
+ self::generateToken();
+ }
+ $uid = Session::getUserId();
+ if ($uid < 1)
self::logout();
self::$user = Database::queryFirst('SELECT * FROM user WHERE userid = :uid LIMIT 1', array(':uid' => $uid));
if (self::$user === false)
@@ -125,7 +133,7 @@ class User
return false;
}
- public static function testPassword($userid, $password)
+ public static function testPassword(string $userid, string $password): bool
{
$ret = Database::queryFirst('SELECT passwd FROM user WHERE userid = :userid LIMIT 1', compact('userid'));
if ($ret === false)
@@ -133,7 +141,7 @@ class User
return Crypto::verify($password, $ret['passwd']);
}
- public static function updatePassword($password)
+ public static function updatePassword(string $password): bool
{
if (!self::isLoggedIn())
return false;
@@ -142,36 +150,27 @@ class User
return Database::exec('UPDATE user SET passwd = :passwd WHERE userid = :userid LIMIT 1', compact('userid', 'passwd')) > 0;
}
- public static function login($user, $pass)
+ public static function login(string $user, string $pass, bool $fixedIp): bool
{
$ret = Database::queryFirst('SELECT userid, passwd FROM user WHERE login = :user LIMIT 1', array(':user' => $user));
if ($ret === false)
return false;
if (!Crypto::verify($pass, $ret['passwd']))
return false;
- Session::create($ret['passwd']);
- Session::set('uid', $ret['userid']);
- Session::set('token', md5($ret['passwd'] . ','
- . rand() . ','
- . time() . ','
- . rand() . ','
- . $_SERVER['REMOTE_ADDR'] . ','
- . rand() . ','
- . $_SERVER['REMOTE_PORT'] . ','
- . rand() . ','
- . $_SERVER['HTTP_USER_AGENT']));
- Session::save();
+ Session::create($ret['passwd'], (int)$ret['userid'], $fixedIp);
+ self::generateToken($ret['passwd']);
return true;
}
- public static function logout()
+ #[NoReturn]
+ public static function logout(): void
{
Session::delete();
Header('Location: ?do=Main&fromlogout');
exit(0);
}
- public static function setLastSeenEvent($eventid)
+ public static function setLastSeenEvent(int $eventid): void
{
if (!self::isLoggedIn())
return;
@@ -189,4 +188,17 @@ class User
return self::$user['lasteventid'];
}
+ private static function generateToken($salt = ''): void
+ {
+ Session::set('token', md5($salt . ','
+ . rand() . ','
+ . time() . ','
+ . rand() . ','
+ . $_SERVER['REMOTE_ADDR'] . ','
+ . rand() . ','
+ . $_SERVER['REMOTE_PORT'] . ','
+ . rand() . ','
+ . $_SERVER['HTTP_USER_AGENT']), false);
+ }
+
}
diff --git a/inc/util.inc.php b/inc/util.inc.php
index 365dc045..267a3971 100644
--- a/inc/util.inc.php
+++ b/inc/util.inc.php
@@ -1,164 +1,27 @@
<?php
+declare(strict_types=1);
+
+use JetBrains\PhpStorm\NoReturn;
+
class Util
{
private static $redirectParams = array();
/**
- * Displays an error message and stops script execution.
- * If CONFIG_DEBUG is true, it will also dump a stack trace
- * and all globally defined variables.
- * (As this might reveal sensitive data you should never enable it in production)
- */
- public static function traceError($message)
- {
- if ((defined('API') && API) || (defined('AJAX') && AJAX) || php_sapi_name() === 'cli') {
- error_log('API ERROR: ' . $message);
- error_log(self::formatBacktracePlain(debug_backtrace()));
- }
- if (php_sapi_name() === 'cli') {
- // Don't spam HTML when invoked via cli, above error_log should have gone to stdout/stderr
- exit(1);
- }
- Header('HTTP/1.1 500 Internal Server Error');
- if (isset($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'html') === false ) {
- Header('Content-Type: text/plain; charset=utf-8');
- echo 'API ERROR: ', $message, "\n", self::formatBacktracePlain(debug_backtrace());
- exit(0);
- }
- Header('Content-Type: text/html; charset=utf-8');
- echo '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><style>', "\n",
- ".arg { color: red; background: white; }\n",
- "h1 a { color: inherit; text-decoration: inherit; font-weight: inherit; }\n",
- '</style><title>Fatal Error</title></head><body>';
- echo '<h1>Flagrant <a href="https://www.youtube.com/watch?v=7rrZ-sA4FQc&t=2m2s" target="_blank">S</a>ystem error</h1>';
- echo "<h2>Message</h2><pre>$message</pre>";
- if (strpos($message, 'Database') !== false) {
- echo '<div><a href="install.php">Try running database setup</a></div>';
- }
- echo "<br><br>";
- if (defined('CONFIG_DEBUG') && CONFIG_DEBUG) {
- global $SLX_ERRORS;
- if (!empty($SLX_ERRORS)) {
- echo '<h2>PHP Errors</h2><pre>';
- foreach ($SLX_ERRORS as $error) {
- echo htmlspecialchars("{$error['errstr']} ({$error['errfile']}:{$error['errline']}\n");
- }
- echo '</pre>';
- }
- echo "<h2>Stack Trace</h2>";
- echo '<pre>', self::formatBacktraceHtml(debug_backtrace()), '</pre>';
- echo "<h2>Globals</h2><pre>";
- echo htmlspecialchars(print_r($GLOBALS, true));
- echo '</pre>';
- } else {
- echo <<<SADFACE
-<pre>
-________________________¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶________
-____________________¶¶¶___________________¶¶¶¶_____
-________________¶¶¶_________________________¶¶¶¶___
-______________¶¶______________________________¶¶¶__
-___________¶¶¶_________________________________¶¶¶_
-_________¶¶_____________________________________¶¶¶
-________¶¶_________¶¶¶¶¶___________¶¶¶¶¶_________¶¶
-______¶¶__________¶¶¶¶¶¶__________¶¶¶¶¶¶_________¶¶
-_____¶¶___________¶¶¶¶____________¶¶¶¶___________¶¶
-____¶¶___________________________________________¶¶
-___¶¶___________________________________________¶¶_
-__¶¶____________________¶¶¶¶____________________¶¶_
-_¶¶_______________¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶______________¶¶__
-_¶¶____________¶¶¶¶___________¶¶¶¶¶___________¶¶___
-¶¶¶_________¶¶¶__________________¶¶__________¶¶____
-¶¶_________¶______________________¶¶________¶¶_____
-¶¶¶______¶________________________¶¶_______¶¶______
-¶¶¶_____¶_________________________¶¶_____¶¶________
-_¶¶¶___________________________________¶¶__________
-__¶¶¶________________________________¶¶____________
-___¶¶¶____________________________¶¶_______________
-____¶¶¶¶______________________¶¶¶__________________
-_______¶¶¶¶¶_____________¶¶¶¶¶_____________________
-</pre>
-SADFACE;
- }
- echo '</body></html>';
- exit(0);
- }
-
- private static function formatArgument($arg, $expandArray = true)
- {
- if (is_string($arg)) {
- $arg = "'$arg'";
- } elseif (is_object($arg)) {
- $arg = 'instanceof ' . get_class($arg);
- } elseif (is_array($arg)) {
- if ($expandArray && count($arg) < 20) {
- $expanded = '';
- foreach ($arg as $key => $value) {
- if (!empty($expanded)) {
- $expanded .= ', ';
- }
- $expanded .= $key . ': ' . self::formatArgument($value, false);
- if (strlen($expanded) > 200)
- break;
- }
- if (strlen($expanded) <= 200)
- return '[' . $expanded . ']';
- }
- $arg = 'Array(' . count($arg) . ')';
- }
- return $arg;
- }
-
- public static function formatBacktraceHtml($trace)
- {
- $output = '';
- foreach ($trace as $idx => $line) {
- $args = array();
- foreach ($line['args'] as $arg) {
- $arg = self::formatArgument($arg);
- $args[] = '<span class="arg">' . htmlspecialchars($arg) . '</span>';
- }
- $frame = str_pad('#' . $idx, 3, ' ', STR_PAD_LEFT);
- $function = htmlspecialchars($line['function']);
- $args = implode(', ', $args);
- $file = preg_replace('~(/[^/]+)$~', '<b>$1</b>', htmlspecialchars($line['file']));
- // Add line
- $output .= $frame . ' ' . $function . '<b>(</b>'
- . $args . '<b>)</b>' . ' @ <i>' . $file . '</i>:' . $line['line'] . "\n";
- }
- return $output;
- }
-
- public static function formatBacktracePlain($trace)
- {
- $output = '';
- foreach ($trace as $idx => $line) {
- $args = array();
- foreach ($line['args'] as $arg) {
- $args[] = self::formatArgument($arg);
- }
- $frame = str_pad('#' . $idx, 3, ' ', STR_PAD_LEFT);
- $args = implode(', ', $args);
- // Add line
- $output .= "\n" . $frame . ' ' . $line['function'] . '('
- . $args . ')' . ' @ ' . $line['file'] . ':' . $line['line'];
- }
- return $output;
- }
-
- /**
* Redirects the user via a '302 Moved' header.
* An active session will be saved, any messages that haven't
* been displayed yet will be appended to the redirect.
+ *
* @param string|false $location Location to redirect to. "false" to redirect to same URL (useful after POSTs)
* @param bool $preferRedirectPost if true, use the value from $_POST['redirect'] instead of $location
*/
- public static function redirect($location = false, $preferRedirectPost = false)
+ #[NoReturn]
+ public static function redirect($location = false, bool $preferRedirectPost = false): void
{
if ($location === false) {
- $location = preg_replace('/([&?])message\[\]\=[^&]*/', '\1', $_SERVER['REQUEST_URI']);
+ $location = preg_replace('/([&?])message\[\]=[^&]*/', '\1', $_SERVER['REQUEST_URI']);
}
- Session::save();
$messages = Message::toRequest();
if ($preferRedirectPost
&& ($redirect = Request::post('redirect', false, 'string')) !== false
@@ -190,7 +53,7 @@ SADFACE;
exit(0);
}
- public static function addRedirectParam($key, $value)
+ public static function addRedirectParam(string $key, string $value): void
{
self::$redirectParams[] = $key . '=' . urlencode($value);
}
@@ -202,7 +65,7 @@ SADFACE;
* token, this function will return false and display an error.
* If the token matches, or the user is not logged in, it will return true.
*/
- public static function verifyToken()
+ public static function verifyToken(): bool
{
if (!User::isLoggedIn() && Session::get('token') === false)
return true;
@@ -219,67 +82,71 @@ SADFACE;
* _word_ is underlined
* \n is line break
*/
- public static function markup($string)
+ public static function markup(string $string): string
{
$string = htmlspecialchars($string);
- $string = preg_replace('#(^|[\n \-_/\.])\*(.+?)\*($|[ \-_/\.\!\?,:])#is', '$1<b>$2</b>$3', $string);
- $string = preg_replace('#(^|[\n \-\*/\.])_(.+?)_($|[ \-\*/\.\!\?,:])#is', '$1<u>$2</u>$3', $string);
- $string = preg_replace('#(^|[\n \-_\*\.])/(.+?)/($|[ \-_\*\.\!\?,:])#is', '$1<i>$2</i>$3', $string);
+ $string = preg_replace('#(^|[\n \-_/.])\*(.+?)\*($|[ \-_/.!?,:])#is', '$1<b>$2</b>$3', $string);
+ $string = preg_replace('#(^|[\n \-*/.])_(.+?)_($|[ \-*/.!?,:])#is', '$1<u>$2</u>$3', $string);
+ $string = preg_replace('#(^|[\n \-_*.])/(.+?)/($|[ \-_*.!?,:])#is', '$1<i>$2</i>$3', $string);
return nl2br($string);
}
/**
- * Convert given number to human readable file size string.
+ * Convert given number to human-readable file size string.
* Will append Bytes, KiB, etc. depending on magnitude of number.
*
* @param float|int $bytes numeric value of the filesize to make readable
* @param int $decimals number of decimals to show, -1 for automatic
* @param int $shift how many units to skip, i.e. if you pass in KiB or MiB
- * @return string human readable string representing the given file size
+ * @return string human-readable string representing the given file size
*/
- public static function readableFileSize($bytes, $decimals = -1, $shift = 0)
+ public static function readableFileSize($bytes, int $decimals = -1, int $shift = 0): string
{
// round doesn't reliably work for large floats, pick workaround depending on OS
- if (PHP_INT_SIZE === 4) {
- $bytes = sprintf('%.0f', $bytes);
- } else {
- $bytes = sprintf('%u', $bytes);
- }
+ $bytes = sprintf('%u', $bytes);
static $sz = array('Byte', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB');
$factor = (int)floor((strlen($bytes) - 1) / 3);
if ($factor === 0) {
$decimals = 0;
} else {
- $bytes = $bytes / pow(1024, $factor);
+ $bytes /= 1024 ** $factor;
if ($decimals === -1) {
- $decimals = 2 - floor(strlen((int)$bytes) - 1);
+ $decimals = 2 - strlen((string)floor($bytes)) - 1;
}
}
- return sprintf("%.{$decimals}f", $bytes) . "\xe2\x80\x89" . $sz[$factor + $shift];
+ return Dictionary::number((float)$bytes, $decimals) . "\xe2\x80\x89" . ($sz[$factor + $shift] ?? '#>PiB#');
}
- public static function sanitizeFilename($name)
+ public static function sanitizeFilename(string $name): string
{
return preg_replace('/[^a-zA-Z0-9_\-]+/', '_', $name);
}
- public static function safePath($path, $prefix = '')
+ /**
+ * Make sure given path is not absolute, and does not contain '..', or weird characters.
+ * Returns sanitized path, or false if invalid. If prefix is given, also make sure
+ * $path starts with it.
+ *
+ * @param string $path path to check for safety
+ * @param string $prefix required prefix of $path
+ */
+ public static function safePath(string $path, string $prefix = ''): ?string
{
if (empty($path))
- return false;
+ return null;
$path = trim($path);
- if ($path{0} == '/' || preg_match('/[\x00-\x19\?\*]/', $path))
- return false;
+ if ($path[0] == '/' || preg_match('/[\x00-\x19?*]/', $path))
+ return null;
if (strpos($path, '..') !== false)
- return false;
+ return null;
if (substr($path, 0, 2) !== './')
$path = "./$path";
- if (empty($prefix))
- return $path;
- if (substr($prefix, 0, 2) !== './')
- $prefix = "./$prefix";
- if (substr($path, 0, strlen($prefix)) !== $prefix)
- return false;
+ if (!empty($prefix)) {
+ if (substr($prefix, 0, 2) !== './')
+ $prefix = "./$prefix";
+ if (substr($path, 0, strlen($prefix)) !== $prefix)
+ return null;
+ }
return $path;
}
@@ -289,7 +156,7 @@ SADFACE;
* @param int $code the code to turn into an error description
* @return string the error description
*/
- public static function uploadErrorString($code)
+ public static function uploadErrorString(int $code): string
{
switch ($code) {
case UPLOAD_ERR_INI_SIZE:
@@ -327,7 +194,7 @@ SADFACE;
* @param string $ip_addr input to check
* @return boolean true iff $ip_addr is a valid public ipv4 address
*/
- public static function isPublicIpv4($ip_addr)
+ public static function isPublicIpv4(string $ip_addr): bool
{
if (!preg_match("/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/", $ip_addr))
return false;
@@ -349,23 +216,6 @@ SADFACE;
}
/**
- * Check whether $arrax contains all keys given in $keyList
- * @param array $array An array
- * @param array $keyList A list of strings which must all be valid keys in $array
- * @return boolean
- */
- public static function hasAllKeys($array, $keyList)
- {
- if (!is_array($array))
- return false;
- foreach ($keyList as $key) {
- if (!isset($array[$key]))
- return false;
- }
- return true;
- }
-
- /**
* Send a file to user for download.
*
* @param string $file path of local file
@@ -375,7 +225,7 @@ SADFACE;
* true: error while reading the file
* - on success, the function does not return
*/
- public static function sendFile($file, $name, $delete = false)
+ public static function sendFile(string $file, string $name, bool $delete = false): bool
{
while ((@ob_get_level()) > 0)
@ob_end_clean();
@@ -403,9 +253,9 @@ SADFACE;
*
* @param int $length number of bytes to return
* @param bool $secure true = only use strong random sources
- * @return string|bool string of requested length, false on error
+ * @return ?string string of requested length, false on error
*/
- public static function randomBytes($length, $secure = true)
+ public static function randomBytes(int $length, bool $secure = true): ?string
{
if (function_exists('random_bytes')) {
try {
@@ -442,7 +292,7 @@ SADFACE;
}
}
if ($secure) {
- return false;
+ return null;
}
$bytes = '';
while ($length > 0) {
@@ -454,9 +304,9 @@ SADFACE;
/**
* @return string a random UUID, v4.
*/
- public static function randomUuid()
+ public static function randomUuid(): string
{
- $b = unpack('h8a/h4b/h12c', self::randomBytes(12));
+ $b = unpack('h8a/h4b/h12c', self::randomBytes(12, false));
return sprintf('%08s-%04s-%04x-%04x-%012s',
// 32 bits for "time_low"
@@ -482,10 +332,11 @@ SADFACE;
/**
* Transform timestamp to easily readable string.
* The format depends on how far the timestamp lies in the past.
+ *
* @param int $ts unix timestamp
- * @return string human readable representation
+ * @return string human-readable representation
*/
- public static function prettyTime($ts)
+ public static function prettyTime(int $ts): string
{
settype($ts, 'int');
if ($ts === 0)
@@ -508,25 +359,25 @@ SADFACE;
/**
* Return localized strings for yes or no depending on $bool
+ *
* @param bool $bool Input to evaluate
* @return string Yes or No, in user's selected language
*/
- public static function boolToString($bool)
+ public static function boolToString(bool $bool): string
{
if ($bool)
- return Dictionary::translate('lang_yes', true);
- return Dictionary::translate('lang_no', true);
+ return Dictionary::translate('lang_yes');
+ return Dictionary::translate('lang_no');
}
/**
* Format a duration, in seconds, into a readable string.
+ *
* @param int $seconds The number to format
- * @param int $showSecs whether to show seconds, or rather cut after minutes
- * @return string
+ * @param bool $showSecs whether to show seconds, or rather cut after minutes
*/
- public static function formatDuration($seconds, $showSecs = true)
+ public static function formatDuration(int $seconds, bool $showSecs = true): string
{
- settype($seconds, 'int');
static $UNITS = ['y' => 31536000, 'mon' => 2592000, 'd' => 86400];
$parts = [];
$prev = false;
@@ -542,7 +393,8 @@ SADFACE;
$prev = true;
}
}
- return implode(' ', $parts) . ' ' . gmdate($showSecs ? 'H:i:s' : 'H:i', $seconds);
+ $parts[] = gmdate($showSecs ? 'H:i:s' : 'H:i', (int)$seconds);
+ return implode(' ', $parts);
}
/**
@@ -551,9 +403,10 @@ SADFACE;
* was a weird problem where firefox would keep sending a cookie with
* path /slx-admin/ but trying to delete it from /slx-admin, which php's
* setcookie automatically sends by default, did not clear it.
+ *
* @param string $name cookie name
*/
- public static function clearCookie($name)
+ public static function clearCookie(string $name): void
{
$parts = explode('/', $_SERVER['SCRIPT_NAME']);
$path = '';
@@ -568,7 +421,7 @@ SADFACE;
/**
* Remove any non-utf8 sequences from string.
*/
- public static function cleanUtf8(string $string) : string
+ public static function cleanUtf8(string $string): string
{
// https://stackoverflow.com/a/1401716/2043481
$regex = '/
@@ -587,15 +440,34 @@ SADFACE;
/**
* Remove non-printable < 0x20 chars from ANSI string, then convert to UTF-8
*/
- public static function ansiToUtf8(string $string) : string
+ public static function ansiToUtf8(string $string): string
{
$regex = '/
(
- (?: [\x20-\xFF] ){1,100} # ignore lower non-printable range
+ [\x20-\xFF]{1,100} # ignore lower non-printable range
)
| . # anything else
/x';
return iconv('MS-ANSI', 'UTF-8', preg_replace($regex, '$1', $string));
}
+ /**
+ * Clamp given value into [min, max] range.
+ * @param mixed $value value to clamp. This should be a number.
+ * @param int $min lower bound
+ * @param int $max upper bound
+ * @param bool $toInt if true, variable type of $value will be set to int in addition to clamping
+ */
+ public static function clamp(&$value, int $min, int $max, bool $toInt = true): void
+ {
+ if (!is_numeric($value) || $value < $min) {
+ $value = $min;
+ } elseif ($value > $max) {
+ $value = $max;
+ }
+ if ($toInt) {
+ settype($value, 'int');
+ }
+ }
+
}
diff --git a/index.php b/index.php
index 1b3eaaec..6b5305b2 100644
--- a/index.php
+++ b/index.php
@@ -1,6 +1,11 @@
<?php
require_once 'config.php';
+if (CONFIG_DEBUG) {
+ error_reporting(E_ALL);
+} else {
+ error_reporting(E_ALL & ~E_DEPRECATED);
+}
if (CONFIG_SQL_PASS === '%MYSQL_OPENSLX_PASS%') {
Header('Content-Type: text/plain; charset=utf-8');
@@ -43,7 +48,7 @@ abstract class Page
public static function render()
{
$pageTitle = self::$module->getPageTitle();
- if ($pageTitle !== false) {
+ if (!empty($pageTitle)) {
Render::setTitle($pageTitle, false);
}
self::$instance->doRender();
@@ -77,7 +82,7 @@ abstract class Page
Module::init();
self::$module = Module::get($name);
if (self::$module === false) {
- Util::traceError('Invalid Module: ' . $name);
+ ErrorHandler::traceError('Invalid Module: ' . $name);
}
self::$module->activate(null, null);
self::$instance = self::$module->newPage();
@@ -150,9 +155,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!Util::verifyToken()) {
if (AJAX) {
die('CSRF/XSS? Missing token in POST request!');
- } else {
- Util::redirect('?do=Main');
}
+ Util::redirect('?do=Main');
}
}
@@ -185,7 +189,7 @@ if (defined('CONFIG_DEBUG') && CONFIG_DEBUG) {
* @param int $code Error code to map
* @return string Readable error type
*/
- function mapErrorCode($code)
+ function mapErrorCode(int $code): string
{
switch ($code) {
case E_PARSE:
@@ -229,7 +233,8 @@ if (CONFIG_DEBUG) {
Render::addTemplate('footer', array('text' =>
round($duration, 3) . 's, '
. Database::getQueryCount() . ' queries, '
- . round(Database::getQueryTime(), 3) . 's query time total'), 'main');
+ . round(Database::getQueryTime(), 3) . 's query time total'
+ ), 'main');
}
// Send page to client.
diff --git a/install.php b/install.php
index 1038b400..6cbbe91f 100644
--- a/install.php
+++ b/install.php
@@ -19,6 +19,13 @@
* they might depend on some tables that do not exist yet. ;)
*/
+use JetBrains\PhpStorm\NoReturn;
+
+if (PHP_INT_SIZE < 8) {
+ echo "32bit platforms no longer supported\n";
+ exit(1);
+}
+
/**
* Report back the update status to the browser/client and terminate execution.
* This has to be called by an update module at some point to signal the result
@@ -27,7 +34,8 @@
* @param string $status one of the UPDATE_* status codes
* @param string $message Human readable description of the status (optional)
*/
-function finalResponse($status, $message = '')
+#[NoReturn]
+function finalResponse(string $status, string $message = '')
{
if (!DIRECT_MODE && AJAX) {
echo json_encode(array('status' => $status, 'message' => $message));
@@ -43,63 +51,101 @@ define('UPDATE_NOOP', 'UPDATE_NOOP'); // Nothing had to be done, everything is u
define('UPDATE_RETRY', 'UPDATE_RETRY'); // Install/update process failed, but should be retried later.
define('UPDATE_FAILED', 'UPDATE_FAILED'); // Fatal error occurred, retry will not resolve the issue.
+/**
+ * Take the return value of a Database::exec() call and emit failure
+ * if it's false.
+ */
+function handleUpdateResult($res)
+{
+ if ($res !== false)
+ return;
+ finalResponse(UPDATE_FAILED, Database::lastError());
+}
+
/*
* Helper functions for dealing with the database
*/
-function tableHasColumn($table, $column)
+function tableHasColumn(string $table, string $column): bool
+{
+ return tableColumnType($table, $column) !== false;
+}
+
+/**
+ * Get type of column, as reported by DESCRIBE <table>;
+ */
+function tableColumnType(string $table, string $column)
+{
+ return tableGetDescribeColumn($table, $column, 'Type');
+}
+
+function tableColumnKeyType($table, $column)
+{
+ return tableGetDescribeColumn($table, $column, 'Key');
+}
+
+/**
+ * For internal use
+ * @param string|string[] $column
+ * @return string|false
+ */
+function tableGetDescribeColumn(string $table, $column, string $what)
{
$table = preg_replace('/\W/', '', $table);
$res = Database::simpleQuery("DESCRIBE `$table`", array(), true);
if ($res !== false) {
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if ((is_array($column) && in_array($row['Field'], $column)) || (is_string($column) && $row['Field'] === $column))
- return true;
+ return $row[$what];
}
}
return false;
}
-function tableGetIndex($table, $index)
+/**
+ * Return name of index that spans all the columns given, in the same order.
+ * Returns false if not found
+ *
+ * @param string[] $columns
+ * @return false|string
+ */
+function tableGetIndex(string $table, array $columns)
{
$table = preg_replace('/\W/', '', $table);
- if (!is_array($index)) {
- $index = [$index];
- }
$res = Database::simpleQuery("SHOW INDEX FROM `$table`", array(), true);
if ($res !== false) {
$matches = [];
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$i = $row['Seq_in_index'] - 1;
- if (isset($index[$i]) && $index[$i] === $row['Column_name']) {
+ if (isset($columns[$i]) && $columns[$i] === $row['Column_name']) {
if (!isset($matches[$row['Key_name']])) {
$matches[$row['Key_name']] = 0;
}
$matches[$row['Key_name']]++;
}
}
- }
- foreach ($matches as $key => $m) {
- if ($m === count($index))
- return $key;
+ foreach ($matches as $key => $m) {
+ if ($m === count($columns))
+ return $key;
+ }
}
return false;
}
-function tableDropColumn($table, $column)
+function tableDropColumn(string $table, string $column): void
{
$table = preg_replace('/\W/', '', $table);
$column = preg_replace('/\W/', '', $column);
$res = Database::simpleQuery("DESCRIBE `$table`", array(), true);
if ($res !== false) {
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if ((is_array($column) && in_array($row['Field'], $column)) || (is_string($column) && $row['Field'] === $column))
Database::exec("ALTER TABLE `$table` DROP `{$row['Field']}`");
}
}
}
-function tableExists($table)
+function tableExists(string $table): bool
{
$res = Database::simpleQuery("SHOW TABLES", array(), true);
while ($row = $res->fetch(PDO::FETCH_NUM)) {
@@ -109,9 +155,9 @@ function tableExists($table)
return false;
}
-function tableRename($old, $new) {
- $res = Database::simpleQuery("RENAME TABLE $old TO $new", []);
- return $res;
+function tableRename(string $old, string $new): bool
+{
+ return Database::simpleQuery("RENAME TABLE $old TO $new", []) !== false;
}
@@ -124,7 +170,7 @@ function tableRename($old, $new) {
* @param string $refColumn referenced column
* @return false|string[] false == doesn't exist, assoc array otherwise
*/
-function tableGetConstraints($table, $column, $refTable, $refColumn)
+function tableGetConstraints(string $table, string $column, string $refTable, string $refColumn)
{
$db = 'openslx';
if (defined('CONFIG_SQL_DB')) {
@@ -157,21 +203,22 @@ function tableGetConstraints($table, $column, $refTable, $refColumn)
* @param string $actions "ON xxx ON yyy" string
* @return string UPDATE_* result code
*/
-function tableAddConstraint($table, $column, $refTable, $refColumn, $actions, $ignoreError = false, $name = '')
+function tableAddConstraint(string $table, string $column, string $refTable, string $refColumn, string $actions,
+ bool $ignoreError = false, $name = ''): string
{
$test = tableExists($refTable) && tableHasColumn($refTable, $refColumn);
if ($test === false) {
- // Most likely, destination table does not exist yet or isn't up to date
+ // Most likely, destination table does not exist yet or isn't up-to-date
return UPDATE_RETRY;
}
// TODO: Refactor function, make this two args
$update = 'RESTRICT';
$delete = 'RESTRICT';
- if (preg_match('/on\s+update\s+(RESTRICT|SET\s+NULL|CASCADE)/ims', $actions, $out)) {
- $update = preg_replace('/\s+/ms', ' ', strtoupper($out[1]));
+ if (preg_match('/on\s+update\s+(RESTRICT|SET\s+NULL|CASCADE)/im', $actions, $out)) {
+ $update = preg_replace('/\s+/m', ' ', strtoupper($out[1]));
}
- if (preg_match('/on\s+delete\s+(RESTRICT|SET\s+NULL|CASCADE)/ims', $actions, $out)) {
- $delete = preg_replace('/\s+/ms', ' ', strtoupper($out[1]));
+ if (preg_match('/on\s+delete\s+(RESTRICT|SET\s+NULL|CASCADE)/im', $actions, $out)) {
+ $delete = preg_replace('/\s+/m', ' ', strtoupper($out[1]));
}
$test = tableGetConstraints($table, $column, $refTable, $refColumn);
if ($test !== false) {
@@ -180,8 +227,10 @@ function tableAddConstraint($table, $column, $refTable, $refColumn, $actions, $i
// Yep, nothing more to do here
return UPDATE_NOOP;
}
+ error_log("$table, $column, $refTable, $refColumn, $actions");
+ error_log("Have: {$test['UPDATE_RULE']} want: $update && Have: {$test['DELETE_RULE']} want: $delete");
// Kill the old one
- $ret = tableDeleteConstraint($table, $test['CONSTRAINT_NAME']);
+ tableDeleteConstraint($table, $test['CONSTRAINT_NAME']);
}
if ($delete === 'CASCADE') {
// Deletes are cascaded, so make sure first that all rows get purged that would
@@ -206,9 +255,8 @@ function tableAddConstraint($table, $column, $refTable, $refColumn, $actions, $i
if ($ret === false) {
if ($ignoreError) {
return UPDATE_FAILED;
- } else {
- finalResponse(UPDATE_FAILED, "Cannot add constraint $table.$column -> $refTable.$refColumn: " . Database::lastError());
}
+ finalResponse(UPDATE_FAILED, "Cannot add constraint $table.$column -> $refTable.$refColumn: " . Database::lastError());
}
return UPDATE_DONE;
}
@@ -220,17 +268,17 @@ function tableAddConstraint($table, $column, $refTable, $refColumn, $actions, $i
* @param string $constraint constraint name
* @return bool success indicator
*/
-function tableDeleteConstraint($table, $constraint)
+function tableDeleteConstraint(string $table, string $constraint): bool
{
return Database::exec("ALTER TABLE `$table` DROP FOREIGN KEY `$constraint`") !== false;
}
-function tableCreate($table, $structure, $fatalOnError = true)
+function tableCreate(string $table, string $structure, bool $fatalOnError = true): string
{
if (tableExists($table)) {
return UPDATE_NOOP;
}
- $ret = Database::exec("CREATE TABLE IF NOT EXISTS `{$table}` ( {$structure} ) ENGINE=InnoDB DEFAULT CHARSET=utf8");
+ $ret = Database::exec("CREATE TABLE IF NOT EXISTS `{$table}` ( {$structure} ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
if ($ret !== false) {
return UPDATE_DONE;
}
@@ -240,7 +288,8 @@ function tableCreate($table, $structure, $fatalOnError = true)
return UPDATE_FAILED;
}
-function responseFromArray($array)
+#[NoReturn]
+function responseFromArray(array $array)
{
if (in_array(UPDATE_FAILED, $array)) {
finalResponse(UPDATE_FAILED, 'Update failed!');
@@ -253,7 +302,6 @@ function responseFromArray($array)
}
finalResponse(UPDATE_NOOP, 'Everything already up to date');
-
}
/*
@@ -296,23 +344,60 @@ if (!Database::init(true)) {
// Good to go so far
-/**
- * @param \Module $module
- * @return bool
- */
-function hasUpdateScript($module)
+function hasUpdateScript(Module $module): bool
{
return is_readable($module->getDir() . '/install.inc.php');
}
-/**
- * @param Module $module
- */
-function runUpdateScript($module)
+function runUpdateScript(Module $module): void
{
require_once $module->getDir() . '/install.inc.php';
}
+// Update collation/encoding etc
+$charsetUpdate = '';
+$COLLATION = 'utf8mb4_unicode_520_ci';
+$res = Database::queryFirst("SELECT @@character_set_database, @@collation_database");
+if ($res['@@character_set_database'] !== 'utf8mb4' || $res['@@collation_database'] !== $COLLATION) {
+ if (!preg_match('/dbname=(\w+)/', CONFIG_SQL_DSN, $out)) {
+ $charsetUpdate = 'Cannot update charset: DB Name unknown';
+ } else {
+ $db = $out[1];
+ $columns = Database::simpleQuery("SELECT
+ TABLE_NAME, COLUMN_NAME, COLUMN_DEFAULT, IS_NULLABLE, COLUMN_TYPE, EXTRA, COLUMN_COMMENT
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = :db AND COLLATION_NAME LIKE 'utf8%' AND COLLATION_NAME <> :collation",
+ ['db' => $db, 'collation' => $COLLATION]);
+ $idx = 0;
+ foreach ($columns as $c) {
+ $idx++;
+ $args = [];
+ $str = $c['COLUMN_TYPE'] . ' CHARACTER SET utf8mb4 ' . $c['EXTRA'];
+ if ($c['IS_NULLABLE'] === 'NO') {
+ $str .= ' NOT NULL';
+ }
+ if (!($c['IS_NULLABLE'] === 'NO' && $c['COLUMN_DEFAULT'] === null)) {
+ $str .= " DEFAULT :def_$idx";
+ $args["def_$idx"] = $c['COLUMN_DEFAULT'];
+ }
+ if (!empty($c['COLUMN_COMMENT'])) {
+ $str .= ' COMMENT :comment';
+ $args['comment'] = $c['COLUMN_COMMENT'];
+ }
+ $str .= ' COLLATE ' . $COLLATION;
+ $query = "ALTER TABLE `{$c['TABLE_NAME']}` MODIFY `{$c['COLUMN_NAME']}` $str";
+ if (Database::exec($query, $args) === false) {
+ $charsetUpdate .= "\n\n--------------------------\n" .
+ "+++ {$c['TABLE_NAME']}.{$c['COLUMN_NAME']} failed: " . Database::lastError();
+ $charsetUpdate .= "\n$query";
+ }
+ }
+ if (empty($charsetUpdate) && Database::exec("ALTER DATABASE `$db` CHARACTER SET utf8mb4 COLLATE $COLLATION") === false) {
+ $charsetUpdate .= "\nCannot update database charset or collation: " . Database::lastError();
+ }
+ }
+} // End utf8 stuff
+
// Build dependency tree
Module::init();
$modules = Module::getEnabled();
@@ -379,7 +464,7 @@ if (DIRECT_MODE) {
<title>Install/Update SLXadmin</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
- <style type="text/css">
+ <style>
body, html {
color: #000;
background: #fff;
@@ -396,6 +481,7 @@ if (DIRECT_MODE) {
</style>
</head>
<body>
+ <pre>$charsetUpdate</pre>
<h1>Modules</h1>
<button onclick="slxRunInstall()" class="install-btn">Install/Upgrade</button>
<br>
diff --git a/modules-available/adduser/lang/de/template-tags.json b/modules-available/adduser/lang/de/template-tags.json
index 7c00cdda..c68c36c0 100644
--- a/modules-available/adduser/lang/de/template-tags.json
+++ b/modules-available/adduser/lang/de/template-tags.json
@@ -2,7 +2,7 @@
"lang_addUser": "Nutzer hinzuf\u00fcgen",
"lang_assignRoles": "Rollen zuweisen",
"lang_changeLoginHint": "Sie k\u00f6nnen den Namen, den der Nutzer beim Login angeben muss, \u00e4ndern. Dies ist nur bei lokalen Nutzern m\u00f6glich, die nicht \u00fcber LDAP\/AD authentifiziert werden.",
- "lang_changeOwnPasswordHint": "Ihr eigenes Passwort k\u00f6nnen Sie \u00fcber den Button \"Passwort \u00e4ndern\" im Men\u00fc \u00e4ndern.",
+ "lang_changeOwnPasswordHint": "Ihr eigenes Passwort k\u00f6nnen Sie \u00e4ndern, indem Sie auf Ihren Nutzernamen in der Men\u00fcleiste klicken.",
"lang_changePassword": "Neues Passwort",
"lang_confirmation": "Wiederholen",
"lang_createUser": "Benutzer anlegen",
diff --git a/modules-available/adduser/lang/en/template-tags.json b/modules-available/adduser/lang/en/template-tags.json
index 89413d12..cf202381 100644
--- a/modules-available/adduser/lang/en/template-tags.json
+++ b/modules-available/adduser/lang/en/template-tags.json
@@ -2,7 +2,7 @@
"lang_addUser": "Add user",
"lang_assignRoles": "Assign roles",
"lang_changeLoginHint": "You can change the login identifier used for logging in. This is only enabled for local accounts that are not linked to LDAP\/AD servers.",
- "lang_changeOwnPasswordHint": "You can change your own password by clicking the \"change password\" button in the menu.",
+ "lang_changeOwnPasswordHint": "You can change your own password by clicking your username in the menu.",
"lang_changePassword": "Change password",
"lang_confirmation": "Confirm Password",
"lang_createUser": "Create User",
diff --git a/modules-available/adduser/page.inc.php b/modules-available/adduser/page.inc.php
index 4ab69919..0ef28a3e 100644
--- a/modules-available/adduser/page.inc.php
+++ b/modules-available/adduser/page.inc.php
@@ -32,10 +32,8 @@ class Page_AddUser extends Page
$email = Request::post('email', '', 'string');
if (empty($login) || empty($pass1) || empty($pass2) || empty($fullname)) {
Message::addError('main.empty-field');
- return;
} elseif ($pass1 !== $pass2) {
Message::addError('password-mismatch');
- return;
} else {
if (Database::queryFirst('SELECT userid FROM user LIMIT 1') !== false) {
User::assertPermission('user.add');
@@ -69,7 +67,6 @@ class Page_AddUser extends Page
}
Message::addInfo('adduser-success');
$this->saveRoles($id);
- return;
}
}
@@ -203,7 +200,7 @@ class Page_AddUser extends Page
} elseif ($show === 'list') {
User::assertPermission('user.view-list');
$page = new Paginate('SELECT userid, login, fullname, phone, email FROM user ORDER BY login', 50);
- $data = ['list' => $page->exec()->fetchAll(PDO::FETCH_ASSOC)];
+ $data = ['list' => $page->exec()->fetchAll()];
foreach ($data['list'] as &$u) {
// Don't allow deleting user 1 and self
$u['hide_delete'] = $u['userid'] == 1 || $u['userid'] == User::getId();
@@ -218,13 +215,13 @@ class Page_AddUser extends Page
}
}
- private function showRoles($userid = false)
+ private function showRoles(int $userid = null): void
{
if (!Module::isAvailable('permissionmanager'))
return;
if (!User::hasPermission('.permissionmanager.users.edit-roles'))
return;
- $data = ['roles' => PermissionUtil::getRoles($userid, false)];
+ $data = ['roles' => PermissionUtil::getRoles($userid)];
Render::addTemplate('user-permissions', $data);
}
diff --git a/modules-available/backup/hooks/cron.inc.php b/modules-available/backup/hooks/cron.inc.php
new file mode 100644
index 00000000..c4a4b1dc
--- /dev/null
+++ b/modules-available/backup/hooks/cron.inc.php
@@ -0,0 +1,40 @@
+<?php
+
+(function () {
+ // Sunday midnight, between 0-4 minutes of hour
+ if (date('Hw') !== '000' || (int)date('i') >= 5)
+ return;
+ $mode = Property::get(BackupRestore::PROP_AUTO_BACKUP_MODE, BackupRestore::BACKUP_MODE_OFF);
+ if ($mode === BackupRestore::BACKUP_MODE_OFF)
+ return;
+ // DO IT
+ $password = trim(Property::get(BackupRestore::PROP_AUTO_BACKUP_PASS, ''));
+ if (empty($password)) {
+ $password = null;
+ }
+ if ($mode === BackupRestore::BACKUP_MODE_VMSTORE) {
+ if (file_exists('/srv/openslx/nfs/.notmounted')) {
+ EventLog::failure("Could not create automatic backup on VMstore as it's not mounted");
+ return;
+ }
+ $destination = '/srv/openslx/nfs/auto_backups/';
+ } else {
+ $destination = '/root/auto_backups/';
+ }
+ $destination .= 'sat-' . Property::getServerIp() . '-' . date('Y-m-d');
+ $task = Taskmanager::submit('BackupRestore', [
+ 'mode' => 'backup',
+ 'password' => $password,
+ 'destination' => $destination,
+ ]);
+ if (!isset($task['id'])) {
+ EventLog::failure("Could not create automatic backup, Taskmanager down");
+ return;
+ }
+ $task = Taskmanager::waitComplete($task, 90000);
+ if (!Taskmanager::isFinished($task) || Taskmanager::isFailed($task) || !isset($task['data']['backupFile'])) {
+ EventLog::failure("Creating backup failed", print_r($task, true));
+ return;
+ }
+ Property::set(BackupRestore::PROP_LAST_BACKUP, time());
+})(); \ No newline at end of file
diff --git a/modules-available/backup/hooks/main-warning.inc.php b/modules-available/backup/hooks/main-warning.inc.php
index 0a0be7cc..68f1dd59 100644
--- a/modules-available/backup/hooks/main-warning.inc.php
+++ b/modules-available/backup/hooks/main-warning.inc.php
@@ -1,8 +1,10 @@
<?php
-$last = Property::get('backup.last-time', 0);
-if ($last === 0) {
- Message::addWarning('backup.last-time-unknown', true);
-} elseif ($last + (30 * 86400) < time()) {
- Message::addWarning('backup.last-time', true, date('d.m.Y', $last));
-}
+if (User::hasPermission(".backup.create")) {
+ $last = Property::get('backup.last-time', 0);
+ if ($last === 0) {
+ Message::addWarning('backup.last-time-unknown', true);
+ } elseif ($last + (30 * 86400) < time()) {
+ Message::addWarning('backup.last-time', true, date('d.m.Y', $last));
+ }
+} \ No newline at end of file
diff --git a/modules-available/backup/inc/backuprestore.inc.php b/modules-available/backup/inc/backuprestore.inc.php
new file mode 100644
index 00000000..931cbf26
--- /dev/null
+++ b/modules-available/backup/inc/backuprestore.inc.php
@@ -0,0 +1,13 @@
+<?php
+
+class BackupRestore
+{
+
+ const PROP_LAST_BACKUP = 'backup.last-time';
+ const PROP_AUTO_BACKUP_PASS = 'backup.auto-passwd';
+ const PROP_AUTO_BACKUP_MODE = 'backup.auto-mode';
+ const BACKUP_MODE_ROOTHOME = 'ROOTHOME';
+ const BACKUP_MODE_VMSTORE = 'VMSTORE';
+ const BACKUP_MODE_OFF = 'OFF';
+
+} \ No newline at end of file
diff --git a/modules-available/backup/lang/de/messages.json b/modules-available/backup/lang/de/messages.json
index 36581b2a..27ee2f16 100644
--- a/modules-available/backup/lang/de/messages.json
+++ b/modules-available/backup/lang/de/messages.json
@@ -1,5 +1,6 @@
{
"backup-failed": "Erstellen des Backups fehlgeschlagen",
+ "invalid-auto-backup-mode": "Ung\u00fcltiger Backup-Modus {{0}}",
"last-time": "Das letzte Backup wurde am {{0}} gemacht. Es wird empfohlen, regelm\u00e4\u00dfig ein Backup des Servers herunterzuladen.",
"last-time-unknown": "Es wird empfohlen, regelm\u00e4\u00dfig ein Backup des Servers herunterzuladen.",
"missing-file": "Es wurde keine Datei ausgew\u00e4hlt!",
diff --git a/modules-available/backup/lang/de/permissions.json b/modules-available/backup/lang/de/permissions.json
index e5f189b8..691eaa45 100644
--- a/modules-available/backup/lang/de/permissions.json
+++ b/modules-available/backup/lang/de/permissions.json
@@ -1,4 +1,5 @@
{
- "create": "Eine Sicherung erstellen und herunterladen.",
- "restore": "Eine Sicherung hochladen und wiederherstellen."
+ "config": "Automatische Sicherung konfigurieren.",
+ "create": "Eine Sicherung erstellen und herunterladen.",
+ "restore": "Eine Sicherung hochladen und wiederherstellen."
} \ No newline at end of file
diff --git a/modules-available/backup/lang/de/template-tags.json b/modules-available/backup/lang/de/template-tags.json
index 7f5e05ab..170c3d96 100644
--- a/modules-available/backup/lang/de/template-tags.json
+++ b/modules-available/backup/lang/de/template-tags.json
@@ -1,21 +1,37 @@
{
+ "lang_autoBackupHeading": "Automatische Backups",
+ "lang_autoBackupOFF": "Keine erstellen",
+ "lang_autoBackupPasswordLabel": "Passwort zur Verschl\u00fcsslung",
+ "lang_autoBackupPasswordPlaceholder": "Leer lassen f\u00fcr unverschl\u00fcsseltes Backup",
+ "lang_autoBackupROOTHOME": "Backup innerhalb der VM unter \/root\/",
+ "lang_autoBackupText": "Sie k\u00f6nnen automatisiert w\u00f6chentliche Backups des Servers erstellen lassen, die dann wahlweise auf der HDD des Servers unter \/root\/ gespeichert werden, oder auf dem ggf. eingeh\u00e4ngten VM-Store. Durch Angabe des optionalen Passworts k\u00f6nnen die Backups zus\u00e4tzlich verschl\u00fcsselt werden, was beim Ablegen der Backups auf dem VM-Store zu empfehlen ist, da die Sicherung mitunter Zertifikate und AD\/LDAP-Passw\u00f6rter enthalten kann.",
+ "lang_autoBackupVMSTORE": "Backup auf den eingebundenen VMstore",
"lang_automaticRebootHint": "Nach dem Import der Konfiguration wird der Server automatisch neugestartet. Bitte stellen Sie daher sicher, dass zur Zeit keine wichtigen Veranstaltungen laufen, oder Up-\/Downloads der bwLehrpool-Suite.",
"lang_backup": "Sichern",
"lang_backupDescription": "Hier k\u00f6nnen Sie die Konfiguration des Satellitenservers sichern. Dies beinhaltet die Datenbank \u00fcber alle Virtuellen Maschinen, Veranstaltungen, Authentifizerungsmodule, Passw\u00f6rter, Proxies, den konfigurierten VM-Store sowie weitere Konfiguration des MiniLinux.\r\nDie Festplattenabbilder der Virtuellen Maschinen auf dem VM-Store werden hierbei nicht gesichert. Eventuelle Backups des Stores m\u00fcssen separat durchgef\u00fchrt werden.",
+ "lang_backupPasswordHint": "Wenn nicht leer, wird das Backup mit diesem Passwort verschl\u00fcsselt. Es muss dann exakt so wieder beim Einspielen angegeben werden.",
+ "lang_backupPasswordLabel": "Passwort zur Verschl\u00fcsselung",
+ "lang_backupPasswordPlaceholder": "W\u00e4hlen Sie ein Passwort",
"lang_backupRestore": "Sichern und Wiederherstellen",
"lang_browseForFile": "Durchsuchen",
+ "lang_checkingArchive": "\u00dcberpr\u00fcfe Archiv",
"lang_download": "Herunterladen",
"lang_dozmodExplanation": "Die Datenbank der bwLehrpool-Suite wiederherstellen. Dazu geh\u00f6ren die Metadaten der Virtuellen Maschinen, die Veranstaltungen, etc. Bitte beachten Sie, dass hierzu auf dem konfigurierten VM-Store die passenden VM-Abbilder vorliegen m\u00fcssen, da diese extern gespeichert werden. Wenn sich der Servername oder die -adresse ge\u00e4ndert haben stellen Sie bitte sicher, dass die relativen Pfade innerhalb des Netzlaufwerks gleich geblieben sind. Ansonsten werden die wiederhergestellten VMs nicht verwendbar sein.",
+ "lang_error": "Fehler",
"lang_lastBackup": "Letzte Sicherung",
"lang_reboot": "Systemneustart",
- "lang_restore": "Hochladen",
+ "lang_restoreButton": "Wiederherstellen",
"lang_restoreConfig": "Konfiguration wiederherstellen",
- "lang_restoreDescription": "Hier k\u00f6nnen Sie ein Backup der Konfiguration wieder einspielen. Bitte beachten Sie, dass der Server dabei neu gestartet wird, daher sollten Sie dies m\u00f6glichst durchf\u00fchren, wenn das System nicht genutzt wird, und keine Dozenten Veranstaltungen oder Virtuelle Labore erstellen oder hoch-\/herunterladen. Bitte beachten Sie, dass dabei auch das urspr\u00fcngliche Passwort der Weboberfl\u00e4che wiederhergestellt wird.",
+ "lang_restoreDescription": "Hier k\u00f6nnen Sie ein Backup der Konfiguration wieder einspielen. Bitte beachten Sie, dass der Server dabei neu gestartet wird, daher sollten Sie dies m\u00f6glichst durchf\u00fchren, wenn das System nicht genutzt wird, und keine Dozierende Veranstaltungen oder Virtuelle Labore erstellen oder hoch-\/herunterladen. Bitte beachten Sie, dass dabei auch das urspr\u00fcngliche Passwort der Weboberfl\u00e4che wiederhergestellt wird.",
"lang_restoreDozmodConfig": "bwLehrpool-Suite-Konfiguration wiederherstellen",
"lang_restoreFailed": "Wiederherstellung der Konfiguration fehlgeschlagen.",
+ "lang_restoreFileLabel": "Backup-Datei",
+ "lang_restoreHeading": "Sicherung des Servers wiederherstellen",
+ "lang_restorePasswordLabel": "Passwort der Sicherung",
+ "lang_restorePasswordPlaceholder": "Leer lassen, falls Backup nicht verschl\u00fcsselt",
"lang_restoreSystemConfig": "Systemkonfiguration wiederherstellen",
"lang_runningDownloads": "Laufende VM-Downloads",
- "lang_runningUploads": "Laufenden VM-Uploads",
+ "lang_runningUploads": "Laufende VM-Uploads",
"lang_selectFile": "Bitte w\u00e4hlen Sie ein Backup-Archiv",
"lang_stopping": "Stoppe",
"lang_systemExplanation": "Die Grundkonfiguration des Satelliten wiederherstellen: Authentifizierungmethode, Passw\u00f6rter, Proxies, VM-Storage, etc.\r\nACHTUNG: Wenn Sie ein Backup von vor WS15\/16 einspielen (Backup-Format vor Version 10), wird die Systemkonfiguration in jedem Fall wiederhergestellt, auch wenn Sie diesen Haken nicht setzen.",
diff --git a/modules-available/backup/lang/en/messages.json b/modules-available/backup/lang/en/messages.json
index 4c9685d5..649b7270 100644
--- a/modules-available/backup/lang/en/messages.json
+++ b/modules-available/backup/lang/en/messages.json
@@ -1,5 +1,6 @@
{
"backup-failed": "Backup failed!",
+ "invalid-auto-backup-mode": "Invalid backup mode {{0}}",
"last-time": "Last backup was downloaded on {{0}}. It's recommended to download a server backup regularly.",
"last-time-unknown": "It's recommended to download a server backup regularly.",
"missing-file": "There was no file selected!",
diff --git a/modules-available/backup/lang/en/permissions.json b/modules-available/backup/lang/en/permissions.json
index ee1d87cb..0e204c8d 100644
--- a/modules-available/backup/lang/en/permissions.json
+++ b/modules-available/backup/lang/en/permissions.json
@@ -1,4 +1,5 @@
{
- "create": "Create and download a backup.",
- "restore": "Upload and restore a backup."
+ "config": "Configure automatic backups.",
+ "create": "Create and download a backup.",
+ "restore": "Upload and restore a backup."
} \ No newline at end of file
diff --git a/modules-available/backup/lang/en/template-tags.json b/modules-available/backup/lang/en/template-tags.json
index d14c9070..da737b6c 100644
--- a/modules-available/backup/lang/en/template-tags.json
+++ b/modules-available/backup/lang/en/template-tags.json
@@ -1,18 +1,34 @@
{
+ "lang_autoBackupHeading": "Scheduled backups",
+ "lang_autoBackupOFF": "Off",
+ "lang_autoBackupPasswordLabel": "Password for encryption",
+ "lang_autoBackupPasswordPlaceholder": "Empty for unencrypted backup",
+ "lang_autoBackupROOTHOME": "Backup to \/root\/",
+ "lang_autoBackupText": "You can enable automated weekly backups to either the server's HDD, or to the mounted VM store. By specifying an optional password, the backups will be encrypted, which is recommended in case you decide to store them on the VM store.",
+ "lang_autoBackupVMSTORE": "Backup to mounted VM store",
"lang_automaticRebootHint": "The server will be rebooted once the import is finished. Please make sure there are no important courses or bwLehrpool-Suite up-\/downloads running.",
"lang_backup": "Backup",
- "lang_backupDescription": "Here you can backup the complete configuration of this satellite server. This includes lecture and virtual machine meta data. The HDD images of the virtual machines on the vm store are not included in this backup, because of their size. If desired, the store needs to be backed up manually.",
+ "lang_backupDescription": "Here you can backup the complete configuration of this satellite server. This includes lecture and virtual machine meta data. The HDD images of the virtual machines on the VM store are not included in this backup, because of their size. If desired, the store needs to be backed up manually.",
+ "lang_backupPasswordHint": "If specified, the backup will be encrypted with this password. You need to enter exactly the same password if you wish to restore the backup at a later time.",
+ "lang_backupPasswordLabel": "Password for encryption",
+ "lang_backupPasswordPlaceholder": "Choose a password",
"lang_backupRestore": "Backup and Restore",
"lang_browseForFile": "Browse",
+ "lang_checkingArchive": "Checking archive",
"lang_download": "Download",
"lang_dozmodExplanation": "This restores all the virtual machine and lecture meta data created using the bwLehrpool-Suite. Please make sure the VM-storage configured still contains all the VM-Images associated with the virtual machines. If the location of the storage changed, make sure the relative paths on the share are still the same, otherwise the virtual machines won't be usable.",
+ "lang_error": "Error",
"lang_lastBackup": "Last backup",
"lang_reboot": "System reboot",
- "lang_restore": "Upload",
+ "lang_restoreButton": "Restore",
"lang_restoreConfig": "Restore config",
"lang_restoreDescription": "Here you can restore a configuration backup. Please note that this will reboot the server, so it is advised to do this while nobody is using the system. Please note that this will also restore the password for the web interface that was active when the configuration backup was created.",
"lang_restoreDozmodConfig": "Restore bwLehrpool-Suite config",
"lang_restoreFailed": "Restoring configuration failed.",
+ "lang_restoreFileLabel": "Backup file",
+ "lang_restoreHeading": "Restore previously backed up configuration",
+ "lang_restorePasswordLabel": "Password for backup file",
+ "lang_restorePasswordPlaceholder": "Leave empty if unencrypted",
"lang_restoreSystemConfig": "Restore system config",
"lang_runningDownloads": "Running VM downloads",
"lang_runningUploads": "Running VM uploads",
diff --git a/modules-available/backup/page.inc.php b/modules-available/backup/page.inc.php
index c6aa09ae..9a2d08be 100644
--- a/modules-available/backup/page.inc.php
+++ b/modules-available/backup/page.inc.php
@@ -3,9 +3,8 @@
class Page_Backup extends Page
{
- const LAST_BACKUP_PROP = 'backup.last-time';
-
- private $action = false;
+ /** @var ?string */
+ private $action = null;
private $templateData = array();
protected function doPreprocess()
@@ -22,6 +21,9 @@ class Page_Backup extends Page
} elseif ($this->action === 'restore') {
User::assertPermission("restore");
$this->restore();
+ } elseif ($this->action === 'config') {
+ User::assertPermission("config");
+ $this->config();
}
User::assertPermission('*');
}
@@ -30,30 +32,49 @@ class Page_Backup extends Page
{
if ($this->action === 'restore') { // TODO: We're in post mode, redirect with all the taskids first...
Render::addTemplate('restore', $this->templateData);
+ } elseif (($taskid = Request::get('errtaskid', false, 'string')) !== false) {
+ Render::addTemplate('task-error', ['taskid' => $taskid]);
} else {
- $lastBackup = (int)Property::get(self::LAST_BACKUP_PROP, 0);
+ // Normal page
+ $title = Property::get('page-title-prefix', '');
+ $bgcolor = Property::get('logo-background', '');
+ $lastBackup = (int)Property::get(BackupRestore::PROP_LAST_BACKUP, 0);
if ($lastBackup === 0) {
$lastBackup = false;
} else {
$lastBackup = date('d.m.Y', $lastBackup);
}
- $params = ['last_backup' => $lastBackup];
- Permission::addGlobalTags($params['perms'], NULL, ['create', 'restore']);
+ $params = [
+ 'id_color' => $bgcolor,
+ 'id_prefix' => $title,
+ 'last_backup' => $lastBackup,
+ 'backup_' . Property::get(BackupRestore::PROP_AUTO_BACKUP_MODE, BackupRestore::BACKUP_MODE_OFF)
+ . '_checked' => 'checked',
+ 'autoBackupPw' => Property::get(BackupRestore::PROP_AUTO_BACKUP_PASS, ''),
+ ];
+ Permission::addGlobalTags($params['perms'], NULL, ['create', 'restore', 'config']);
Render::addTemplate('_page', $params);
}
}
private function backup()
{
- $task = Taskmanager::submit('BackupRestore', array('mode' => 'backup'));
+ $password = trim(Request::post('passwd', '', 'string'));
+ if (empty($password)) {
+ $password = null;
+ }
+ EventLog::info('Creating backup on ' . Property::getServerIp());
+ $task = Taskmanager::submit('BackupRestore', [
+ 'mode' => 'backup',
+ 'password' => $password,
+ ]);
if (!isset($task['id'])) {
Message::addError('backup-failed');
Util::redirect('?do=Backup');
}
- $task = Taskmanager::waitComplete($task, 30000);
+ $task = Taskmanager::waitComplete($task, 60000);
if (!Taskmanager::isFinished($task) || !isset($task['data']['backupFile'])) {
- Taskmanager::addErrorMessage($task);
- Util::redirect('?do=Backup');
+ Util::redirect('?do=backup&errtaskid=' . $task['id']);
}
while ((@ob_get_level()) > 0)
@ob_end_clean();
@@ -62,8 +83,12 @@ class Page_Backup extends Page
Message::addError('main.error-read', $task['data']['backupFile']);
Util::redirect('?do=Backup');
}
+ $userFn = 'satellite-backup_' . Property::getServerIp() . '_' . date('Y.m.d-H.i.s') . '.tgz';
+ if ($password !== null) {
+ $userFn .= '.aes';
+ }
Header('Content-Type: application/octet-stream', true);
- Header('Content-Disposition: attachment; filename=' . 'satellite-backup_' . Property::getServerIp() . '_' . date('Y.m.d-H.i.s') . '.tgz');
+ Header('Content-Disposition: attachment; filename=' . $userFn);
Header('Content-Length: ' . @filesize($task['data']['backupFile']));
while (!feof($fh)) {
$data = fread($fh, 16000);
@@ -77,7 +102,7 @@ class Page_Backup extends Page
}
@fclose($fh);
@unlink($task['data']['backupFile']);
- Property::set(self::LAST_BACKUP_PROP, time());
+ Property::set(BackupRestore::PROP_LAST_BACKUP, time());
die();
}
@@ -91,11 +116,28 @@ class Page_Backup extends Page
Message::addError('upload-failed', Util::uploadErrorString($_FILES['backupfile']['error']));
Util::redirect('?do=Backup');
}
+ $password = trim(Request::post('passwd', '', 'string'));
+ if (empty($password)) {
+ $password = null;
+ }
$tempfile = '/tmp/bwlp-' . mt_rand(1, 100000) . '-' . crc32($_SERVER['REMOTE_ADDR']) . '.tgz';
if (!move_uploaded_file($_FILES['backupfile']['tmp_name'], $tempfile)) {
Message::addError('main.error-write', $tempfile);
Util::redirect('?do=Backup');
}
+ copy($tempfile, $tempfile . '2');
+ // Check if correct password first etc.
+ $task = Taskmanager::submit('BackupRestore', [
+ 'mode' => 'test',
+ 'password' => $password,
+ 'backupFile' => $tempfile . '2',
+ ]);
+ $task = Taskmanager::waitComplete($task, 5000);
+ @unlink($tempfile . '2');
+ if (Taskmanager::isFailed($task)) {
+ @unlink($tempfile);
+ Util::redirect('?do=backup&errtaskid=' . $task['id']);
+ }
// Got uploaded file, now shut down all the daemons etc.
$parent = Trigger::stopDaemons(null, $this->templateData);
// Unmount store
@@ -109,16 +151,16 @@ class Page_Backup extends Page
$this->templateData['mountid'] = $task['id'];
$parent = $task['id'];
}
- EventLog::info('Creating backup on ' . Property::getServerIp());
// Finally run restore
- $task = Taskmanager::submit('BackupRestore', array(
+ $task = Taskmanager::submit('BackupRestore', [
'mode' => 'restore',
+ 'password' => $password,
'backupFile' => $tempfile,
'parentTask' => $parent,
'failOnParentFail' => false,
'restoreOpenslx' => Request::post('restore_openslx', 'off') === 'on',
'restoreDozmod' => Request::post('restore_dozmod', 'off') === 'on',
- ));
+ ]);
if (isset($task['id'])) {
$this->templateData['restoreid'] = $task['id'];
$parent = $task['id'];
@@ -139,8 +181,26 @@ class Page_Backup extends Page
));
// Leave this comment so the i18n scanner finds it:
// Message::addSuccess('restore-done');
- if (isset($task['id']))
+ if (isset($task['id'])) {
$this->templateData['rebootid'] = $task['id'];
+ }
+ }
+
+ private function config()
+ {
+ $password = trim(Request::post('passwd', '', 'string'));
+ if (empty($password)) {
+ $password = null;
+ }
+ $mode = Request::post('auto-backup-mode', false, 'string');
+ if ($mode !== BackupRestore::BACKUP_MODE_OFF && $mode !== BackupRestore::BACKUP_MODE_ROOTHOME
+ && $mode !== BackupRestore::BACKUP_MODE_VMSTORE) {
+ Message::addError('invalid-auto-backup-mode', $mode);
+ Util::redirect('?do=backup');
+ }
+ Property::set(BackupRestore::PROP_AUTO_BACKUP_MODE, $mode);
+ Property::set(BackupRestore::PROP_AUTO_BACKUP_PASS, $password);
+ Util::redirect('?do=backup');
}
}
diff --git a/modules-available/backup/permissions/permissions.json b/modules-available/backup/permissions/permissions.json
index 1f778ab6..41db74c9 100644
--- a/modules-available/backup/permissions/permissions.json
+++ b/modules-available/backup/permissions/permissions.json
@@ -4,5 +4,8 @@
},
"restore": {
"location-aware": false
+ },
+ "config": {
+ "location-aware": false
}
} \ No newline at end of file
diff --git a/modules-available/backup/templates/_page.html b/modules-available/backup/templates/_page.html
index 3e57c033..818f42cd 100644
--- a/modules-available/backup/templates/_page.html
+++ b/modules-available/backup/templates/_page.html
@@ -7,13 +7,20 @@
<div class="panel-heading">{{lang_backup}}</div>
<div class="panel-body {{perms.create.disabled}}">
<p>{{lang_backupDescription}}</p>
+ <div class="form-group">
+ <label for="passwd-in">{{lang_backupPasswordLabel}}</label>
+ <input id="passwd-in" type="{{password_type}}" class="form-control" name="passwd"
+ placeholder="{{lang_backupPasswordPlaceholder}}">
+ </div>
+ <i>{{lang_backupPasswordHint}}</i>
<p class="text-right">
{{lang_lastBackup}}:
{{^last_backup}}{{lang_unknown}}{{/last_backup}}
{{last_backup}}
</p>
<button id="b-btn" {{perms.create.disabled}} class="btn btn-primary pull-right" type="submit">
- <span class="glyphicon glyphicon-save"></span> {{lang_download}}
+ <span class="glyphicon glyphicon-save"></span>
+ {{lang_download}}
</button>
</div>
</div>
@@ -23,25 +30,31 @@
<input type="hidden" name="token" value="{{token}}">
<input type="hidden" name="action" value="restore">
<div class="panel panel-default">
- <div class="panel-heading">{{lang_restore}}</div>
- <div class="panel-body {{perms.restore.disabled}}">
+ <div class="panel-heading">{{id_prefix}} {{lang_restoreHeading}}</div>
+ <div class="panel-body {{perms.restore.disabled}}" {{#id_color}}style="border:5px solid {{.}}"{{/id_color}}>
<p>{{lang_restoreDescription}}</p>
+ <label for="file-out">{{lang_restoreFileLabel}}</label>
<div class="input-group upload-ex">
- <input type="text" class="form-control" readonly placeholder="{{lang_selectFile}}">
+ <input id="file-out" type="text" class="form-control" readonly placeholder="{{lang_selectFile}}">
<span class="input-group-btn">
<span class="btn btn-default btn-file">
{{lang_browseForFile}}&hellip; <input type="file" name="backupfile" {{perms.restore.disabled}}>
</span>
</span>
</div>
- <div>
+ <div class="form-group">
+ <label for="passwd-out">{{lang_restorePasswordLabel}}</label>
+ <input id="passwd-out" type="{{password_type}}" class="form-control" name="passwd"
+ placeholder="{{lang_restorePasswordPlaceholder}}">
+ </div>
+ <div class="form-group">
<div class="checkbox">
<input type="checkbox" name="restore_openslx" checked="checked" id="id-sysonfig">
<label for="id-sysonfig"><b>{{lang_restoreSystemConfig}}</b></label>
</div>
<p><i>{{lang_systemExplanation}}</i></p>
</div>
- <div>
+ <div class="form-group">
<div class="checkbox">
<input type="checkbox" name="restore_dozmod" checked="checked" id="id-dozmod">
<label for="id-dozmod"><b>{{lang_restoreDozmodConfig}}</b></label>
@@ -53,7 +66,44 @@
{{lang_runningUploads}}: <span class="uploads">??</span>,
{{lang_runningDownloads}}: <span class="downloads">??</span>
</div>
- <button {{perms.restore.disabled}} class="btn btn-primary pull-right" type="submit"><span class="glyphicon glyphicon-open"></span> {{lang_restore}}</button>
+ <button {{perms.restore.disabled}} class="btn btn-primary pull-right" type="submit">
+ <span class="glyphicon glyphicon-open"></span>
+ {{id_prefix}}
+ {{lang_restoreButton}}
+ </button>
+ </div>
+ </div>
+</form>
+
+<form action="?do=Backup" method="post">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="action" value="config">
+ <div class="panel panel-default">
+ <div class="panel-heading">{{lang_autoBackupHeading}}</div>
+ <div class="panel-body {{perms.config.disabled}}">
+ <p>{{lang_autoBackupText}}</p>
+ <div class="radio">
+ <input id="auto-backup-OFF" type="radio" name="auto-backup-mode" value="OFF" {{backup_OFF_checked}}>
+ <label for="auto-backup-OFF">{{lang_autoBackupOFF}}</label>
+ </div>
+ <div class="radio">
+ <input id="auto-backup-ROOTHOME" type="radio" name="auto-backup-mode" value="ROOTHOME" {{backup_ROOTHOME_checked}}>
+ <label for="auto-backup-ROOTHOME">{{lang_autoBackupROOTHOME}}</label>
+ </div>
+ <div class="radio">
+ <input id="auto-backup-VMSTORE" type="radio" name="auto-backup-mode" value="VMSTORE" {{backup_VMSTORE_checked}}>
+ <label for="auto-backup-VMSTORE">{{lang_autoBackupVMSTORE}}</label>
+ </div>
+ <div class="form-group">
+ <label for="passwd-auto">{{lang_autoBackupPasswordLabel}}</label>
+ <input id="passwd-auto" type="{{password_type}}" class="form-control" name="passwd"
+ value="{{autoBackupPw}}" placeholder="{{lang_autoBackupPasswordPlaceholder}}">
+ </div>
+ <i>{{lang_backupPasswordHint}}</i>
+ <button {{perms.config.disabled}} class="btn btn-primary pull-right" type="submit">
+ <span class="glyphicon glyphicon-floppy-disk"></span>
+ {{lang_save}}
+ </button>
</div>
</div>
</form>
diff --git a/modules-available/backup/templates/restore.html b/modules-available/backup/templates/restore.html
index c9c19d2b..3e57a3ee 100644
--- a/modules-available/backup/templates/restore.html
+++ b/modules-available/backup/templates/restore.html
@@ -20,6 +20,7 @@
<script type="text/javascript">
var slxDotInterval = false;
+ var restoreSuccess = false;
function restoreCb(task)
{
if (!task || !task.statusCode)
@@ -28,6 +29,7 @@
$('#restorefailed').show('slow');
}
if (task.statusCode === 'TASK_ERROR' || task.statusCode === 'TASK_FINISHED') {
+ restoreSuccess = (task.statusCode === 'TASK_FINISHED');
startRebootPoll();
}
}
@@ -48,14 +50,18 @@
}
$('#dots').text($('#dots').text() + '..');
slxTimeoutId = setTimeout(rebootPoll, 3500);
- $.ajax({url: "index.php?do=Main", timeout: 3000}).success(function(data, textStatus, jqXHR) {
+ $.ajax({url: "?do=main", timeout: 3000}).success(function(data, textStatus, jqXHR) {
if (textStatus !== "success" && textStatus !== "notmodified")
return;
if (data.indexOf('Status: DB running') === -1)
return;
clearTimeout(slxTimeoutId);
setTimeout(function() {
- window.location.replace("index.php?do=Main&message[]=success%7Cbackup.restore-done");
+ if (restoreSuccess) {
+ window.location.replace("?do=main&message[]=success%7Cbackup.restore-done");
+ } else {
+ window.location.replace("?do=main");
+ }
}, 3500);
});
}
diff --git a/modules-available/backup/templates/task-error.html b/modules-available/backup/templates/task-error.html
new file mode 100644
index 00000000..56837799
--- /dev/null
+++ b/modules-available/backup/templates/task-error.html
@@ -0,0 +1,6 @@
+<div class="panel panel-default">
+ <div class="panel-heading panel-danger">{{lang_error}}</div>
+ <div class="panel-body">
+ <div data-tm-id="{{taskid}}" data-tm-log="messages">{{lang_checkingArchive}}</div>
+ </div>
+</div>
diff --git a/modules-available/baseconfig/hooks/locations-column.inc.php b/modules-available/baseconfig/hooks/locations-column.inc.php
new file mode 100644
index 00000000..ca30d56e
--- /dev/null
+++ b/modules-available/baseconfig/hooks/locations-column.inc.php
@@ -0,0 +1,76 @@
+<?php
+
+if (!User::hasPermission('.baseconfig.view'))
+ return null;
+
+class BaseconfigLocationColumn extends AbstractLocationColumn
+{
+
+ private $byLoc;
+ private $byMachine;
+
+ public function __construct(array $allowedLocationIds)
+ {
+ if (in_array(0, $allowedLocationIds)) {
+ $extra = 'OR m.locationid IS NULL';
+ } else {
+ $extra = '';
+ }
+ // Count overridden config vars
+ $this->byLoc = Database::queryKeyValueList("SELECT locationid, Count(*) AS cnt
+ FROM `setting_location`
+ WHERE locationid IN (:allowedLocationIds)
+ GROUP BY locationid", compact('allowedLocationIds'));
+ // Confusing because the count might be inaccurate within a branch
+ //$this->propagateFields($locationList, '', 'overriddenVars', 'overriddenClass');
+ // Count machines with overriden var(s)
+ $this->byMachine = Database::queryKeyValueList("SELECT IFNULL(m.locationid, 0), Count(DISTINCT sm.machineuuid) AS cnt
+ FROM setting_machine sm
+ INNER JOIN machine m USING (machineuuid)
+ WHERE (m.locationid IN (:allowedLocationIds) $extra)
+ GROUP BY m.locationid", compact('allowedLocationIds'));
+ // There WHERE statement drops clients without location - but this cannot be displayed by the locations
+ // table anyways as of now - maybe implement some day? Or just encourage everyone to have a root location
+ }
+
+ public function getColumnHtml(int $locationId): string
+ {
+ $ret = '';
+ if ($this->byLoc[$locationId] ?? 0) {
+ $title = htmlspecialchars(Dictionary::translateFileModule('baseconfig', 'module', 'overriden-vars-for-location'));
+ $ret .= <<<EOF
+ <span class="badge" title="{$title}">
+ <span class="glyphicon glyphicon-home"></span> {$this->byLoc[$locationId]}
+ </span>
+EOF;
+ }
+ if ($this->byMachine[$locationId] ?? 0) {
+ $title = htmlspecialchars(Dictionary::translateFileModule('baseconfig', 'module', 'overriden-vars-machines'));
+ $ret .= <<<EOF
+ <span class="badge" title="{$title}">
+ <span class="glyphicon glyphicon-tasks"></span> {$this->byMachine[$locationId]}
+ </span>
+EOF;
+ }
+ return $ret;
+ }
+
+ public function getEditUrl(int $locationId): string
+ {
+ if (!User::hasPermission('.baseconfig.edit', $locationId))
+ return '';
+ return '?do=baseconfig&module=locations&locationid=' . $locationId;
+ }
+
+ public function header(): string
+ {
+ return Dictionary::translateFileModule('baseconfig', 'module', 'location-column-header');
+ }
+
+ public function priority(): int
+ {
+ return 1000;
+ }
+}
+
+return new BaseconfigLocationColumn($allowedLocationIds); \ No newline at end of file
diff --git a/modules-available/baseconfig/inc/baseconfig.inc.php b/modules-available/baseconfig/inc/baseconfig.inc.php
index 064e0f89..36622dce 100644
--- a/modules-available/baseconfig/inc/baseconfig.inc.php
+++ b/modules-available/baseconfig/inc/baseconfig.inc.php
@@ -17,13 +17,15 @@ class BaseConfig
*/
public static function prepareFromRequest()
{
- $ip = $_SERVER['REMOTE_ADDR'];
+ $ip = $_SERVER['REMOTE_ADDR'] ?? null;
+ if ($ip === null)
+ ErrorHandler::traceError('No REMOTE_ADDR given in $_SERVER');
if (substr($ip, 0, 7) === '::ffff:') {
$ip = substr($ip, 7);
}
- $uuid = Request::any('uuid', false, 'string');
- if ($uuid !== false && strlen($uuid) !== 36) {
- $uuid = false;
+ $uuid = Request::any('uuid', null, 'string');
+ if ($uuid !== null && strlen($uuid) !== 36) {
+ $uuid = null;
}
// Handle any hooks by other modules first
// other modules should generally only populate $configVars
@@ -42,10 +44,10 @@ class BaseConfig
* 'locationid'
* @param array $overrides key value pairs of overrides
*/
- public static function prepareWithOverrides($overrides)
+ public static function prepareWithOverrides(array $overrides): void
{
self::$overrides = $overrides;
- $ip = $uuid = false;
+ $ip = $uuid = null;
if (self::hasOverride('ip')) {
$ip = self::getOverride('ip');
}
@@ -78,12 +80,12 @@ class BaseConfig
// Dump global config from DB
ConfigHolder::setContext('<global>', function($id) {
return [
- 'name' => Dictionary::translate('source-global', true),
+ 'name' => Dictionary::translate('source-global'),
'locationid' => 0,
];
});
$res = Database::simpleQuery('SELECT setting, value, displayvalue FROM setting_global');
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if (!isset($defaults[$row['setting']]))
continue; // Setting is not defined in any <module>/baseconfig/settings.json
ConfigHolder::add($row['setting'], $row, -1);
@@ -98,7 +100,7 @@ class BaseConfig
{
ConfigHolder::setContext('<default>', function($id) {
return [
- 'name' => Dictionary::translate('source-default', true),
+ 'name' => Dictionary::translate('source-default'),
'locationid' => 0,
];
});
@@ -107,8 +109,9 @@ class BaseConfig
}
}
- private static function handleModule($name, $ip, $uuid, $needJsonHook) // Pass ip and uuid instead of global to make them read only
+ private static function handleModule(string $name, ?string $ip, ?string $uuid, bool $needJsonHook): void
{
+ // Pass ip and uuid instead of global to make them read only
if (isset(self::$modulesDone[$name]))
return;
self::$modulesDone[$name] = true;
@@ -130,12 +133,12 @@ class BaseConfig
self::handleModule($dep, $ip, $uuid, $needJsonHook);
}
ConfigHolder::setContext($name);
- (function ($file, $ip, $uuid) {
+ (function (string $file, ?string $ip, ?string $uuid) {
include_once($file);
})($file, $ip, $uuid);
}
- public static function hasOverride($key)
+ public static function hasOverride($key): bool
{
return array_key_exists($key, self::$overrides);
}
diff --git a/modules-available/baseconfig/inc/baseconfigutil.inc.php b/modules-available/baseconfig/inc/baseconfigutil.inc.php
index a48eb93b..4d1ef8c1 100644
--- a/modules-available/baseconfig/inc/baseconfigutil.inc.php
+++ b/modules-available/baseconfig/inc/baseconfigutil.inc.php
@@ -16,7 +16,7 @@ class BaseConfigUtil
* @param \Module $module optional, only consider given module, not all enabled modules
* @return array all known config variables
*/
- public static function getVariables($module = false)
+ public static function getVariables($module = false): array
{
$settings = array();
if ($module === false) {
@@ -39,16 +39,13 @@ class BaseConfigUtil
/**
* Get configuration categories for given module, or all modules if false is passed.
- *
- * @param \Module $module
- * @return array
*/
- public static function getCategories($module = false)
+ public static function getCategories(Module $module = null): array
{
$categories = array();
- if ($module === false) {
+ if ($module === null) {
$module = '*';
- } elseif (is_object($module)) {
+ } else {
$module = $module->getIdentifier();
}
foreach (glob("modules/{$module}/baseconfig/categories.json", GLOB_NOSORT) as $file) {
@@ -70,7 +67,8 @@ class BaseConfigUtil
* @param array $vars list of vars as obtained from BaseConfigUtil::getVariables()
* @param array $values key-value-pairs of variable assignments to work with
*/
- public static function markShadowedVars(&$vars, $values) {
+ public static function markShadowedVars(array &$vars, array $values): void
+ {
foreach ($vars as $key => &$var) {
if (!isset($var['shadows']))
continue;
diff --git a/modules-available/baseconfig/inc/configholder.inc.php b/modules-available/baseconfig/inc/configholder.inc.php
index 224f2aab..75b43460 100644
--- a/modules-available/baseconfig/inc/configholder.inc.php
+++ b/modules-available/baseconfig/inc/configholder.inc.php
@@ -19,7 +19,7 @@ class ConfigHolder
* @param false|string|array $value false to unset, string value, or array with keys value and displayvalue
* @param int $prio priority of this value, in case the same key gets set multiple times
*/
- public static function add($key, $value, $prio = 0)
+ public static function add(string $key, $value, int $prio = 0): void
{
if (!isset(self::$config[$key])) {
self::$config[$key] = [];
@@ -43,22 +43,19 @@ class ConfigHolder
}
}
- public static function get($key)
+ public static function get(string $key): ?string
{
if (!isset(self::$config[$key]))
- return false;
+ return null;
return self::$config[$key][0]['value'];
}
- /**
- * @param callable $func
- */
- public static function addPostHook($func)
+ public static function addPostHook(callable $func): void
{
self::$postHooks[] = array('context' => &self::$context, 'function' => $func);
}
- public static function applyPostHooks()
+ public static function applyPostHooks(): void
{
foreach (self::$postHooks as $hook) {
$newContext = $hook['context'];
@@ -69,7 +66,7 @@ class ConfigHolder
self::$postHooks = [];
}
- public static function getRecursiveConfig($prettyPrint = true)
+ public static function getRecursiveConfig(bool $prettyPrint = true): array
{
$ret = [];
foreach (self::$config as $key => $list) {
@@ -97,7 +94,7 @@ class ConfigHolder
return $ret;
}
- public static function outputConfig()
+ public static function outputConfig(): void
{
foreach (self::$config as $key => $list) {
echo str_pad('# ' . $key . ' ', 35, '#', STR_PAD_BOTH), "\n";
@@ -129,7 +126,7 @@ class ConfigHolder
* @param string $string input
* @return string escaped sh string
*/
- private static function escape($string)
+ private static function escape(string $string): string
{
return str_replace(["'", "\n", "\r"], ["'\"'\"'", ' ', ' '], $string);
}
diff --git a/modules-available/baseconfig/inc/validator.inc.php b/modules-available/baseconfig/inc/validator.inc.php
index 60cda5a4..be71c3df 100644
--- a/modules-available/baseconfig/inc/validator.inc.php
+++ b/modules-available/baseconfig/inc/validator.inc.php
@@ -32,11 +32,12 @@ class Validator
case 'multilist':
return self::validateMultiList($data[1], $displayValue);
case 'multiinput':
- return self::validateMultiInput($data[1], $displayValue);
+ return self::validateMultiInput($data[1] ?? [], $displayValue);
+ case 'suggestions':
+ return true;
default:
- Util::traceError('Unknown validation method: ' . $data[0]);
+ ErrorHandler::traceError('Unknown validation method: ' . $data[0]);
}
- return false; // make code inspector happy - doesn't know traceError doesn't return
}
@@ -44,7 +45,7 @@ class Validator
* Validate linux password. If already in $6$ hash form,
* the unchanged value will be returned.
* if empty, an empty string will also be returned.
- * Otherwise it it assumed that the value is a plain text
+ * Otherwise, it is assumed that the value is a plain text
* password that is supposed to be hashed.
*/
private static function linuxPassword($displayValue)
@@ -62,7 +63,7 @@ class Validator
* @param string $displayValue network path
* @return string cleaned up path
*/
- private static function networkShare(&$displayValue)
+ private static function networkShare(string &$displayValue): string
{
$displayValue = trim($displayValue);
if (substr($displayValue, 0, 2) === '\\\\')
@@ -75,18 +76,19 @@ class Validator
/**
* Validate value against list.
+ *
* @param string $list The list as a string of items, separated by "|"
* @param string $displayValue The value to validate
* @return boolean|string The value, if in list, false otherwise
*/
- private static function validateList($list, &$displayValue)
+ private static function validateList(string $list, string $displayValue)
{
$list = explode('|', $list);
if (in_array($displayValue, $list))
return $displayValue;
return false;
}
- private static function validateMultiList($list, &$displayValue)
+ private static function validateMultiList(string $list, array &$displayValue): string
{
$allowedValues = explode('|', $list);
$values = [];
@@ -99,7 +101,7 @@ class Validator
return $displayValue;
}
- private static function validateMultiInput($list, &$displayValue)
+ private static function validateMultiInput($list, $displayValue)
{
return $displayValue;
}
diff --git a/modules-available/baseconfig/lang/de/module.json b/modules-available/baseconfig/lang/de/module.json
index f7dbf53a..a9c2c6bf 100644
--- a/modules-available/baseconfig/lang/de/module.json
+++ b/modules-available/baseconfig/lang/de/module.json
@@ -1,5 +1,8 @@
{
+ "location-column-header": "Konfig.-Variablen",
"module_name": "Konfigurationsvariablen",
+ "overriden-vars-for-location": "F\u00fcr diesen Ort \u00fcberschriebene Variablen",
+ "overriden-vars-machines": "Rechner an diesem Ort, die \u00fcberschriebene Variablen haben",
"source-default": "Auslieferungszustand",
"source-global": "Global"
} \ No newline at end of file
diff --git a/modules-available/baseconfig/lang/en/module.json b/modules-available/baseconfig/lang/en/module.json
index 97e19f92..5a25bcff 100644
--- a/modules-available/baseconfig/lang/en/module.json
+++ b/modules-available/baseconfig/lang/en/module.json
@@ -1,5 +1,8 @@
{
+ "location-column-header": "Config. Vars",
"module_name": "Config Variables",
+ "overriden-vars-for-location": "Number of variables overridden at this location",
+ "overriden-vars-machines": "Machines at this location that have overridden variables",
"source-default": "Factory default",
"source-global": "Global"
} \ No newline at end of file
diff --git a/modules-available/baseconfig/page.inc.php b/modules-available/baseconfig/page.inc.php
index 1566464b..5d684a8e 100644
--- a/modules-available/baseconfig/page.inc.php
+++ b/modules-available/baseconfig/page.inc.php
@@ -68,7 +68,7 @@ class Page_BaseConfig extends Page
if (isset($var['shadowed']))
continue;
$validator = $var['validator'];
- $displayValue = (isset($newValues[$key]) ? $newValues[$key] : '');
+ $displayValue = $newValues[$key] ?? '';
// Validate data first!
$mangledValue = Validator::validate($validator, $displayValue);
if ($mangledValue === false) {
@@ -101,7 +101,7 @@ class Page_BaseConfig extends Page
foreach ($this->categories as $catid => $val) {
Dashboard::addSubmenu(
'#category_' . $catid,
- Dictionary::translateFileModule($this->categories[$catid]['module'], 'config-variable-categories', $catid, true)
+ Dictionary::translateFileModule($this->categories[$catid]['module'], 'config-variable-categories', $catid)
);
}
}
@@ -120,12 +120,11 @@ class Page_BaseConfig extends Page
User::assertPermission('view', $lid);
$editForbidden = !User::hasPermission('edit', $lid);
// Get stuff that's set in DB already
+ $fields = '';
if ($this->targetModule !== false && isset($this->qry_extra['field'])) {
- $fields = '';
$where = " WHERE {$this->qry_extra['field']} = :field_value";
$params = array('field_value' => $this->qry_extra['field_value']);
} else {
- $fields = '';
$where = '';
$params = array();
}
@@ -136,7 +135,7 @@ class Page_BaseConfig extends Page
// Remember missing variables
$missing = $varsFromJson;
// Populate structure with existing config from db
- $this->fillSettings($varsFromJson, $settings, $missing, $this->qry_extra['table'], $fields, $where, $params, false);
+ $this->fillSettings($varsFromJson, $settings, $missing, $this->qry_extra['table'], $fields, $where, $params);
// Add entries that weren't in the db (global), setup override checkbox (module specific)
foreach ($varsFromJson as $key => $var) {
if ($this->targetModule !== false && !isset($settings[$var['catid']]['settings'][$key])) {
@@ -154,7 +153,7 @@ class Page_BaseConfig extends Page
}
}
if (!isset($entry['shadows'])) {
- $entry['shadows'] = isset($var['shadows']) ? $var['shadows'] : null;
+ $entry['shadows'] = $var['shadows'] ?? null;
}
$entry += array(
'item' => $this->makeInput(
@@ -166,7 +165,7 @@ class Page_BaseConfig extends Page
),
'description' => Util::markup(Dictionary::translateFileModule($var['module'], 'config-variables', $key)),
'setting' => $key,
- 'tree' => isset($parents[$key]) ? $parents[$key] : false,
+ 'tree' => $parents[$key] ?? false,
);
}
unset($entry);
@@ -195,15 +194,15 @@ class Page_BaseConfig extends Page
) + $this->qry_extra);
}
- private function fillSettings($vars, &$settings, &$missing, $table, $fields, $where, $params, $sourceName)
+ private function fillSettings($vars, &$settings, &$missing, $table, $fields, $where, $params): void
{
$res = Database::simpleQuery("SELECT setting, value, displayvalue $fields FROM $table "
. " {$where} ORDER BY setting ASC", $params);
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if (!isset($missing[$row['setting']]))
continue;
if (!isset($vars[$row['setting']]) || !is_array($vars[$row['setting']])) {
- $unknown[] = $row['setting'];
+ //$unknown[] = $row['setting'];
continue;
}
unset($missing[$row['setting']]);
@@ -261,7 +260,7 @@ class Page_BaseConfig extends Page
$this->qry_extra = $hook;
}
- private function getPermissionLocationId()
+ private function getPermissionLocationId(): int
{
if (!isset($this->qry_extra['locationResolver']) || !isset($this->qry_extra['field_value']))
return 0;
@@ -281,10 +280,8 @@ class Page_BaseConfig extends Page
/**
* Create html snippet for setting, based on given validator
- * @param string $validator
- * @return boolean
*/
- private function makeInput($validator, $setting, $current, $shadows, $disabled)
+ private function makeInput(string $validator, string $setting, string $current, ?array $shadows, bool $disabled): string
{
/* for the html snippet we need: */
$args = array('class' => 'form-control', 'name' => "setting[$setting]", 'id' => $setting);
@@ -294,7 +291,7 @@ class Page_BaseConfig extends Page
if ($disabled) {
$args['disabled'] = true;
}
- $inner = "";
+ $extra = $inner = "";
/* -- */
$parts = explode(':', $validator, 2);
@@ -314,6 +311,21 @@ class Page_BaseConfig extends Page
unset($args['type']);
$current = '';
+ } elseif ($parts[0] === 'suggestions') {
+
+ $extra = '<datalist id="list-' . $setting . '">';
+ $items = explode('|', $parts[1]);
+ foreach ($items as $item) {
+ $extra .= '<option>' . htmlspecialchars($item) . '</option>';
+ }
+ $extra .= '</datalist>';
+
+ $tag = 'input';
+ $args['value'] = $current;
+ $args['type'] = 'text';
+ $args['list'] = 'list-' . $setting;
+ $current = '';
+
} elseif ($parts[0] == 'multilist') {
$items = explode('|', $parts[1]);
@@ -362,7 +374,7 @@ class Page_BaseConfig extends Page
$output .= '>' . $inner . "</$tag>";
}
- return $output;
+ return $output . $extra;
}
}
diff --git a/modules-available/baseconfig/templates/_page.html b/modules-available/baseconfig/templates/_page.html
index ef10ac26..5faff391 100644
--- a/modules-available/baseconfig/templates/_page.html
+++ b/modules-available/baseconfig/templates/_page.html
@@ -113,15 +113,20 @@ document.addEventListener("DOMContentLoaded", function () {
var rules = $this.data('shadows');
if (!rules) return;
var currentValue = $this.val();
+ const disabled = [];
for (var triggerVal in rules) {
if (!rules.hasOwnProperty(triggerVal))
continue;
var targets = rules[triggerVal];
for (var i = 0; i < targets.length; ++i) {
var target = targets[i];
+ if (disabled.includes(target)) {
+ continue;
+ }
var inp = $('#' + target);
var selitem = inp.data('selitem');
if (currentValue === triggerVal) {
+ disabled.push(target);
inp.prop('disabled', true);
if (selitem) selitem.disable();
inp.filter('.multilist').multiselect('disable');
diff --git a/modules-available/baseconfig_bwidm/hooks/translation.inc.php b/modules-available/baseconfig_bwidm/hooks/translation.inc.php
index a53500fc..da7c70af 100644
--- a/modules-available/baseconfig_bwidm/hooks/translation.inc.php
+++ b/modules-available/baseconfig_bwidm/hooks/translation.inc.php
@@ -16,10 +16,8 @@ $HANDLER['subsections'] = array(
/**
* Configuration categories.
- * @param \Module $module
- * @return array
*/
-$HANDLER['grep_config-variable-categories'] = function($module) {
+$HANDLER['grep_config-variable-categories'] = function (Module $module): array {
if (!$module->activate(1, false))
return array();
$want = BaseConfigUtil::getCategories($module);
@@ -31,10 +29,8 @@ $HANDLER['grep_config-variable-categories'] = function($module) {
/**
* Configuration variables.
- * @param \Module $module
- * @return array
*/
-$HANDLER['grep_config-variables'] = function($module) {
+$HANDLER['grep_config-variables'] = function (Module $module): array {
if (!$module->activate(1, false))
return array();
$want = BaseConfigUtil::getVariables($module);
diff --git a/modules-available/baseconfig_bwlp/baseconfig/settings.json b/modules-available/baseconfig_bwlp/baseconfig/settings.json
index fa8dd437..f01c7bf9 100644
--- a/modules-available/baseconfig_bwlp/baseconfig/settings.json
+++ b/modules-available/baseconfig_bwlp/baseconfig/settings.json
@@ -79,6 +79,12 @@
"permissions": "2",
"validator": "list:no|yes"
},
+ "SLX_DHCP_OTHER_NICS": {
+ "catid": "networking",
+ "defaultvalue": "no",
+ "permissions": "2",
+ "validator": "list:no|yes"
+ },
"SLX_JUMBO_FRAMES": {
"catid": "networking",
"defaultvalue": "no",
@@ -187,6 +193,12 @@
"permissions": "2",
"validator": ""
},
+ "SLX_PRINT_REUSE_PASSWORD": {
+ "catid": "sysconfig",
+ "defaultvalue": "no",
+ "permissions": "2",
+ "validator": "list:no|yes"
+ },
"SLX_AUTOSTART_UUID": {
"catid": "vmchooser",
"defaultvalue": "",
@@ -206,9 +218,9 @@
},
"SLX_PREFERRED_SOUND_OUTPUT": {
"catid": "sysconfig",
- "defaultvalue": "SOUNDCARD",
+ "defaultvalue": "",
"permissions": "2",
- "validator": "list:HDMI|SOUNDCARD"
+ "validator": "suggestions:Analog Stereo Duplex Speakers|HDMI Digital Stereo|Headphones|Front Headphones|Rear Headphones"
},
"SLX_VM_SOUND": {
"catid": "sysconfig",
@@ -239,5 +251,17 @@
"defaultvalue": "",
"permissions": "2",
"validator": "regex:/^(\\s*(\\S+=\\d+)(\\s+\\S+=\\d+)*\\s*|)$/i"
+ },
+ "SLX_NTFSFREE": {
+ "catid": "other",
+ "defaultvalue": "never",
+ "permissions": "2",
+ "validator": "list:never|backup|always"
+ },
+ "SLX_ID44_CRYPT": {
+ "catid": "other",
+ "defaultvalue": "ON",
+ "permissions": "2",
+ "validator": "list:ON|OFF"
}
}
diff --git a/modules-available/baseconfig_bwlp/hooks/translation.inc.php b/modules-available/baseconfig_bwlp/hooks/translation.inc.php
index a53500fc..da7c70af 100644
--- a/modules-available/baseconfig_bwlp/hooks/translation.inc.php
+++ b/modules-available/baseconfig_bwlp/hooks/translation.inc.php
@@ -16,10 +16,8 @@ $HANDLER['subsections'] = array(
/**
* Configuration categories.
- * @param \Module $module
- * @return array
*/
-$HANDLER['grep_config-variable-categories'] = function($module) {
+$HANDLER['grep_config-variable-categories'] = function (Module $module): array {
if (!$module->activate(1, false))
return array();
$want = BaseConfigUtil::getCategories($module);
@@ -31,10 +29,8 @@ $HANDLER['grep_config-variable-categories'] = function($module) {
/**
* Configuration variables.
- * @param \Module $module
- * @return array
*/
-$HANDLER['grep_config-variables'] = function($module) {
+$HANDLER['grep_config-variables'] = function (Module $module): array {
if (!$module->activate(1, false))
return array();
$want = BaseConfigUtil::getVariables($module);
diff --git a/modules-available/baseconfig_bwlp/lang/de/config-variables.json b/modules-available/baseconfig_bwlp/lang/de/config-variables.json
index 9ba946fc..41292d25 100644
--- a/modules-available/baseconfig_bwlp/lang/de/config-variables.json
+++ b/modules-available/baseconfig_bwlp/lang/de/config-variables.json
@@ -5,14 +5,18 @@
"SLX_BIOS_CLOCK": "Legt fest, ob und wie die interne Uhr des Rechners im Bezug auf die Systemzeit des \/MiniLinux\/ gesetzt werden soll.\r\n*off* = Die interne Uhr des Rechners wird nicht ver\u00e4ndert.\r\n*local* = Die interne Uhr wird auf die Lokalzeit gesetzt. Bevorzugt wenn z.B. noch eine native Windows-Installation auf dem PC vorhanden ist.\r\n*utc* = Die interne Uhr wird auf die \/Koordinierte Weltzeit\/ gesetzt. Dies ist die g\u00e4ngige Einstellung in einem reinen Linux-Umfeld.",
"SLX_BRIDGE_OTHER_NICS": "Sofern ein Client mehrere Netzwerkkarten besitzt, k\u00f6nnen Sie mittels dieser Option alle weiteren gefundenen Karten in die VM durchreichen.",
"SLX_DEMO_PASS": "Passwort f\u00fcr den eingebauten *demo*-Account. Leer lassen, um das Einloggen zu verbieten.\r\n\/Hinweis\/: Das Passwort wird im Klartext in der lokalen Datenbank hinterlegt, jedoch immer gehasht an die Clients \u00fcbermittelt (SHA-512 mit Salt). Wenn Sie das Passwort auch im Satelliten nicht im Klartext speichern wollen, k\u00f6nnen Sie hier auch ein vorgehashtes Passwort eintragen (im *$6$....*-Format).",
+ "SLX_DHCP_OTHER_NICS": "Sofern ein Client mehrere Netzwerkkarten besitzt, k\u00f6nnen Sie mittels dieser Option einen DHCP-Client auf den weiteren Interfaces starten lassen. Ansonsten bleiben diese unkonfiguriert, k\u00f6nnen jedoch weiterhin mittels SLX_BRIDGE_OTHER_NICS in VMs durchgeleitet werden.",
"SLX_FORCE_RESOLUTION": "Wenn gesetzt, wird unabh\u00e4ngig von ermittelten Bildschirmdaten immer diese Aufl\u00f6sung konfiguriert.\r\nWenn Sie hier eine mit dem verbundenen Bildschirm inkompatible Ausl\u00f6sung setzen, bleibt mitunter der Bildschirm schwarz.",
+ "SLX_ID44_CRYPT": "Wenn aktiviert, wird die ID44-Partition mit einem tempor\u00e4ren Key verschl\u00fcsselt, der beim Booten generiert und nirgends gespeichert wird. Nach dem Abschalten eines Clients sind dann keine Daten (VM-Diffs, tempor\u00e4re Daten einer nativen Sitzung) mehr wiederherstellbar.\r\n\r\nDie Einstellung wird erst ab Version 32r1 des Grundsystems ausgewertet, \u00e4ltere Versionen verschl\u00fcsseln die Partition nicht.",
"SLX_JUMBO_FRAMES": "Setzt die MTU auf den Clients auf 9000, statt wie \u00fcblich 1500. Da dies mit alten\/schlechten Routern oder Switches zu Problemen f\u00fchren k\u00f6nnte, ist diese Option standardm\u00e4\u00dfig deaktiviert.",
- "SLX_LOGOUT_TIMEOUT": "Zeit in Sekunden, die eine Benutzersitzung ohne Aktion sein darf, bevor sie beendet wird.Feld leer lassen, um die Funktion zu deaktivieren.",
+ "SLX_LOGOUT_TIMEOUT": "Zeit in Sekunden, die eine Benutzersitzung ohne Aktion sein darf, bevor sie beendet wird. Feld leer lassen, um die Funktion zu deaktivieren.",
"SLX_NET_DOMAIN": "DNS-Dom\u00e4ne, in die sich die Clients eingliedern, sofern der DHCP Server keine solche vorgibt.",
"SLX_NET_SEARCH": "Per Leerzeichen getrennte Liste von Suchdom\u00e4nen, die der Client verwenden soll, sofern der DHCP-Server keine Vorgabe macht.",
+ "SLX_NTFSFREE": "Bestimmt, ob freier Speicherplatz auf NTFS-Partitionen als tempor\u00e4rer Speicher, \u00e4quivalent zu einer ID44-Partition, genutzt werden soll.\r\n\r\n*never* deaktiviert diese Funktion.\r\n*backup* verwendet zun\u00e4chst eine ID44 Partition oder RAM-Disk, wenn keine Partition vorhanden ist, und weicht erst auf eine eventuell vorhandene NTFS-Partition aus, wenn der Speicher knapp wird.\r\n*always* verwendet immer eine vorhandene NTFS-Partition als tempor\u00e4ren Speicher, au\u00dfer es wurde eine ID44-Partition gefunden, die >= 100GiB ist.\r\n\r\nDiese Funktionalit\u00e4t steht nur bei Verwendung des MaxiLinux zur Verf\u00fcgung. Eine NTFS-Partition kann nur dann verwendet werden, wenn sie zuvor sauber ausgeh\u00e4ngt wurde, d.h. i.d.R., dass Windows ordnungsgem\u00e4\u00df heruntergefahren wurde.",
"SLX_NTP_SERVER": "Adresse des NTP-Zeitservers. Es k\u00f6nnen mehrere Server mit Leerzeichen getrennt angegeben werden.Die Server werden der Reihe nach angefragt, bis ein antwortender Server gefunden wird.",
"SLX_PASSTHROUGH_USB_ID": "Geben Sie hier eindeutige IDs von USB-Ger\u00e4ten an, die direkt in die VMs weitergereicht werden sollen. Das erwartete Format ist *vendorID:productID* , als jeweils vierstellige Hexadezimalzahlen, beispielsweise *1234:abcd* .\r\nMehrere IDs k\u00f6nnen als leerzeichengetrennte Liste angegeben werden.",
- "SLX_PREFERRED_SOUND_OUTPUT": "Bevorzugte Ausgabemethode f\u00fcr Sound. Standardm\u00e4\u00dfig werden dedizierte Soundkarten bevorzugt, da die Ausgabe \u00fcber HDMI mitunter Probleme bereiten kann, besonders wenn im Betrieb Bildschirme an- oder abgesteckt werden.",
+ "SLX_PREFERRED_SOUND_OUTPUT": "Bevorzugte Ausgabemethode f\u00fcr Sound. Wird dieses Feld leer gelassen, w\u00e4hlt das System eine geeignete Ausgabemethode. Entspricht dies nicht der gew\u00fcnschten Ausgabemethode, kann hier explizit eine Schnittstelle gew\u00e4hlt werden. Dazu k\u00f6nnen Sie zun\u00e4chst an einem betroffenen Ger\u00e4t mittels der Lautst\u00e4rkeregelung in der PVS-Leiste die Ausgabe korrekt konfigurieren, und dann in diesem Textfeld den Namen, oder Teile des Namens des Ausgabeger\u00e4tes und Anschlusses angeben, z.B. \"HDMI\", oder \"HDMI 2\". Dadurch wird das Ausgabeger\u00e4t mit der namentlich besten \u00dcbereinstimmung ausgew\u00e4hlt.",
+ "SLX_PRINT_REUSE_PASSWORD": "Wenn aktiviert, und der Druckserver Nutzername\/Passwort anfordert, wird das Login-Passwort des aktuell angemeldeten Nutzers verwendet, anstatt erneut das Passwort per Dialog abzufragen.",
"SLX_PRINT_USER_PREFIX": "Pr\u00e4fix, was im Authentifizierungsdialog der PrinterGUI dem Benutzernamen vorangestellt wird.\r\nWenn das Drucksystem auf einem AD-Server l\u00e4uft und der Dom\u00e4nenname vorangestellt werden muss, tragen Sie hier *domain\\* ein. Achten Sie auf die Angabe des Backslashes, er wird nicht automatisch angeh\u00e4ngt. Falls das Drucksystem mit dem reinen Benutzernamen zurecht kommt, k\u00f6nnen Sie das Feld leer lassen.",
"SLX_PROXY_BLACKLIST": "Adressen bzw. Adressbereiche, f\u00fcr die der Proxyserver nicht verwendet werden soll (z.B. der Adressbereich der Einrichtung). G\u00fcltige Angaben sind einzelne IP-Adressen, sowie IP-Bereiche in CIDR-Notation (z.B. 1.2.0.0\/16). Mehrere Angaben k\u00f6nnen durch Leerzeichen getrennt werden.",
"SLX_PROXY_IP": "Die Adresse des zu verwendenden Proxy Servers.",
diff --git a/modules-available/baseconfig_bwlp/lang/en/config-variables.json b/modules-available/baseconfig_bwlp/lang/en/config-variables.json
index b5fc62cd..adec0cc2 100644
--- a/modules-available/baseconfig_bwlp/lang/en/config-variables.json
+++ b/modules-available/baseconfig_bwlp/lang/en/config-variables.json
@@ -4,15 +4,19 @@
"SLX_AUTOSTART_UUID": "ID of a lecture which is automatically started. The lecture-ID is found in the detail window of a lecture in the bwLehrpool-Suite. \r\n\r\n*This solution is only temporary. In later versions this feature will probably be moved to another section*",
"SLX_BIOS_CLOCK": "Specifies whether and how the internal clock of the computer should be set in relation to the system time of the \/MiniLinux\/.\r\n*off* = The internal clock of the computer is not changed.\r\n*local* = The internal clock is set to local time. Preferably if, for example, there is still a native Windows installation available on the PC.\r\n*utc* = The internal clock is set to the \/Coordinated Universal Time\/. This is the most common setup in a pure Linux environment.",
"SLX_BRIDGE_OTHER_NICS": "If enabled, additional network cards installed in the Client will be bridged to the VM. ",
- "SLX_DEMO_PASS": "Password for the *demo* account. Leave empty to disallow logging in as the demo user.\r\n\/Hint\/: The password SHA-512-with-salt hashed before it's being sent to the client. It's only stored in clear text on the Satellite Server. If you want to have it hashed on the server too, you can supply a pre-hashed password in \/$6$...$...\/-format.",
+ "SLX_DEMO_PASS": "Password for the *demo* account. Leave empty to disallow logging in as the demo user.\r\n\/Hint\/: The password SHA-512-with-salt hashed before it's being sent to the client. It's only stored in clear text on the satellite server. If you want to have it hashed on the server too, you can supply a pre-hashed password in \/$6$...$...\/-format.",
+ "SLX_DHCP_OTHER_NICS": "If enabled, a DHCP client will be launched for each additional network card installed in the Client. Otherwise, these stay unconfigured, but can still be bridged into VMs by enabling SLX_BRIDGE_OTHER_NICS.",
"SLX_FORCE_RESOLUTION": "If set, this resolution will be configured on the client regardless of what the connected screen(s) say they're capable of.\r\n\r\nIf you set this to something the connected screen is not compatible with, you might end up with a blank screen.",
+ "SLX_ID44_CRYPT": "If enabled, the ID44 partition will be encrypted with a temporary key, generated at boot and not saved anywhere. After powering down the client, it should be impossible to recover any data from the partition (VM diffs, temporary files, logs).\r\n\r\nThis option is honored by MaxiLinux 32r1 and above; older versions will never encrypt the partition.",
"SLX_JUMBO_FRAMES": "Increases the MTU on the clients from 1500 to 9000. As this can lead to issues with old\/bad routers and switches, this option is disabled by default.",
- "SLX_LOGOUT_TIMEOUT": "Time in seconds, in which a user session may remain without action before it is terminated.Leave field blank to disable the function.",
+ "SLX_LOGOUT_TIMEOUT": "Time in seconds, in which a user session may remain without action before it is terminated. Leave field blank to disable the function.",
"SLX_NET_DOMAIN": "DNS domain in which the client integrate, provided the DHCP server does not specifies such.",
"SLX_NET_SEARCH": "Space separated list of DNS search domains to use in case the DHCP server doesn't supply any.",
+ "SLX_NTFSFREE": "Set whether free space on NTFS partitions will be used as temporary storage, just like an ID44 partition would.\r\n\r\n*never* disables this feature.\r\n*backup* only uses that space if the regular ID44 partition or RAM disk runs out of space.\r\n*always* will immediately make use of NTFS partitions, unless there is a large (>= 100GiB) ID44 partition.\r\n\r\nThis feature is only available when using MaxiLinux, and it only works if the NTFS partition has been unmounted cleanly before, i.e. Windows has been shut down properly.",
"SLX_NTP_SERVER": "Address of the NTP time server. Multiple servers can be specified separated by spaces.The servers are queried in sequence until a responding server is found.",
"SLX_PASSTHROUGH_USB_ID": "Specify IDs of USB devices that should be passed through to the VM directly.\r\nThe expected format is *vendorID:productID* , where each ID is a 4-digit hexadecimal number, e.g. *1234:abcd* \r\nMultiple IDs can be given as a space-separated list.",
- "SLX_PREFERRED_SOUND_OUTPUT": "Preferred sound output method.\r\nDefaults to dedicated sound card, since using HDMI can be unreliable, especially if screens get (un)plugged while the (virtual) machine is running.",
+ "SLX_PREFERRED_SOUND_OUTPUT": "Preferred output method for sound. If this field is left empty, the system selects a suitable output method. If this does not correspond to the desired output method, an interface can be explicitly selected here. To do this, you can first configure the output correctly on an affected machine using the volume control in the PVS toolbar, and then enter the name\u2013or parts of the name\u2013of the output device and port in this text field, e.g. \"HDMI\", or \"HDMI 2\". This will select the output device with the closest matching name.",
+ "SLX_PRINT_REUSE_PASSWORD": "If enabled, re-use login password, instead of popping up a password dialog. Only applies if the print server requires username\/password.",
"SLX_PRINT_USER_PREFIX": "Prefix to add to the user name in the authentication dialog of PrinterGUI.\r\nIf your print server belongs to a Windows domain and requires the domain name prefixed, set this field to *domainname\\*. Note the trailing backslash, it will not be inserted automatically. If your print server just wants the plain user name, this field should be left blank.",
"SLX_PROXY_BLACKLIST": "Address or addresses ranges in which the proxy server is not used (for example the address range of the device). Valid entries are individual IP addresses and IP ranges in CIDR notation (for example 1.2.0.0\/16). Multiple selections can be separated by spaces.",
"SLX_PROXY_IP": "The address to use for the proxy server.",
@@ -23,7 +27,7 @@
"SLX_REBOOT_SCHEDULE": "Fixed time to reboot the computer, even if there is a user active.\r\nSeveral times can be specified, separated by spaces.",
"SLX_REMOTE_LOG_SESSIONS": "Determines whether logins and logouts of the users should be reported to the satellite.\r\n*yes* = log with user ID\r\n*anonymous* = anonymous logging\r\n*no* = no logging",
"SLX_RESOLUTION_MAPPING": "Here you can make static assignments of display outputs to resolutions from *SLX_FORCE_RESOLUTION*. The format is a list of OUTPUTNAME=Index, separated by spaces, e.g.\r\n\r\nHDMI1=0 HDMI2=0 HDMI3=1\r\n\r\nIf the variable *SLX_FORCE_RESOLUTION* has the value *1024x768 800x600*, then the outputs HDMI1 and HDMI2 will have the resolution 1024x768 and show the same content (\"cloned left screen\"), and HDMI3 will have the resolution 800x600 and extend the desktop to the right.\r\n\r\nUsing this configuration option should only be necessary for unusual screen setups.",
- "SLX_ROOT_PASS": "The root password of the client system. Only required for diagnostic purposes on the client.Leave field blank to disallow root logins.\r\n\/Hint\/: The password SHA-512-with-salt hashed before it's being sent to the client. It's only stored in clear text on the Satellite Server. If you want to have it hashed on the server too, you can supply a pre-hashed password in \/$6$...$...\/-format.",
+ "SLX_ROOT_PASS": "The root password of the client system. Only required for diagnostic purposes on the client.Leave field blank to disallow root logins.\r\n\/Hint\/: The password SHA-512-with-salt hashed before it's being sent to the client. It's only stored in clear text on the satellite server. If you want to have it hashed on the server too, you can supply a pre-hashed password in \/$6$...$...\/-format.",
"SLX_SCREEN_SAVER_GRACE_TIME": "If the screen saver activates after the configured timeout, the user can disable it again by just moving the mouse or pressing a key, without entering their password again. This is called the screen saver grace period, which is configurable in seconds.",
"SLX_SCREEN_SAVER_TIMEOUT": "Timeout for screen saver activation. If the user is idle for this long (in seconds), the screen saver will activate and lock the screen, so the user password is required to unlock the screen again.",
"SLX_SCREEN_STANDBY_TIMEOUT": "Time in seconds after which the screen will enter power saving mode, if the client is not in use.",
diff --git a/modules-available/dnbd3/baseconfig/getconfig.inc.php b/modules-available/dnbd3/baseconfig/getconfig.inc.php
index e4f84d81..eff821fc 100644
--- a/modules-available/dnbd3/baseconfig/getconfig.inc.php
+++ b/modules-available/dnbd3/baseconfig/getconfig.inc.php
@@ -1,5 +1,8 @@
<?php
+/** @var ?string $uuid */
+/** @var ?string $ip */
+
if (Dnbd3::isEnabled()) {
if (!Dnbd3::hasNfsFallback()) {
ConfigHolder::add("SLX_VM_NFS", false, 1000);
@@ -12,7 +15,7 @@ if (Dnbd3::isEnabled()) {
// Locations from closest to furthest (order)
$locations = ConfigHolder::get('SLX_LOCATIONS');
-if ($locations === false) {
+if ($locations === null) {
$locationIds = [0];
} else {
$locationIds = explode(' ', $locations);
@@ -29,14 +32,14 @@ $res = Database::simpleQuery('SELECT s.fixedip, m.clientip, sxl.locationid FROM
$locationsAssoc = array_flip($locationIds);
$servers = array();
$fallback = array();
-while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+foreach ($res as $row) {
if ($row['fixedip'] === '<self>') {
$row['fixedip'] = Property::getServerIp();
- $defPrio = 2000;
+ $defPrio = Dnbd3::preferLocal() ? 500 : 2000;
} else {
$defPrio = 1000;
}
- $ip = $row['fixedip'] ? $row['fixedip'] : $row['clientip'];
+ $ip = $row['fixedip'] ?: $row['clientip'];
// See if this server is meant for the client at all
if (!is_null($row['locationid']) && !isset($locationsAssoc[$row['locationid']])) {
$fallback[$ip] = true;
@@ -50,7 +53,7 @@ while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
$row['locationid'] = $serverLoc;
}
}
- $old = isset($servers[$ip]) ? $servers[$ip] : PHP_INT_MAX;
+ $old = $servers[$ip] ?? PHP_INT_MAX;
if (is_null($row['locationid']) || !isset($locationsAssoc[$row['locationid']])) {
$servers[$ip] = min($defPrio . '.' . mt_rand(), $old);
} else {
diff --git a/modules-available/dnbd3/hooks/main-warning.inc.php b/modules-available/dnbd3/hooks/main-warning.inc.php
index 5f8a844f..ead0a259 100644
--- a/modules-available/dnbd3/hooks/main-warning.inc.php
+++ b/modules-available/dnbd3/hooks/main-warning.inc.php
@@ -6,8 +6,8 @@ if (Dnbd3::isEnabled() && User::hasPermission('.dnbd3.access-page')) {
LEFT JOIN machine m USING (machineuuid)
WHERE errormsg IS NOT NULL');
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- $error = $row['errormsg'] ? $row['errormsg'] : '<unknown error>';
+ foreach ($res as $row) {
+ $error = $row['errormsg'] ?: '<unknown error>';
$lastSeen = Util::prettyTime($row['dnbd3lastseen']);
if ($row['fixedip'] === '<self>') {
Message::addError('dnbd3.main-dnbd3-unreachable', true, $error, $lastSeen);
diff --git a/modules-available/dnbd3/hooks/translation.inc.php b/modules-available/dnbd3/hooks/translation.inc.php
index cb1854b4..9ff593cf 100644
--- a/modules-available/dnbd3/hooks/translation.inc.php
+++ b/modules-available/dnbd3/hooks/translation.inc.php
@@ -16,10 +16,8 @@ $HANDLER['subsections'] = array(
/**
* Configuration variables.
- * @param \Module $module
- * @return array
*/
-$HANDLER['grep_config-variables'] = function($module) {
+$HANDLER['grep_config-variables'] = function(Module $module): array {
if (!$module->activate(1, false) || !Module::isAvailable('baseconfig'))
return array();
$want = BaseConfigUtil::getVariables($module);
diff --git a/modules-available/dnbd3/inc/dnbd3.inc.php b/modules-available/dnbd3/inc/dnbd3.inc.php
index ccd783d9..def4e062 100644
--- a/modules-available/dnbd3/inc/dnbd3.inc.php
+++ b/modules-available/dnbd3/inc/dnbd3.inc.php
@@ -4,10 +4,11 @@ class Dnbd3 {
const PROP_ENABLED = 'dnbd3.enabled';
const PROP_NFS_FALLBACK = 'dnbd3.nfs-fallback';
+ const PROP_PREFER_LOCAL = 'dnbd3.prefer-local';
- public static function isEnabled()
+ public static function isEnabled(): bool
{
- return Property::get(self::PROP_ENABLED, 0) ? true : false;
+ return (bool)Property::get(self::PROP_ENABLED, 0);
}
public static function setEnabled($bool)
@@ -16,14 +17,44 @@ class Dnbd3 {
Trigger::mount(false, true);
}
- public static function hasNfsFallback()
+ public static function hasNfsFallback(): bool
{
- return Property::get(self::PROP_NFS_FALLBACK, 0) ? true : false;
+ return (bool)Property::get(self::PROP_NFS_FALLBACK, 0);
}
public static function setNfsFallback($bool)
{
Property::set(self::PROP_NFS_FALLBACK, $bool ? 1 : 0);
}
+ public static function preferLocal(): bool
+ {
+ return (bool)Property::get(self::PROP_PREFER_LOCAL, 0);
+ }
+
+ public static function setPreferLocal($bool)
+ {
+ Property::set(self::PROP_PREFER_LOCAL, $bool ? 1 : 0);
+ }
+
+ public static function getActiveServers(): array
+ {
+ $res = Database::simpleQuery('SELECT s.serverid, m.clientip, s.fixedip
+ FROM dnbd3_server s
+ LEFT JOIN machine m ON (s.machineuuid = m.machineuuid)
+ WHERE s.lastseen > :cutoff', ['cutoff' => CONFIG_DEBUG ? 0 : time() - 310]);
+ $lookup = [];
+ foreach ($res as $row) {
+ $lookup[$row['fixedip'] ?? $row['clientip'] ?? ''] = $row['serverid'];
+ }
+ return $lookup;
+ }
+
+ public static function getServer(string $serverId)
+ {
+ return Database::queryFirst('SELECT s.serverid, IFNULL(s.fixedip, m.clientip) AS clientip
+ FROM dnbd3_server s
+ LEFT JOIN machine m ON (s.machineuuid = m.machineuuid)
+ WHERE s.serverid = :id', ['id' => $serverId]);
+ }
}
diff --git a/modules-available/dnbd3/inc/dnbd3rpc.inc.php b/modules-available/dnbd3/inc/dnbd3rpc.inc.php
index 6e7480c0..f6bbf0ca 100644
--- a/modules-available/dnbd3/inc/dnbd3rpc.inc.php
+++ b/modules-available/dnbd3/inc/dnbd3rpc.inc.php
@@ -2,26 +2,25 @@
class Dnbd3Rpc {
- const QUERY_UNREACHABLE = 1;
- const QUERY_NOT_200 = 2;
- const QUERY_NOT_JSON = 3;
+ const ERROR_UNREACHABLE = 1;
+ const ERROR_NOT_200 = 2;
+ const ERROR_NOT_JSON = 3;
- private static function translateServer($server)
+ const QUERY_STATS = 'stats';
+ const QUERY_CLIENTS = 'clients';
+ const QUERY_IMAGES = 'images';
+ const QUERY_SPACE = 'space';
+ const QUERY_CONFIG = 'config';
+ const QUERY_ALTSERVERS = 'altservers';
+
+ private static function translateServer(string $server): string
{
// Special case - local server
if ($server === '<self>') {
$server = '127.0.0.1:5003';
- } elseif (($out = Dnbd3Util::matchAddress($server))) {
- if (isset($out['v4'])) {
- $server = $out['v4'];
- } else {
- $server = '[' . $out['v6'] . ']';
- }
- if (isset($out['port'])) {
- $server .= $out['port'];
- } else {
- $server .= ':5003';
- }
+ } elseif (($out = Dnbd3Util::matchAddress($server)) !== false) {
+ $server = $out['v4'] ?? '[' . $out['v6'] . ']';
+ $server .= $out['port'] ?? ':5003';
}
return $server;
}
@@ -30,44 +29,24 @@ class Dnbd3Rpc {
* Query given DNBD3 server for status information.
*
* @param string $server server address
- * @param bool $stats include general stats
- * @param bool $clients include client list
- * @param bool $images include image list
- * @param bool $diskSpace include disk space stats
- * @param bool $config get config
- * @param bool $altservers list of alt servers with status
- * @return int|array the queried data as an array, or false on error
+ * @param array $queryOptions Options to query, self::QUERY_*
+ * @return int|array the queried data as an array, or error code (self::ERROR_*) on error
*/
- public static function query($server, $stats, $clients, $images, $diskSpace = false, $config = false, $altservers = false)
+ public static function query(string $server, array $queryOptions)
{
$server = self::translateServer($server);
- $url = 'http://' . $server . '/query?';
- if ($stats) {
- $url .= 'q=stats&';
- }
- if ($clients) {
- $url .= 'q=clients&';
- }
- if ($images) {
- $url .= 'q=images&';
- }
- if ($diskSpace) {
- $url .= 'q=space&';
- }
- if ($config) {
- $url .= 'q=config&';
- }
- if ($altservers) {
- $url .= 'q=altservers&';
+ $url = 'http://' . $server . '/query?q=version';
+ if (!empty($queryOptions)) {
+ $url .= '&q=' . implode('&q=', $queryOptions);
}
$str = Download::asString($url, 3, $code);
if ($str === false)
- return self::QUERY_UNREACHABLE;
+ return self::ERROR_UNREACHABLE;
if ($code !== 200)
- return self::QUERY_NOT_200;
+ return self::ERROR_NOT_200;
$ret = json_decode($str, true);
if (!is_array($ret))
- return self::QUERY_NOT_JSON;
+ return self::ERROR_NOT_JSON;
return $ret;
}
@@ -76,10 +55,86 @@ class Dnbd3Rpc {
$server = self::translateServer($server);
$str = Download::asString('http://' . $server . '/cachemap?id=' . $imgId, 3, $code);
if ($str === false)
- return self::QUERY_UNREACHABLE;
+ return self::ERROR_UNREACHABLE;
if ($code !== 200)
- return self::QUERY_NOT_200;
+ return self::ERROR_NOT_200;
return $str;
}
+ /**
+ * Get statistics for multiple servers at once.
+ * @param string[] $servers
+ */
+ public static function getStatsMulti(array $servers, array $queryOptions = [], int $timeout = 2): array
+ {
+ if (empty($servers))
+ return [];
+ $extra = '';
+ if (!empty($queryOptions)) {
+ $extra = '&q=' . implode('&q=', $queryOptions);
+ }
+ $active = [];
+ $mh = curl_multi_init();
+ curl_multi_setopt($mh, CURLMOPT_MAXCONNECTS, 8);
+ curl_multi_setopt($mh, CURLMOPT_MAX_HOST_CONNECTIONS, 2);
+ curl_multi_setopt($mh, CURLMOPT_MAX_TOTAL_CONNECTIONS, 8);
+ foreach ($servers as $server) {
+ $url = 'http://' . self::translateServer($server) . '/query?q=version' . $extra;
+ $res = curl_init($url);
+ if ($res === false) {
+ error_log("curl_init($url) failed");
+ continue;
+ }
+ curl_setopt_array($res, [
+ CURLOPT_CONNECTTIMEOUT => $timeout,
+ CURLOPT_TIMEOUT => $timeout,
+ CURLOPT_FOLLOWLOCATION => 0,
+ CURLOPT_ACCEPT_ENCODING => '', // Use everything libcurl supports
+ CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
+ CURLOPT_RETURNTRANSFER => 1,
+ ]);
+ $err = curl_multi_add_handle($mh, $res);
+ if ($err !== 0) {
+ error_log("curl_multi_add_handle() failed with $err");
+ curl_close($res);
+ } else {
+ $active[(int)$res] = $server;
+ }
+ }
+ // Wait
+ $running = 1;
+ $result = [];
+ $startTime = microtime(true);
+ for (;;) {
+ $ret = curl_multi_exec($mh, $running);
+ while (($info = curl_multi_info_read($mh)) !== false) {
+ if ($info['msg'] === CURLMSG_DONE) {
+ if (isset($active[(int)$info['handle']])) {
+ $server = $active[(int)$info['handle']];
+ unset($active[(int)$info['handle']]);
+ if ($info['result'] === CURLE_OK) {
+ $data = json_decode(curl_multi_getcontent($info['handle']), true);
+ if (is_array($data)) {
+ $data['ts'] = microtime(true);
+ $result[$server] = $data;
+ }
+ }
+ }
+ curl_multi_remove_handle($mh, $info['handle']);
+ curl_close($info['handle']);
+ }
+ }
+ $delay = ($startTime + $timeout) - microtime(true);
+ if ($ret !== CURLM_OK || !$running || $delay <= 0)
+ break;
+ $sret = curl_multi_select($mh, $delay);
+ if ($sret < 0) {
+ error_log("curl_multi_select returned $sret");
+ break;
+ }
+ }
+ curl_multi_close($mh);
+ return $result;
+ }
+
}
diff --git a/modules-available/dnbd3/inc/dnbd3util.inc.php b/modules-available/dnbd3/inc/dnbd3util.inc.php
index 8e355370..314c44fe 100644
--- a/modules-available/dnbd3/inc/dnbd3util.inc.php
+++ b/modules-available/dnbd3/inc/dnbd3util.inc.php
@@ -12,7 +12,7 @@ class Dnbd3Util {
$res = Database::simpleQuery('SELECT s.serverid, s.machineuuid, s.fixedip, s.lastup, s.lastdown, m.clientip
FROM dnbd3_server s
LEFT JOIN machine m USING (machineuuid)');
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if (!empty($row['machineuuid'])) {
$allUuids[$row['machineuuid']] = true;
}
@@ -64,12 +64,12 @@ class Dnbd3Util {
// Now query them all
$NOW = time();
foreach ($servers as $server) {
- $data = Dnbd3Rpc::query($server['addr'], true, false, false, true);
- if ($data === Dnbd3Rpc::QUERY_UNREACHABLE) {
+ $data = Dnbd3Rpc::query($server['addr'], [Dnbd3Rpc::QUERY_STATS, Dnbd3Rpc::QUERY_SPACE]);
+ if ($data === Dnbd3Rpc::ERROR_UNREACHABLE) {
$error = 'No (HTTP) reply from ' . $server['addr'];
- } elseif ($data === Dnbd3Rpc::QUERY_NOT_200) {
+ } elseif ($data === Dnbd3Rpc::ERROR_NOT_200) {
$error = 'No HTTP 200 OK from ' . $server['addr'];
- } elseif ($data === Dnbd3Rpc::QUERY_NOT_JSON) {
+ } elseif ($data === Dnbd3Rpc::ERROR_NOT_JSON) {
$error = 'Reply to status query is not JSON';
} elseif (!is_array($data) || !isset($data['runId'])) {
if (is_array($data) && isset($data['errorMsg'])) {
@@ -108,11 +108,9 @@ class Dnbd3Util {
/**
* A client is booting that has runmode dnbd3 proxy - set config vars accordingly.
*
- * @param string $machineUuid
* @param string $mode always 'proxy'
- * @param string $modeData
*/
- public static function runmodeConfigHook($machineUuid, $mode, $modeData)
+ public static function runmodeConfigHook(string $machineUuid, string $mode, ?string $modeData)
{
$self = Property::getServerIp();
// Get all directly assigned locations
@@ -121,11 +119,11 @@ class Dnbd3Util {
WHERE machineuuid = :uuid',
array('uuid' => $machineUuid));
$assignedLocs = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$assignedLocs[] = $row['locationid'];
}
- $modeData = (array)json_decode($modeData, true) + self::defaultRunmodeConfig();
- if (!empty($assignedLocs) && isset($modeData['firewall']) && $modeData['firewall']) {
+ $modeData = (array)json_decode($modeData ?? '{}', true) + self::defaultRunmodeConfig();
+ if (!empty($assignedLocs) && ($modeData['firewall'] ?? false)) {
// Get all sub-locations too
$recursiveLocs = $assignedLocs;
$locations = Location::getLocationsAssoc();
@@ -138,13 +136,10 @@ class Dnbd3Util {
array('locs' => array_values($recursiveLocs)));
// Coalesce overlapping ranges
$floatIp = ip2long($self); // Float for 32bit php :/
- if (PHP_INT_SIZE === 4) {
- $floatIp = (float)sprintf('%u', $floatIp); // Float for 32bit php :/
- }
$ranges = [['startaddr' => $floatIp, 'endaddr' => $floatIp]];
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- settype($row['startaddr'], PHP_INT_SIZE === 4 ? 'float' : 'int');
- settype($row['endaddr'], PHP_INT_SIZE === 4 ? 'float' : 'int');
+ foreach ($res as $row) {
+ settype($row['startaddr'], 'int');
+ settype($row['endaddr'], 'int');
self::mergeRanges($ranges, $row);
}
// Got subnets, build whitelist
@@ -164,8 +159,8 @@ class Dnbd3Util {
$public = array();
$private = array();
$public[$self] = $self;
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- $ip = $row['fixedip'] ? $row['fixedip'] : $row['clientip'];
+ foreach ($res as $row) {
+ $ip = $row['fixedip'] ?: $row['clientip'];
if ($ip === '<self>') {
continue;
}
@@ -215,13 +210,9 @@ class Dnbd3Util {
* @param int $end end address
* @return string CIDR notation
*/
- private static function range2Cidr($start, $end)
+ private static function range2Cidr(int $start, int $end): string
{
- if (PHP_INT_SIZE > 4) {
- $bin = decbin((int)$start ^ (int)$end);
- } else {
- $bin = decbin((int)(float)$start ^ (int)(float)$end);
- }
+ $bin = decbin($start ^ $end);
if ($bin === '0')
return long2ip($start);
$mask = 32 - strlen($bin);
@@ -252,18 +243,21 @@ class Dnbd3Util {
// $row['startaddr'] must lie before range start, otherwise we'd have hit the case above
$row['endaddr'] = $ranges[$key]['endaddr'];
unset($ranges[$key]);
- continue;
+ //continue;
}
}
$ranges[] = $row;
}
- public static function defaultRunmodeConfig()
+ /**
+ * @return array{bgr: bool, firewall: bool}
+ */
+ public static function defaultRunmodeConfig(): array
{
- return array(
+ return [
'bgr' => true,
'firewall' => false
- );
+ ];
}
public static function matchAddress($server)
diff --git a/modules-available/dnbd3/lang/de/config-variables.json b/modules-available/dnbd3/lang/de/config-variables.json
index 75020efc..f41cf517 100644
--- a/modules-available/dnbd3/lang/de/config-variables.json
+++ b/modules-available/dnbd3/lang/de/config-variables.json
@@ -1,4 +1,4 @@
{
- "SLX_DNBD3_MIN_GB": "Experimentell!",
- "SLX_DNBD3_MIN_GB_HASH": "Experimentell!"
+ "SLX_DNBD3_MIN_GB": "Mindestgr\u00f6\u00dfe der *ID45-Partition* in Gigabyte, damit lokales Caching aktiviert wird.\r\n\r\n*Hinweis:* Ein leerer Wert oder Werte kleiner 10 deaktivieren die Funktion.",
+ "SLX_DNBD3_MIN_GB_HASH": "Mindestgr\u00f6\u00dfe der *ID45-Partition* in Gigabyte, bei der durch lokales Caching angefragte Daten immer auf 16MB Bl\u00f6cke aufgef\u00fcllt werden (Hashblock-Verfahren). Diese Funktion belegt i.d.R. deutlich mehr freien Speicher im lokalen Cache als regul\u00e4res Caching.\r\n\r\nNur in Kombination mit *SLX_DNBD3_MIN_GB* sinnvoll."
} \ No newline at end of file
diff --git a/modules-available/dnbd3/lang/de/template-tags.json b/modules-available/dnbd3/lang/de/template-tags.json
index 532d1c32..d58c033f 100644
--- a/modules-available/dnbd3/lang/de/template-tags.json
+++ b/modules-available/dnbd3/lang/de/template-tags.json
@@ -3,7 +3,7 @@
"lang_advancedConfigDesc": "Hier handelt es sich um Optionen, die normalerweise keiner weiteren Anpassung bed\u00fcrfen, im Einzelfall jedoch die Performance verbessern k\u00f6nnen, bzw. bessere Anpassung an lokale Gegebenheiten erm\u00f6glichen. Unpassende Werte k\u00f6nnen einen Proxy-Server jedoch effektiv unbrauchbar machen. \u00c4nderungen dieser Werte erfordern ebenfalls einen Neustart des Proxies.",
"lang_advancedProxyConfig": "Erweiterte Konfiguration",
"lang_allowNfsFallback": "NFS-Fallback aktivieren",
- "lang_allowedSubnets": "Zum Zugriff freigegebene Subnets",
+ "lang_allowedSubnets": "Freigegebene Subnets",
"lang_altservers": "Uplinks",
"lang_backgroundReplication": "Replikation im Hintergrund",
"lang_backgroundReplicationInfo": "Sobald eine VM \u00fcber den Proxy angefragt wird, spiegelt der Proxy im Hintergrund den vollst\u00e4ndigen Inhalt des VM-Abbildes, nicht nur die angefragten Bl\u00f6cke.",
@@ -24,6 +24,7 @@
"lang_dnbd3Status": "DNBD3 Status",
"lang_editProxyHeading": "Proxy-Einstellungen bearbeiten",
"lang_enableDnbd3": "DNBD3 f\u00fcr VMs aktivieren",
+ "lang_enableDnbd3Hint": "Sie haben Proxy-Server angelegt, aber DNBD3 nicht f\u00fcr VMs aktiviert. Aktuell wird nur der Bootvorgang des Grundsystems \u00fcber DNBD3 durchgef\u00fchrt, die VM-Images werden weiterhin direkt vom NFS\/CIFS-Server geladen, und profitieren nicht von Lastverteilung und Failover.",
"lang_enabled": "Aktiviert",
"lang_enterIpOfServer": "Bitte geben Sie die IP-Adresse des hinzuzuf\u00fcgenden Servers ein",
"lang_externalServer": "Externer DNBD3-Server",
@@ -35,12 +36,12 @@
"lang_global": "Global",
"lang_image": "Image",
"lang_imageList": "Image-Liste",
- "lang_isProxy": "DNBD3 Proxy",
+ "lang_isProxy": "DNBD3-Proxy",
"lang_lastSeen": "Letzte Aktivit\u00e4t",
"lang_latency": "Latenz",
"lang_location": "Ort",
"lang_locations": "Orte",
- "lang_manageAccessTo": "Zugriff auf Server festlegen:",
+ "lang_manageAccessTo": "Verwendung festlegen:",
"lang_managedServer": "Automatisch konfigurierter DNBD3-Proxy",
"lang_managedServerAdd": "Automatisch konfigurierten Proxy hinzuf\u00fcgen",
"lang_managedServerHelp": "Automatisch konfigurierte DNBD3-Proxies booten wie gew\u00f6hnliche bwLehrpool-Clients via PXE \u00fcber den Satellitenserver. Sobald ein bwLehrpool-Client als DNBD3-Proxy konfiguriert wird, erh\u00e4lt er beim Booten eine gesonderte Konfiguration, sodass er fortan exklusiv als DNBD3-Proxy arbeitet, und nicht mehr als Arbeitsstation zur Verf\u00fcgung steht. Der Vorteil ist, dass die Konfiguration automatisiert erfolgt, und durch w\u00f6chentliche Reboots sichergestellt wird, dass eventuelle Updates des MiniLinux angewendet werden. In diesem Fall legen Sie bitte eine Partition mit der ID 45 auf der Festplatte des Proxy-Servers an; diese wird persistent Behandelt und im Gegensatz zur ID44-Partition nicht beim Booten formatiert. Generell sollte diese Partition so gro\u00df wie m\u00f6glich sein, abh\u00e4ngig von der Anzahl der genutzten VMs. Bei Platzmangel l\u00f6scht der Proxy automatisch die VM, die am l\u00e4ngsten nicht verwendet wurde, um neuen VMs Platz zu machen. Weitere Informationen dazu finden Sie im Wiki.",
@@ -49,6 +50,7 @@
"lang_numFails": "Fehler",
"lang_overrideIp": "Zu verwendende IP-Adresse",
"lang_overrideIpInfo": "Normalerweise wird die automatisch per DHCP zugewiesene Adresse auf dem Boot-Interface verwendet. Falls der Proxy mit weiteren Netzwerkkarten ausgestattet ist (die ebenfalls per DHCP konfiguriert werden) kann durch Angabe einer solchen Alternativadresse hier die Verwendung der entsprechenden Karte erzwungen werden.",
+ "lang_preferSatDnbd3": "Bevorzuge Sat-Server f\u00fcr initiale Verbindung",
"lang_proxyConfig": "Konfiguration",
"lang_proxyLocationText": "Hier k\u00f6nnen Sie festlegen, dass nur Clients aus bestimmten R\u00e4umen\/Orten diesen Proxy verwenden. Damit vermeiden Sie die Metrikmessung zwischen Client und Proxy, wenn aufgrund der Infrastruktur bereits bekannt ist, dass dieser Proxy nur f\u00fcr bestimmte R\u00e4ume sinnvoll ist. ",
"lang_proxyServerTHead": "Server\/Proxy",
@@ -68,6 +70,7 @@
"lang_txTotal": "Gesamt gesendet",
"lang_unusedFor": "Ungenutzt",
"lang_uplink": "Uplink",
+ "lang_uploadSpeed": "Ausgehend",
"lang_uptime": "Aktuelle Laufzeit",
"lang_wantToDelete": "Wollen Sie diesen Server wirklich entfernen? (Rebooten\/Ausschalten muss in diesem Fall manuell vorgenommen werden)"
} \ No newline at end of file
diff --git a/modules-available/dnbd3/lang/en/config-variables.json b/modules-available/dnbd3/lang/en/config-variables.json
index 726ce821..d7d4da66 100644
--- a/modules-available/dnbd3/lang/en/config-variables.json
+++ b/modules-available/dnbd3/lang/en/config-variables.json
@@ -1,4 +1,4 @@
{
- "SLX_DNBD3_MIN_GB": "Experimental",
- "SLX_DNBD3_MIN_GB_HASH": "Experimental"
+ "SLX_DNBD3_MIN_GB": "Minimum size of the *ID45 partition* in gigabytes for local caching to be enabled.\r\n\r\n*Note:* An empty value or values smaller than 10 disable the feature.",
+ "SLX_DNBD3_MIN_GB_HASH": "Minimum size of the *ID45 partition* in gigabytes, where data requested by local caching is always padded to 16MB blocks (hashblock method). This function usually occupies significantly more free memory in the local cache than regular caching. \r\n\r\nOnly useful in combination with *SLX_DNBD3_MIN_GB*."
} \ No newline at end of file
diff --git a/modules-available/dnbd3/lang/en/template-tags.json b/modules-available/dnbd3/lang/en/template-tags.json
index 483991be..890aa0c2 100644
--- a/modules-available/dnbd3/lang/en/template-tags.json
+++ b/modules-available/dnbd3/lang/en/template-tags.json
@@ -24,6 +24,7 @@
"lang_dnbd3Status": "DNBD3 status",
"lang_editProxyHeading": "Edit proxy settings",
"lang_enableDnbd3": "Enable DNBD3 for VMs",
+ "lang_enableDnbd3Hint": "You have created proxy servers, but did not enable DNBD3 for VMs. Currently, only the boot process of the client is done via DNBD3, the VM images are still loaded directly from the NFS\/CIFS server and do not benefit from load balancing and failover.",
"lang_enabled": "Enabled",
"lang_enterIpOfServer": "Please enter the ip address ot the server",
"lang_externalServer": "External DNBD3-Server",
@@ -35,12 +36,12 @@
"lang_global": "Global",
"lang_image": "Image",
"lang_imageList": "Image list",
- "lang_isProxy": "DNBD3 proxy",
+ "lang_isProxy": "DNBD3-Proxy",
"lang_lastSeen": "Last seen",
"lang_latency": "Latency",
"lang_location": "Location",
"lang_locations": "Locations",
- "lang_manageAccessTo": "Manage access to server:",
+ "lang_manageAccessTo": "Manage usage of server:",
"lang_managedServer": "Automatically configured DNBD3-Proxy",
"lang_managedServerAdd": "Add automatically configured proxy",
"lang_managedServerHelp": "Automatically configured DNBD3-Proxies will boot like normal bwLehrpool-Clients over PXE and the satellite server. If a client is configured as proxy it will boot with a different configuration and acts exclusively as proxy. The client can therefore not be used as a normal working station.\r\nThe advantage is that you don't need to install or configure anything else. The client will reboot every week to get possible updates ot the minilinux.\r\nIf you want to use this feature, please create a partition with ID 45 on the local hard disk of the proxy server. In contrast to the ID 44 partition which is formatted after every reboot, this partition is persistent. As a rule of thumb the partition should be as big as possible. If there is no space left the proxy will delete the VM which hasn't be used for the longest time. More information in the wiki.",
@@ -49,6 +50,7 @@
"lang_numFails": "Errors",
"lang_overrideIp": "IP address to use",
"lang_overrideIpInfo": "Usually the address that the DHCP server assigns to the boot interface of the proxy will be used. If the proxy has multiple interfaces (that also get an address assigned via DHCP) you can specify that address here to enforce their usage instead.",
+ "lang_preferSatDnbd3": "Prefer satellite server for initial connection",
"lang_proxyConfig": "Configuration",
"lang_proxyLocationText": "Here you can restrict the usage of this proxy to certain locations. This can be useful if the usage is only reasonable from some locations. That may be because of the network infrastructure.",
"lang_proxyServerTHead": "Server\/Proxy",
@@ -68,6 +70,7 @@
"lang_txTotal": "Total sent",
"lang_unusedFor": "Unused",
"lang_uplink": "Uplink",
+ "lang_uploadSpeed": "Egress",
"lang_uptime": "Uptime",
"lang_wantToDelete": "Do you really want to delete this server? (Reboot\/Shutdown has to be done manually)"
} \ No newline at end of file
diff --git a/modules-available/dnbd3/page.inc.php b/modules-available/dnbd3/page.inc.php
index d0842c23..6b0df8e4 100644
--- a/modules-available/dnbd3/page.inc.php
+++ b/modules-available/dnbd3/page.inc.php
@@ -25,7 +25,7 @@ class Page_Dnbd3 extends Page
} elseif ($action === 'savelocations') {
$this->saveServerLocations();
} elseif ($action === 'toggle-usage') {
- $this->toggleUsage();
+ $this->saveGenericSettings();
}
if (Request::isPost()) {
Util::redirect('?do=dnbd3');
@@ -34,7 +34,7 @@ class Page_Dnbd3 extends Page
private function editServer()
{
- $server = $this->getServerById();
+ $server = $this->getServerFromQuery();
if (!isset($server['machineuuid'])) {
Message::addError('not-automatic-server', $server['ip']);
return;
@@ -45,12 +45,15 @@ class Page_Dnbd3 extends Page
$overrideIp = false;
$sip = Request::post('fixedip', null, 'string');
if (empty($sip)) {
+ // Reset IP override
$overrideIp = null;
- } elseif ($server['fixedip'] !== $overrideIp) {
+ } elseif ($server['fixedip'] !== $sip) {
+ // IP override is set and different from current value
if (Dnbd3Util::matchAddress($sip) === false) {
Message::addError('invalid-ip', $sip);
return;
}
+ // Dupcheck
$res = Database::queryFirst('SELECT serverid FROM dnbd3_server s
LEFT JOIN machine m USING (machineuuid)
WHERE s.fixedip = :ip OR m.clientip = :ip', ['ip' => $sip]);
@@ -68,7 +71,7 @@ class Page_Dnbd3 extends Page
}
$advancedSettings = [];
foreach (Request::post('extra', [], 'array') as $name => $value) {
- $value = preg_replace('/[^0-9KMGTmhdBb]/', '', $value);
+ $value = preg_replace('/[^0-9KMGTmhdBbtruefals]/', '', $value);
if ($value === '')
continue;
$advancedSettings[$name] = $value;
@@ -77,18 +80,20 @@ class Page_Dnbd3 extends Page
json_encode(compact('bgr', 'firewall', 'advancedSettings')), false);
}
- private function toggleUsage()
+ private function saveGenericSettings()
{
User::assertPermission('toggle-usage');
$enabled = Request::post('enabled', false, 'bool');
$nfs = Request::post('with-nfs', false, 'bool');
+ $preferLocal = Request::post('prefer-local', false, 'bool');
Dnbd3::setEnabled($enabled);
Dnbd3::setNfsFallback($nfs);
+ Dnbd3::setPreferLocal($preferLocal);
}
private function saveServerLocations()
{
- $server = $this->getServerById();
+ $server = $this->getServerFromQuery();
$this->assertPermission($server);
$locids = Request::post('location', [], 'array');
if (empty($locids)) {
@@ -129,7 +134,7 @@ class Page_Dnbd3 extends Page
private function deleteServer()
{
- $server = $this->getServerById();
+ $server = $this->getServerFromQuery();
$this->assertPermission($server);
if ($server['fixedip'] === '<self>')
return;
@@ -174,7 +179,7 @@ class Page_Dnbd3 extends Page
$NOW = time();
$externalAllowed = User::hasPermission('configure.external');
$locsRunmode = User::getAllowedLocations('configure.proxy');
- while ($server = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $server) {
if (!is_null($server['machineuuid'])) {
// Auto proxy
if (!isset($dynClients[$server['machineuuid']])) {
@@ -208,7 +213,7 @@ class Page_Dnbd3 extends Page
$server['diskUsePercent'] = 0;
}
$server['self'] = ($server['fixedip'] === '<self>');
- if (isset($server['clientip']) && !is_null($server['clientip'])) {
+ if (isset($server['clientip'])) {
if ($NOW - $server['lastseen'] > 360) {
$server['slxDown'] = true;
} else {
@@ -251,7 +256,9 @@ class Page_Dnbd3 extends Page
'enabled' => Dnbd3::isEnabled(),
'enabled_checked_s' => Dnbd3::isEnabled() ? 'checked' : '',
'nfs_checked_s' => Dnbd3::hasNfsFallback() ? 'checked' : '',
- 'rebootcontrol' => Module::isAvailable('rebootcontrol', false)
+ 'local_checked_s' => Dnbd3::preferLocal() ? 'checked' : '',
+ 'rebootcontrol' => Module::isAvailable('rebootcontrol', false),
+ 'show_enable_warning' => count($servers) > 1 && !Dnbd3::isEnabled(),
);
Permission::addGlobalTags($data['perms'], null, ['view.details', 'refresh', 'toggle-usage', 'configure.proxy', 'configure.external']);
Render::addTemplate('page-serverlist', $data);
@@ -261,9 +268,10 @@ class Page_Dnbd3 extends Page
{
User::assertPermission('view.details');
Module::isAvailable('js_stupidtable');
- $server = $this->getServerById();
+ $server = $this->getServerFromQuery();
Render::addTemplate('page-proxy-header', $server);
- $stats = Dnbd3Rpc::query($server['ip'], true, true, true, true, true, true);
+ $stats = Dnbd3Rpc::query($server['ip'], [Dnbd3Rpc::QUERY_STATS, Dnbd3Rpc::QUERY_CLIENTS,
+ Dnbd3Rpc::QUERY_IMAGES, Dnbd3Rpc::QUERY_SPACE, Dnbd3Rpc::QUERY_CONFIG, Dnbd3Rpc::QUERY_ALTSERVERS]);
if (!is_array($stats) || !isset($stats['runId'])) {
Message::addError('server-unreachable');
return;
@@ -354,8 +362,9 @@ class Page_Dnbd3 extends Page
$loc['clientCount'] = 0;
$loc['recCount'] = 0;
}
+ unset($loc);
$showLocs = false;
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
settype($row['locationid'], 'int');
$loc =& $locCount[$row['locationid']];
$loc['clientCount'] = $row['cnt'];
@@ -407,7 +416,7 @@ class Page_Dnbd3 extends Page
private function showServerLocationEdit()
{
- $server = $this->getServerById();
+ $server = $this->getServerFromQuery();
$this->assertPermission($server);
// Get selected ones
$res = Database::simpleQuery('SELECT locationid FROM dnbd3_server_x_location WHERE serverid = :serverid',
@@ -433,11 +442,9 @@ class Page_Dnbd3 extends Page
Render::addTemplate('page-server-locations', $server);
}
- private function getServerById($serverId = false)
+ private function getServerFromQuery(): array
{
- if ($serverId === false) {
- $serverId = Request::any('server', false, 'int');
- }
+ $serverId = Request::any('server', false, 'int');
if ($serverId === false) {
if (AJAX)
die('Missing parameter');
@@ -492,6 +499,8 @@ class Page_Dnbd3 extends Page
$this->ajaxReboot();
} elseif ($action === 'cachemap') {
$this->ajaxCacheMap();
+ } elseif ($action === 'stats') {
+ $this->ajaxStats();
} else {
die($action . '???');
}
@@ -513,12 +522,12 @@ class Page_Dnbd3 extends Page
if ($res !== false)
die('{"error": "Server with this IP already exists", "fatal": true}');
// Query
- $reply = Dnbd3Rpc::query($ip,true, false, false, true);
- if ($reply === Dnbd3Rpc::QUERY_UNREACHABLE)
+ $reply = Dnbd3Rpc::query($ip, [Dnbd3Rpc::QUERY_STATS, Dnbd3Rpc::QUERY_SPACE]);
+ if ($reply === Dnbd3Rpc::ERROR_UNREACHABLE)
die('{"error": "Could not reach server"}');
- if ($reply === Dnbd3Rpc::QUERY_NOT_200)
+ if ($reply === Dnbd3Rpc::ERROR_NOT_200)
die('{"error": "Server did not reply with 200 OK"}');
- if ($reply === Dnbd3Rpc::QUERY_NOT_JSON)
+ if ($reply === Dnbd3Rpc::ERROR_NOT_JSON)
die('{"error": "No JSON received from server"}');
if (!is_array($reply) || !isset($reply['uptime']) || !isset($reply['clientCount']))
die('{"error": "Reply does not suggest this is a dnbd3 server"}');
@@ -527,7 +536,7 @@ class Page_Dnbd3 extends Page
private function ajaxEditServer()
{
- $server = $this->getServerById();
+ $server = $this->getServerFromQuery();
if (!isset($server['machineuuid'])) {
echo 'Not automatic server.';
return;
@@ -547,6 +556,7 @@ class Page_Dnbd3 extends Page
'dnbd3.bgrMinClients',
'dnbd3.bgrWindowSize',
'dnbd3.autoFreeDiskSpaceDelay',
+ 'dnbd3.sparseFiles',
'limits.maxClients',
'limits.maxImages',
'limits.maxPayload',
@@ -558,7 +568,7 @@ class Page_Dnbd3 extends Page
private function ajaxReboot()
{
- $server = $this->getServerById();
+ $server = $this->getServerFromQuery();
if (!isset($server['machineuuid'])) {
die('Not automatic server.');
}
@@ -597,18 +607,18 @@ class Page_Dnbd3 extends Page
private function ajaxCacheMap()
{
- $server = $this->getServerById();
+ $server = $this->getServerFromQuery();
$imgId = Request::any('id', 0, 'int');
if ($imgId <= 0) {
Header('HTTP/1.1 400 Bad Request');
die('Invalid/no image id');
}
$data = Dnbd3Rpc::getCacheMap($server['ip'], $imgId);
- if ($data === Dnbd3Rpc::QUERY_UNREACHABLE) {
+ if ($data === Dnbd3Rpc::ERROR_UNREACHABLE) {
Header('HTTP/1.1 504 Gateway Timeout');
die('Proxy not reachable');
}
- if ($data === Dnbd3Rpc::QUERY_NOT_200) {
+ if ($data === Dnbd3Rpc::ERROR_NOT_200) {
Header('HTTP/1.1 503 Service Unavailable');
die("Proxy didn't reply with 200 OK");
}
@@ -616,30 +626,16 @@ class Page_Dnbd3 extends Page
die($data);
}
- private function genChunk($acc)
+ private function ajaxStats()
{
- static $last = -1;
- static $count = 0;
- if ($acc !== false) {
- if ($acc > 15) {
- $acc = 15;
- }
- $acc = round($acc);
- if ($last === $acc) {
- $count++;
- return '';
- }
- }
- if ($last !== -1) {
- if ($count === 1)
- return '<b style="background:#0' . dechex($acc) . '0"></b>';
- $line = '<b style="background:#0' . dechex($last) . '0;flex-grow:' . $count . '"></b>';
- } else {
- $line = '';
+ $lookup = Dnbd3::getActiveServers();
+ $result = Dnbd3Rpc::getStatsMulti(array_keys($lookup), [Dnbd3Rpc::QUERY_STATS]);
+ $return = [];
+ foreach ($result as $ip => $data) {
+ $return[$lookup[$ip]] = $data;
}
- $last = $acc;
- $count = 1;
- return $line;
+ Header('Content-Type: application/json; charset=utf-8');
+ die(json_encode($return));
}
}
diff --git a/modules-available/dnbd3/templates/fragment-server-settings.html b/modules-available/dnbd3/templates/fragment-server-settings.html
index 316b883c..794c5fd9 100644
--- a/modules-available/dnbd3/templates/fragment-server-settings.html
+++ b/modules-available/dnbd3/templates/fragment-server-settings.html
@@ -33,7 +33,7 @@
<label for="ex-{{name}}">{{name}}</label>
</div>
<div class="col-sm-4">
- <input id="ex-{{name}}" class="form-control" type="text" pattern="[0-9]*[KMGTmhd]?[Bb]?" value="{{value}}"
+ <input id="ex-{{name}}" class="form-control" type="text" pattern="[0-9KMGTmhdtruefals]*" value="{{value}}"
name="extra[{{name}}]">
</div>
</div>
diff --git a/modules-available/dnbd3/templates/page-proxy-images.html b/modules-available/dnbd3/templates/page-proxy-images.html
index e7fc2b3c..0dd06801 100644
--- a/modules-available/dnbd3/templates/page-proxy-images.html
+++ b/modules-available/dnbd3/templates/page-proxy-images.html
@@ -4,8 +4,8 @@
<thead>
<tr>
<th data-sort="string">{{lang_image}}</th>
- <th class="text-right slx-smallcol" data-sort="int">{{lang_clients}}</th>
- <th class="text-right slx-smallcol" data-sort="int">{{lang_size}}</th>
+ <th class="text-right slx-smallcol" data-sort="int" data-sort-default="desc">{{lang_clients}}</th>
+ <th class="text-right slx-smallcol" data-sort="int" data-sort-default="desc">{{lang_size}}</th>
<th class="text-right slx-smallcol" data-sort="int">{{lang_complete}}</th>
<th class="text-right slx-smallcol" data-sort="int">{{lang_unusedFor}}</th>
<th class="slx-smallcol" data-sort="string">{{lang_uplink}}</th>
diff --git a/modules-available/dnbd3/templates/page-proxy-stats.html b/modules-available/dnbd3/templates/page-proxy-stats.html
index 9c3a4a84..4bc411d1 100644
--- a/modules-available/dnbd3/templates/page-proxy-stats.html
+++ b/modules-available/dnbd3/templates/page-proxy-stats.html
@@ -1,7 +1,10 @@
<div class="panel panel-default">
<div class="panel-body">
- <div class="pull-right">
- {{lang_uptime}}: <b>{{uptime_s}}</b>
+ <div class="pull-right text-right">
+ {{#version}}
+ <div>{{version}}</div>
+ {{/version}}
+ <div>{{lang_uptime}}: <b>{{uptime_s}}</b></div>
</div>
<div>
{{lang_sessionTx}}: <b>{{bytesSent_s}}</b>
diff --git a/modules-available/dnbd3/templates/page-serverlist.html b/modules-available/dnbd3/templates/page-serverlist.html
index cdbd0789..bcb0d766 100644
--- a/modules-available/dnbd3/templates/page-serverlist.html
+++ b/modules-available/dnbd3/templates/page-serverlist.html
@@ -1,6 +1,12 @@
<h1>{{lang_dnbd3Management}}</h1>
<p><i>{{lang_dnbd3IntroText}}</i></p>
+<style>
+ .shd { text-shadow: #fff 1px 1px 2px; border:1px solid #ddd; min-width:100px; }
+ .shd:empty { display: none; }
+ #speed-graph { width: 100%; height: 100px; margin: 3px; border-radius: 3px; }
+</style>
+
<div class="panel panel-default">
<div class="panel-heading">
{{lang_dnbd3Status}}:
@@ -10,7 +16,7 @@
</b>
– <a href="#" data-toggle="collapse" data-target="#toggle-div">{{lang_changeDnbd3Status}}</a>
</div>
- <div class="panel-collapse collapse" id="toggle-div">
+ <div class="panel-collapse {{^show_enable_warning}}collapse{{/show_enable_warning}}" id="toggle-div">
<div class="panel-body">
<form method="post" action="?do=dnbd3">
<input type="hidden" name="token" value="{{token}}">
@@ -18,10 +24,19 @@
<input id="enable-dnbd3" type="checkbox" name="enabled" {{enabled_checked_s}} {{perms.toggle-usage.disabled}}>
<label for="enable-dnbd3">{{lang_enableDnbd3}}</label>
</div>
+ {{#show_enable_warning}}
+ <div class="text-warning">
+ {{lang_enableDnbd3Hint}}
+ </div>
+ {{/show_enable_warning}}
<div class="checkbox">
<input id="allow-nfs" type="checkbox" name="with-nfs" {{nfs_checked_s}} {{perms.toggle-usage.disabled}}>
<label for="allow-nfs">{{lang_allowNfsFallback}}</label>
</div>
+ <div class="checkbox">
+ <input id="prefer-local" type="checkbox" name="prefer-local" {{local_checked_s}} {{perms.toggle-usage.disabled}}>
+ <label for="prefer-local">{{lang_preferSatDnbd3}}</label>
+ </div>
<button type="submit" name="action" value="toggle-usage" class="btn btn-success" {{perms.toggle-usage.disabled}}>
<span class="glyphicon glyphicon-floppy-disk"></span>
{{lang_save}}
@@ -53,6 +68,7 @@
<th>{{lang_proxyServerTHead}}</th>
<th class="text-right">{{lang_storageSize}}</th>
<th class="text-right">{{lang_clientCount}}</th>
+ <th style="min-width:116px">{{lang_uploadSpeed}}</th>
<th class="text-right">{{lang_lastSeen}}</th>
<th class="text-right">{{lang_uptime}}</th>
<th class="text-right">{{lang_txTotal}}</th>
@@ -96,8 +112,8 @@
<div class="small">{{hostname}}</div>
</td>
<td data-sort="int" data-sort-default="desc" data-sort-value="{{disktotal}}">
- <div style="border:1px solid #ddd;background:linear-gradient(to right, #f85 {{diskUsePercent}}%, transparent {{diskUsePercent}}%)"
- class="text-center text-nowrap"
+ <div style="background:linear-gradient(to right, #f85 {{diskUsePercent}}%, transparent {{diskUsePercent}}%)"
+ class="text-center text-nowrap shd"
title="{{lang_diskFree}}: {{diskfree_s}}">
{{disktotal_s}}
</div>
@@ -107,9 +123,12 @@
</div>
{{/errormsg}}
</td>
- <td data-sort="int" data-sort-default="desc" class="text-right">
+ <td data-sort="int" data-sort-default="desc" class="text-right text-nowrap" id="clientcount-{{serverid}}">
{{clientcount}}
</td>
+ <td data-sort="int" data-sort-default="desc" class="text-right text-nowrap">
+ <div id="upspeed-{{serverid}}" class="text-center text-nowrap shd"></div>
+ </td>
<td data-sort="int" data-sort-default="desc" data-sort-value="{{dnbd3lastseen}}" class="text-right text-nowrap">
{{dnbd3lastseen_s}}
</td>
@@ -302,6 +321,8 @@
</div>
<div class="clearfix"></div>
+<div class="slx-space"></div>
+<canvas id="speed-graph"></canvas>
<script type="application/javascript"><!--
document.addEventListener('DOMContentLoaded', function () {
@@ -421,6 +442,133 @@ document.addEventListener('DOMContentLoaded', function () {
query();
rebootServerId = 0;
});
+ // live speed
+ var hiddenProp;
+ if (typeof document.hidden !== "undefined") { // Opera 12.10 and Firefox 18 and later support
+ hiddenProp = "hidden";
+ } else if (typeof document.msHidden !== "undefined") {
+ hiddenProp = "msHidden";
+ } else if (typeof document.webkitHidden !== "undefined") {
+ hiddenProp = "webkitHidden";
+ } else {
+ hiddenProp = null;
+ }
+ var formatBytes = function(bytes) {
+ if (bytes < 1024) return bytes.toFixed(0) + '\u2009B';
+ if (bytes < 1048576) return (bytes / 1024).toFixed(0) + '\u2009KiB';
+ if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + '\u2009MiB';
+ if (bytes < 1099511627776) return (bytes / 1073741824).toFixed(2) + '\u2009GiB';
+ return (bytes / 1099511627776).toFixed(2) + '\u2009TiB';
+ };
+ var calcBackgroundStyle = function(speed) {
+ const colors = ['#eee', '#cfc', '#6f6', '#bc3', '#f00', '#f88'];
+ const limits = [1048576, 10485760, 104857600, 1073741824, 10737418240];
+ for (var i = 0; i < 4; ++i) {
+ if (speed < limits[i]) break;
+ }
+ const percent = Math.round(Math.max(0, Math.min(100, speed / limits[i] * 100)));
+ return { background: 'linear-gradient(90deg, ' + colors[i+1] + ' ' + percent + '%, ' + colors[i] + ' ' + percent + '%)' };
+ };
+ var lastSpeedList = {};
+ var history = [];
+ var inactiveCount = 0;
+ var updateSpeed = function() {
+ if (hiddenProp && document[hiddenProp]) {
+ if (++inactiveCount > 300)
+ return;
+ } else {
+ if (inactiveCount > 300) {
+ history.push(-1);
+ }
+ inactiveCount = 0;
+ }
+ $.ajax('?do=dnbd3&action=stats').done(function(elist) {
+ var speedSum = 0;
+ for (var k in elist) {
+ var e = elist[k];
+ if (lastSpeedList[k]) {
+ var lastSpeed = lastSpeedList[k];
+ if (lastSpeed['ts'] < e['ts']) {
+ var $speed = $('#upspeed-' + k);
+ var s = (e['bytesSent'] - lastSpeed['bytesSent']) / (e['ts'] - lastSpeed['ts']);
+ $speed.text(formatBytes(s) + "/s").css(calcBackgroundStyle(s));
+ speedSum += s;
+ }
+ }
+ var $clients = $('#clientcount-' + k);
+ $clients.text(e['clientCount'] + e['serverCount']);
+ }
+ history.push(speedSum);
+ while (history.length > 500) history.shift();
+ for (k in lastSpeedList) {
+ if (!elist[k]) {
+ $('#upspeed-' + k).text('???').css('background', '#aaa');
+ $('#clientcount-' + k).text('-');
+ }
+ }
+ lastSpeedList = elist;
+ updateGraph();
+ });
+ };
+ updateSpeed();
+ setInterval(updateSpeed, 2500);
+ var graph = document.getElementById('speed-graph');
+ var updateGraph = function() {
+ var i;
+ var gctx = graph.getContext('2d');
+ graph.width = Math.floor(graph.clientWidth / graph.clientHeight * 100);
+ graph.height = 100;
+ gctx.fillStyle = '#eee';
+ gctx.fillRect(0, 0, graph.width, graph.height);
+ var part = history.slice(-Math.floor(graph.width / 10));
+ var max = 1;
+ var peakIdx = -1;
+ var peakCount = 0;
+ var peakList = {};
+ for (i = 0; i < part.length; ++i) {
+ if (part[i] > max) max = part[i];
+ }
+ for (i = 0; i < part.length; ++i) {
+ if (peakIdx === -1 || part[i] > part[peakIdx]) {
+ peakIdx = i;
+ peakCount = 0;
+ } else if ((part[peakIdx] - part[i]) / max > 0.1) {
+ if (peakCount > 3) {
+ peakList[part.length - peakIdx - 1] = 1;
+ peakIdx = -1;
+ } else {
+ peakCount++;
+ }
+ } else {
+ peakIdx = i;
+ }
+ }
+ if (peakCount > 1) {
+ peakList[part.length - peakIdx - 1] = 1;
+ }
+ const BAR_COLOR = '#999';
+ part.reverse();
+ gctx.fillStyle = BAR_COLOR;
+ gctx.font = "9pt Arial";
+ gctx.textBaseline = 'top';
+ for (i = 0; i < part.length; ++i) {
+ var x = graph.width - i*10;
+ if (part[i] === -1) {
+ gctx.fillStyle = '#bbb';
+ gctx.fillRect(x - 5, 0, 1, 100);
+ gctx.fillStyle = BAR_COLOR;
+ } else {
+ var v = Math.round((1 - part[i] / max) * 100);
+ gctx.fillRect(x - 10, v, 9, 100);
+ if (peakList[i]) {
+ gctx.fillStyle = '#333';
+ gctx.fillText(formatBytes(part[i]) + "/s", x + 1, v);
+ gctx.fillStyle = BAR_COLOR;
+ }
+ }
+ }
+ gctx.stroke();
+ };
});
//--></script> \ No newline at end of file
diff --git a/modules-available/dozmod/api.inc.php b/modules-available/dozmod/api.inc.php
index d9f7354c..b5030cc5 100644
--- a/modules-available/dozmod/api.inc.php
+++ b/modules-available/dozmod/api.inc.php
@@ -10,6 +10,8 @@
**/
+use JetBrains\PhpStorm\NoReturn;
+
if (!Module::isAvailable('locations')) {
die('require locations module');
}
@@ -17,27 +19,30 @@ if (!Module::isAvailable('locations')) {
define('LIST_URL', CONFIG_DOZMOD_URL . '/vmchooser/list');
define('VMX_URL', CONFIG_DOZMOD_URL . '/vmchooser/lecture');
-$availableRessources = ['list', 'vmx', 'netrules', 'runscript', 'metadata', 'netshares'];
+$availableRessources = ['list', 'netrules', 'metadata', 'imagemeta'];
/* BEGIN: A simple caching mechanism ---------------------------- */
-function cache_hash($obj)
+function cache_hash($obj): string
{
return md5(serialize($obj));
}
-function cache_key_to_filename($key)
+function cache_key_to_filename(string $key): string
{
return "/tmp/bwlp-slxadmin-cache-$key";
}
-function cache_put($key, $value)
+function cache_put(string $key, string $value): void
{
$filename = cache_key_to_filename($key);
- file_put_contents($filename, $value);
+ // Try to avoid another client concurrently accessing the cache seeing an empty file
+ $tmp = $filename . '-' . mt_rand();
+ file_put_contents($tmp, $value);
+ rename($tmp, $filename);
}
-function cache_has($key)
+function cache_has(string $key): bool
{
$filename = cache_key_to_filename($key);
$mtime = @filemtime($filename);
@@ -46,21 +51,18 @@ function cache_has($key)
return false; // cache miss
}
$now = time();
- if ($now < $mtime || $now - $mtime > CONFIG_DOZMOD_EXPIRE) {
- return false;
- } else {
- return true;
- }
+ return $now >= $mtime && $now - $mtime <= CONFIG_DOZMOD_EXPIRE;
}
-function cache_get($key)
+function cache_get(string $key): string
{
$filename = cache_key_to_filename($key);
return file_get_contents($filename);
}
/* good for large binary files */
-function cache_get_passthru($key)
+#[NoReturn]
+function cache_get_passthru(string $key): void
{
$filename = cache_key_to_filename($key);
$fp = fopen($filename, "r");
@@ -68,7 +70,7 @@ function cache_get_passthru($key)
fpassthru($fp);
exit;
}
- error_log('Cannot passthrough cache file ' . $filename);
+ error_log('DMSD-cache: Cannot passthrough cache file ' . $filename);
}
/* END: Cache ---------------------------------------------------- */
@@ -76,7 +78,7 @@ function cache_get_passthru($key)
/* this script requires 2 (3 with implicit client ip) parameters
*
-* resource = vmx,...
+* resource = list,metadata,...
* lecture_uuid = client can choose
**/
@@ -85,11 +87,16 @@ function cache_get_passthru($key)
* Takes raw lecture list xml, returns array of uuids.
*
* @param string $responseXML XML from dozmod server
- * @return array list of UUIDs, false on error
+ * @return array list of UUIDs
*/
-function xmlToLectureIds($responseXML)
+function xmlToLectureIds(string $responseXML): array
{
- $xml = new SimpleXMLElement($responseXML);
+ try {
+ $xml = new SimpleXMLElement($responseXML);
+ } catch (Exception $e) {
+ EventLog::warning('Error parsing XML response data from DMSD: ' . $e->getMessage(), $responseXML);
+ return [];
+ }
if (!isset($xml->eintrag))
return [];
@@ -102,7 +109,8 @@ function xmlToLectureIds($responseXML)
return $uuids;
}
-function sendExamModeMismatch()
+#[NoReturn]
+function sendExamModeMismatch(): void
{
Header('Content-Type: text/xml; charset=utf-8');
echo
@@ -112,8 +120,8 @@ function sendExamModeMismatch()
<image_name param="null"/>
<priority param="100"/>
<creator param="Ernie Esslingen"/>
- <short_description param="Klausurmodus geändert, bitte PC neustarten"/>
- <long_description param="Der Klausurmodus wurde ein- oder ausgeschaltet, bitte starten Sie den PC neu"/>
+ <short_description param="Prüfungsmodus geändert, bitte PC neustarten"/>
+ <long_description param="Der Prüfungsmodus wurde ein- oder ausgeschaltet, bitte starten Sie den PC neu"/>
<uuid param="exam-mode-warning"/>
<virtualmachine param="exam-mode-warning"/>
<os param="debian8"/>
@@ -142,7 +150,7 @@ BLA;
}
/** Caching wrapper around _getLecturesForLocations() */
-function getListForLocations($locationIds, $raw)
+function getListForLocations(array $locationIds, bool $raw)
{
/* if in any of the locations there is an exam active, consider the client
to be in "exam-mode" and only offer him exams (no lectures) */
@@ -179,7 +187,12 @@ function getListForLocations($locationIds, $raw)
if ($examMode) {
$url .= '&exams';
}
+ $t = microtime(true);
$value = Download::asString($url, 60, $code);
+ $t = microtime(true) - $t;
+ if ($t > 5) {
+ error_log("DMSD-cache: Download of lecture list took $t ($code)");
+ }
if ($value === false || $code < 200 || $code > 299)
return false;
cache_put($rawKey, $value);
@@ -191,30 +204,37 @@ function getListForLocations($locationIds, $raw)
return $list;
}
-function getLectureUuidsForLocations($locationIds)
+function getLectureUuidsForLocations(array $locationIds)
{
return getListForLocations($locationIds, false);
}
-function outputLectureXmlForLocation($locationIds)
+function outputLectureXmlForLocation(array $locationIds)
{
return getListForLocations($locationIds, true);
}
-function _getVmData($lecture_uuid, $subResource = false)
+function _getVmData(string $lecture_uuid, string $subResource = null)
{
$url = VMX_URL . '/' . $lecture_uuid;
- if ($subResource !== false) {
+ if ($subResource !== null) {
$url .= '/' . $subResource;
}
+ $t = microtime(true);
$response = Download::asString($url, 60, $code);
- if ($code < 200 || $code > 299)
+ $t = microtime(true) - $t;
+ if ($t > 5) {
+ error_log("DMSD-cache: Download of $subResource took $t ($code)");
+ }
+ if ($code < 200 || $code > 299) {
+ error_log("DMSD-cache: Return code $code, payload len " . strlen($response));
return (int)$code;
+ }
return $response;
}
/** Caching wrapper around _getVmData() **/
-function outputResource($lecture_uuid, $resource)
+function outputResource(string $lecture_uuid, string $resource): void
{
if ($resource === 'metadata') {
// HACK: config.tgz is compressed, don't use gzip output handler
@@ -235,7 +255,7 @@ function outputResource($lecture_uuid, $resource)
} else {
$value = _getVmData($lecture_uuid, $resource);
if ($value === false)
- return false;
+ return;
if (is_int($value)) {
http_response_code($value);
exit;
@@ -243,16 +263,16 @@ function outputResource($lecture_uuid, $resource)
cache_put($key, $value);
die($value);
}
- return false;
}
+#[NoReturn]
function fatalDozmodUnreachable()
{
Header('HTTP/1.1 504 Gateway Timeout');
die('DMSD currently not available');
}
-function readLectureParam($locationIds)
+function readLectureParam(array $locationIds): string
{
$lecture = Request::get('lecture', false, 'string');
if ($lecture === false) {
@@ -272,12 +292,26 @@ function readLectureParam($locationIds)
}
+// in this context the lecture param is an image id (container),
+// just read and check if valid.
+// TODO do we need to check if this is allowed?
+function readImageParam(): string
+{
+ $image = Request::get('lecture', false, 'string');
+
+ if ($image === false) {
+ Header('HTTP/1.1 400 Bad Request');
+ die('Missing IMAGE UUID');
+ }
+ return $image;
+}
+
// -----------------------------------------------------------------------------//
/* request data, don't trust */
$resource = Request::get('resource', false, 'string');
if ($resource === false) {
- Util::traceError("you have to specify the 'resource' parameter");
+ ErrorHandler::traceError("you have to specify the 'resource' parameter");
}
if (!in_array($resource, $availableRessources)) {
@@ -298,12 +332,11 @@ $location_ids = Location::getLocationRootChain($location_ids);
if ($resource === 'list') {
outputLectureXmlForLocation($location_ids);
// Won't return on success...
- fatalDozmodUnreachable();
+} elseif ($resource === 'imagemeta') {
+ $image = readImageParam();
+ outputResource($image, $resource);
} else {
$lecture = readLectureParam($location_ids);
outputResource($lecture, $resource);
- fatalDozmodUnreachable();
}
-
-Header('HTTP/1.1 400 Bad Request');
-die("I don't know how to give you that resource");
+fatalDozmodUnreachable();
diff --git a/modules-available/dozmod/lang/de/messages.json b/modules-available/dozmod/lang/de/messages.json
index 7eba0b2d..c904b0c8 100644
--- a/modules-available/dozmod/lang/de/messages.json
+++ b/modules-available/dozmod/lang/de/messages.json
@@ -1,28 +1,28 @@
{
"all-templates-reset": "Alle Templates wurden zur\u00fcckgesetzt",
- "dozmod-error": "Fehler bei der Kommunikation mit dem bwLehrpool-Suite server: {{0}}",
+ "dozmod-error": "Fehler bei der Kommunikation mit dem bwLehrpool-Suite Server: {{0}}",
"images-pending-delete-exist": "Zur L\u00f6schung markierte VM-Versionen: {{0}}",
"ldap-filter-created": "LDAP Filter wurde erfolgreich erstellt",
"ldap-filter-deleted": "LDAP Filter wurde erfolgreich gel\u00f6scht",
"ldap-filter-id-missing": "Fehlende LDAP Filter ID",
- "ldap-filter-insert-failed": "LDAP filter konnte der Datenbank nicht hinzugef\u00fcgt werden",
+ "ldap-filter-insert-failed": "LDAP Filter konnte der Datenbank nicht hinzugef\u00fcgt werden",
"ldap-filter-save-missing-information": "Es fehlen LDAP Filter Informationen",
"ldap-filter-saved": "LDAP Filter wurde erfolgreich gespeichert",
"ldap-invalid-filter-id": "Ung\u00fcltige LDAP Filter ID",
"mail-config-saved": "Mail-Konfiguration gespeichert",
- "networkrule-deleted": "Netzwerk-Regel gel\u00f6scht",
+ "networkrule-deleted": "Netzwerkregel gel\u00f6scht",
"networkrule-empty-set": "Leeres Regelset; nicht gespeichert",
"networkrule-invalid-direction": "Ung\u00fcltige Richtung: {{0}}",
"networkrule-invalid-host": "Ung\u00fcltiger Host: {{0}}; Zeile ignoriert",
"networkrule-invalid-port": "Ung\u00fcltiger Port: {{0}}; Zeile ignoriert",
"networkrule-invalid-ruleid": "Nicht-existierende Regel: {{0}}",
- "networkrule-saved": "Netzwerk-Regel gespeichert",
+ "networkrule-saved": "Netzwerkregel gespeichert",
"networkshare-deleted": "Netzlaufwerk gel\u00f6scht",
"networkshare-invalid-auth-type": "Ung\u00fcltiger Authentifizierungs-Typ: {{0}}",
"networkshare-invalid-shareid": "Nicht-existierender Share: {{0}}",
"networkshare-missing-path": "Fehlende Pfadangabe",
"networkshare-saved": "Netzlaufwerk gespeichert",
- "no-expired-images": "Keine Abgelaufenen VM-Versionen",
+ "no-expired-images": "Keine abgelaufenen VM-Versionen",
"nothing-submitted": "Es wurde nichts \u00fcbermittelt",
"runscript-deleted": "Skript gel\u00f6scht",
"runscript-invalid-id": "Ung\u00fcltige Skript-ID: {{0}}",
diff --git a/modules-available/dozmod/lang/de/template-tags.json b/modules-available/dozmod/lang/de/template-tags.json
index 39c37c08..338e8e42 100644
--- a/modules-available/dozmod/lang/de/template-tags.json
+++ b/modules-available/dozmod/lang/de/template-tags.json
@@ -5,6 +5,8 @@
"lang_addShare": "Netzlaufwerk hinzuf\u00fcgen",
"lang_allowLoginByDefault": "Login standardm\u00e4\u00dfig erlauben",
"lang_allowLoginDescription": "Wenn diese Option aktiviert ist, k\u00f6nnen sich alle Mitarbeiter der Einrichtung \u00fcber die bwLehrpool-Suite anmelden und VMs\/Veranstaltungen verwalten. Wenn Sie diese Option deaktivieren, m\u00fcssen Sie in der Untersektion \"Benutzer und Berechtigungen\" jeden Benutzer nach dem ersten Loginversuch manuell freischalten.",
+ "lang_allowStudentDownload": "Studenten den Download lizenzfreier VMs erlauben",
+ "lang_allowStudentDownloadDescription": "Wenn diese Option aktiviert ist, k\u00f6nnen Studenten alle VMs herunterladen, bei denen beim Upload der Haken \"enth\u00e4lt lizenzpflichtige Software\" nicht gesetzt wurde. Es liegt hier in der Verantwortung der VM-Ersteller, diesen Haken nur unter entsprechenden Umst\u00e4nden zu entfernen.",
"lang_asteriskRequired": "Felder mit (*) sind erforderlich",
"lang_authMethod": "Authentifizierung",
"lang_blockCount": "Anzahl Bl\u00f6cke",
@@ -14,7 +16,7 @@
"lang_confirmDeleteOrphanedFiles": "Sind Sie sicher, dass Sie alle aufgelisteten Dateien unwiderruflich vom VM-Store l\u00f6schen wollen?",
"lang_createTime": "Erstellt",
"lang_currentFilter": "Aktueller Filter",
- "lang_defaultImagePermissionAdmin": "Administrieren",
+ "lang_defaultImagePermissionAdmin": "Administration",
"lang_defaultImagePermissionDownload": "Download",
"lang_defaultImagePermissionEdit": "Bearbeiten",
"lang_defaultImagePermissionLink": "Veranstaltung verkn\u00fcpfen",
@@ -68,6 +70,7 @@
"lang_maxLectureVisibility": "Sp\u00e4testes Enddatum einer Veranstaltung (Tage in der Zukunft)",
"lang_maxLocationsPerLecture": "Max. explizite Orte pro Veranstaltung",
"lang_maxTransfers": "Maximale Zahl gleichzeitiger Up-\/Downloads pro Benutzer",
+ "lang_maxVmHddSizeGb": "Maximale VM-Gr\u00f6\u00dfe (GiB, 0 = kein Limit)",
"lang_minimized": "Minimiert",
"lang_miscOptions": "Verschiedene Einstellungen",
"lang_modified": "Modifiziert",
@@ -116,7 +119,7 @@
"lang_senderAddress": "Absenderadresse",
"lang_senderName": "Absender Anzeigename",
"lang_serverSideCopy": "Serverseitiges Kopieren",
- "lang_serverSideCopyDescription": "Wenn aktiviert, werden bei VM-Uploads solche Bl\u00f6cke, die bereits in anderen VM-Abbildern vorhanden sind, nicht erneut vom Client hochgeladen, sondern durch den Satelliten-Server vom verwendeten Fileserver gelesen und kopiert. Abh\u00e4ngig von der Netzwerkinfrastruktur und Hardwareausstattung des Fileservers kann dies den Uploadvorgang merklich beschleunigen. Da diese Funktion allerdings zus\u00e4tzliche I\/O-Last auf dem Fileserver erzeugt, ist ihre Verwendung u.U. nicht erw\u00fcnscht.",
+ "lang_serverSideCopyDescription": "Wenn aktiviert, werden bei VM-Uploads solche Bl\u00f6cke, die bereits in anderen VM-Abbildern vorhanden sind, nicht erneut vom Client hochgeladen, sondern durch den Satellitenserver vom verwendeten Fileserver gelesen und kopiert. Abh\u00e4ngig von der Netzwerkinfrastruktur und Hardwareausstattung des Fileservers kann dies den Uploadvorgang merklich beschleunigen. Da diese Funktion allerdings zus\u00e4tzliche I\/O-Last auf dem Fileserver erzeugt, ist ihre Verwendung u.U. nicht erw\u00fcnscht.",
"lang_shareDeleteConfirm": "Wollen Sie dieses Netzlaufwerk wirklich l\u00f6schen?",
"lang_size": "Gr\u00f6\u00dfe",
"lang_spaceWastedDuplication": "Potentiell durch mehrfach vorkommende Bl\u00f6cke belegter Speicherplatz",
diff --git a/modules-available/dozmod/lang/en/template-tags.json b/modules-available/dozmod/lang/en/template-tags.json
index b4a1d9e8..b741e03d 100644
--- a/modules-available/dozmod/lang/en/template-tags.json
+++ b/modules-available/dozmod/lang/en/template-tags.json
@@ -5,6 +5,8 @@
"lang_addShare": "Add Network Share",
"lang_allowLoginByDefault": "Allow all staff members to login and use the bwLehrpool-Suite",
"lang_allowLoginDescription": "If this option is enabled, all members of the organization marked as staff or employee are allowed to login to this server and manage VMs\/courses. Otherwise, new users need to be individually allowed access after their first login attempt by visiting the sub page \"users and permissions\" in this web interface.",
+ "lang_allowStudentDownload": "Allow students to download license-free VMs",
+ "lang_allowStudentDownloadDescription": "If enabled, students can download all VMs that don't have the \"contains software subject to licensing\" checkbox enabled (to be set when uploading a new VM). The author of a VM is responsible for only removing that checkbox for applicable VMs.",
"lang_asteriskRequired": "Fields marked with (*) are required",
"lang_authMethod": "Authentication",
"lang_blockCount": "Block count",
@@ -65,9 +67,10 @@
"lang_mailDescription": "Fill in the following fields if you want to notify tutors\/professors\/lecturers about expiring VMs and lectures. If you leave one of the required fields blank, the feature will be disabled.",
"lang_mailTemplates": "E-Mail Templates",
"lang_maxImageValidity": "New VM validity (days)",
- "lang_maxLectureVisibility": "Max time lecture end date may lie in the future (days)",
+ "lang_maxLectureVisibility": "Max. time lecture end date may lie in the future (days)",
"lang_maxLocationsPerLecture": "Max. explicit locations per lecture",
- "lang_maxTransfers": "Max concurrent transfers per user",
+ "lang_maxTransfers": "Max. concurrent transfers per user",
+ "lang_maxVmHddSizeGb": "Max. VM size (GiB, 0 = unlimited)",
"lang_minimized": "Minimized",
"lang_miscOptions": "Misc options",
"lang_modified": "modified",
diff --git a/modules-available/dozmod/page.inc.php b/modules-available/dozmod/page.inc.php
index cf6e4857..4a43d881 100644
--- a/modules-available/dozmod/page.inc.php
+++ b/modules-available/dozmod/page.inc.php
@@ -22,7 +22,7 @@ class Page_DozMod extends Page
break;
}
} elseif (!in_array($this->section, $this->validSections)) {
- Util::traceError('Invalid section: ' . $this->section);
+ ErrorHandler::traceError('Invalid section: ' . $this->section);
}
// Check permissions
User::assertPermission($this->section . '.*');
@@ -68,7 +68,7 @@ class Page_DozMod extends Page
/* add sub-menus */
foreach ($this->validSections as $section) {
if ($section !== 'special' && User::hasPermission($section . '.*')) {
- Dashboard::addSubmenu('?do=dozmod&section=' . $section, Dictionary::translate('submenu_' . $section, true));
+ Dashboard::addSubmenu('?do=dozmod&section=' . $section, Dictionary::translate('submenu_' . $section));
}
}
}
@@ -78,7 +78,6 @@ class Page_DozMod extends Page
/* different pages for different sections */
if ($this->haveSubPage !== false) {
SubPage::doRender();
- return;
}
}
@@ -90,7 +89,6 @@ class Page_DozMod extends Page
if ($this->haveSubPage !== false) {
SubPage::doAjax();
- return;
}
}
diff --git a/modules-available/dozmod/pages/actionlog.inc.php b/modules-available/dozmod/pages/actionlog.inc.php
index eaa5218c..182198c2 100644
--- a/modules-available/dozmod/pages/actionlog.inc.php
+++ b/modules-available/dozmod/pages/actionlog.inc.php
@@ -11,7 +11,7 @@ class SubPage
User::assertPermission("actionlog.view");
self::$action = Request::get('action', '', 'string');
if (self::$action !== '' && self::$action !== 'showtarget' && self::$action !== 'showuser') {
- Util::traceError('Invalid action for actionlog: "' . self::$action . '"');
+ ErrorHandler::traceError('Invalid action for actionlog: "' . self::$action . '"');
}
self::$uuid = Request::get('uuid', '', 'string');
}
@@ -92,7 +92,7 @@ class SubPage
return $desc;
}
- private static function addImageHeader()
+ private static function addImageHeader(): bool
{
$image = Database::queryFirst('SELECT o.userid AS ouserid, o.firstname AS ofirstname, o.lastname AS olastname,
u.userid AS uuserid, u.firstname AS ufirstname, u.lastname AS ulastname,
@@ -114,7 +114,7 @@ class SubPage
return $image !== false;
}
- private static function addLectureHeader()
+ private static function addLectureHeader(): bool
{
$lecture = Database::queryFirst('SELECT o.userid AS ouserid, o.firstname AS ofirstname, o.lastname AS olastname,
u.userid AS uuserid, u.firstname AS ufirstname, u.lastname AS ulastname,
@@ -144,7 +144,7 @@ class SubPage
// query action log
$res = Database::simpleQuery($query, $params);
$events = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$row['dateline_s'] = date('d.m.Y H:i', $row['dateline']);
if (isset($row['imgname'])) {
$row['targeturl'] = '?do=dozmod&section=actionlog&action=showtarget&uuid=' . $row['targetid'];
diff --git a/modules-available/dozmod/pages/expiredimages.inc.php b/modules-available/dozmod/pages/expiredimages.inc.php
index 3217ae1e..ab563273 100644
--- a/modules-available/dozmod/pages/expiredimages.inc.php
+++ b/modules-available/dozmod/pages/expiredimages.inc.php
@@ -8,7 +8,7 @@ class SubPage
}
- private static function loadExpiredImages()
+ private static function loadExpiredImages(): array
{
$res = Database::simpleQuery("SELECT b.displayname,
own.firstname, own.lastname, own.userid,
@@ -22,7 +22,7 @@ class SubPage
ORDER BY b.displayname ASC, v.createtime ASC");
$NOW = time();
$rows = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if ($row['latexptime'] > $NOW && $row['latdelstate'] === 'KEEP') {
$row['hasNewerClass'] = 'glyphicon-ok green';
$row['checked'] = 'checked';
@@ -43,14 +43,9 @@ class SubPage
public static function doRender()
{
$expiredImages = self::loadExpiredImages();
-
- if (empty($expiredImages)) {
- Message::addSuccess('no-expired-images');
- } else {
- $data = ['images' => $expiredImages];
- Permission::addGlobalTags($data['perm'], null, ['expiredimages.delete', 'orphaned.scan']);
- Render::addTemplate('images-delete', $data);
- }
+ $data = ['images' => $expiredImages];
+ Permission::addGlobalTags($data['perm'], null, ['expiredimages.delete', 'orphaned.scan']);
+ Render::addTemplate('images-delete', $data);
}
public static function doAjax()
diff --git a/modules-available/dozmod/pages/mailconfig.inc.php b/modules-available/dozmod/pages/mailconfig.inc.php
index 08205f2e..aa03a4d3 100644
--- a/modules-available/dozmod/pages/mailconfig.inc.php
+++ b/modules-available/dozmod/pages/mailconfig.inc.php
@@ -34,7 +34,7 @@ class SubPage
Util::redirect('?do=DozMod&section=mailconfig');
}
- private static function cleanMailArray()
+ private static function cleanMailArray(): array
{
$keys = array('host', 'port', 'ssl', 'senderAddress', 'replyTo', 'username', 'password', 'serverName');
$data = array();
diff --git a/modules-available/dozmod/pages/networkrules.inc.php b/modules-available/dozmod/pages/networkrules.inc.php
index 710e90a9..218b7b06 100644
--- a/modules-available/dozmod/pages/networkrules.inc.php
+++ b/modules-available/dozmod/pages/networkrules.inc.php
@@ -74,7 +74,7 @@ class SubPage
$res = Database::simpleQuery('SELECT ruleid, rulename, ruledata
FROM sat.presetnetworkrule ORDER BY rulename ASC');
$rows = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$rows[] = $row;
}
Render::addTemplate('networkrules', [
diff --git a/modules-available/dozmod/pages/networkshares.inc.php b/modules-available/dozmod/pages/networkshares.inc.php
index 659321b4..852a8c67 100644
--- a/modules-available/dozmod/pages/networkshares.inc.php
+++ b/modules-available/dozmod/pages/networkshares.inc.php
@@ -66,7 +66,7 @@ class SubPage
$res = Database::simpleQuery('SELECT shareid, sharename, sharedata, active
FROM sat.presetnetworkshare ORDER BY sharename ASC');
$rows = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$dec = json_decode($row['sharedata'], true);
if (!is_array($dec)) {
$dec = [];
diff --git a/modules-available/dozmod/pages/runscripts.inc.php b/modules-available/dozmod/pages/runscripts.inc.php
index 9e6062d4..5665ba83 100644
--- a/modules-available/dozmod/pages/runscripts.inc.php
+++ b/modules-available/dozmod/pages/runscripts.inc.php
@@ -98,7 +98,7 @@ class SubPage
FROM sat.presetrunscript
ORDER BY scriptname ASC');
$rows = [];
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if ($row['visibility'] == 0) {
$row['visibility'] = 'eye-close';
} elseif ($row['visibility'] == 1) {
@@ -139,7 +139,7 @@ class SubPage
$res = Database::simpleQuery('SELECT o.osid, o.displayname, pxo.osid AS isvalid FROM sat.operatingsystem o
LEFT JOIN sat.presetrunscript_x_operatingsystem pxo ON (o.osid = pxo.osid AND pxo.runscriptid = :runscriptid)
ORDER BY o.displayname ASC', ['runscriptid' => $id]);
- while ($osrow = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $osrow) {
$row['oslist'][] = [
'osid' => $osrow['osid'],
'displayname' => $osrow['displayname'],
diff --git a/modules-available/dozmod/pages/runtimeconfig.inc.php b/modules-available/dozmod/pages/runtimeconfig.inc.php
index ab8500f2..f5790e82 100644
--- a/modules-available/dozmod/pages/runtimeconfig.inc.php
+++ b/modules-available/dozmod/pages/runtimeconfig.inc.php
@@ -31,12 +31,13 @@ class SubPage
'maxTransfers' => array('min' => 1, 'max' => 10),
],
'bool' => [
- 'allowLoginByDefault' => array('default' => false)
+ 'allowLoginByDefault' => array('default' => false),
+ 'allowStudentDownload' => array('default' => false),
],
];
foreach ($params as $type => $list) {
foreach ($list as $field => $limits) {
- $default = isset($limits['default']) ? $limits['default'] : false;
+ $default = $limits['default'] ?? false;
$value = Request::post($field, $default, $type);
if (isset($limits['min']) && $value < $limits['min']) {
$value = $limits['min'];
@@ -52,6 +53,8 @@ class SubPage
if (!in_array($data['serverSideCopy'], ['OFF', 'ON', 'AUTO', 'USER'])) {
$data['serverSideCopy'] = 'OFF';
}
+ // VM size limit
+ $data['vmSizeLimit'] = ceil(max(0, Request::post('vmSizeLimit', '0', 'float') * 1024 * 1024 * 1024));
/* ensure types */
settype($data['defaultLecturePermissions']['edit'], 'boolean');
@@ -61,6 +64,7 @@ class SubPage
settype($data['defaultImagePermissions']['link'], 'boolean');
settype($data['defaultImagePermissions']['download'], 'boolean');
+ // Write to DB - java server app wil reload this periodically
$data = json_encode($data);
Database::exec('INSERT INTO sat.configuration (parameter, value)'
. ' VALUES (:param, :value)'
@@ -81,31 +85,35 @@ class SubPage
$runtimeConf = json_decode($runtimeConf['value'], true);
/* convert some value to corresponding "selected" texts */
- if ($runtimeConf['defaultLecturePermissions']['edit']) {
+ if ($runtimeConf['defaultLecturePermissions']['edit'] ?? false) {
$runtimeConf['defaultLecturePermissions']['edit'] = 'checked';
}
- if ($runtimeConf['defaultLecturePermissions']['admin']) {
+ if ($runtimeConf['defaultLecturePermissions']['admin'] ?? false) {
$runtimeConf['defaultLecturePermissions']['admin'] = 'checked';
}
- if ($runtimeConf['defaultImagePermissions']['edit']) {
+ if ($runtimeConf['defaultImagePermissions']['edit'] ?? false) {
$runtimeConf['defaultImagePermissions']['edit'] = 'checked';
}
- if ($runtimeConf['defaultImagePermissions']['admin']) {
+ if ($runtimeConf['defaultImagePermissions']['admin'] ?? false) {
$runtimeConf['defaultImagePermissions']['admin'] = 'checked';
}
- if ($runtimeConf['defaultImagePermissions']['link']) {
+ if ($runtimeConf['defaultImagePermissions']['link'] ?? false) {
$runtimeConf['defaultImagePermissions']['link'] = 'checked';
}
- if ($runtimeConf['defaultImagePermissions']['download']) {
+ if ($runtimeConf['defaultImagePermissions']['download'] ?? false) {
$runtimeConf['defaultImagePermissions']['download'] = 'checked';
}
- if ($runtimeConf['allowLoginByDefault']) {
+ if ($runtimeConf['allowLoginByDefault'] ?? false) {
$runtimeConf['allowLoginByDefault'] = 'checked';
}
+ if ($runtimeConf['allowStudentDownload'] ?? false) {
+ $runtimeConf['allowStudentDownload'] = 'checked';
+ }
if (isset($runtimeConf['serverSideCopy'])) {
$runtimeConf[$runtimeConf['serverSideCopy'] . '_selected'] = 'selected';
}
+ $runtimeConf['vmSizeLimit'] = ceil(($runtimeConf['vmSizeLimit'] ?? 0) / (1024 * 1024 * 1024));
}
$runtimeConf['allowedSave'] = User::hasPermission("runtimeconfig.save");
Render::addTemplate('runtimeconfig', $runtimeConf);
diff --git a/modules-available/dozmod/pages/special.inc.php b/modules-available/dozmod/pages/special.inc.php
index 5a532b51..d6ac53d6 100644
--- a/modules-available/dozmod/pages/special.inc.php
+++ b/modules-available/dozmod/pages/special.inc.php
@@ -15,7 +15,7 @@ class SubPage
. " GROUP BY blocksha1, blocksize HAVING blockcount > 1 ORDER BY blockcount DESC, blocksha1 ASC");
$data = array('hashes' => array());
$spaceWasted = 0;
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$row['hash_hex'] = bin2hex($row['blocksha1']);
$row['blocksize_s'] = Util::readableFileSize($row['blocksize']);
$data['hashes'][] = $row;
@@ -57,7 +57,7 @@ class SubPage
die('Database error: ' . Database::lastError());
}
$data = array('rows' => array());
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$row['createtime_s'] = date('d.m.Y H:i', $row['createtime']);
$row['filesize_s'] = Util::readableFileSize($row['filesize']);
$data['rows'][] = $row;
diff --git a/modules-available/dozmod/pages/templates.inc.php b/modules-available/dozmod/pages/templates.inc.php
index b857115f..b916e14c 100644
--- a/modules-available/dozmod/pages/templates.inc.php
+++ b/modules-available/dozmod/pages/templates.inc.php
@@ -69,7 +69,7 @@ class SubPage
]);
}
- private static function forcmp($string)
+ private static function forcmp(string $string): string
{
return trim(str_replace("\r\n", "\n", $string));
}
diff --git a/modules-available/dozmod/pages/users.inc.php b/modules-available/dozmod/pages/users.inc.php
index 50f0f763..fe00a71b 100644
--- a/modules-available/dozmod/pages/users.inc.php
+++ b/modules-available/dozmod/pages/users.inc.php
@@ -42,7 +42,7 @@ class SubPage
. ' LEFT JOIN sat.organization USING (organizationid)'
. ' ORDER BY lastname ASC, firstname ASC');
$rows = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
settype($row['lastlogin'], 'int');
$row['canlogin'] = self::checked($row['canlogin']);
$row['issuperuser'] = self::checked($row['issuperuser']);
@@ -63,14 +63,14 @@ class SubPage
$res = Database::simpleQuery('SELECT organizationid, displayname, canlogin FROM sat.organization'
. ' ORDER BY displayname ASC');
$rows = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- $row['canlogin'] = self::checked($row['canlogin']);
+ foreach ($res as $row) {
+ $row['canlogin'] = self::checked((bool)$row['canlogin']);
$rows[] = $row;
}
Render::addTemplate('orglist', array('organizations' => $rows));
}
- private static function checked($val)
+ private static function checked(bool $val): string
{
if ($val)
return 'checked="checked"';
diff --git a/modules-available/dozmod/templates/runtimeconfig.html b/modules-available/dozmod/templates/runtimeconfig.html
index 44fb4106..1540042d 100644
--- a/modules-available/dozmod/templates/runtimeconfig.html
+++ b/modules-available/dozmod/templates/runtimeconfig.html
@@ -19,7 +19,7 @@
<div class="checkbox">
<input type="checkbox" name="defaultLecturePermissions[admin]" value="1" {{defaultLecturePermissions.admin}} id ="lecture_admin" class="form-control">
- <label for"lecture_admin">
+ <label for="lecture_admin">
{{lang_lecturePermissionAdmin}}
</label>
</div>
@@ -94,6 +94,12 @@
<input name="maxTransfers" class="form-control" type="number" value="{{maxTransfers}}" min="1" max="10" pattern="^\d+$">
</td>
</tr>
+ <tr class="input-group">
+ <td class="input-group-addon">{{lang_maxVmHddSizeGb}}</td>
+ <td>
+ <input name="vmSizeLimit" class="form-control" type="number" value="{{vmSizeLimit}}" min="0" max="65535" pattern="\d+">
+ </td>
+ </tr>
</table>
</fieldset>
<br>
@@ -107,6 +113,14 @@
<p><i>{{lang_allowLoginDescription}}</i></p>
</div>
<br>
+ <div class="checkbox">
+ <input type="checkbox" name="allowStudentDownload" value="1" {{allowStudentDownload}} id="allowStudentDownload" class="form-control">
+ <label for="allowStudentDownload">
+ {{lang_allowStudentDownload}}
+ </label>
+ <p><i>{{lang_allowStudentDownloadDescription}}</i></p>
+ </div>
+ <br>
<div>
<label for="serverSideCopy">
{{lang_serverSideCopy}}
diff --git a/modules-available/eventlog/hooks/cron.inc.php b/modules-available/eventlog/hooks/cron.inc.php
index 180bafd3..05a6921e 100644
--- a/modules-available/eventlog/hooks/cron.inc.php
+++ b/modules-available/eventlog/hooks/cron.inc.php
@@ -1,5 +1,69 @@
<?php
if (mt_rand(1, 10) === 1) {
- Database::exec("DELETE FROM eventlog WHERE (UNIX_TIMESTAMP() - 86400 * 190) > dateline");
+ // One year of event log
+ Database::exec("DELETE FROM eventlog WHERE (UNIX_TIMESTAMP() - 86400 * 365) > dateline");
+ // Keep at least 20 events or 7 days worth of samples (whichever is more)
+ $types = Database::simpleQuery("SELECT type, Count(*) AS num, Min(dateline) as oldest
+ FROM `notification_sample` GROUP BY type");
+ $cutoff = time() - 86400 * 7;
+ $maxCutoff = time() - 86400 * 365; // But don't keep anything for more than a year
+ foreach ($types as $type) {
+ if ($type['num'] > 20 && $type['oldest'] < $cutoff) {
+ // This type has more than 30 and the oldest one is older than 7 days
+ // find out which one takes priority
+ $thisCutoff = $cutoff;
+ $find = Database::queryFirst("SELECT dateline FROM notification_sample
+ WHERE type = :type AND dateline
+ ORDER BY dateline DESC
+ LIMIT 29, 1",
+ ['type' => $type['type']]);
+ // The 30th entry is older than 7 days? Bump the cutoff dateline back to this date,
+ // so we keep at least 20 entries
+ if ($find !== false && $find['dateline'] < $thisCutoff) {
+ $thisCutoff = $find['dateline'];
+ }
+ Database::exec("DELETE FROM notification_sample
+ WHERE type = :type AND dateline < :dateline",
+ ['type' => $type['type'], 'dateline' => max($thisCutoff, $maxCutoff)]);
+ }
+ }
}
+
+// Add missing/virtual columns to sample data
+$todo = Database::simpleQuery("SELECT sampleid, data FROM notification_sample WHERE extended = 0 LIMIT 10");
+foreach ($todo as $sample) {
+ $data = json_decode($sample['data'], true);
+ // First, add all the machine columns
+ if (isset($data['machineuuid'])) {
+ $row = Database::queryFirst("SELECT " . implode(',', FilterRuleProcessor::MACHINE_COLUMNS)
+ . " FROM machine WHERE machineuuid = :uuid", ['uuid' => $data['machineuuid']]);
+ } elseif (isset($data['clientip'])) {
+ $row = Database::queryFirst("SELECT " . implode(',', FilterRuleProcessor::MACHINE_COLUMNS)
+ . " FROM machine WHERE clientip = :ip ORDER BY lastseen DESC LIMIT 1", ['ip' => $data['clientip']]);
+ } else {
+ $row = false;
+ }
+ if ($row !== false) {
+ $data += $row;
+ }
+ // Add virtual statistics columns
+ if (isset($data['machineuuid']) && Module::isAvailable('statistics')) {
+ foreach (FilterRuleProcessor::HW_QUERIES as $key => $elem) {
+ if (isset($data[$key]))
+ continue; // Already present...
+ $q = new HardwareQuery($elem[0], $data['machineuuid']);
+ $q->addColumn($elem[2], $elem[1]);
+ $res = $q->query();
+ if ($res !== false) {
+ $row = $res->fetch();
+ if ($row !== false && $row[$elem[1]] !== null) {
+ $data[$key] = $row[$elem[1]];
+ }
+ }
+ }
+ }
+ // Finally, update entry
+ Database::exec("UPDATE notification_sample SET extended = 1, data = :data WHERE sampleid = :id",
+ ['id' => $sample['sampleid'], 'data' => json_encode($data)]);
+} \ No newline at end of file
diff --git a/modules-available/eventlog/inc/filterruleprocessor.inc.php b/modules-available/eventlog/inc/filterruleprocessor.inc.php
new file mode 100644
index 00000000..dd0160d7
--- /dev/null
+++ b/modules-available/eventlog/inc/filterruleprocessor.inc.php
@@ -0,0 +1,350 @@
+<?php
+
+class FilterRuleProcessor
+{
+
+ const MACHINE_COLUMNS = ['machineuuid', 'clientip', 'locationid', 'macaddr', 'firstseen', 'lastseen', 'logintime',
+ 'lastboot', 'state', 'realcores', 'mbram', 'kvmstate', 'cpumodel', 'systemmodel', 'id44mb', 'id45mb',
+ 'live_memsize', 'live_tmpsize', 'live_swapsize', 'live_id45size', 'live_memfree', 'live_tmpfree',
+ 'live_swapfree', 'live_id45free', 'live_cpuload', 'live_cputemp', 'badsectors', 'hostname', 'currentrunmode',
+ 'currentsession', 'currentuser', 'notes', 'standbysem'];
+
+ // <device-type>, <property>, <is_global_property>
+ const HW_QUERIES = [
+ 'cpu_sockets' => [HardwareInfo::MAINBOARD, 'cpu-sockets', false],
+ 'cpu_cores' => [HardwareInfo::MAINBOARD, 'cpu-cores', false],
+ 'cpu_threads' => [HardwareInfo::MAINBOARD, 'cpu-threads', false],
+
+ 'ram_max' => [HardwareInfo::MAINBOARD, 'Memory Maximum Capacity', true],
+ 'ram_slots' => [HardwareInfo::MAINBOARD, 'Memory Slot Count', true],
+ 'ram_manufacturer' => [HardwareInfo::RAM_MODULE, 'Manufacturer', true],
+ 'ram_part_no' => [HardwareInfo::RAM_MODULE, 'Part Number', true],
+ 'ram_speed_design' => [HardwareInfo::RAM_MODULE, 'Speed', true],
+ 'ram_speed_current' => [HardwareInfo::RAM_MODULE, 'Configured Memory Speed', false],
+ 'ram_size' => [HardwareInfo::RAM_MODULE, 'Size', true],
+ 'ram_type' => [HardwareInfo::RAM_MODULE, 'Type', true],
+ 'ram_form_factor' => [HardwareInfo::RAM_MODULE, 'Form Factor', true],
+ 'ram_serial_no' => [HardwareInfo::RAM_MODULE, 'Serial Number', false],
+ 'ram_voltage_min' => [HardwareInfo::RAM_MODULE, 'Minimum Voltage', true],
+ 'ram_voltage_max' => [HardwareInfo::RAM_MODULE, 'Maximum Voltage', true],
+ 'ram_voltage_current' => [HardwareInfo::RAM_MODULE, 'Configured Voltage', false],
+
+ 'mobo_manufacturer' => [HardwareInfo::MAINBOARD, 'Manufacturer', true],
+ 'mobo_product' => [HardwareInfo::MAINBOARD, 'Product Name', true],
+ 'mobo_type' => [HardwareInfo::MAINBOARD, 'Type', true],
+ 'mobo_version' => [HardwareInfo::MAINBOARD, 'Version', true],
+ 'mobo_serial_no' => [HardwareInfo::MAINBOARD, 'Serial Number', false],
+ 'mobo_asset_tag' => [HardwareInfo::MAINBOARD, 'Asset Tag', false],
+
+ 'sys_manufacturer' => [HardwareInfo::DMI_SYSTEM, 'Manufacturer', true],
+ 'sys_product' => [HardwareInfo::DMI_SYSTEM, 'Product Name', true],
+ 'sys_version' => [HardwareInfo::DMI_SYSTEM, 'Version', true],
+ 'sys_wakeup_type' => [HardwareInfo::DMI_SYSTEM, 'Wake-up Type', true],
+ 'sys_serial_no' => [HardwareInfo::DMI_SYSTEM, 'Serial Number', false],
+ 'sys_uuid' => [HardwareInfo::DMI_SYSTEM, 'UUID', false],
+ 'sys_sku' => [HardwareInfo::DMI_SYSTEM, 'SKU Number', false],
+
+ 'pci_class' => [HardwareInfo::PCI_DEVICE, 'class', true],
+ 'pci_vendor' => [HardwareInfo::PCI_DEVICE, 'vendor', true],
+ 'pci_device' => [HardwareInfo::PCI_DEVICE, 'device', true],
+
+ 'hdd_ifspeed' => [HardwareInfo::HDD, 'interface_speed//max', true],
+ 'hdd_blocksize' => [HardwareInfo::HDD, 'physical_block_size', true],
+ 'hdd_rpm' => [HardwareInfo::HDD, 'rotation_rate', true],
+ 'hdd_size' => [HardwareInfo::HDD, 'size', true],
+ 'hdd_sata_version' => [HardwareInfo::HDD, 'sata_version', true],
+ 'hdd_model' => [HardwareInfo::HDD, 'model', true],
+
+ 'nic_speed' => [HardwareInfo::MAINBOARD, 'nic-speed', false],
+ 'nic_duplex' => [HardwareInfo::MAINBOARD, 'nic-duplex', false],
+ ];
+
+ /*
+ * filter:
+ * [
+ * [path, op, arg, result],
+ * ...
+ * ]
+ *
+ * path: slash separated path in multi-dimensional array. Supports "*" for everything on a level
+ * op: <, >, = etc, or "regex"
+ * arg: what to match via op
+ * result: if not empty, a string that's added to the fired event. use %1% for the matched value (simple ops),
+ * or %n% for capture group of regex. supports a couple suffixes like b for bytes, which will turn
+ * a byte value into a human readable string, eg %1b% will turn 1234567 into 1.18MiB.
+ * ts = timestamp, d = duration.
+ */
+
+ /**
+ * Called from anywhere within slx-admin when some form of event happens.
+ * @param string $type the event. Will either be client state like ~poweron, ~runstate etc. or a client log type
+ * @param array $data A structured array containing event specific data that can be matched.
+ */
+ public static function applyFilterRules(string $type, array $data)
+ {
+ static $lastType;
+ // Kinda hacky - if there's a "data" key in the array, and it starts with '{',
+ // we assume it's the large machine hw info blob and discard it.
+ if (isset($data['data']) && $data['data'][0] === '{') {
+ unset($data['data']);
+ }
+ if ($lastType !== $type) {
+ $lastType = $type;
+ $exists = Database::queryFirst("SELECT type
+ FROM notification_sample
+ WHERE type = :type AND dateline > UNIX_TIMESTAMP() - 3600 LIMIT 1",
+ ['type' => $type]);
+ if ($exists === false) {
+ Database::exec("INSERT INTO notification_sample (type, dateline, data)
+ VALUES (:type, UNIX_TIMESTAMP(), :data)", [
+ 'type' => $type,
+ 'data' => json_encode($data),
+ ]);
+ }
+ }
+ $types = explode('-', $type);
+ for ($i = 1; $i < count($types); ++$i) {
+ $types[$i] = $types[$i-1] . '-' . $types[$i];
+ }
+ $res = Database::simpleQuery("SELECT ruleid, datafilter, subject, message
+ FROM notification_rule
+ WHERE type IN (:types)",
+ ['types' => $types]);
+ // Iterate over all matching filter rules
+ foreach ($res as $rule) {
+ if (empty($rule['message']) && empty($rule['subject'])) {
+ error_log('Filter rule with empty subject and message');
+ continue;
+ }
+ $filters = json_decode($rule['datafilter'], true);
+ $globalMatch = true;
+ $values = [];
+ // Iterate over all filter-paths of this rule
+ foreach ($filters['list'] as $key => $filter) {
+ $index = $filter['index'] ?? $key;
+ $path = explode('/', $filter['path']);
+ // Get all items from $data that match the path
+ $items = self::get($path, $data);
+ if (empty($items)) {
+ // If empty, add an empty string to result, so != can match
+ $items[] = '';
+ }
+ // Iterate over matches in $data - can be multiple if path contains '*'
+ foreach ($items as $item) {
+ if ($item === null || is_array($item))
+ continue;
+ $match = self::matches($item, $filter);
+ if ($match === null)
+ continue;
+ // Combine if multiple matches
+ $values[$index] = self::combine($values[$index] ?? [], $match);
+ }
+ if (!isset($values[$index])) {
+ $globalMatch = false;
+ break;
+ }
+ }
+ if ($globalMatch) {
+ self::fireEvent($rule, $values);
+ }
+ }
+ }
+
+ /**
+ * Fire event for given rule, fill templates with data from $values
+ */
+ private static function fireEvent(array $rule, array $values)
+ {
+ $ruleid = (int)$rule['ruleid'];
+ $subject = self::fillTemplate($rule['subject'], $values);
+ $message = self::fillTemplate($rule['message'], $values);
+ $ids = Database::queryColumnArray("SELECT transportid
+ FROM notification_rule_x_transport sfxb
+ WHERE sfxb.ruleid = :ruleid", ['ruleid' => $ruleid]);
+ $group = NotificationTransport::newGroup(...$ids);
+ $group->fire($subject, $message, $values);
+ }
+
+ /**
+ * Get value at given path from assoc array. Calls itself recursively until path
+ * is just one element. Supports special '*' path element, which will return all
+ * items at the current level. For this reason, the return value is always an array.
+ * This function is "hacky", as it tries to figure out whether the current key is
+ * 1) the last path element and 2) matches a known column from the machines array.
+ * If there exists no such key at the current level, it will be checked whether
+ * machineuuid (preferred) or clientip exist at the current level, and if so, they
+ * will be used to query the missing data from the database.
+ *
+ * @param array $path array of all the path elements
+ * @param array $data data to wade through, first element of $path should be in it
+ * @return array all the matched values
+ */
+ private static function get(array $path, array &$data): array
+ {
+ if (empty($path))
+ return [];
+ $pathElement = array_shift($path);
+ // Get everything on this level
+ if ($pathElement === '*') {
+ $return = [];
+ if (empty($path)) {
+ // End, everything needs to be primitive types
+ foreach ($data as $elem) {
+ if (!is_array($elem)) {
+ $return[] = $elem;
+ }
+ }
+ } else {
+ // Expected to go deeper
+ foreach ($data as $elem) {
+ if (is_array($elem)) {
+ $return = array_merge($return, self::get($path, $elem));
+ }
+ }
+ }
+ return $return;
+ }
+
+ if (!array_key_exists($pathElement, $data)
+ && (isset($data['clientip']) || isset($data['machineuuid']))) {
+ // An unknown key was requested, but we have clientip or machineuuid....
+ if (in_array($pathElement, self::MACHINE_COLUMNS) || !isset($data['machineuuid'])) {
+ // Key matches a column from machine table, OR we don't have machineuuid but clientip
+ // try to fetch it. Second condition is in case we have a HW_QUERIES virtual column.
+ if ($pathElement !== 'machineuuid' && isset($data['machineuuid'])) {
+ $row = Database::queryFirst("SELECT " . implode(',', self::MACHINE_COLUMNS)
+ . " FROM machine WHERE machineuuid = :uuid", ['uuid' => $data['machineuuid']]);
+ } elseif ($pathElement !== 'clientip' && isset($data['clientip'])) {
+ $row = Database::queryFirst("SELECT " . implode(',', self::MACHINE_COLUMNS)
+ . " FROM machine WHERE clientip = :ip ORDER BY lastseen DESC LIMIT 1", ['ip' => $data['clientip']]);
+ } else {
+ $row = false;
+ }
+ if ($row !== false) {
+ $data += $row;
+ }
+ }
+ if (isset($data['machineuuid'])
+ && isset(self::HW_QUERIES[$pathElement]) && Module::isAvailable('statistics')) {
+ // Key matches a predefined hwinfo property, resolve....
+ $q = new HardwareQuery(self::HW_QUERIES[$pathElement][0], $data['machineuuid']);
+ $q->addColumn(self::HW_QUERIES[$pathElement][2], self::HW_QUERIES[$pathElement][1]);
+ $res = $q->query();
+ if ($res !== false) {
+ foreach ($res as $row) {
+ $data[$pathElement][] = $row[self::HW_QUERIES[$pathElement][1]];
+ }
+ }
+ }
+ }
+
+ if (!array_key_exists($pathElement, $data))
+ return [];
+ if (empty($path) && !is_array($data[$pathElement]))
+ return [$data[$pathElement]];
+ if (empty($path) && ArrayUtil::isOnlyPrimitiveTypes($data[$pathElement]))
+ return $data[$pathElement];
+ if (is_array($data[$pathElement]))
+ return self::get($path, $data[$pathElement]);
+ return []; // No match
+ }
+
+ /**
+ * @param string $item item to match, string or number as string
+ * @param array $filter filter struct [op, arg, result]
+ * @return ?array null if op doesn't match, processed result otherwise
+ */
+ private static function matches(string $item, array $filter): ?array
+ {
+ $ok = false;
+ switch ($filter['op']) {
+ case '*':
+ $ok = true;
+ break;
+ case '>':
+ $ok = $item > $filter['arg'];
+ break;
+ case '>=':
+ $ok = $item >= $filter['arg'];
+ break;
+ case '<':
+ $ok = $item < $filter['arg'];
+ break;
+ case '<=':
+ $ok = $item <= $filter['arg'];
+ break;
+ case '=':
+ $ok = $item == $filter['arg'];
+ break;
+ case '!=':
+ $ok = $item != $filter['arg'];
+ break;
+ case 'regex':
+ $ok = (bool)preg_match($filter['arg'], $item, $out);
+ break;
+ default:
+ EventLog::warning("Invalid filter OP: {$filter['op']}");
+ }
+ if (!$ok) // No match
+ return null;
+ // Fake $out array for simple matches
+ if ($filter['op'] !== 'regex') {
+ $out = [1 => $item];
+ }
+ return $out ?? [];
+ }
+
+ private static function fillTemplate(string $template, array $values): string
+ {
+ return preg_replace_callback('/%([0-9]+)(?::([0-9]+|[a-z][a-z0-9_]*))?\.?([a-z]*)%/i', function($m) use ($values) {
+ if (!isset($values[$m[1]]))
+ return '<invalid row index #' . $m[1] . '>';
+ if (($m[2] ?? '') === '') {
+ $m[2] = 1;
+ }
+ if (!isset($values[$m[1]][$m[2]]))
+ return '<invalid column index #' . $m[2] . ' for row #' . $m[1] . '>';
+ $v = $values[$m[1]][$m[2]];
+ $shift = 0;
+ switch ($m[3]) {
+ case 'gb':
+ $shift++;
+ // fallthrough
+ case 'mb':
+ $shift++;
+ // fallthrough
+ case 'kb':
+ $shift++;
+ // fallthrough
+ case 'b':
+ return Util::readableFileSize((int)$v, -1, $shift);
+ case 'ts':
+ return Util::prettyTime((int)$v);
+ case 'd':
+ return Util::formatDuration((int)$v);
+ case 'L':
+ if (Module::isAvailable('locations'))
+ return Location::getName((int)$v) ?: '-';
+ break;
+ case '':
+ break;
+ default:
+ $v .= '(unknown suffix ' . $m[3] . ')';
+ }
+ return $v;
+ }, $template);
+ }
+
+ private static function combine(array $a, array $b): array
+ {
+ foreach ($b as $k => $v) {
+ if (isset($a[$k])) {
+ $a[$k] .= ', ' . $v;
+ } else {
+ $a[$k] = $v;
+ }
+ }
+ return $a;
+ }
+
+}
diff --git a/modules-available/eventlog/inc/notificationtransport.inc.php b/modules-available/eventlog/inc/notificationtransport.inc.php
new file mode 100644
index 00000000..499f6371
--- /dev/null
+++ b/modules-available/eventlog/inc/notificationtransport.inc.php
@@ -0,0 +1,279 @@
+<?php
+
+abstract class NotificationTransport
+{
+
+ public static function getInstance(array $data)
+ {
+ switch ($data['type'] ?? '') {
+ case 'mail':
+ return new MailNotificationTransport($data);
+ case 'irc':
+ return new IrcNotificationTransport($data);
+ case 'http':
+ return new HttpNotificationTransport($data);
+ case 'group':
+ return new GroupNotificationTransport($data);
+ }
+ error_log('Invalid Notification Transport: ' . ($data['type'] ?? '(unset)'));
+ return null;
+ }
+
+ public static function newGroup(int ...$ids): GroupNotificationTransport
+ {
+ return new GroupNotificationTransport(['group-list' => $ids]);
+ }
+
+ public abstract function __construct(array $data);
+
+ public abstract function toString(): string;
+
+ public abstract function fire(string $subject, string $message, array $raw): bool;
+
+ public abstract function isValid(): bool;
+
+}
+
+class MailNotificationTransport extends NotificationTransport
+{
+
+ /** @var int */
+ private $mailConfigId;
+
+ /** @var int[] */
+ private $userIds;
+
+ /** @var string */
+ private $extraMails;
+
+ public function __construct(array $data)
+ {
+ $this->mailConfigId = (int)($data['mail-config-id'] ?? 0);
+ $this->userIds = array_map(function ($i) { return (int)$i; }, $data['mail-users'] ?? []);
+ $this->extraMails = (string)($data['mail-extra-mails'] ?? '');
+ }
+
+ public function toString(): string
+ {
+ static $mailList = null;
+ if ($mailList === null) {
+ $mailList = Database::queryIndexedList("SELECT configid, host, senderaddress, replyto
+ FROM mail_config");
+ }
+ $str = 'Via: ' . ($mailList[$this->mailConfigId]['host'] ?? '<none>')
+ . ' as ' . ($mailList[$this->mailConfigId]['senderaddress'] ?? $mailList[$this->mailConfigId]['replyto'] ?? '<none>');
+ if (!empty($this->userIds)) {
+ $str .= ', Users: ' . count($this->userIds);
+ }
+ if (!empty($this->extraMails)) {
+ $str .= ', External: ' . substr_count($this->extraMails, '@');
+ }
+ return $str;
+ }
+
+ public function fire(string $subject, string $message, array $raw): bool
+ {
+ if (!$this->isValid())
+ return false;
+ $addrsOut = [];
+ if (preg_match_all('/[^@\s]+@[^@\s]+/', $this->extraMails, $out)) {
+ $addrsOut = $out[0];
+ }
+ if (!empty($this->userIds)) {
+ $mails = Database::queryColumnArray("SELECT email
+ FROM user
+ WHERE userid IN (:users)",
+ ['users' => $this->userIds]);
+ foreach ($mails as $mail) {
+ if (preg_match('/^[^@\s]+@[^@\s]+$/', $mail)) {
+ $addrsOut[] = $mail;
+ }
+ }
+ }
+ if (empty($addrsOut))
+ return false;
+ Mailer::queue($this->mailConfigId, $addrsOut, $subject, $message);
+ return true;
+ }
+
+ public function isValid(): bool
+ {
+ if ($this->mailConfigId === 0)
+ return false;
+ $mailer = Mailer::instanceFromConfig($this->mailConfigId);
+ return $mailer !== null;
+ }
+}
+
+class IrcNotificationTransport extends NotificationTransport
+{
+
+ private $server;
+
+ private $serverPasswort;
+
+ private $target;
+
+ private $nickName;
+
+ public function __construct(array $data)
+ {
+ $this->server = $data['irc-server'] ?? '';
+ $this->serverPasswort = $data['irc-server-password'] ?? '';
+ $this->target = $data['irc-target'] ?? '';
+ $this->nickName = $data['irc-nickname'] ?? 'BWLP-' . mt_rand(10000, 99999);
+ }
+
+ public function toString(): string
+ {
+ return '(' . $this->server . '), ' . $this->nickName . ' @ ' . $this->target;
+ }
+
+ public function fire(string $subject, string $message, array $raw): bool
+ {
+ if (!$this->isValid())
+ return false;
+ return !Taskmanager::isFailed(Taskmanager::submit('IrcNotification', [
+ 'serverAddress' => $this->server,
+ 'serverPassword' => $this->serverPasswort,
+ 'channel' => $this->target,
+ 'message' => preg_replace('/[\r\n]+\s*/', ' ', $message),
+ 'nickName' => $this->nickName,
+ ]));
+ }
+
+ public function isValid(): bool
+ {
+ return !empty($this->server) && !empty($this->target);
+ }
+}
+
+class HttpNotificationTransport extends NotificationTransport
+{
+
+ /** @var string */
+ private $uri;
+
+ /** @var string */
+ private $method;
+
+ /** @var string */
+ private $postField;
+
+ /** @var string */
+ private $postFormat;
+
+ public function __construct(array $data)
+ {
+ $this->uri = $data['http-uri'] ?? '';
+ $this->method = $data['http-method'] ?? 'POST';
+ $this->postField = $data['http-post-field'] ?? 'message=%TEXT%&subject=%SUBJECT%';
+ $this->postFormat = $data['http-post-format'] ?? 'FORM';
+ }
+
+ public function toString(): string
+ {
+ return $this->uri . ' (' . $this->method . ')';
+ }
+
+ public function fire(string $subject, string $message, array $raw): bool
+ {
+ if (!$this->isValid())
+ return false;
+ $url = str_replace(['%TEXT%', '%SUBJECT%'], [urlencode($message), urlencode($subject)], $this->uri);
+ if ($this->method === 'POST') {
+ switch ($this->postFormat) {
+ case 'FORM':
+ $body = str_replace(['%TEXT%', '%SUBJECT%'], [urlencode($message), urlencode($subject)], $this->postField);
+ $ctype = 'application/x-www-form-urlencoded';
+ break;
+ case 'JSON':
+ $body = str_replace(['%TEXT%', '%SUBJECT%'], [json_encode($message),
+ json_encode($subject)], $this->postField);
+ $ctype = 'application/json';
+ break;
+ default:
+ $out = [];
+ foreach ($raw as $k1 => $a) {
+ foreach ($a as $k2 => $v) {
+ $out["$k1.$k2"] = $v;
+ }
+ }
+ $body = json_encode($out);
+ $ctype = 'application/json';
+ }
+ } else {
+ $body = null;
+ $ctype = null;
+ }
+ return !Taskmanager::isFailed(Taskmanager::submit('HttpRequest', [
+ 'url' => $url,
+ 'postData' => $body,
+ 'contentType' => $ctype,
+ ]));
+ }
+
+ public function isValid(): bool
+ {
+ return !empty($this->uri);
+ }
+}
+
+class GroupNotificationTransport extends NotificationTransport
+{
+
+ /** @var int[] list of contained notification transports */
+ private $list;
+
+ public function __construct(array $data)
+ {
+ $this->list = array_map(function ($i) { return (int)$i; }, $data['group-list'] ?? []);
+ }
+
+ public function toString(): string
+ {
+ static $groupList = null;
+ if ($groupList === null) {
+ $groupList = Database::queryKeyValueList("SELECT transportid, title FROM notification_backend");
+ }
+ $out = array_map(function ($i) use ($groupList) { return $groupList[$i] ?? "#$i"; }, $this->list);
+ return implode(', ', $out);
+ }
+
+ public function fire(string $subject, string $message, array $raw): bool
+ {
+ // This is static, so recursing into groups will keep track of ones we already saw
+ static $done = false;
+ $first = ($done === false);
+ if ($first) { // Non-recursive call, init list
+ $done = [];
+ }
+ $list = array_diff($this->list, $done);
+ if (!empty($list)) {
+ $done = array_merge($done, $list);
+ $res = Database::simpleQuery("SELECT data FROM notification_backend WHERE transportid IN (:ids)",
+ ['ids' => $list]);
+ foreach ($res as $row) {
+ $data = json_decode($row['data'], true);
+ if (is_array($data)) {
+ $inst = NotificationTransport::getInstance($data);
+ if ($inst !== null) {
+ $inst->fire($subject, $message, $raw);
+ }
+ }
+ }
+ }
+ if ($first) {
+ $done = false; // Outer-most call, reset
+ }
+ return true;
+ }
+
+ public function isValid(): bool
+ {
+ // Do we really care about empty groups? They might be pointless, but not really invalid
+ // We could consider groups containing invalid IDs as invalid, but that would mean that we
+ // potentially ignore all the other existing IDs in this group, as it would never fire
+ return true;
+ }
+} \ No newline at end of file
diff --git a/modules-available/eventlog/install.inc.php b/modules-available/eventlog/install.inc.php
index e5fd32f6..75286af5 100644
--- a/modules-available/eventlog/install.inc.php
+++ b/modules-available/eventlog/install.inc.php
@@ -13,6 +13,47 @@ KEY `dateline` (`dateline`),
KEY `logtypeid` (`logtypeid`,`dateline`)
");
+$res[] = tableCreate('notification_rule', '
+ `ruleid` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+ `title` varchar(100) NOT NULL,
+ `description` text,
+ `type` varchar(40) CHARACTER SET ascii NOT NULL,
+ `datafilter` blob NOT NULL,
+ `subject` varchar(200) NOT NULL,
+ `message` text NOT NULL,
+ `predefid` int(10) UNSIGNED NULL DEFAULT NULL,
+ PRIMARY KEY (`ruleid`),
+ KEY `type` (`type`),
+ UNIQUE KEY `predefid` (`predefid`)
+');
+
+$res[] = tableCreate('notification_backend', '
+ `transportid` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+ `title` varchar(100) NOT NULL,
+ `description` text,
+ `data` blob,
+ PRIMARY KEY (`transportid`),
+ KEY (`title`)
+');
+
+$res[] = tableCreate('notification_rule_x_transport', '
+ `ruleid` int(10) UNSIGNED NOT NULL,
+ `transportid` int(10) UNSIGNED NOT NULL,
+ PRIMARY KEY (`ruleid`, `transportid`),
+ KEY (`transportid`)
+');
+
+$res[] = tableCreate('notification_sample', "
+ `sampleid` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+ `dateline` int(10) UNSIGNED NOT NULL,
+ `extended` tinyint(1) NOT NULL DEFAULT '0',
+ `type` varchar(40) CHARACTER SET ascii NOT NULL,
+ `data` mediumblob,
+ KEY (`type`, `dateline`),
+ KEY (`extended`),
+ PRIMARY KEY (`sampleid`)
+");
+
// Update path
if (!tableHasColumn('eventlog', 'extra')) {
@@ -22,6 +63,48 @@ if (!tableHasColumn('eventlog', 'extra')) {
$res[] = UPDATE_DONE;
}
+// 2021-06-15: Add constraints to filter/backend stuff
+$res[] = tableAddConstraint('notification_rule_x_transport', 'ruleid',
+ 'notification_rule', 'ruleid', 'ON UPDATE CASCADE ON DELETE CASCADE');
+$res[] = tableAddConstraint('notification_rule_x_transport', 'transportid',
+ 'notification_backend', 'transportid', 'ON UPDATE CASCADE ON DELETE CASCADE');
+
+// 2022-07-20: Add flag to see if notification_sample has been extended
+if (!tableHasColumn('notification_sample', 'extended')) {
+ if (Database::exec("ALTER TABLE notification_sample
+ ADD COLUMN `extended` tinyint(1) NOT NULL DEFAULT '0',
+ ADD KEY (`extended`),
+ ADD COLUMN `sampleid` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+ ADD PRIMARY KEY (`sampleid`)") === false) {
+ finalResponse(UPDATE_FAILED, 'Could not add extended flag to notification_sample: ' . Database::lastError());
+ }
+ $res[] = UPDATE_DONE;
+}
+
+// 2023-08-03: Add a few example filters
+if (!tableHasColumn('notification_rule', 'predefid')) {
+ if (Database::exec("ALTER TABLE notification_rule
+ ADD COLUMN `predefid` int(10) UNSIGNED NULL DEFAULT NULL,
+ ADD UNIQUE KEY `predefid` (`predefid`)") === false) {
+ finalResponse(UPDATE_FAILED, 'Could not add predefid to notification_rule: ' . Database::lastError());
+ }
+ $res[] = UPDATE_DONE;
+}
+$q = "INSERT IGNORE INTO `notification_rule`
+ (predefid, title, `description`, type, datafilter, subject, message)
+ VALUES
+ (1,'Session start','Benachrichtigung über jede gestartete Session','.vmchooser-session','{\"list\":[{\"path\":\"clientip\",\"op\":\"*\",\"index\":0},{\"path\":\"currentuser\",\"op\":\"*\",\"index\":2},{\"path\":\"sessionName\",\"op\":\"*\",\"index\":3}]}','Sitzungsstart','Client %0% - User %2% startet Veranstaltung %3%'),
+ (2,'PowerON: bwlp-Default','Kein spezieller Betriebsmodus','~poweron','{\"list\":[{\"path\":\"clientip\",\"op\":\"*\",\"index\":0},{\"path\":\"currentrunmode\",\"op\":\"=\",\"arg\":\"\",\"index\":1},{\"path\":\"locationid\",\"op\":\"*\",\"index\":2}]}','PowerON-Event','Client %0% in Raum %2L% startet im Modus \'Standard\''),
+ (3,'Serverlog','Jegliche neue Einträge im Server-Log','#serverlog','{\"list\":[{\"path\":\"type\",\"op\":\"*\",\"index\":0},{\"path\":\"message\",\"op\":\"*\",\"index\":1},{\"path\":\"details\",\"op\":\"*\",\"index\":2}]}','','[%0%] - %1% - %2%'),
+ (4,'PowerON: Exammode','Rechner startet im Klausurmodus','~poweron','{\"list\":[{\"path\":\"clientip\",\"op\":\"*\",\"index\":0},{\"path\":\"currentrunmode\",\"op\":\"=\",\"arg\":\"exams\",\"index\":1}]}','','Client %0% startet im Modus \'Prüfung\''),
+ (5,'PowerON: Remoteaccess','Rechner startet im Fernzugriffsmodus','~poweron','{\"list\":[{\"path\":\"clientip\",\"op\":\"*\",\"index\":0},{\"path\":\"currentrunmode\",\"op\":\"=\",\"arg\":\"remoteaccess\",\"index\":1}]}','','Client %0% startet im Modus \'Fernzugriff\''),
+ (6,'NIC: Slow Uplink','Rechner ist mit weniger als 1GBit/s mit dem Switch verbunden','~poweron','{\"list\":[{\"path\":\"clientip\",\"op\":\"*\",\"index\":0},{\"path\":\"nic_speed\",\"op\":\"<\",\"arg\":\"1000\",\"index\":1}]}','','Client %0% hat einen Uplink von %1% MBit/s'),
+ (7,'First boot of new Client','Neuer Client wurde gestartet, der dem Server bisher nicht bekannt war','~poweron','{\"list\":[{\"path\":\"clientip\",\"op\":\"*\",\"index\":0},{\"path\":\"hostname\",\"op\":\"*\",\"index\":1},{\"path\":\"oldlastseen\",\"op\":\"=\",\"arg\":\"0\",\"index\":2}]}','','Neuer Client %0% (%1%) bootet bwLehrpool'),
+ (8,'Rechner mit Platte, ohne ID44','Besagter Rechner hat zwar eine HDD/SSD verbaut, aber keine ID44-Partition','~poweron','{\"list\":[{\"path\":\"clientip\",\"op\":\"*\",\"index\":0},{\"path\":\"hdd_size\",\"op\":\">\",\"arg\":\"0\",\"index\":1},{\"path\":\"id44mb\",\"op\":\"=\",\"arg\":\"0\",\"index\":2},{\"path\":\"hostname\",\"op\":\"*\",\"index\":3}]}','Hallo','%3% (%0%) hat HDD %1b%, keine ID44 Partition'),
+ (9,'Lahmes Netzwerk, keine ID45','Rechner ist mit weniger als 1GBit/s mit dem Switch verbunden, und besitzt keine ID45-Partition','~poweron','{\"list\":[{\"path\":\"id45mb\",\"op\":\"=\",\"arg\":\"0\",\"index\":0},{\"path\":\"nic_speed\",\"op\":\"<\",\"arg\":\"1000\",\"index\":1},{\"path\":\"hostname\",\"op\":\"*\",\"index\":2},{\"path\":\"nic_speed\",\"op\":\">\",\"arg\":\"0\",\"index\":3},{\"path\":\"clientip\",\"op\":\"*\",\"index\":4}]}','Lahm','%2% (%4%) hat %1%MBit und keine ID45'),
+ (10,'Drehende Platte','Rechner hat eine Rotierende HDD und keine SSD.','~poweron','{\"list\":[{\"path\":\"hdd_rpm\",\"op\":\">\",\"arg\":\"0\",\"index\":0},{\"path\":\"clientip\",\"op\":\"*\",\"index\":1},{\"path\":\"hostname\",\"op\":\"*\",\"index\":2}]}','Da dreht was im Rechner...','Rechner %2% (%1%) hat drehende Platte (%0% RPM)')";
+Database::exec($q);
+
// Create response for browser
if (in_array(UPDATE_DONE, $res)) {
diff --git a/modules-available/eventlog/lang/de/messages.json b/modules-available/eventlog/lang/de/messages.json
new file mode 100644
index 00000000..662ea8c1
--- /dev/null
+++ b/modules-available/eventlog/lang/de/messages.json
@@ -0,0 +1,9 @@
+{
+ "event-mailconfig-saved": "Mail-Konfiguration {{0}} gespeichert",
+ "event-rule-saved": "Ereignisregel {{0}} gespeichert",
+ "invalid-mailconfig-id": "Ung\u00fcltige Konfigurations-ID {{0}}",
+ "invalid-rule-id": "Ung\u00fcltige Regel-ID {{0}}",
+ "invalid-transport-id": "Ung\u00fcltige Transport-ID {{0}}",
+ "no-valid-filters": "Keine g\u00fcltigen Filter angegeben",
+ "transport-saved": "Transport {{0}} gespeichert"
+} \ No newline at end of file
diff --git a/modules-available/eventlog/lang/de/module.json b/modules-available/eventlog/lang/de/module.json
index 8217fc02..93326963 100644
--- a/modules-available/eventlog/lang/de/module.json
+++ b/modules-available/eventlog/lang/de/module.json
@@ -1,3 +1,3 @@
{
- "module_name": "Server-Log"
+ "module_name": "Ereignisse"
} \ No newline at end of file
diff --git a/modules-available/eventlog/lang/de/permissions.json b/modules-available/eventlog/lang/de/permissions.json
index 7f1087bf..9d74bc19 100644
--- a/modules-available/eventlog/lang/de/permissions.json
+++ b/modules-available/eventlog/lang/de/permissions.json
@@ -1,3 +1,9 @@
{
- "view": "Server Log anschauen."
+ "filter.mailconfig.edit": "EMail-Konfigurationen bearbeiten.",
+ "filter.mailconfig.view": "EMail-Konfigurationen sehen.",
+ "filter.rules.edit": "Filterregeln bearbeiten.",
+ "filter.rules.view": "Filterregeln sehen.",
+ "filter.transports.edit": "Transporte bearbeiten.",
+ "filter.transports.view": "Transporte sehen.",
+ "view": "Server Log anschauen."
} \ No newline at end of file
diff --git a/modules-available/eventlog/lang/de/template-tags.json b/modules-available/eventlog/lang/de/template-tags.json
index 6ad75329..fd9cc532 100644
--- a/modules-available/eventlog/lang/de/template-tags.json
+++ b/modules-available/eventlog/lang/de/template-tags.json
@@ -1,6 +1,66 @@
{
+ "lang_add": "Hinzuf\u00fcgen",
+ "lang_additionalMailAddresses": "Zus\u00e4tzliche EMail-Adressen",
+ "lang_autoJson": "Vollst\u00e4ndige JSON-Daten",
+ "lang_autoJsonHelp": "Das Feld \"POST-Format\" wird ignoriert. Die POST-Daten sind eine JSON-Struktur mit allen zum Ereignis geh\u00f6renden Daten. Die Felder \"Betreff\" und \"Nachricht\" werden ebenfalls ignoriert.",
+ "lang_copy": "Kopieren",
+ "lang_createFilter": "Filter erstellen",
+ "lang_description": "Beschreibung",
"lang_details": "Details",
+ "lang_editFilter": "Filter bearbeiten",
+ "lang_editMail": "EMail bearbeiten",
+ "lang_editTransport": "Transport bearbeiten",
"lang_event": "Ereignis",
"lang_eventLog": "Serverprotokoll",
+ "lang_filterArg": "Argument",
+ "lang_filterExampleHelpText": "Hier k\u00f6nnen Sie Filter f\u00fcr verschiedene Ereignisse festlegen. Die Felder \"Typ\" und \"Pfad\" enthalten Beispielwerte, die je nach Browser mit einem einfachen oder Doppelklick angezeigt werden.",
+ "lang_filterOp": "Op",
+ "lang_filterPath": "Pfad",
+ "lang_filterResult": "Ausgabe-String",
+ "lang_filterRules": "Filterregeln",
+ "lang_formDataHelp": "POSTed das Event als \"urlencoded\" an die angegebene URL. Verwenden Sie die Platzhalter %SUBJECT% und %TEXT% f\u00fcr die entsprechenden Felder.",
+ "lang_hintRegex": "Wenn sie einen RegEx verwenden, k\u00f6nnen Sie \"capture groups\" benutzen, und im Betreff oder Nachrichtenfeld referenzieren.",
+ "lang_host": "Host",
+ "lang_http": "HTTP",
+ "lang_httpMethod": "HTTP-Methode",
+ "lang_httpPostField": "HTTP POST Payload",
+ "lang_httpPostFormat": "HTTP POST payload Content-Type",
+ "lang_httpUri": "URI",
+ "lang_id": "ID",
+ "lang_index": "Index",
+ "lang_irc": "IRC",
+ "lang_ircNickname": "Nickname",
+ "lang_ircServer": "Server",
+ "lang_ircServerPassword": "Server-Passwort",
+ "lang_ircTarget": "Senden an #Kanal\/Nick",
+ "lang_jsonStringHelp": "Formulieren Sie eine JSON-Struktur, die die Platzhalter %SUBJECT% und %TEXT% f\u00fcr beliebige Werte enth\u00e4lt.",
+ "lang_logAndEvents": "Server-Log und Ereignis\u00fcberwachung",
+ "lang_mail": "EMail",
+ "lang_mailConfig": "EMail-Konfiguration",
+ "lang_mailUsers": "Via Mail zu benachrichtigende Nutzer",
+ "lang_mailconfigs": "EMail-Konfigurationen",
+ "lang_messageTemplate": "Nachrichtenvorlage",
+ "lang_messageTemplateHelp": "Sie k\u00f6nnen mittels der Platzhalter %0%, %1%, etc. Bezug auf die Filter oben nehmen. F\u00fcr Filter vom Typ \"regex\" k\u00f6nnen Sie sich mittels %n:1%, %n:2% etc. auf die \"Capture Groups\" des Regul\u00e4ren Ausdrucks beziehen. Des Weiteren k\u00f6nnen numerische Werte durch Suffixe formatiert\/in lesbare Form umgewandelt werden. Geben Sie durch das Suffix an, welche Einheit die zu formatierende Zahl hat: \"b\", \"kb\", \"mb\", \"gb\" f\u00fcr Bytes (resp. Kilo, Mega, etc.), \"ts\" als Unix-Timestamp, \"d\" als Dauer in Sekunden, \"L\" f\u00fcr eine Location-ID.",
+ "lang_noMailConfig": "Keine EMail-Konfiguration",
+ "lang_optionalDescription": "Beschreibung (als Referenz, wird nicht f\u00fcr die Verarbeitung verwendet)",
+ "lang_port": "Port",
+ "lang_postUseSUBJECTandTEXThint": "Sie k\u00f6nnen die Platzhalter %SUBJECT% und %TEXT% verwenden, die entsprechend des ausgew\u00e4hlten POST-Formats escaped werden.",
+ "lang_reallyDelete": "Konfiguration l\u00f6schen?",
+ "lang_replyTo": "Antwort an",
+ "lang_rules": "Regeln",
+ "lang_sampleData": "Beispieldaten",
+ "lang_selectTransports": "Transporte ausw\u00e4hlen",
+ "lang_senderAddress": "Absenderadresse",
+ "lang_ssl": "TLS",
+ "lang_sslExplicit": "Explizites TLS",
+ "lang_sslImplicit": "Implizites TLS",
+ "lang_sslNone": "Kein TLS",
+ "lang_subject": "Betreff",
+ "lang_title": "Titel",
+ "lang_transportGroup": "Transportgruppe",
+ "lang_transports": "Transporte",
+ "lang_type": "Typ",
+ "lang_typeExample": "Beispiel",
+ "lang_uriUseSUBJECTandTEXThint": "Sie k\u00f6nnen %SUBJECT% und %TEXT% in der URI verwenden. Sie werden durch die entsprechenden Felder des Filters ersetzt.",
"lang_when": "Wann"
} \ No newline at end of file
diff --git a/modules-available/eventlog/lang/en/messages.json b/modules-available/eventlog/lang/en/messages.json
new file mode 100644
index 00000000..3d8eec5f
--- /dev/null
+++ b/modules-available/eventlog/lang/en/messages.json
@@ -0,0 +1,9 @@
+{
+ "event-mailconfig-saved": "Saved mail configuration {{0}}",
+ "event-rule-saved": "Saved rule {{0}}",
+ "invalid-mailconfig-id": "Invalid mail config ID {{0}}",
+ "invalid-rule-id": "Invalid rule ID {{0}}",
+ "invalid-transport-id": "Invalid transport ID {{0}}",
+ "no-valid-filters": "No valid filters provided",
+ "transport-saved": "Saved transport {{0}}"
+} \ No newline at end of file
diff --git a/modules-available/eventlog/lang/en/module.json b/modules-available/eventlog/lang/en/module.json
index 0fc536f3..574d36bb 100644
--- a/modules-available/eventlog/lang/en/module.json
+++ b/modules-available/eventlog/lang/en/module.json
@@ -1,3 +1,3 @@
{
- "module_name": "Server Log"
+ "module_name": "Events"
} \ No newline at end of file
diff --git a/modules-available/eventlog/lang/en/permissions.json b/modules-available/eventlog/lang/en/permissions.json
index ec438a4b..9a65dcaa 100644
--- a/modules-available/eventlog/lang/en/permissions.json
+++ b/modules-available/eventlog/lang/en/permissions.json
@@ -1,3 +1,9 @@
{
- "view": "View server log."
+ "filter.mailconfig.edit": "Edit email configs.",
+ "filter.mailconfig.view": "View email configs.",
+ "filter.rules.edit": "Edit filter rules.",
+ "filter.rules.view": "View filter rules.",
+ "filter.transports.edit": "Edit transports.",
+ "filter.transports.view": "View transports.",
+ "view": "View server log."
} \ No newline at end of file
diff --git a/modules-available/eventlog/lang/en/template-tags.json b/modules-available/eventlog/lang/en/template-tags.json
index 3132b97c..7535b86b 100644
--- a/modules-available/eventlog/lang/en/template-tags.json
+++ b/modules-available/eventlog/lang/en/template-tags.json
@@ -1,6 +1,66 @@
{
+ "lang_add": "Add",
+ "lang_additionalMailAddresses": "Additional mail addresses",
+ "lang_autoJson": "Full JSON data",
+ "lang_autoJsonHelp": "The field \"POST format\" is ignored. The POST payload is a JSON struct with all available data regarding the according event. \"Subject\" and \"Message\" are ignored as well.",
+ "lang_copy": "Copy",
+ "lang_createFilter": "Create filter",
+ "lang_description": "Description",
"lang_details": "Details",
+ "lang_editFilter": "Edit filter",
+ "lang_editMail": "Edit mail",
+ "lang_editTransport": "Edit transport",
"lang_event": "Event",
"lang_eventLog": "Server Log",
+ "lang_filterArg": "Argument",
+ "lang_filterExampleHelpText": "Here you can define filters for different events. Fields \"type\" and \"path\" contain example values, which can be displayed by single- or double-clicking (depending on browser) the according field.",
+ "lang_filterOp": "Op",
+ "lang_filterPath": "Path",
+ "lang_filterResult": "Resulting string",
+ "lang_filterRules": "Filter rules",
+ "lang_formDataHelp": "POST event als urlencoded form data. Put your desired post string into the field above and use %TEXT% and %SUBJECT% as placeholders for where the according event fields go.",
+ "lang_hintRegex": "If you use a regex, you can use capture groups in the filter expression, and refer to them later on in your message body.",
+ "lang_host": "Host",
+ "lang_http": "HTTP",
+ "lang_httpMethod": "HTTP method",
+ "lang_httpPostField": "HTTP post payload",
+ "lang_httpPostFormat": "HTTP post payload Content-Type",
+ "lang_httpUri": "URI",
+ "lang_id": "ID",
+ "lang_index": "Index",
+ "lang_irc": "IRC",
+ "lang_ircNickname": "Nickname",
+ "lang_ircServer": "Server",
+ "lang_ircServerPassword": "Server password",
+ "lang_ircTarget": "Target Channel\/Nick",
+ "lang_jsonStringHelp": "Supply a json struct containing placeholders %SUBJECT% and\/or %TEXT% in the \"HTTP post payload\" fied above.",
+ "lang_logAndEvents": "Server log and event filtering",
+ "lang_mail": "Mail",
+ "lang_mailConfig": "Mail config",
+ "lang_mailUsers": "Users to mail",
+ "lang_mailconfigs": "Mail configs",
+ "lang_messageTemplate": "Message template",
+ "lang_messageTemplateHelp": "You can refer to the matched rules above by using their index in percentage-signs, like %0%, %1%, etc. If you use a regex with capture groups, you can refer to them individually by using %n:1%, %n:2% etc. Furthermode, you can format raw numbers by appending \"b\", \"kb\", \"mb\", \"gb\" to interpret the given value as bytes (or kilobytes, megabytes, etc.), \"ts\" if the input value is a unix timestamp, \"d\" to turn a duration in seconds into human readable format, and \"L\" to turn a location id into the according location name.",
+ "lang_noMailConfig": "No mail config",
+ "lang_optionalDescription": "Description (for reference only)",
+ "lang_port": "Port",
+ "lang_postUseSUBJECTandTEXThint": "Use %SUBJECT% and %TEXT% placeholders which will be escaped properly, according to chosen POST format.",
+ "lang_reallyDelete": "Config deletion?",
+ "lang_replyTo": "Reply to",
+ "lang_rules": "Rules",
+ "lang_sampleData": "Sample data",
+ "lang_selectTransports": "Select transports",
+ "lang_senderAddress": "Sender address",
+ "lang_ssl": "TLS",
+ "lang_sslExplicit": "Explicit TLS",
+ "lang_sslImplicit": "Implicit TLS",
+ "lang_sslNone": "No TLS",
+ "lang_subject": "Subject",
+ "lang_title": "Title",
+ "lang_transportGroup": "Transport group",
+ "lang_transports": "Transports",
+ "lang_type": "Type",
+ "lang_typeExample": "Example",
+ "lang_uriUseSUBJECTandTEXThint": "You can use %SUBJECT% and %TEXT% in the URI too. They will be url-encoded.",
"lang_when": "When"
} \ No newline at end of file
diff --git a/modules-available/eventlog/page.inc.php b/modules-available/eventlog/page.inc.php
index 1c81983c..56fc97e2 100644
--- a/modules-available/eventlog/page.inc.php
+++ b/modules-available/eventlog/page.inc.php
@@ -3,56 +3,65 @@
class Page_EventLog extends Page
{
+ private $show;
+
protected function doPreprocess()
{
User::load();
- User::assertPermission('view');
- User::setLastSeenEvent(Property::getLastWarningId());
- }
- protected function doRender()
- {
- Render::addTemplate("heading");
- $lines = array();
- $paginate = new Paginate("SELECT logid, dateline, logtypeid, description, extra FROM eventlog ORDER BY logid DESC", 50);
- $res = $paginate->exec();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- $row['date'] = Util::prettyTime($row['dateline']);
- $row['icon'] = $this->typeToIcon($row['logtypeid']);
- $row['color'] = $this->typeToColor($row['logtypeid']);
- $lines[] = $row;
+ $this->show = Request::any('show', false, 'string');
+ if ($this->show === false && Request::isGet()) {
+ if (User::hasPermission('view')) {
+ $this->show = 'log';
+ } elseif (User::hasPermission('filter.rules.view')) {
+ $this->show = 'rules';
+ } else {
+ User::assertPermission('filter.transports.view');
+ $this->show = 'transports';
+ }
+ }
+ if ($this->show !== false) {
+ $this->show = preg_replace('/[^a-z0-9_\-]/', '', $this->show);
+ if (!file_exists('modules/eventlog/pages/' . $this->show . '.inc.php')) {
+ Message::addError('main.invalid-action', $this->show);
+ Util::redirect('?do=eventlog');
+ } else {
+ require_once 'modules/eventlog/pages/' . $this->show . '.inc.php';
+ SubPage::doPreprocess();
+ }
+ }
+ if (Request::isPost()) {
+ Util::redirect('?do=eventlog&show=' . $this->show);
}
-
- $paginate->render('_page', array(
- 'list' => $lines
- ));
}
- private function typeToIcon($type)
+ protected function doRender()
{
- switch ($type) {
- case 'info':
- return 'ok';
- case 'warning':
- return 'exclamation-sign';
- case 'failure':
- return 'remove';
- default:
- return 'question-sign';
+ Render::addTemplate('page-header', ['active_' . $this->show => 'active']);
+ if ($this->show !== false) {
+ SubPage::doRender();
}
}
- private function typeToColor($type)
+ protected function doAjax()
{
- switch ($type) {
- case 'info':
- return '';
- case 'warning':
- return 'orange';
- case 'failure':
- return 'red';
- default:
- return '';
+ // XXX Should go into rules.inc.php
+ User::assertPermission('filter.rules.edit');
+ if (Request::any('show') === 'rules') {
+ $type = Request::any('type', Request::REQUIRED, 'string');
+ $res = Database::simpleQuery('SELECT data FROM notification_sample
+ WHERE type = :type ORDER BY dateline DESC LIMIT 5',
+ ['type' => $type]);
+ $output = [];
+ foreach ($res as $row) {
+ $row = json_decode($row['data'], true);
+ if (is_array($row)) {
+ $output += $row;
+ }
+ }
+ ksort($output);
+ Header('Content-Type: application/json');
+ echo json_encode($output);
}
}
diff --git a/modules-available/eventlog/pages/log.inc.php b/modules-available/eventlog/pages/log.inc.php
new file mode 100644
index 00000000..66826b08
--- /dev/null
+++ b/modules-available/eventlog/pages/log.inc.php
@@ -0,0 +1,56 @@
+<?php
+
+class SubPage
+{
+
+ public static function doPreprocess()
+ {
+ User::assertPermission('view');
+ User::setLastSeenEvent(Property::getLastWarningId());
+ }
+
+ public static function doRender()
+ {
+ $lines = array();
+ $paginate = new Paginate("SELECT logid, dateline, logtypeid, description, extra FROM eventlog ORDER BY logid DESC", 50);
+ $res = $paginate->exec();
+ foreach ($res as $row) {
+ $row['date'] = Util::prettyTime($row['dateline']);
+ $row['icon'] = self::typeToIcon($row['logtypeid']);
+ $row['color'] = self::typeToColor($row['logtypeid']);
+ $lines[] = $row;
+ }
+
+ $paginate->render('_page', array(
+ 'list' => $lines
+ ));
+ }
+
+ private static function typeToIcon(string $type): string
+ {
+ switch ($type) {
+ case 'info':
+ return 'ok';
+ case 'warning':
+ return 'exclamation-sign';
+ case 'failure':
+ return 'remove';
+ default:
+ return 'question-sign';
+ }
+ }
+
+ private static function typeToColor(string $type): string
+ {
+ switch ($type) {
+ case 'warning':
+ return 'orange';
+ case 'failure':
+ return 'red';
+ case 'info':
+ default:
+ return '';
+ }
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/eventlog/pages/mailconfigs.inc.php b/modules-available/eventlog/pages/mailconfigs.inc.php
new file mode 100644
index 00000000..0ba71104
--- /dev/null
+++ b/modules-available/eventlog/pages/mailconfigs.inc.php
@@ -0,0 +1,99 @@
+<?php
+
+class SubPage
+{
+
+ public static function doPreprocess()
+ {
+ if (Request::isPost()) {
+ User::assertPermission('filter.mailconfigs.edit');
+ $action = Request::post('action');
+ if ($action === 'save-mailconfig') {
+ self::saveMailconfig();
+ } elseif ($action === 'delete-mailconfig') {
+ self::deleteMailconfig();
+ } else {
+ Message::addError('main.invalid-action', $action);
+ }
+ Util::redirect('?do=eventlog&show=mailconfigs');
+ }
+ }
+
+ private static function saveMailconfig()
+ {
+ User::assertPermission('filter.mailconfigs.edit');
+ $id = Request::post('id', Request::REQUIRED, 'int');
+ $data = [
+ 'host' => Request::post('host', Request::REQUIRED, 'string'),
+ 'port' => Request::post('port', Request::REQUIRED, 'int'),
+ 'ssl' => Request::post('ssl', Request::REQUIRED, 'string'),
+ 'senderaddress' => Request::post('senderaddress', Request::REQUIRED, 'string'),
+ 'replyto' => Request::post('replyto', '', 'string'),
+ 'username' => Request::post('username', '', 'string'),
+ 'password' => Request::post('password', '', 'string'),
+ ];
+ if ($id === 0) {
+ // NEW
+ Database::exec("INSERT INTO mail_config (host, port, `ssl`, senderaddress, replyto, username, password)
+ VALUES (:host, :port, :ssl, :senderaddress, :replyto, :username, :password)", $data);
+ } else {
+ // UPDATE
+ $data['configid'] = $id;
+ Database::exec("UPDATE mail_config SET host = :host, port = :port, `ssl` = :ssl,
+ senderaddress = :senderaddress, replyto = :replyto, username = :username, password = :password
+ WHERE configid = :configid", $data);
+ }
+ Message::addSuccess("event-mailconfig-saved", $id);
+ Util::redirect('?do=eventlog&show=mailconfigs');
+ }
+
+ private static function deleteMailconfig()
+ {
+ User::assertPermission('filter.mailconfigs.edit');
+ $id = Request::post('id', Request::REQUIRED, 'int');
+ Database::exec("DELETE FROM mail_config WHERE configid = :id", ['id' => $id]);
+ }
+
+ /*
+ *
+ */
+
+ public static function doRender()
+ {
+ User::assertPermission('filter.mailconfigs.view');
+ $id = Request::get('id', null, 'int');
+ if ($id !== null) {
+ self::showMailconfigEditor($id);
+ } else {
+ // LIST
+ $data = [];
+ $data['configs'] = Database::queryAll('SELECT configid, host, port, `ssl`, senderaddress, replyto
+ FROM mail_config
+ ORDER BY host');
+ Render::addTemplate('page-filters-mailconfigs', $data);
+ }
+ }
+
+ /**
+ * @param int $id Config to edit. If id is 0, a new config will be created.
+ */
+ private static function showMailconfigEditor(int $id)
+ {
+ User::assertPermission('filter.mailconfigs.edit');
+ if ($id !== 0) {
+ // EDIT
+ $data = Database::queryFirst('SELECT configid, host, port, `ssl`, senderaddress, replyto,
+ username, password
+ FROM mail_config
+ WHERE configid = :id', ['id' => $id]);
+ if ($data === false) {
+ Message::addError('invalid-mailconfig-id', $id);
+ Util::redirect('?do=eventlog&show=mailconfigs');
+ }
+ } else {
+ $data = ['configid' => 0];
+ }
+ Render::addTemplate('page-filters-edit-mailconfig', $data);
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/eventlog/pages/rules.inc.php b/modules-available/eventlog/pages/rules.inc.php
new file mode 100644
index 00000000..7b43cfdb
--- /dev/null
+++ b/modules-available/eventlog/pages/rules.inc.php
@@ -0,0 +1,187 @@
+<?php
+
+class SubPage
+{
+
+ const OP_LIST = ['*', '=', '!=', '<', '<=', '>', '>=', 'regex'];
+
+ public static function doPreprocess()
+ {
+ if (Request::isPost()) {
+ User::assertPermission('filter.rules.edit');
+ $action = Request::post('action');
+ if ($action === 'save-filter') {
+ self::saveRule();
+ } elseif ($action === 'delete-filter') {
+ self::deleteRule();
+ } else {
+ Message::addError('main.invalid-action', $action);
+ }
+ Util::redirect('?do=eventlog&show=rules');
+ }
+ }
+
+ private static function saveRule()
+ {
+ User::assertPermission('filter.rules.edit');
+ $id = Request::post('id', Request::REQUIRED, 'int');
+ $type = Request::post('type', Request::REQUIRED, 'string');
+ $title = Request::post('title', Request::REQUIRED, 'string');
+ $message = Request::post('message', Request::REQUIRED, 'string');
+ $transports = Request::post('transports', [], 'array');
+ $filters = Request::post('filter', Request::REQUIRED, 'array');
+ $filters = array_filter($filters, function ($item) {
+ return is_array($item) && !empty($item['path']) && !empty($item['op']);
+ });
+ foreach ($filters as $index => &$item) {
+ $item['index'] = $index;
+ }
+ unset($item);
+ if (empty($filters)) {
+ Message::addError('no-valid-filters');
+ Util::redirect('?do=eventlog&show=rules');
+ }
+ if ($id === 0) {
+ $id = null;
+ }
+ $data = [
+ 'id' => $id,
+ 'type' => $type,
+ 'title' => $title,
+ 'description' => Request::post('description', '', 'string'),
+ 'data' => json_encode(['list' => array_values($filters)]),
+ 'subject' => Request::post('subject', '', 'string'),
+ 'message' => $message,
+ ];
+ if ($id === null) {
+ // NEW
+ Database::exec("INSERT INTO notification_rule (ruleid, title, description, type, datafilter, subject, message)
+ VALUES (:id, :title, :description, :type, :data, :subject, :message)", $data);
+ $id = Database::lastInsertId();
+ } else {
+ Database::exec("UPDATE notification_rule SET type = :type, title = :title, description = :description, datafilter = :data,
+ subject = :subject, message = :message
+ WHERE ruleid = :id", $data);
+ }
+ if (empty($transports)) {
+ Database::exec("DELETE FROM notification_rule_x_transport WHERE ruleid = :id", ['id' => $id]);
+ } else {
+ Database::exec("DELETE FROM notification_rule_x_transport
+ WHERE ruleid = :id AND transportid NOT IN (:transports)",
+ ['id' => $id, 'transports' => $transports]);
+ Database::exec("INSERT IGNORE INTO notification_rule_x_transport (ruleid, transportid)
+ VALUES :list", ['list' => array_map(function ($i) use ($id) { return [$id, $i]; }, $transports)]);
+ }
+ Message::addSuccess("event-rule-saved", $id);
+ Util::redirect('?do=eventlog&show=rules');
+ }
+
+ private static function deleteRule()
+ {
+ User::assertPermission('filter.rules.edit');
+ $id = Request::post('id', Request::REQUIRED, 'int');
+ Database::exec("DELETE FROM notification_rule WHERE ruleid = :id", ['id' => $id]);
+ }
+
+ /*
+ *
+ */
+
+ public static function doRender()
+ {
+ User::assertPermission('filter.rules.view');
+ $id = Request::get('id', null, 'int');
+ if ($id !== null) {
+ self::showRuleEditor($id);
+ } else {
+ // LIST
+ $data = [];
+ $data['filters'] = Database::queryAll('SELECT ruleid, type, title, datafilter,
+ Count(transportid) AS useCount
+ FROM notification_rule
+ LEFT JOIN notification_rule_x_transport sfxb USING (ruleid)
+ GROUP BY ruleid, title
+ ORDER BY title, ruleid');
+ Permission::addGlobalTags($data['perms'], null, ['filter.rules.edit']);
+ Render::addTemplate('page-filters-rules', $data);
+ }
+ }
+
+ /**
+ * @param int $id Rule to edit. If id is 0, a new rule will be created.
+ */
+ private static function showRuleEditor(int $id)
+ {
+ // EDIT
+ User::assertPermission('filter.rules.edit');
+ $filterIdx = 0;
+ $knownIdxList = [];
+ if ($id !== 0) {
+ $data = Database::queryFirst('SELECT ruleid, title, description, type, datafilter, subject, message
+ FROM notification_rule WHERE ruleid = :id', ['id' => $id]);
+ if ($data === false) {
+ Message::addError('invalid-rule-id', $id);
+ Util::redirect('?do=eventlog&show=rules');
+ }
+ if (Request::get('copy', false, 'bool')) {
+ $data['ruleid'] = 0;
+ $data['title'] = '';
+ }
+ $list = json_decode($data['datafilter'], true);
+ if (!is_array($list['list'])) {
+ $list['list'] = [];
+ }
+ foreach ($list['list'] as $item) {
+ if (isset($item['index'])) {
+ $knownIdxList[] = $item['index'];
+ }
+ }
+ foreach ($list['list'] as &$item) {
+ if (!isset($item['index'])) {
+ while (in_array($filterIdx, $knownIdxList)) {
+ $filterIdx++;
+ }
+ $item['index'] = $filterIdx++;
+ }
+ $item['operators'] = [];
+ foreach (self::OP_LIST as $op) {
+ $item['operators'][] = [
+ 'name' => $op,
+ 'selected' => ($op === $item['op']) ? 'selected' : '',
+ ];
+ }
+ }
+ $data['filter'] = $list['list'];
+ } else {
+ // New entry
+ $data = ['filter' => [], 'ruleid' => 0];
+ }
+ // Add suggestions for type
+ $data['types'] = Database::queryColumnArray("SELECT DISTINCT type
+ FROM notification_sample
+ ORDER BY type");
+ //
+ Module::isAvailable('bootstrap_multiselect');
+ $data['transports'] = Database::queryAll("SELECT nb.transportid, nb.title,
+ IF(sfxb.ruleid IS NULL, '', 'selected') AS selected
+ FROM notification_backend nb
+ LEFT JOIN notification_rule_x_transport sfxb ON (sfxb.transportid = nb.transportid AND sfxb.ruleid = :id)",
+ ['id' => $id]);
+ if (Module::isAvailable('statistics')) {
+ // Filter keys to suggest for events with machineuuid in data
+ $data['machine_keys'] = array_keys(FilterRuleProcessor::HW_QUERIES);
+ }
+ // Add a few empty rows at the bottom
+ for ($i = 0; $i < 8; ++$i) {
+ while (in_array($filterIdx, $knownIdxList)) {
+ $filterIdx++;
+ }
+ $data['filter'][] = [
+ 'index' => $filterIdx++,
+ 'operators' => array_map(function ($item) { return ['name' => $item]; }, self::OP_LIST),
+ ];
+ }
+ Render::addTemplate('page-filters-edit-rule', $data);
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/eventlog/pages/transports.inc.php b/modules-available/eventlog/pages/transports.inc.php
new file mode 100644
index 00000000..439c650c
--- /dev/null
+++ b/modules-available/eventlog/pages/transports.inc.php
@@ -0,0 +1,179 @@
+<?php
+
+class SubPage
+{
+
+ public static function doPreprocess()
+ {
+ if (Request::isPost()) {
+ User::assertPermission('filter.transports.edit');
+ $action = Request::post('action');
+ if ($action === 'save-transport') {
+ self::saveTransport();
+ } elseif ($action === 'delete-transport') {
+ self::deleteTransport();
+ } else {
+ Message::addError('main.invalid-action', $action);
+ }
+ Util::redirect('?do=eventlog&show=transports');
+ }
+ }
+
+ private static function saveTransport()
+ {
+ User::assertPermission('filter.transports.edit');
+ $id = Request::post('id', Request::REQUIRED, 'int');
+ $rules = Request::post('rules', [], 'array');
+ static $types = [
+ 'type' => [Request::REQUIRED, 'string', ['mail', 'irc', 'http', 'group']],
+ 'mail-config-id' => [0, 'int'],
+ 'mail-users' => [[], 'int[]'],
+ 'mail-extra-mails' => ['', 'string'],
+ 'irc-server' => ['', 'string'],
+ 'irc-server-password' => ['', 'string'],
+ 'irc-target' => ['', 'string'],
+ 'irc-nickname' => ['', 'string'],
+ 'http-uri' => ['', 'string'],
+ 'http-method' => ['', 'string', ['GET', 'POST']],
+ 'http-post-field' => ['', 'string'],
+ 'http-post-format' => ['', 'string', ['FORM', 'JSON', 'JSON_AUTO']],
+ 'group-list' => [[], 'int[]'],
+ ];
+ $data = [];
+ foreach ($types as $key => $def) {
+ if (substr($def[1], -1) === ']') {
+ $type = substr($def[1], 0, -2);
+ $array = true;
+ } else {
+ $type = $def[1];
+ $array = false;
+ }
+ if ($array) {
+ $value = Request::post($key, [], 'array');
+ foreach ($value as &$v) {
+ settype($v, $type);
+ if (isset($def[2]) && !in_array($v, $def[2])) {
+ Message::addWarning('main.value-invalid', $key, $v);
+ }
+ }
+ } else {
+ $value = Request::post($key, $def[0], $type);
+ if (isset($def[2]) && !in_array($value, $def[2])) {
+ Message::addWarning('main.value-invalid', $key, $value);
+ }
+ }
+ $data[$key] = $value;
+ }
+ //die(print_r($data));
+ $params = [
+ 'title' => Request::post('title', 'Backend', 'string'),
+ 'description' => Request::post('description', '', 'string'),
+ 'data' => json_encode($data),
+ ];
+ if ($id === 0) {
+ $res = Database::exec("INSERT INTO notification_backend (title, description, data)
+ VALUES (:title, :description, :data)", $params);
+ $id = Database::lastInsertId();
+ } else {
+ $params['transportid'] = $id;
+ $res = Database::exec("UPDATE notification_backend
+ SET title = :title, description = :description, data = :data
+ WHERE transportid = :transportid", $params);
+ }
+ if (empty($rules)) {
+ Database::exec("DELETE FROM notification_rule_x_transport WHERE transportid = :id", ['id' => $id]);
+ } else {
+ Database::exec("DELETE FROM notification_rule_x_transport
+ WHERE transportid = :id AND ruleid NOT IN (:rules)",
+ ['id' => $id, 'rules' => $rules]);
+ Database::exec("INSERT IGNORE INTO notification_rule_x_transport (ruleid, transportid)
+ VALUES :list", ['list' => array_map(function ($i) use ($id) { return [$i, $id]; }, $rules)]);
+ }
+ if ($res > 0) {
+ Message::addSuccess('transport-saved', $id);
+ }
+ Util::redirect('?do=eventlog&show=transports&section=transports');
+ }
+
+ private static function deleteTransport()
+ {
+ User::assertPermission('filter.transports.edit');
+ $id = Request::post('id', Request::REQUIRED, 'int');
+ Database::exec("DELETE FROM notification_backend WHERE transportid = :id", ['id' => $id]);
+ }
+
+ /*
+ *
+ */
+
+ public static function doRender()
+ {
+ User::assertPermission('filter.transports.view');
+ $id = Request::get('id', null, 'int');
+ if ($id !== null) {
+ self::showTransportEditor($id);
+ } else {
+ // LIST
+ $data = [];
+ $data['transports'] = [];
+ foreach (Database::queryAll('SELECT transportid, title, data,
+ Count(ruleid) AS useCount
+ FROM notification_backend nb
+ LEFT JOIN notification_rule_x_transport sfxb USING (transportid)
+ GROUP BY transportid, title
+ ORDER BY title, transportid') as $transport) {
+ $json = json_decode($transport['data'], true);
+ $transport['type'] = $json['type'];
+ $transport['details'] = NotificationTransport::getInstance($json);
+ $data['transports'][] = $transport;
+ }
+ Render::addTemplate('page-filters-transports', $data);
+ }
+ }
+
+ /**
+ * @param int $id Transport to edit, 0 to create a new one
+ */
+ private static function showTransportEditor(int $id)
+ {
+ User::assertPermission('filter.transports.edit');
+ if ($id !== 0) {
+ $entry = Database::queryFirst('SELECT transportid, title, description, data
+ FROM notification_backend
+ WHERE transportid = :id', ['id' => $id]);
+ if ($entry === false) {
+ Message::addError('invalid-transport-id', $id);
+ Util::redirect('?do=eventlog&show=transports&section=transports');
+ }
+ $entry['data'] = json_decode($entry['data'], true);
+ $entry[($entry['data']['type'] ?? '') . '_selected'] = 'selected';
+ $entry[($entry['data']['http-method'] ?? '') . '_selected'] = 'selected';
+ $entry[($entry['data']['http-post-format'] ?? '') . '_selected'] = 'selected';
+ } else {
+ $entry = ['transportid' => $id, 'data' => [], 'backends' => []];
+ }
+ $entry['users'] = [];
+ foreach (Database::queryAll("SELECT userid, login, fullname, email FROM user ORDER BY login") as $row) {
+ $row['disabled'] = strpos($row['email'], '@') ? '' : 'disabled';
+ $row['selected'] = in_array($row['userid'], $entry['data']['mail-users'] ?? []) ? 'selected' : '';
+ $entry['users'][] = $row;
+ }
+ $entry['mailconfigs'] = [];
+ foreach (Database::queryAll("SELECT configid, host, port, senderaddress FROM mail_config") as $row) {
+ $row['selected'] = $row['configid'] == ($entry['data']['mail-config-id'] ?? []) ? 'selected' : '';
+ $entry['mailconfigs'][] = $row;
+ }
+ foreach (Database::queryAll("SELECT transportid, title FROM notification_backend") as $row) {
+ $row['selected'] = in_array($row['transportid'], ($entry['data']['group-list'] ?? [])) ? 'selected' : '';
+ $entry['backends'][] = $row;
+ }
+ Module::isAvailable('bootstrap_multiselect');
+ $entry['rules'] = Database::queryAll("SELECT sf.ruleid, sf.title,
+ IF(sfxb.transportid IS NULL, '', 'selected') AS selected
+ FROM notification_rule sf
+ LEFT JOIN notification_rule_x_transport sfxb ON (sf.ruleid = sfxb.ruleid AND sfxb.transportid = :id)",
+ ['id' => $id]);
+ Render::addTemplate('page-filters-edit-transport', $entry);
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/eventlog/permissions/permissions.json b/modules-available/eventlog/permissions/permissions.json
index a1748957..13af297a 100644
--- a/modules-available/eventlog/permissions/permissions.json
+++ b/modules-available/eventlog/permissions/permissions.json
@@ -1,5 +1,23 @@
{
"view": {
"location-aware": false
+ },
+ "filter.rules.view": {
+ "location-aware": false
+ },
+ "filter.rules.edit": {
+ "location-aware": false
+ },
+ "filter.transports.view": {
+ "location-aware": false
+ },
+ "filter.transports.edit": {
+ "location-aware": false
+ },
+ "filter.mailconfig.view": {
+ "location-aware": false
+ },
+ "filter.mailconfig.edit": {
+ "location-aware": false
}
} \ No newline at end of file
diff --git a/modules-available/eventlog/templates/_page.html b/modules-available/eventlog/templates/_page.html
index 239286f8..facdd205 100644
--- a/modules-available/eventlog/templates/_page.html
+++ b/modules-available/eventlog/templates/_page.html
@@ -1,3 +1,4 @@
+<h2>{{lang_eventLog}}</h2>
{{{pagenav}}}
<table class="table table-striped table-condensed">
<thead>
@@ -11,7 +12,7 @@
<tr>
<td><span class="glyphicon glyphicon-{{icon}}" title="{{logtypeid}}"></span></td>
<td class="text-center" nowrap="nowrap">{{date}}</td>
- <td class="{{color}}">{{description}}</td>
+ <td class="{{color}} log-line">{{description}}</td>
<td class="text-center">{{#extra}}
<a class="btn btn-default btn-xs" onclick="$('#details-body').html($('#extra-{{logid}}').html())" data-toggle="modal" data-target="#myModal">&raquo;</a>
<div class="hidden" id="extra-{{logid}}">{{extra}}</div>
@@ -35,3 +36,13 @@
</div>
</div>
</div>
+<script>
+ (function () {
+ var x = document.getElementsByClassName('log-line');
+ for (var i = 0; i < x.length; ++i) {
+ var y = x[i];
+ y.innerHTML = y.innerHTML.replace(/(client) ([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})/i,
+ '$1 <a href="?do=statistics&amp;uuid=$2">$2</a>');
+ }
+ })();
+</script> \ No newline at end of file
diff --git a/modules-available/eventlog/templates/heading.html b/modules-available/eventlog/templates/heading.html
deleted file mode 100644
index 37612a77..00000000
--- a/modules-available/eventlog/templates/heading.html
+++ /dev/null
@@ -1 +0,0 @@
-<h1>{{lang_eventLog}}</h1> \ No newline at end of file
diff --git a/modules-available/eventlog/templates/page-filters-edit-mailconfig.html b/modules-available/eventlog/templates/page-filters-edit-mailconfig.html
new file mode 100644
index 00000000..1cf77057
--- /dev/null
+++ b/modules-available/eventlog/templates/page-filters-edit-mailconfig.html
@@ -0,0 +1,53 @@
+<h2>{{lang_editMail}} {{#senderaddress}}–{{/senderaddress}} {{senderaddress}}</h2>
+
+<form method="post" action="?do=eventlog&amp;show=mailconfigs">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="id" value="{{configid}}">
+ <div class="form-group row">
+ <div class="col-md-4">
+ <label for="i-host">{{lang_host}}</label>
+ <input id="i-host" class="form-control" name="host" value="{{host}}" required>
+ </div>
+ <div class="col-md-4">
+ <label for="i-ssl">{{lang_ssl}}</label>
+ <select name="ssl" id="i-ssl" class="form-control">
+ <option value="IMPLICIT" {{IMPLICIT_selected}}>{{lang_sslImplicit}}</option>
+ <option value="NONE" {{NONE_selected}}>{{lang_sslNone}}</option>
+ <option value="EXPLICIT" {{EXPLICIT_selected}}>{{lang_sslExplicit}}</option>
+ </select>
+ </div>
+ <div class="col-md-4">
+ <label for="i-port">{{lang_port}}</label>
+ <input id="i-port" type="number" min="1" max="65535" class="form-control" name="port" value="{{port}}" required>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="col-sm-6">
+ <label for="i-username">{{lang_username}}</label>
+ <input id="i-username" class="form-control" name="username" value="{{username}}">
+ </div>
+ <div class="col-sm-6">
+ <label for="i-password">{{lang_password}}</label>
+ <input id="i-password" type="{{password_type}}" class="form-control" name="password" value="{{password}}">
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="col-sm-6">
+ <label for="i-senderaddress">{{lang_senderAddress}}</label>
+ <input id="i-senderaddress" class="form-control" name="senderaddress" value="{{senderaddress}}" required>
+ </div>
+ <div class="col-sm-6">
+ <label for="i-replyto">{{lang_replyTo}}</label>
+ <input id="i-replyto" class="form-control" name="replyto" value="{{replyto}}">
+ </div>
+ </div>
+ <div class="buttonbar text-right">
+ <a class="btn btn-default" href="?do=eventlog&amp;show=mailconfigs">
+ {{lang_cancel}}
+ </a>
+ <button class="btn btn-primary" type="submit" name="action" value="save-mailconfig">
+ <span class="glyphicon glyphicon-floppy-disk"></span>
+ {{lang_save}}
+ </button>
+ </div>
+</form> \ No newline at end of file
diff --git a/modules-available/eventlog/templates/page-filters-edit-rule.html b/modules-available/eventlog/templates/page-filters-edit-rule.html
new file mode 100644
index 00000000..42d601f2
--- /dev/null
+++ b/modules-available/eventlog/templates/page-filters-edit-rule.html
@@ -0,0 +1,219 @@
+<h2>
+ {{#ruleid}}
+ {{lang_editFilter}}
+ {{/ruleid}}
+ {{^ruleid}}
+ {{lang_createFilter}}
+ {{/ruleid}}
+</h2>
+<h3>{{title}}</h3>
+
+<p>{{lang_filterExampleHelpText}}</p>
+
+<form method="post" action="?do=eventlog&amp;show=rules">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="id" value="{{ruleid}}">
+ <div class="form-group row">
+ <div class="col-md-4">
+ <label for="i-type">{{lang_type}}</label>
+ <input autocomplete="off" id="i-type" list="i-types" class="form-control" name="type" value="{{type}}"
+ required>
+ <datalist id="i-types">
+ {{#types}}
+ <option value="{{.}}">{{lang_typeExample}}: {{.}}</option>
+ {{/types}}
+ </datalist>
+ </div>
+ <div class="col-md-8">
+ <label for="i-title">{{lang_title}}</label>
+ <input id="i-title" class="form-control" name="title" value="{{title}}" required>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="col-md-12">
+ <label for="i-description">{{lang_description}}</label>
+ <textarea id="i-description" class="form-control" name="description" rows="4">{{description}}</textarea>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="col-md-12">
+ <label for="i-transports">{{lang_transports}}</label>
+ <select multiple name="transports[]" id="i-transports" class="form-control multilist">
+ {{#transports}}
+ <option value="{{transportid}}" {{selected}}>{{title}}</option>
+ {{/transports}}
+ </select>
+ </div>
+ </div>
+ {{#filter}}
+ <div class="form-group row filter-rule-row">
+ <div class="col-md-1 col-sm-3">
+ <label>{{lang_index}}</label>
+ <span class="form-control">{{index}}</span>
+ </div>
+ <div class="col-md-2 col-sm-9">
+ <label for="key-{{index}}">
+ {{lang_filterPath}}
+ </label>
+ <input id="key-{{index}}" class="form-control filter-key" name="filter[{{index}}][path]" value="{{path}}"
+ list="filter-keys"
+ autocomplete="off" data-index="{{index}}">
+ </div>
+ <div class="col-md-1 col-sm-6">
+ <label for="op-{{index}}">
+ {{lang_filterOp}}
+ </label>
+ <select id="op-{{index}}" class="form-control op-select" name="filter[{{index}}][op]" data-index="{{index}}">
+ {{#operators}}
+ <option {{selected}}>{{name}}</option>
+ {{/operators}}
+ </select>
+ </div>
+ <div class="col-md-3 col-sm-6">
+ <label for="arg-{{index}}">
+ {{lang_filterArg}}
+ </label>
+ <input id="arg-{{index}}" class="form-control op-arg" name="filter[{{index}}][arg]" value="{{arg}}"
+ data-index="{{index}}">
+ </div>
+ <div class="col-md-5 col-sm-12 small">
+ <label>{{lang_sampleData}}</label>
+ <div id="sample-{{index}}" style="word-break:break-all"></div>
+ </div>
+ </div>
+ {{/filter}}
+ <datalist id="filter-keys">
+ </datalist>
+ <datalist id="machine-filter-keys">
+ {{#machine_keys}}
+ <option>{{.}}</option>
+ {{/machine_keys}}
+ </datalist>
+ <div>
+ <p>{{lang_hintRegex}}</p>
+ </div>
+ <div class="form-group">
+ <label for="i-subject">{{lang_subject}}</label>
+ <input id="i-subject" class="form-control" name="subject" value="{{subject}}">
+ </div>
+ <div class="form-group">
+ <label for="msg-txt">
+ {{lang_messageTemplate}}
+ </label>
+ <textarea required id="msg-txt" name="message" class="form-control" rows="10" cols="80">{{message}}</textarea>
+ <p>
+ {{lang_messageTemplateHelp}}
+ </p>
+ </div>
+ <div class="buttonbar text-right">
+ <a class="btn btn-default" href="?do=eventlog&amp;show=rules">
+ {{lang_cancel}}
+ </a>
+ <button class="btn btn-primary" type="submit" name="action" value="save-filter">
+ <span class="glyphicon glyphicon-floppy-disk"></span>
+ {{lang_save}}
+ </button>
+ </div>
+</form>
+
+<script>
+ document.addEventListener('DOMContentLoaded', function () {
+ var $multilists = $("select.multilist");
+ if ($multilists.multiselect) {
+ $multilists.multiselect({
+ includeSelectAllOption: true,
+ buttonWidth: '100%',
+ buttonClass: 'form-control'
+ });
+ }
+ $('.op-select').change(function () {
+ var $t = $(this);
+ var disabled = $t.val() === '*';
+ $('.op-arg[data-index=' + $t.data('index') + ']').prop('disabled', disabled);
+ }).change();
+ var currentType = {};
+ var typeSamples = {};
+ var typeChanged = true;
+ var $type = $('#i-type');
+ var $list = $('#filter-keys');
+ var $fkInputs = $('.filter-key');
+ var $filterRows = $('.filter-rule-row');
+
+ // If type changed, fetch sample data, or use cached, and populate autocomplete
+ var typeFieldChangeUpdate = function () {
+ if (!typeChanged)
+ return;
+ typeChanged = false;
+ var typeVal = $type.val();
+ if (typeSamples[typeVal]) {
+ setAutocomplete(typeVal);
+ return;
+ }
+ $.ajax('?do=eventlog&show=rules', {
+ data: {type: typeVal, token: TOKEN},
+ method: 'POST',
+ dataType: 'json'
+ }).done(function (data) {
+ typeSamples[typeVal] = data;
+ setAutocomplete(typeVal);
+ });
+ };
+
+ // Flag if type changed
+ $type.change(function () {
+ typeChanged = true;
+ }).blur(typeFieldChangeUpdate);
+
+ // Population function
+ function setAutocomplete(type) {
+ var t = typeSamples[type];
+ var m = false;
+ $list.empty();
+ if (!t)
+ return;
+ currentType = t;
+ for (var k in t) {
+ if (!t.hasOwnProperty(k))
+ continue;
+ $list.append($('<option>').text(k));
+ if (k === 'machineuuid') m = true;
+ }
+ if (m) {
+ //$list.append($('#machine-filter-keys').clone());
+ }
+ $fkInputs.change();
+ }
+
+ // Display sample data
+ var chFn = function () {
+ var $this = $(this);
+ var wat = currentType[$this.val()];
+ if (typeof(wat) !== 'undefined') {
+ if (typeof(wat) === 'string') {
+ wat = wat.replace("\r", "\\r").replace("\n", "\\n");
+ }
+ if (wat.length > 180) {
+ wat = wat.substr(0, 180) + '...';
+ }
+ } else {
+ wat = '';
+ }
+ var index = $this.data('index');
+ $('#sample-' + index).text(wat);
+ var empties = 0;
+ $filterRows.each(function() {
+ var $this = $(this);
+ if ($this.find('.filter-key').val().length === 0) {
+ empties++;
+ if (empties > 2) {
+ $this.hide();
+ } else {
+ $this.show();
+ }
+ }
+ });
+ };
+ $fkInputs.on('input', chFn).change(chFn).each(chFn);
+ typeFieldChangeUpdate();
+ });
+</script>
diff --git a/modules-available/eventlog/templates/page-filters-edit-transport.html b/modules-available/eventlog/templates/page-filters-edit-transport.html
new file mode 100644
index 00000000..d8be6892
--- /dev/null
+++ b/modules-available/eventlog/templates/page-filters-edit-transport.html
@@ -0,0 +1,190 @@
+<h2>{{lang_editTransport}} {{#title}}–{{/title}} {{title}}</h2>
+
+<form method="post" action="?do=eventlog&amp;show=transports">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="id" value="{{transportid}}">
+
+ <div class="form-group row">
+ <div class="col-sm-6">
+ <label for="title">{{lang_title}}</label>
+ <input id="title" name="title" class="form-control" value="{{title}}" required>
+ </div>
+ <div class="col-sm-6">
+ <label for="transport-select">
+ {{lang_type}}
+ </label>
+ <select id="transport-select" class="form-control" name="type">
+ <option value="mail" {{mail_selected}}>{{lang_mail}}</option>
+ <option value="irc" {{irc_selected}}>{{lang_irc}}</option>
+ <option value="http" {{http_selected}}>{{lang_http}}</option>
+ <option value="group" {{group_selected}}>{{lang_transportGroup}}</option>
+ </select>
+ </div>
+ </div>
+ <hr>
+
+ <div class="transport-list">
+ <div id="transport-mail">
+ <div class="form-group row">
+ <div class="col-md-6">
+ <label for="mail-config">{{lang_mailConfig}}</label>
+ <select class="form-control" name="mail-config-id" id="mail-config">
+ {{^mailconfigs}}
+ <option value="0" disabled>{{lang_noMailConfig}}</option>
+ {{/mailconfigs}}
+ {{#mailconfigs}}
+ <option value="{{configid}}" {{selected}}>{{senderaddress}} @ {{host}}:{{port}}</option>
+ {{/mailconfigs}}
+ </select>
+ </div>
+ <div class="col-md-6">
+ <label for="mail-users">{{lang_mailUsers}}</label>
+ <select class="form-control multilist" name="mail-users[]" multiple id="mail-users">
+ {{#users}}
+ <option value="{{userid}}" {{selected}} {{disabled}}>{{login}} - {{fullname}} - {{email}}</option>
+ {{/users}}
+ </select>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="col-md-12">
+ <label for="mail-extra-mails">{{lang_additionalMailAddresses}}</label>
+ <textarea class="form-control" name="mail-extra-mails" id="mail-extra-mails">{{data.mail-extra-mails}}</textarea>
+ </div>
+ </div>
+ </div>
+
+ <div id="transport-irc">
+ <div class="form-group row">
+ <div class="col-md-4 col-sm-6">
+ <label for="irc-server">{{lang_ircServer}}</label>
+ <input id="irc-server" name="irc-server" class="form-control" value="{{data.irc-server}}"
+ placeholder="irc.example.com">
+ </div>
+ <div class="col-md-4 col-sm-6">
+ <label for="irc-target">{{lang_ircTarget}}</label>
+ <input id="irc-target" name="irc-target" class="form-control" value="{{data.irc-target}}" placeholder="#foo">
+ </div>
+ <div class="col-md-4 col-sm-6">
+ <label for="irc-server-passwd">{{lang_ircServerPassword}}</label>
+ <input id="irc-server-passwd" name="irc-server-passwd" class="form-control"
+ value="{{data.irc-server-passwd}}">
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="col-md-4 col-sm-6">
+ <label for="irc-nickname">{{lang_ircNickname}}</label>
+ <input id="irc-nickname" name="irc-nickname" class="form-control" value="{{data.irc-nickname}}"
+ placeholder="Brunhilde">
+ </div>
+ </div>
+ </div>
+
+ <div id="transport-http">
+ <div class="form-group row">
+ <div class="col-md-10">
+ <label for="http-uri">{{lang_httpUri}}</label>
+ <input id="http-uri" name="http-uri" class="form-control" value="{{data.http-uri}}"
+ placeholder="https://example.com/bwlp">
+ <p>{{lang_uriUseSUBJECTandTEXThint}}</p>
+ </div>
+ <div class="col-md-2">
+ <label for="http-method">{{lang_httpMethod}}</label>
+ <select id="http-method" name="http-method" class="form-control">
+ <option {{POST_selected}}>POST</option>
+ <option {{GET_selected}}>GET</option>
+ </select>
+ </div>
+ </div>
+ <div id="post-options" class="form-group row">
+ <div class="col-md-7">
+ <label for="http-post-field">{{lang_httpPostField}}</label>
+ <input id="http-post-field" name="http-post-field" class="form-control" value="{{data.http-post-field}}"
+ placeholder="key=1234&message=%TEXT%">
+ <p>{{lang_postUseSUBJECTandTEXThint}}</p>
+ </div>
+ <div class="col-md-5">
+ <label for="http-post-format">{{lang_httpPostFormat}}</label>
+ <select id="http-post-format" name="http-post-format" class="form-control">
+ <option value="FORM" {{FORM_selected}} aria-describedby="d-fd">FORM-data (urlencode)</option>
+ <option value="JSON" {{JSON_selected}} aria-describedby="d-js">json string</option>
+ <option value="JSON_AUTO" {{JSON_AUTO_selected}} aria-describedby="d-aj">{{lang_autoJson}}</option>
+ </select>
+ <div id="d-fd"><b>FORM-data</b>: {{lang_formDataHelp}}</div>
+ <div id="d-js"><b>json string</b>: {{lang_jsonStringHelp}}</div>
+ <div id="d-aj"><b>{{lang_autoJson}}</b>: {{lang_autoJsonHelp}}</div>
+ </div>templates
+ </div>
+ <div class="form-group">
+ <label for="http-method"></label>
+ </div>
+ </div>
+
+ <div id="transport-group">
+ <div class="form-group">
+ <label for="group-list">{{lang_selectTransports}}</label>
+ <select class="form-control multilist" name="group-list[]" multiple id="group-list">
+ {{#backends}}
+ <option value="{{transportid}}" {{selected}}>{{title}}</option>
+ {{/backends}}
+ </select>
+ </div>
+ </div>
+ </div>
+ <hr>
+
+ <div class="form-group">
+ <label for="i-rules">{{lang_rules}}</label>
+ <select multiple name="rules[]" id="i-rules" class="form-control multilist">
+ {{#rules}}
+ <option value="{{ruleid}}" {{selected}}>{{title}}</option>
+ {{/rules}}
+ </select>
+ </div>
+
+ <div class="form-group">
+ <label for="description-box">{{lang_optionalDescription}}</label>
+ <textarea id="description-box" name="description" class="form-control" rows="10">{{description}}</textarea>
+ </div>
+
+ <div class="buttonbar text-right">
+ <a class="btn btn-default" href="?do=eventlog&amp;show=transports">
+ {{lang_cancel}}
+ </a>
+ <button class="btn btn-primary" type="submit" name="action" value="save-transport">
+ <span class="glyphicon glyphicon-floppy-disk"></span>
+ {{lang_save}}
+ </button>
+ </div>
+</form>
+
+<script>
+ document.addEventListener('DOMContentLoaded', function () {
+ // Show proper transport options
+ $('#transport-select').change(function () {
+ $('.transport-list > div').hide();
+ $('#transport-' + $('#transport-select').val()).show();
+ }).change();
+ // Init multilist of available
+ var $multilists = $("select.multilist");
+ if ($multilists.multiselect) {
+ $multilists.multiselect({
+ includeSelectAllOption: true,
+ buttonWidth: '100%',
+ buttonClass: 'form-control'
+ });
+ }
+ // Hide POST options for GET
+ $('#http-method').change(function () {
+ if ($(this).val() === 'POST') {
+ $('#post-options').show();
+ } else {
+ $('#post-options').hide();
+ }
+ }).change();
+ // Disable POST input for JSON_AUTO
+ $('#http-post-format').change(function () {
+ $('#http-post-field').prop('disabled', $(this).val() === 'JSON_AUTO');
+ }).change();
+ });
+</script> \ No newline at end of file
diff --git a/modules-available/eventlog/templates/page-filters-mailconfigs.html b/modules-available/eventlog/templates/page-filters-mailconfigs.html
new file mode 100644
index 00000000..08901f87
--- /dev/null
+++ b/modules-available/eventlog/templates/page-filters-mailconfigs.html
@@ -0,0 +1,42 @@
+<form method="post" action="?do=eventlog&amp;show=mailconfigs">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="action" value="delete-mailconfig">
+ <table class="table">
+ <thead>
+ <tr>
+ <th class="slx-smallcol">{{lang_id}}</th>
+ <th class="slx-smallcol">{{lang_host}}</th>
+ <th class="slx-smallcol">{{lang_ssl}}</th>
+ <th>{{lang_senderAddress}}</th>
+ <th class="slx-smallcol">{{lang_edit}}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{#configs}}
+ <tr>
+ <td>{{configid}}</td>
+ <td class="text-nowrap">{{host}}:{{port}}</td>
+ <td class="text-nowrap">{{ssl}}</td>
+ <td>{{senderaddress}}{{^senderaddress}}{{replyto}}{{/senderaddress}}</td>
+ <td class="text-nowrap">
+ <a class="btn btn-xs btn-default" href="?do=eventlog&amp;show=mailconfigs&amp;id={{configid}}">
+ <span class="glyphicon glyphicon-edit" aria-label="{{lang_edit}}"></span>
+ </a>
+ <button class="btn btn-xs btn-danger" type="submit" name="id" value="{{configid}}"
+ data-confirm="{{lang_reallyDelete}}">
+ <span class="glyphicon glyphicon-trash"></span>
+ </button>
+ </td>
+ </tr>
+ {{/configs}}
+ </tbody>
+ </table>
+
+</form>
+
+<div class="buttonbar text-right">
+ <a class="btn btn-success" href="?do=eventlog&amp;show=mailconfigs&amp;id=0">
+ <span class="glyphicon glyphicon-plus"></span>
+ {{lang_add}}
+ </a>
+</div> \ No newline at end of file
diff --git a/modules-available/eventlog/templates/page-filters-rules.html b/modules-available/eventlog/templates/page-filters-rules.html
new file mode 100644
index 00000000..56bf0871
--- /dev/null
+++ b/modules-available/eventlog/templates/page-filters-rules.html
@@ -0,0 +1,48 @@
+<form method="post" action="?do=eventlog&amp;show=rules">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="action" value="delete-filter">
+ <table class="table">
+ <thead>
+ <tr>
+ <th class="slx-smallcol">{{lang_id}}</th>
+ <th class="slx-smallcol">{{lang_type}}</th>
+ <th>{{lang_title}}</th>
+ <!--th>{{lang_details}}</th-->
+ <th class="slx-smallcol"></th>
+ <th class="slx-smallcol">{{lang_edit}}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{#filters}}
+ <tr>
+ <td>{{ruleid}}</td>
+ <td>{{type}}</td>
+ <td class="text-nowrap">{{title}}</td>
+ <td><span class="badge">{{useCount}}</span></td>
+ <td class="slx-smallcol">
+ <a class="btn btn-xs btn-default {{perms.filter.rules.edit.disabled}}"
+ href="?do=eventlog&amp;show=rules&amp;id={{ruleid}}" title="{{lang_edit}}">
+ <span class="glyphicon glyphicon-edit" aria-label="{{lang_edit}}"></span>
+ </a>
+ <a class="btn btn-xs btn-default {{perms.filter.rules.edit.disabled}}"
+ href="?do=eventlog&amp;show=rules&amp;id={{ruleid}}&amp;copy=1" title="{{lang_copy}}">
+ <span class="glyphicon glyphicon-duplicate" aria-label="{{lang_copy}}"></span>
+ </a>
+ <button class="btn btn-xs btn-danger" type="submit" name="id" value="{{ruleid}}" title="{{lang_delete}}"
+ data-confirm="{{lang_reallyDelete}}" {{perms.filter.rules.edit.disabled}}>
+ <span class="glyphicon glyphicon-trash"></span>
+ </button>
+ </td>
+ </tr>
+ {{/filters}}
+ </tbody>
+ </table>
+
+</form>
+
+<div class="buttonbar text-right">
+ <a class="btn btn-success {{perms.filter.rules.edit.disabled}}" href="?do=eventlog&amp;show=rules&amp;id=0">
+ <span class="glyphicon glyphicon-plus"></span>
+ {{lang_add}}
+ </a>
+</div> \ No newline at end of file
diff --git a/modules-available/eventlog/templates/page-filters-transports.html b/modules-available/eventlog/templates/page-filters-transports.html
new file mode 100644
index 00000000..3047e437
--- /dev/null
+++ b/modules-available/eventlog/templates/page-filters-transports.html
@@ -0,0 +1,45 @@
+<form method="post" action="?do=eventlog&amp;show=transports">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="action" value="delete-transport">
+ <table class="table">
+ <thead>
+ <tr>
+ <th class="slx-smallcol">{{lang_id}}</th>
+ <th class="slx-smallcol">{{lang_type}}</th>
+ <th>{{lang_title}}</th>
+ <th>{{lang_details}}</th>
+ <th class="slx-smallcol"></th>
+ <th class="slx-smallcol">{{lang_edit}}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{#transports}}
+ <tr>
+ <td>{{transportid}}</td>
+ <td>{{type}}</td>
+ <td>{{title}}</td>
+ <td class="small">{{details.toString}}</td>
+ <td><span class="badge">{{useCount}}</span></td>
+ <td class="slx-smallcol">
+ <a class="btn btn-xs btn-default"
+ href="?do=eventlog&amp;show=transports&amp;id={{transportid}}">
+ <span class="glyphicon glyphicon-edit" aria-label="{{lang_edit}}"></span>
+ </a>
+ <button class="btn btn-xs btn-danger" type="submit" name="id" value="{{transportid}}"
+ data-confirm="{{lang_reallyDelete}}">
+ <span class="glyphicon glyphicon-trash"></span>
+ </button>
+ </td>
+ </tr>
+ {{/transports}}
+ </tbody>
+ </table>
+
+</form>
+
+<div class="buttonbar text-right">
+ <a class="btn btn-success" href="?do=eventlog&amp;show=transports&amp;id=0">
+ <span class="glyphicon glyphicon-plus"></span>
+ {{lang_add}}
+ </a>
+</div> \ No newline at end of file
diff --git a/modules-available/eventlog/templates/page-header.html b/modules-available/eventlog/templates/page-header.html
new file mode 100644
index 00000000..a5e30af9
--- /dev/null
+++ b/modules-available/eventlog/templates/page-header.html
@@ -0,0 +1,16 @@
+<h1>{{lang_logAndEvents}}</h1>
+
+<ul class="nav nav-tabs">
+ <li class="{{active_log}}">
+ <a href="?do=eventlog&amp;show=log">{{lang_eventLog}}</a>
+ </li>
+ <li class="{{active_rules}}">
+ <a href="?do=eventlog&amp;show=rules">{{lang_filterRules}}</a>
+ </li>
+ <li class="{{active_transports}}">
+ <a href="?do=eventlog&amp;show=transports">{{lang_transports}}</a>
+ </li>
+ <li class="{{active_mailconfigs}}">
+ <a href="?do=eventlog&amp;show=mailconfigs">{{lang_mailconfigs}}</a>
+ </li>
+</ul> \ No newline at end of file
diff --git a/modules-available/exams/baseconfig/getconfig.inc.php b/modules-available/exams/baseconfig/getconfig.inc.php
index 120bdbff..7e4a70df 100644
--- a/modules-available/exams/baseconfig/getconfig.inc.php
+++ b/modules-available/exams/baseconfig/getconfig.inc.php
@@ -1,24 +1,27 @@
<?php
-$foofoo = function($machineUuid) {
+/** @var ?string $uuid */
+/** @var ?string $ip */
+
+if ($uuid !== null) {
// Leave clients in any runmode alone
$res = Database::queryFirst('SELECT machineuuid FROM runmode WHERE machineuuid = :uuid',
- array('uuid' => $machineUuid), true);
+ array('uuid' => $uuid), true);
if (is_array($res))
return;
// Check if exam mode should apply
$locations = ConfigHolder::get('SLX_LOCATIONS');
- if ($locations === false) {
+ if ($locations === null) {
$locationIds = [];
} else {
$locationIds = explode(' ', $locations);
}
if (Exams::isInExamMode($locationIds, $lectureId, $autoLogin)) {
ConfigHolder::add('SLX_EXAM', 'yes', 10000);
- if (strlen($lectureId) > 0) {
+ if (!empty($lectureId)) {
ConfigHolder::add('SLX_EXAM_START', $lectureId, 10000);
}
- if (strlen($autoLogin) > 0) {
+ if (!empty($autoLogin)) {
ConfigHolder::add('SLX_AUTOLOGIN', $autoLogin, 10000);
}
ConfigHolder::add('SLX_SYSTEMD_TARGET', 'exam-mode', 10000);
@@ -26,6 +29,4 @@ $foofoo = function($machineUuid) {
// No saver
ConfigHolder::add('SLX_SCREEN_SAVER_TIMEOUT', '0', 1000);
}
-};
-
-$foofoo($uuid); \ No newline at end of file
+} \ No newline at end of file
diff --git a/modules-available/exams/inc/exams.inc.php b/modules-available/exams/inc/exams.inc.php
index da4dec85..2a54c262 100644
--- a/modules-available/exams/inc/exams.inc.php
+++ b/modules-available/exams/inc/exams.inc.php
@@ -4,14 +4,11 @@ class Exams
{
/**
- * @param int[] of location ids. must bot be an associative array.
- * @return: bool true iff for any of the given location ids an exam is scheduled.
+ * @param int[] $locationIds of location ids. must be an associative array.
+ * @return bool true iff for any of the given location ids an exam is scheduled.
**/
- public static function isInExamMode($locationIds, &$lectureId = false, &$autoLogin = false)
+ public static function isInExamMode(array $locationIds, ?string &$lectureId = null, ?string &$autoLogin = null): bool
{
- if (!is_array($locationIds)) {
- $locationIds = array($locationIds);
- }
if (empty($locationIds)) {
$locationIds[] = 0;
}
diff --git a/modules-available/exams/page.inc.php b/modules-available/exams/page.inc.php
index 868f5927..d229b883 100644
--- a/modules-available/exams/page.inc.php
+++ b/modules-available/exams/page.inc.php
@@ -23,7 +23,7 @@ class Page_Exams extends Page
} else {
$tmp = Database::simpleQuery("SELECT locationid FROM exams_x_location WHERE examid= :examid", array('examid' => $examidOrLocations));
$active = array();
- while ($row = $tmp->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($tmp as $row) {
$active[] = (int)$row['locationid'];
}
}
@@ -40,7 +40,7 @@ class Page_Exams extends Page
. "GROUP BY examid "
. "ORDER BY examid ASC");
- while ($exam = $tmp->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($tmp as $exam) {
$view = $edit = false;
// User has permission for all locations
if (in_array(0, $this->userViewLocations)) {
@@ -87,7 +87,7 @@ class Page_Exams extends Page
"GROUP BY lectureid " .
"ORDER BY starttime ASC, displayname ASC",
['rangeMax' => $this->rangeMax, 'rangeMin' => $this->rangeMin]);
- while ($lecture = $tmp->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($tmp as $lecture) {
$this->lectures[] = $lecture;
}
}
@@ -103,7 +103,7 @@ class Page_Exams extends Page
}
// returns true if user is allowed to edit the exam
- protected function userCanEditExam($examid = NULL)
+ protected function userCanEditExam(string $examid = NULL): bool
{
if (in_array(0, $this->userEditLocations)) // Trivial case -- don't query if global perms
return true;
@@ -111,16 +111,19 @@ class Page_Exams extends Page
return User::hasPermission('exams.edit');
// Check locations of existing exam
$res = Database::simpleQuery("SELECT locationid FROM exams_x_location WHERE examid= :examid", array('examid' => $examid));
- while ($locId = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $locId) {
if (!in_array($locId['locationid'], $this->userEditLocations))
return false;
}
return true;
}
- // checks if user is allowed to save an exam with all the locations
- // needs information if it's add (second para = true) or edit (second para = false)
- protected function userCanEditLocation($locationids) {
+ /**
+ * checks if user is allowed to save an exam with all the locations
+ * needs information if it's add (second para = true) or edit (second para = false)
+ */
+ protected function userCanEditLocation(array $locationids): bool
+ {
return empty(array_diff($locationids, $this->userEditLocations));
}
@@ -213,7 +216,10 @@ class Page_Exams extends Page
return json_encode($out);
}
- protected function makeExamsForTemplate()
+ /**
+ * @return array{exams: array, decollapse: bool}
+ */
+ protected function makeExamsForTemplate(): array
{
$out = [];
$now = time();
@@ -248,7 +254,7 @@ class Page_Exams extends Page
return ['exams' => $out, 'decollapse' => $hasCollapsed];
}
- protected function makeLectureExamList()
+ protected function makeLectureExamList(): array
{
$out = [];
$now = time();
@@ -288,7 +294,7 @@ class Page_Exams extends Page
] + $source;
}
- private function isDateSane($time)
+ private function isDateSane(int $time): bool
{
return ($time >= strtotime('-10 years') && $time <= strtotime('+10 years'));
}
@@ -296,7 +302,7 @@ class Page_Exams extends Page
private function saveExam()
{
if (!Request::isPost()) {
- Util::traceError('Is not post');
+ ErrorHandler::traceError('Is not post');
}
/* process form-data */
$locationids = Request::post('locations', [], "ARRAY");
@@ -453,7 +459,7 @@ class Page_Exams extends Page
} elseif ($this->action === false) {
- Util::traceError("action not implemented");
+ ErrorHandler::traceError("action not implemented");
}
}
diff --git a/modules-available/js_chart/clientscript.js b/modules-available/js_chart/clientscript.js
index 3a0a2c87..5399a164 100644
--- a/modules-available/js_chart/clientscript.js
+++ b/modules-available/js_chart/clientscript.js
@@ -1,11 +1,13 @@
/*!
- * Chart.js
- * http://chartjs.org/
- * Version: 1.0.2
- *
- * Copyright 2015 Nick Downie
- * Released under the MIT license
- * https://github.com/nnnick/Chart.js/blob/master/LICENSE.md
+ * Chart.js v3.8.0
+ * https://www.chartjs.org
+ * (c) 2022 Chart.js Contributors
+ * Released under the MIT License
*/
-(function(){"use strict";var t=this,i=t.Chart,e=function(t){this.canvas=t.canvas,this.ctx=t;var i=function(t,i){return t["offset"+i]?t["offset"+i]:document.defaultView.getComputedStyle(t).getPropertyValue(i)},e=this.width=i(t.canvas,"Width"),n=this.height=i(t.canvas,"Height");t.canvas.width=e,t.canvas.height=n;var e=this.width=t.canvas.width,n=this.height=t.canvas.height;return this.aspectRatio=this.width/this.height,s.retinaScale(this),this};e.defaults={global:{animation:!0,animationSteps:60,animationEasing:"easeOutQuart",showScale:!0,scaleOverride:!1,scaleSteps:null,scaleStepWidth:null,scaleStartValue:null,scaleLineColor:"rgba(0,0,0,.1)",scaleLineWidth:1,scaleShowLabels:!0,scaleLabel:"<%=value%>",scaleIntegersOnly:!0,scaleBeginAtZero:!1,scaleFontFamily:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",scaleFontSize:12,scaleFontStyle:"normal",scaleFontColor:"#666",responsive:!1,maintainAspectRatio:!0,showTooltips:!0,customTooltips:!1,tooltipEvents:["mousemove","touchstart","touchmove","mouseout"],tooltipFillColor:"rgba(0,0,0,0.8)",tooltipFontFamily:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",tooltipFontSize:14,tooltipFontStyle:"normal",tooltipFontColor:"#fff",tooltipTitleFontFamily:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",tooltipTitleFontSize:14,tooltipTitleFontStyle:"bold",tooltipTitleFontColor:"#fff",tooltipYPadding:6,tooltipXPadding:6,tooltipCaretSize:8,tooltipCornerRadius:6,tooltipXOffset:10,tooltipTemplate:"<%if (label){%><%=label%>: <%}%><%= value %>",multiTooltipTemplate:"<%= value %>",multiTooltipKeyBackground:"#fff",onAnimationProgress:function(){},onAnimationComplete:function(){}}},e.types={};var s=e.helpers={},n=s.each=function(t,i,e){var s=Array.prototype.slice.call(arguments,3);if(t)if(t.length===+t.length){var n;for(n=0;n<t.length;n++)i.apply(e,[t[n],n].concat(s))}else for(var o in t)i.apply(e,[t[o],o].concat(s))},o=s.clone=function(t){var i={};return n(t,function(e,s){t.hasOwnProperty(s)&&(i[s]=e)}),i},a=s.extend=function(t){return n(Array.prototype.slice.call(arguments,1),function(i){n(i,function(e,s){i.hasOwnProperty(s)&&(t[s]=e)})}),t},h=s.merge=function(){var t=Array.prototype.slice.call(arguments,0);return t.unshift({}),a.apply(null,t)},l=s.indexOf=function(t,i){if(Array.prototype.indexOf)return t.indexOf(i);for(var e=0;e<t.length;e++)if(t[e]===i)return e;return-1},r=(s.where=function(t,i){var e=[];return s.each(t,function(t){i(t)&&e.push(t)}),e},s.findNextWhere=function(t,i,e){e||(e=-1);for(var s=e+1;s<t.length;s++){var n=t[s];if(i(n))return n}},s.findPreviousWhere=function(t,i,e){e||(e=t.length);for(var s=e-1;s>=0;s--){var n=t[s];if(i(n))return n}},s.inherits=function(t){var i=this,e=t&&t.hasOwnProperty("constructor")?t.constructor:function(){return i.apply(this,arguments)},s=function(){this.constructor=e};return s.prototype=i.prototype,e.prototype=new s,e.extend=r,t&&a(e.prototype,t),e.__super__=i.prototype,e}),c=s.noop=function(){},u=s.uid=function(){var t=0;return function(){return"chart-"+t++}}(),d=s.warn=function(t){window.console&&"function"==typeof window.console.warn&&console.warn(t)},p=s.amd="function"==typeof define&&define.amd,f=s.isNumber=function(t){return!isNaN(parseFloat(t))&&isFinite(t)},g=s.max=function(t){return Math.max.apply(Math,t)},m=s.min=function(t){return Math.min.apply(Math,t)},v=(s.cap=function(t,i,e){if(f(i)){if(t>i)return i}else if(f(e)&&e>t)return e;return t},s.getDecimalPlaces=function(t){return t%1!==0&&f(t)?t.toString().split(".")[1].length:0}),S=s.radians=function(t){return t*(Math.PI/180)},x=(s.getAngleFromPoint=function(t,i){var e=i.x-t.x,s=i.y-t.y,n=Math.sqrt(e*e+s*s),o=2*Math.PI+Math.atan2(s,e);return 0>e&&0>s&&(o+=2*Math.PI),{angle:o,distance:n}},s.aliasPixel=function(t){return t%2===0?0:.5}),y=(s.splineCurve=function(t,i,e,s){var n=Math.sqrt(Math.pow(i.x-t.x,2)+Math.pow(i.y-t.y,2)),o=Math.sqrt(Math.pow(e.x-i.x,2)+Math.pow(e.y-i.y,2)),a=s*n/(n+o),h=s*o/(n+o);return{inner:{x:i.x-a*(e.x-t.x),y:i.y-a*(e.y-t.y)},outer:{x:i.x+h*(e.x-t.x),y:i.y+h*(e.y-t.y)}}},s.calculateOrderOfMagnitude=function(t){return Math.floor(Math.log(t)/Math.LN10)}),C=(s.calculateScaleRange=function(t,i,e,s,n){var o=2,a=Math.floor(i/(1.5*e)),h=o>=a,l=g(t),r=m(t);l===r&&(l+=.5,r>=.5&&!s?r-=.5:l+=.5);for(var c=Math.abs(l-r),u=y(c),d=Math.ceil(l/(1*Math.pow(10,u)))*Math.pow(10,u),p=s?0:Math.floor(r/(1*Math.pow(10,u)))*Math.pow(10,u),f=d-p,v=Math.pow(10,u),S=Math.round(f/v);(S>a||a>2*S)&&!h;)if(S>a)v*=2,S=Math.round(f/v),S%1!==0&&(h=!0);else if(n&&u>=0){if(v/2%1!==0)break;v/=2,S=Math.round(f/v)}else v/=2,S=Math.round(f/v);return h&&(S=o,v=f/S),{steps:S,stepValue:v,min:p,max:p+S*v}},s.template=function(t,i){function e(t,i){var e=/\W/.test(t)?new Function("obj","var p=[],print=function(){p.push.apply(p,arguments);};with(obj){p.push('"+t.replace(/[\r\t\n]/g," ").split("<%").join(" ").replace(/((^|%>)[^\t]*)'/g,"$1\r").replace(/\t=(.*?)%>/g,"',$1,'").split(" ").join("');").split("%>").join("p.push('").split("\r").join("\\'")+"');}return p.join('');"):s[t]=s[t];return i?e(i):e}if(t instanceof Function)return t(i);var s={};return e(t,i)}),w=(s.generateLabels=function(t,i,e,s){var o=new Array(i);return labelTemplateString&&n(o,function(i,n){o[n]=C(t,{value:e+s*(n+1)})}),o},s.easingEffects={linear:function(t){return t},easeInQuad:function(t){return t*t},easeOutQuad:function(t){return-1*t*(t-2)},easeInOutQuad:function(t){return(t/=.5)<1?.5*t*t:-0.5*(--t*(t-2)-1)},easeInCubic:function(t){return t*t*t},easeOutCubic:function(t){return 1*((t=t/1-1)*t*t+1)},easeInOutCubic:function(t){return(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2)},easeInQuart:function(t){return t*t*t*t},easeOutQuart:function(t){return-1*((t=t/1-1)*t*t*t-1)},easeInOutQuart:function(t){return(t/=.5)<1?.5*t*t*t*t:-0.5*((t-=2)*t*t*t-2)},easeInQuint:function(t){return 1*(t/=1)*t*t*t*t},easeOutQuint:function(t){return 1*((t=t/1-1)*t*t*t*t+1)},easeInOutQuint:function(t){return(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2)},easeInSine:function(t){return-1*Math.cos(t/1*(Math.PI/2))+1},easeOutSine:function(t){return 1*Math.sin(t/1*(Math.PI/2))},easeInOutSine:function(t){return-0.5*(Math.cos(Math.PI*t/1)-1)},easeInExpo:function(t){return 0===t?1:1*Math.pow(2,10*(t/1-1))},easeOutExpo:function(t){return 1===t?1:1*(-Math.pow(2,-10*t/1)+1)},easeInOutExpo:function(t){return 0===t?0:1===t?1:(t/=.5)<1?.5*Math.pow(2,10*(t-1)):.5*(-Math.pow(2,-10*--t)+2)},easeInCirc:function(t){return t>=1?t:-1*(Math.sqrt(1-(t/=1)*t)-1)},easeOutCirc:function(t){return 1*Math.sqrt(1-(t=t/1-1)*t)},easeInOutCirc:function(t){return(t/=.5)<1?-0.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},easeInElastic:function(t){var i=1.70158,e=0,s=1;return 0===t?0:1==(t/=1)?1:(e||(e=.3),s<Math.abs(1)?(s=1,i=e/4):i=e/(2*Math.PI)*Math.asin(1/s),-(s*Math.pow(2,10*(t-=1))*Math.sin(2*(1*t-i)*Math.PI/e)))},easeOutElastic:function(t){var i=1.70158,e=0,s=1;return 0===t?0:1==(t/=1)?1:(e||(e=.3),s<Math.abs(1)?(s=1,i=e/4):i=e/(2*Math.PI)*Math.asin(1/s),s*Math.pow(2,-10*t)*Math.sin(2*(1*t-i)*Math.PI/e)+1)},easeInOutElastic:function(t){var i=1.70158,e=0,s=1;return 0===t?0:2==(t/=.5)?1:(e||(e=.3*1.5),s<Math.abs(1)?(s=1,i=e/4):i=e/(2*Math.PI)*Math.asin(1/s),1>t?-.5*s*Math.pow(2,10*(t-=1))*Math.sin(2*(1*t-i)*Math.PI/e):s*Math.pow(2,-10*(t-=1))*Math.sin(2*(1*t-i)*Math.PI/e)*.5+1)},easeInBack:function(t){var i=1.70158;return 1*(t/=1)*t*((i+1)*t-i)},easeOutBack:function(t){var i=1.70158;return 1*((t=t/1-1)*t*((i+1)*t+i)+1)},easeInOutBack:function(t){var i=1.70158;return(t/=.5)<1?.5*t*t*(((i*=1.525)+1)*t-i):.5*((t-=2)*t*(((i*=1.525)+1)*t+i)+2)},easeInBounce:function(t){return 1-w.easeOutBounce(1-t)},easeOutBounce:function(t){return(t/=1)<1/2.75?7.5625*t*t:2/2.75>t?1*(7.5625*(t-=1.5/2.75)*t+.75):2.5/2.75>t?1*(7.5625*(t-=2.25/2.75)*t+.9375):1*(7.5625*(t-=2.625/2.75)*t+.984375)},easeInOutBounce:function(t){return.5>t?.5*w.easeInBounce(2*t):.5*w.easeOutBounce(2*t-1)+.5}}),b=s.requestAnimFrame=function(){return window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.oRequestAnimationFrame||window.msRequestAnimationFrame||function(t){return window.setTimeout(t,1e3/60)}}(),P=s.cancelAnimFrame=function(){return window.cancelAnimationFrame||window.webkitCancelAnimationFrame||window.mozCancelAnimationFrame||window.oCancelAnimationFrame||window.msCancelAnimationFrame||function(t){return window.clearTimeout(t,1e3/60)}}(),L=(s.animationLoop=function(t,i,e,s,n,o){var a=0,h=w[e]||w.linear,l=function(){a++;var e=a/i,r=h(e);t.call(o,r,e,a),s.call(o,r,e),i>a?o.animationFrame=b(l):n.apply(o)};b(l)},s.getRelativePosition=function(t){var i,e,s=t.originalEvent||t,n=t.currentTarget||t.srcElement,o=n.getBoundingClientRect();return s.touches?(i=s.touches[0].clientX-o.left,e=s.touches[0].clientY-o.top):(i=s.clientX-o.left,e=s.clientY-o.top),{x:i,y:e}},s.addEvent=function(t,i,e){t.addEventListener?t.addEventListener(i,e):t.attachEvent?t.attachEvent("on"+i,e):t["on"+i]=e}),k=s.removeEvent=function(t,i,e){t.removeEventListener?t.removeEventListener(i,e,!1):t.detachEvent?t.detachEvent("on"+i,e):t["on"+i]=c},F=(s.bindEvents=function(t,i,e){t.events||(t.events={}),n(i,function(i){t.events[i]=function(){e.apply(t,arguments)},L(t.chart.canvas,i,t.events[i])})},s.unbindEvents=function(t,i){n(i,function(i,e){k(t.chart.canvas,e,i)})}),R=s.getMaximumWidth=function(t){var i=t.parentNode;return i.clientWidth},T=s.getMaximumHeight=function(t){var i=t.parentNode;return i.clientHeight},A=(s.getMaximumSize=s.getMaximumWidth,s.retinaScale=function(t){var i=t.ctx,e=t.canvas.width,s=t.canvas.height;window.devicePixelRatio&&(i.canvas.style.width=e+"px",i.canvas.style.height=s+"px",i.canvas.height=s*window.devicePixelRatio,i.canvas.width=e*window.devicePixelRatio,i.scale(window.devicePixelRatio,window.devicePixelRatio))}),M=s.clear=function(t){t.ctx.clearRect(0,0,t.width,t.height)},W=s.fontString=function(t,i,e){return i+" "+t+"px "+e},z=s.longestText=function(t,i,e){t.font=i;var s=0;return n(e,function(i){var e=t.measureText(i).width;s=e>s?e:s}),s},B=s.drawRoundedRectangle=function(t,i,e,s,n,o){t.beginPath(),t.moveTo(i+o,e),t.lineTo(i+s-o,e),t.quadraticCurveTo(i+s,e,i+s,e+o),t.lineTo(i+s,e+n-o),t.quadraticCurveTo(i+s,e+n,i+s-o,e+n),t.lineTo(i+o,e+n),t.quadraticCurveTo(i,e+n,i,e+n-o),t.lineTo(i,e+o),t.quadraticCurveTo(i,e,i+o,e),t.closePath()};e.instances={},e.Type=function(t,i,s){this.options=i,this.chart=s,this.id=u(),e.instances[this.id]=this,i.responsive&&this.resize(),this.initialize.call(this,t)},a(e.Type.prototype,{initialize:function(){return this},clear:function(){return M(this.chart),this},stop:function(){return P(this.animationFrame),this},resize:function(t){this.stop();var i=this.chart.canvas,e=R(this.chart.canvas),s=this.options.maintainAspectRatio?e/this.chart.aspectRatio:T(this.chart.canvas);return i.width=this.chart.width=e,i.height=this.chart.height=s,A(this.chart),"function"==typeof t&&t.apply(this,Array.prototype.slice.call(arguments,1)),this},reflow:c,render:function(t){return t&&this.reflow(),this.options.animation&&!t?s.animationLoop(this.draw,this.options.animationSteps,this.options.animationEasing,this.options.onAnimationProgress,this.options.onAnimationComplete,this):(this.draw(),this.options.onAnimationComplete.call(this)),this},generateLegend:function(){return C(this.options.legendTemplate,this)},destroy:function(){this.clear(),F(this,this.events);var t=this.chart.canvas;t.width=this.chart.width,t.height=this.chart.height,t.style.removeProperty?(t.style.removeProperty("width"),t.style.removeProperty("height")):(t.style.removeAttribute("width"),t.style.removeAttribute("height")),delete e.instances[this.id]},showTooltip:function(t,i){"undefined"==typeof this.activeElements&&(this.activeElements=[]);var o=function(t){var i=!1;return t.length!==this.activeElements.length?i=!0:(n(t,function(t,e){t!==this.activeElements[e]&&(i=!0)},this),i)}.call(this,t);if(o||i){if(this.activeElements=t,this.draw(),this.options.customTooltips&&this.options.customTooltips(!1),t.length>0)if(this.datasets&&this.datasets.length>1){for(var a,h,r=this.datasets.length-1;r>=0&&(a=this.datasets[r].points||this.datasets[r].bars||this.datasets[r].segments,h=l(a,t[0]),-1===h);r--);var c=[],u=[],d=function(){var t,i,e,n,o,a=[],l=[],r=[];return s.each(this.datasets,function(i){t=i.points||i.bars||i.segments,t[h]&&t[h].hasValue()&&a.push(t[h])}),s.each(a,function(t){l.push(t.x),r.push(t.y),c.push(s.template(this.options.multiTooltipTemplate,t)),u.push({fill:t._saved.fillColor||t.fillColor,stroke:t._saved.strokeColor||t.strokeColor})},this),o=m(r),e=g(r),n=m(l),i=g(l),{x:n>this.chart.width/2?n:i,y:(o+e)/2}}.call(this,h);new e.MultiTooltip({x:d.x,y:d.y,xPadding:this.options.tooltipXPadding,yPadding:this.options.tooltipYPadding,xOffset:this.options.tooltipXOffset,fillColor:this.options.tooltipFillColor,textColor:this.options.tooltipFontColor,fontFamily:this.options.tooltipFontFamily,fontStyle:this.options.tooltipFontStyle,fontSize:this.options.tooltipFontSize,titleTextColor:this.options.tooltipTitleFontColor,titleFontFamily:this.options.tooltipTitleFontFamily,titleFontStyle:this.options.tooltipTitleFontStyle,titleFontSize:this.options.tooltipTitleFontSize,cornerRadius:this.options.tooltipCornerRadius,labels:c,legendColors:u,legendColorBackground:this.options.multiTooltipKeyBackground,title:t[0].label,chart:this.chart,ctx:this.chart.ctx,custom:this.options.customTooltips}).draw()}else n(t,function(t){var i=t.tooltipPosition();new e.Tooltip({x:Math.round(i.x),y:Math.round(i.y),xPadding:this.options.tooltipXPadding,yPadding:this.options.tooltipYPadding,fillColor:this.options.tooltipFillColor,textColor:this.options.tooltipFontColor,fontFamily:this.options.tooltipFontFamily,fontStyle:this.options.tooltipFontStyle,fontSize:this.options.tooltipFontSize,caretHeight:this.options.tooltipCaretSize,cornerRadius:this.options.tooltipCornerRadius,text:C(this.options.tooltipTemplate,t),chart:this.chart,custom:this.options.customTooltips}).draw()},this);return this}},toBase64Image:function(){return this.chart.canvas.toDataURL.apply(this.chart.canvas,arguments)}}),e.Type.extend=function(t){var i=this,s=function(){return i.apply(this,arguments)};if(s.prototype=o(i.prototype),a(s.prototype,t),s.extend=e.Type.extend,t.name||i.prototype.name){var n=t.name||i.prototype.name,l=e.defaults[i.prototype.name]?o(e.defaults[i.prototype.name]):{};e.defaults[n]=a(l,t.defaults),e.types[n]=s,e.prototype[n]=function(t,i){var o=h(e.defaults.global,e.defaults[n],i||{});return new s(t,o,this)}}else d("Name not provided for this chart, so it hasn't been registered");return i},e.Element=function(t){a(this,t),this.initialize.apply(this,arguments),this.save()},a(e.Element.prototype,{initialize:function(){},restore:function(t){return t?n(t,function(t){this[t]=this._saved[t]},this):a(this,this._saved),this},save:function(){return this._saved=o(this),delete this._saved._saved,this},update:function(t){return n(t,function(t,i){this._saved[i]=this[i],this[i]=t},this),this},transition:function(t,i){return n(t,function(t,e){this[e]=(t-this._saved[e])*i+this._saved[e]},this),this},tooltipPosition:function(){return{x:this.x,y:this.y}},hasValue:function(){return f(this.value)}}),e.Element.extend=r,e.Point=e.Element.extend({display:!0,inRange:function(t,i){var e=this.hitDetectionRadius+this.radius;return Math.pow(t-this.x,2)+Math.pow(i-this.y,2)<Math.pow(e,2)},draw:function(){if(this.display){var t=this.ctx;t.beginPath(),t.arc(this.x,this.y,this.radius,0,2*Math.PI),t.closePath(),t.strokeStyle=this.strokeColor,t.lineWidth=this.strokeWidth,t.fillStyle=this.fillColor,t.fill(),t.stroke()}}}),e.Arc=e.Element.extend({inRange:function(t,i){var e=s.getAngleFromPoint(this,{x:t,y:i}),n=e.angle>=this.startAngle&&e.angle<=this.endAngle,o=e.distance>=this.innerRadius&&e.distance<=this.outerRadius;return n&&o},tooltipPosition:function(){var t=this.startAngle+(this.endAngle-this.startAngle)/2,i=(this.outerRadius-this.innerRadius)/2+this.innerRadius;return{x:this.x+Math.cos(t)*i,y:this.y+Math.sin(t)*i}},draw:function(t){var i=this.ctx;i.beginPath(),i.arc(this.x,this.y,this.outerRadius,this.startAngle,this.endAngle),i.arc(this.x,this.y,this.innerRadius,this.endAngle,this.startAngle,!0),i.closePath(),i.strokeStyle=this.strokeColor,i.lineWidth=this.strokeWidth,i.fillStyle=this.fillColor,i.fill(),i.lineJoin="bevel",this.showStroke&&i.stroke()}}),e.Rectangle=e.Element.extend({draw:function(){var t=this.ctx,i=this.width/2,e=this.x-i,s=this.x+i,n=this.base-(this.base-this.y),o=this.strokeWidth/2;this.showStroke&&(e+=o,s-=o,n+=o),t.beginPath(),t.fillStyle=this.fillColor,t.strokeStyle=this.strokeColor,t.lineWidth=this.strokeWidth,t.moveTo(e,this.base),t.lineTo(e,n),t.lineTo(s,n),t.lineTo(s,this.base),t.fill(),this.showStroke&&t.stroke()},height:function(){return this.base-this.y},inRange:function(t,i){return t>=this.x-this.width/2&&t<=this.x+this.width/2&&i>=this.y&&i<=this.base}}),e.Tooltip=e.Element.extend({draw:function(){var t=this.chart.ctx;t.font=W(this.fontSize,this.fontStyle,this.fontFamily),this.xAlign="center",this.yAlign="above";var i=this.caretPadding=2,e=t.measureText(this.text).width+2*this.xPadding,s=this.fontSize+2*this.yPadding,n=s+this.caretHeight+i;this.x+e/2>this.chart.width?this.xAlign="left":this.x-e/2<0&&(this.xAlign="right"),this.y-n<0&&(this.yAlign="below");var o=this.x-e/2,a=this.y-n;if(t.fillStyle=this.fillColor,this.custom)this.custom(this);else{switch(this.yAlign){case"above":t.beginPath(),t.moveTo(this.x,this.y-i),t.lineTo(this.x+this.caretHeight,this.y-(i+this.caretHeight)),t.lineTo(this.x-this.caretHeight,this.y-(i+this.caretHeight)),t.closePath(),t.fill();break;case"below":a=this.y+i+this.caretHeight,t.beginPath(),t.moveTo(this.x,this.y+i),t.lineTo(this.x+this.caretHeight,this.y+i+this.caretHeight),t.lineTo(this.x-this.caretHeight,this.y+i+this.caretHeight),t.closePath(),t.fill()}switch(this.xAlign){case"left":o=this.x-e+(this.cornerRadius+this.caretHeight);break;case"right":o=this.x-(this.cornerRadius+this.caretHeight)}B(t,o,a,e,s,this.cornerRadius),t.fill(),t.fillStyle=this.textColor,t.textAlign="center",t.textBaseline="middle",t.fillText(this.text,o+e/2,a+s/2)}}}),e.MultiTooltip=e.Element.extend({initialize:function(){this.font=W(this.fontSize,this.fontStyle,this.fontFamily),this.titleFont=W(this.titleFontSize,this.titleFontStyle,this.titleFontFamily),this.height=this.labels.length*this.fontSize+(this.labels.length-1)*(this.fontSize/2)+2*this.yPadding+1.5*this.titleFontSize,this.ctx.font=this.titleFont;var t=this.ctx.measureText(this.title).width,i=z(this.ctx,this.font,this.labels)+this.fontSize+3,e=g([i,t]);this.width=e+2*this.xPadding;var s=this.height/2;this.y-s<0?this.y=s:this.y+s>this.chart.height&&(this.y=this.chart.height-s),this.x>this.chart.width/2?this.x-=this.xOffset+this.width:this.x+=this.xOffset},getLineHeight:function(t){var i=this.y-this.height/2+this.yPadding,e=t-1;return 0===t?i+this.titleFontSize/2:i+(1.5*this.fontSize*e+this.fontSize/2)+1.5*this.titleFontSize},draw:function(){if(this.custom)this.custom(this);else{B(this.ctx,this.x,this.y-this.height/2,this.width,this.height,this.cornerRadius);var t=this.ctx;t.fillStyle=this.fillColor,t.fill(),t.closePath(),t.textAlign="left",t.textBaseline="middle",t.fillStyle=this.titleTextColor,t.font=this.titleFont,t.fillText(this.title,this.x+this.xPadding,this.getLineHeight(0)),t.font=this.font,s.each(this.labels,function(i,e){t.fillStyle=this.textColor,t.fillText(i,this.x+this.xPadding+this.fontSize+3,this.getLineHeight(e+1)),t.fillStyle=this.legendColorBackground,t.fillRect(this.x+this.xPadding,this.getLineHeight(e+1)-this.fontSize/2,this.fontSize,this.fontSize),t.fillStyle=this.legendColors[e].fill,t.fillRect(this.x+this.xPadding,this.getLineHeight(e+1)-this.fontSize/2,this.fontSize,this.fontSize)},this)}}}),e.Scale=e.Element.extend({initialize:function(){this.fit()},buildYLabels:function(){this.yLabels=[];for(var t=v(this.stepValue),i=0;i<=this.steps;i++)this.yLabels.push(C(this.templateString,{value:(this.min+i*this.stepValue).toFixed(t)}));this.yLabelWidth=this.display&&this.showLabels?z(this.ctx,this.font,this.yLabels):0},addXLabel:function(t){this.xLabels.push(t),this.valuesCount++,this.fit()},removeXLabel:function(){this.xLabels.shift(),this.valuesCount--,this.fit()},fit:function(){this.startPoint=this.display?this.fontSize:0,this.endPoint=this.display?this.height-1.5*this.fontSize-5:this.height,this.startPoint+=this.padding,this.endPoint-=this.padding;var t,i=this.endPoint-this.startPoint;for(this.calculateYRange(i),this.buildYLabels(),this.calculateXLabelRotation();i>this.endPoint-this.startPoint;)i=this.endPoint-this.startPoint,t=this.yLabelWidth,this.calculateYRange(i),this.buildYLabels(),t<this.yLabelWidth&&this.calculateXLabelRotation()},calculateXLabelRotation:function(){this.ctx.font=this.font;var t,i,e=this.ctx.measureText(this.xLabels[0]).width,s=this.ctx.measureText(this.xLabels[this.xLabels.length-1]).width;if(this.xScalePaddingRight=s/2+3,this.xScalePaddingLeft=e/2>this.yLabelWidth+10?e/2:this.yLabelWidth+10,this.xLabelRotation=0,this.display){var n,o=z(this.ctx,this.font,this.xLabels);this.xLabelWidth=o;for(var a=Math.floor(this.calculateX(1)-this.calculateX(0))-6;this.xLabelWidth>a&&0===this.xLabelRotation||this.xLabelWidth>a&&this.xLabelRotation<=90&&this.xLabelRotation>0;)n=Math.cos(S(this.xLabelRotation)),t=n*e,i=n*s,t+this.fontSize/2>this.yLabelWidth+8&&(this.xScalePaddingLeft=t+this.fontSize/2),this.xScalePaddingRight=this.fontSize/2,this.xLabelRotation++,this.xLabelWidth=n*o;this.xLabelRotation>0&&(this.endPoint-=Math.sin(S(this.xLabelRotation))*o+3)}else this.xLabelWidth=0,this.xScalePaddingRight=this.padding,this.xScalePaddingLeft=this.padding},calculateYRange:c,drawingArea:function(){return this.startPoint-this.endPoint},calculateY:function(t){var i=this.drawingArea()/(this.min-this.max);return this.endPoint-i*(t-this.min)},calculateX:function(t){var i=(this.xLabelRotation>0,this.width-(this.xScalePaddingLeft+this.xScalePaddingRight)),e=i/Math.max(this.valuesCount-(this.offsetGridLines?0:1),1),s=e*t+this.xScalePaddingLeft;return this.offsetGridLines&&(s+=e/2),Math.round(s)},update:function(t){s.extend(this,t),this.fit()},draw:function(){var t=this.ctx,i=(this.endPoint-this.startPoint)/this.steps,e=Math.round(this.xScalePaddingLeft);this.display&&(t.fillStyle=this.textColor,t.font=this.font,n(this.yLabels,function(n,o){var a=this.endPoint-i*o,h=Math.round(a),l=this.showHorizontalLines;t.textAlign="right",t.textBaseline="middle",this.showLabels&&t.fillText(n,e-10,a),0!==o||l||(l=!0),l&&t.beginPath(),o>0?(t.lineWidth=this.gridLineWidth,t.strokeStyle=this.gridLineColor):(t.lineWidth=this.lineWidth,t.strokeStyle=this.lineColor),h+=s.aliasPixel(t.lineWidth),l&&(t.moveTo(e,h),t.lineTo(this.width,h),t.stroke(),t.closePath()),t.lineWidth=this.lineWidth,t.strokeStyle=this.lineColor,t.beginPath(),t.moveTo(e-5,h),t.lineTo(e,h),t.stroke(),t.closePath()},this),n(this.xLabels,function(i,e){var s=this.calculateX(e)+x(this.lineWidth),n=this.calculateX(e-(this.offsetGridLines?.5:0))+x(this.lineWidth),o=this.xLabelRotation>0,a=this.showVerticalLines;0!==e||a||(a=!0),a&&t.beginPath(),e>0?(t.lineWidth=this.gridLineWidth,t.strokeStyle=this.gridLineColor):(t.lineWidth=this.lineWidth,t.strokeStyle=this.lineColor),a&&(t.moveTo(n,this.endPoint),t.lineTo(n,this.startPoint-3),t.stroke(),t.closePath()),t.lineWidth=this.lineWidth,t.strokeStyle=this.lineColor,t.beginPath(),t.moveTo(n,this.endPoint),t.lineTo(n,this.endPoint+5),t.stroke(),t.closePath(),t.save(),t.translate(s,o?this.endPoint+12:this.endPoint+8),t.rotate(-1*S(this.xLabelRotation)),t.font=this.font,t.textAlign=o?"right":"center",t.textBaseline=o?"middle":"top",t.fillText(i,0,0),t.restore()},this))}}),e.RadialScale=e.Element.extend({initialize:function(){this.size=m([this.height,this.width]),this.drawingArea=this.display?this.size/2-(this.fontSize/2+this.backdropPaddingY):this.size/2},calculateCenterOffset:function(t){var i=this.drawingArea/(this.max-this.min);return(t-this.min)*i},update:function(){this.lineArc?this.drawingArea=this.display?this.size/2-(this.fontSize/2+this.backdropPaddingY):this.size/2:this.setScaleSize(),this.buildYLabels()},buildYLabels:function(){this.yLabels=[];for(var t=v(this.stepValue),i=0;i<=this.steps;i++)this.yLabels.push(C(this.templateString,{value:(this.min+i*this.stepValue).toFixed(t)}))},getCircumference:function(){return 2*Math.PI/this.valuesCount},setScaleSize:function(){var t,i,e,s,n,o,a,h,l,r,c,u,d=m([this.height/2-this.pointLabelFontSize-5,this.width/2]),p=this.width,g=0;for(this.ctx.font=W(this.pointLabelFontSize,this.pointLabelFontStyle,this.pointLabelFontFamily),i=0;i<this.valuesCount;i++)t=this.getPointPosition(i,d),e=this.ctx.measureText(C(this.templateString,{value:this.labels[i]})).width+5,0===i||i===this.valuesCount/2?(s=e/2,t.x+s>p&&(p=t.x+s,n=i),t.x-s<g&&(g=t.x-s,a=i)):i<this.valuesCount/2?t.x+e>p&&(p=t.x+e,n=i):i>this.valuesCount/2&&t.x-e<g&&(g=t.x-e,a=i);l=g,r=Math.ceil(p-this.width),o=this.getIndexAngle(n),h=this.getIndexAngle(a),c=r/Math.sin(o+Math.PI/2),u=l/Math.sin(h+Math.PI/2),c=f(c)?c:0,u=f(u)?u:0,this.drawingArea=d-(u+c)/2,this.setCenterPoint(u,c)},setCenterPoint:function(t,i){var e=this.width-i-this.drawingArea,s=t+this.drawingArea;this.xCenter=(s+e)/2,this.yCenter=this.height/2},getIndexAngle:function(t){var i=2*Math.PI/this.valuesCount;return t*i-Math.PI/2},getPointPosition:function(t,i){var e=this.getIndexAngle(t);return{x:Math.cos(e)*i+this.xCenter,y:Math.sin(e)*i+this.yCenter}},draw:function(){if(this.display){var t=this.ctx;if(n(this.yLabels,function(i,e){if(e>0){var s,n=e*(this.drawingArea/this.steps),o=this.yCenter-n;if(this.lineWidth>0)if(t.strokeStyle=this.lineColor,t.lineWidth=this.lineWidth,this.lineArc)t.beginPath(),t.arc(this.xCenter,this.yCenter,n,0,2*Math.PI),t.closePath(),t.stroke();else{t.beginPath();for(var a=0;a<this.valuesCount;a++)s=this.getPointPosition(a,this.calculateCenterOffset(this.min+e*this.stepValue)),0===a?t.moveTo(s.x,s.y):t.lineTo(s.x,s.y);t.closePath(),t.stroke()}if(this.showLabels){if(t.font=W(this.fontSize,this.fontStyle,this.fontFamily),this.showLabelBackdrop){var h=t.measureText(i).width;t.fillStyle=this.backdropColor,t.fillRect(this.xCenter-h/2-this.backdropPaddingX,o-this.fontSize/2-this.backdropPaddingY,h+2*this.backdropPaddingX,this.fontSize+2*this.backdropPaddingY)}t.textAlign="center",t.textBaseline="middle",t.fillStyle=this.fontColor,t.fillText(i,this.xCenter,o)}}},this),!this.lineArc){t.lineWidth=this.angleLineWidth,t.strokeStyle=this.angleLineColor;for(var i=this.valuesCount-1;i>=0;i--){if(this.angleLineWidth>0){var e=this.getPointPosition(i,this.calculateCenterOffset(this.max));t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(e.x,e.y),t.stroke(),t.closePath()}var s=this.getPointPosition(i,this.calculateCenterOffset(this.max)+5);t.font=W(this.pointLabelFontSize,this.pointLabelFontStyle,this.pointLabelFontFamily),t.fillStyle=this.pointLabelFontColor;var o=this.labels.length,a=this.labels.length/2,h=a/2,l=h>i||i>o-h,r=i===h||i===o-h;t.textAlign=0===i?"center":i===a?"center":a>i?"left":"right",t.textBaseline=r?"middle":l?"bottom":"top",t.fillText(this.labels[i],s.x,s.y)}}}}}),s.addEvent(window,"resize",function(){var t;return function(){clearTimeout(t),t=setTimeout(function(){n(e.instances,function(t){t.options.responsive&&t.resize(t.render,!0)})},50)}}()),p?define(function(){return e}):"object"==typeof module&&module.exports&&(module.exports=e),t.Chart=e,e.noConflict=function(){return t.Chart=i,e}}).call(this),function(){"use strict";var t=this,i=t.Chart,e=i.helpers,s={scaleBeginAtZero:!0,scaleShowGridLines:!0,scaleGridLineColor:"rgba(0,0,0,.05)",scaleGridLineWidth:1,scaleShowHorizontalLines:!0,scaleShowVerticalLines:!0,barShowStroke:!0,barStrokeWidth:2,barValueSpacing:5,barDatasetSpacing:1,legendTemplate:'<ul class="<%=name.toLowerCase()%>-legend"><% for (var i=0; i<datasets.length; i++){%><li><span style="background-color:<%=datasets[i].fillColor%>"></span><%if(datasets[i].label){%><%=datasets[i].label%><%}%></li><%}%></ul>'};i.Type.extend({name:"Bar",defaults:s,initialize:function(t){var s=this.options;this.ScaleClass=i.Scale.extend({offsetGridLines:!0,calculateBarX:function(t,i,e){var n=this.calculateBaseWidth(),o=this.calculateX(e)-n/2,a=this.calculateBarWidth(t);return o+a*i+i*s.barDatasetSpacing+a/2},calculateBaseWidth:function(){return this.calculateX(1)-this.calculateX(0)-2*s.barValueSpacing},calculateBarWidth:function(t){var i=this.calculateBaseWidth()-(t-1)*s.barDatasetSpacing;return i/t}}),this.datasets=[],this.options.showTooltips&&e.bindEvents(this,this.options.tooltipEvents,function(t){var i="mouseout"!==t.type?this.getBarsAtEvent(t):[];this.eachBars(function(t){t.restore(["fillColor","strokeColor"])}),e.each(i,function(t){t.fillColor=t.highlightFill,t.strokeColor=t.highlightStroke}),this.showTooltip(i)}),this.BarClass=i.Rectangle.extend({strokeWidth:this.options.barStrokeWidth,showStroke:this.options.barShowStroke,ctx:this.chart.ctx}),e.each(t.datasets,function(i){var s={label:i.label||null,fillColor:i.fillColor,strokeColor:i.strokeColor,bars:[]};this.datasets.push(s),e.each(i.data,function(e,n){s.bars.push(new this.BarClass({value:e,label:t.labels[n],datasetLabel:i.label,strokeColor:i.strokeColor,fillColor:i.fillColor,highlightFill:i.highlightFill||i.fillColor,highlightStroke:i.highlightStroke||i.strokeColor}))},this)},this),this.buildScale(t.labels),this.BarClass.prototype.base=this.scale.endPoint,this.eachBars(function(t,i,s){e.extend(t,{width:this.scale.calculateBarWidth(this.datasets.length),x:this.scale.calculateBarX(this.datasets.length,s,i),y:this.scale.endPoint}),t.save()},this),this.render()},update:function(){this.scale.update(),e.each(this.activeElements,function(t){t.restore(["fillColor","strokeColor"])}),this.eachBars(function(t){t.save()}),this.render()},eachBars:function(t){e.each(this.datasets,function(i,s){e.each(i.bars,t,this,s)},this)},getBarsAtEvent:function(t){for(var i,s=[],n=e.getRelativePosition(t),o=function(t){s.push(t.bars[i])},a=0;a<this.datasets.length;a++)for(i=0;i<this.datasets[a].bars.length;i++)if(this.datasets[a].bars[i].inRange(n.x,n.y))return e.each(this.datasets,o),s;return s},buildScale:function(t){var i=this,s=function(){var t=[];return i.eachBars(function(i){t.push(i.value)}),t},n={templateString:this.options.scaleLabel,height:this.chart.height,width:this.chart.width,ctx:this.chart.ctx,textColor:this.options.scaleFontColor,fontSize:this.options.scaleFontSize,fontStyle:this.options.scaleFontStyle,fontFamily:this.options.scaleFontFamily,valuesCount:t.length,beginAtZero:this.options.scaleBeginAtZero,integersOnly:this.options.scaleIntegersOnly,calculateYRange:function(t){var i=e.calculateScaleRange(s(),t,this.fontSize,this.beginAtZero,this.integersOnly);e.extend(this,i)},xLabels:t,font:e.fontString(this.options.scaleFontSize,this.options.scaleFontStyle,this.options.scaleFontFamily),lineWidth:this.options.scaleLineWidth,lineColor:this.options.scaleLineColor,showHorizontalLines:this.options.scaleShowHorizontalLines,showVerticalLines:this.options.scaleShowVerticalLines,gridLineWidth:this.options.scaleShowGridLines?this.options.scaleGridLineWidth:0,gridLineColor:this.options.scaleShowGridLines?this.options.scaleGridLineColor:"rgba(0,0,0,0)",padding:this.options.showScale?0:this.options.barShowStroke?this.options.barStrokeWidth:0,showLabels:this.options.scaleShowLabels,display:this.options.showScale};this.options.scaleOverride&&e.extend(n,{calculateYRange:e.noop,steps:this.options.scaleSteps,stepValue:this.options.scaleStepWidth,min:this.options.scaleStartValue,max:this.options.scaleStartValue+this.options.scaleSteps*this.options.scaleStepWidth}),this.scale=new this.ScaleClass(n)},addData:function(t,i){e.each(t,function(t,e){this.datasets[e].bars.push(new this.BarClass({value:t,label:i,x:this.scale.calculateBarX(this.datasets.length,e,this.scale.valuesCount+1),y:this.scale.endPoint,width:this.scale.calculateBarWidth(this.datasets.length),base:this.scale.endPoint,strokeColor:this.datasets[e].strokeColor,fillColor:this.datasets[e].fillColor}))
-},this),this.scale.addXLabel(i),this.update()},removeData:function(){this.scale.removeXLabel(),e.each(this.datasets,function(t){t.bars.shift()},this),this.update()},reflow:function(){e.extend(this.BarClass.prototype,{y:this.scale.endPoint,base:this.scale.endPoint});var t=e.extend({height:this.chart.height,width:this.chart.width});this.scale.update(t)},draw:function(t){var i=t||1;this.clear();this.chart.ctx;this.scale.draw(i),e.each(this.datasets,function(t,s){e.each(t.bars,function(t,e){t.hasValue()&&(t.base=this.scale.endPoint,t.transition({x:this.scale.calculateBarX(this.datasets.length,s,e),y:this.scale.calculateY(t.value),width:this.scale.calculateBarWidth(this.datasets.length)},i).draw())},this)},this)}})}.call(this),function(){"use strict";var t=this,i=t.Chart,e=i.helpers,s={segmentShowStroke:!0,segmentStrokeColor:"#fff",segmentStrokeWidth:2,percentageInnerCutout:50,animationSteps:100,animationEasing:"easeOutBounce",animateRotate:!0,animateScale:!1,legendTemplate:'<ul class="<%=name.toLowerCase()%>-legend"><% for (var i=0; i<segments.length; i++){%><li><span style="background-color:<%=segments[i].fillColor%>"></span><%if(segments[i].label){%><%=segments[i].label%><%}%></li><%}%></ul>'};i.Type.extend({name:"Doughnut",defaults:s,initialize:function(t){this.segments=[],this.outerRadius=(e.min([this.chart.width,this.chart.height])-this.options.segmentStrokeWidth/2)/2,this.SegmentArc=i.Arc.extend({ctx:this.chart.ctx,x:this.chart.width/2,y:this.chart.height/2}),this.options.showTooltips&&e.bindEvents(this,this.options.tooltipEvents,function(t){var i="mouseout"!==t.type?this.getSegmentsAtEvent(t):[];e.each(this.segments,function(t){t.restore(["fillColor"])}),e.each(i,function(t){t.fillColor=t.highlightColor}),this.showTooltip(i)}),this.calculateTotal(t),e.each(t,function(t,i){this.addData(t,i,!0)},this),this.render()},getSegmentsAtEvent:function(t){var i=[],s=e.getRelativePosition(t);return e.each(this.segments,function(t){t.inRange(s.x,s.y)&&i.push(t)},this),i},addData:function(t,i,e){var s=i||this.segments.length;this.segments.splice(s,0,new this.SegmentArc({value:t.value,outerRadius:this.options.animateScale?0:this.outerRadius,innerRadius:this.options.animateScale?0:this.outerRadius/100*this.options.percentageInnerCutout,fillColor:t.color,highlightColor:t.highlight||t.color,showStroke:this.options.segmentShowStroke,strokeWidth:this.options.segmentStrokeWidth,strokeColor:this.options.segmentStrokeColor,startAngle:1.5*Math.PI,circumference:this.options.animateRotate?0:this.calculateCircumference(t.value),label:t.label})),e||(this.reflow(),this.update())},calculateCircumference:function(t){return 2*Math.PI*(Math.abs(t)/this.total)},calculateTotal:function(t){this.total=0,e.each(t,function(t){this.total+=Math.abs(t.value)},this)},update:function(){this.calculateTotal(this.segments),e.each(this.activeElements,function(t){t.restore(["fillColor"])}),e.each(this.segments,function(t){t.save()}),this.render()},removeData:function(t){var i=e.isNumber(t)?t:this.segments.length-1;this.segments.splice(i,1),this.reflow(),this.update()},reflow:function(){e.extend(this.SegmentArc.prototype,{x:this.chart.width/2,y:this.chart.height/2}),this.outerRadius=(e.min([this.chart.width,this.chart.height])-this.options.segmentStrokeWidth/2)/2,e.each(this.segments,function(t){t.update({outerRadius:this.outerRadius,innerRadius:this.outerRadius/100*this.options.percentageInnerCutout})},this)},draw:function(t){var i=t?t:1;this.clear(),e.each(this.segments,function(t,e){t.transition({circumference:this.calculateCircumference(t.value),outerRadius:this.outerRadius,innerRadius:this.outerRadius/100*this.options.percentageInnerCutout},i),t.endAngle=t.startAngle+t.circumference,t.draw(),0===e&&(t.startAngle=1.5*Math.PI),e<this.segments.length-1&&(this.segments[e+1].startAngle=t.endAngle)},this)}}),i.types.Doughnut.extend({name:"Pie",defaults:e.merge(s,{percentageInnerCutout:0})})}.call(this),function(){"use strict";var t=this,i=t.Chart,e=i.helpers,s={scaleShowGridLines:!0,scaleGridLineColor:"rgba(0,0,0,.05)",scaleGridLineWidth:1,scaleShowHorizontalLines:!0,scaleShowVerticalLines:!0,bezierCurve:!0,bezierCurveTension:.4,pointDot:!0,pointDotRadius:4,pointDotStrokeWidth:1,pointHitDetectionRadius:20,datasetStroke:!0,datasetStrokeWidth:2,datasetFill:!0,legendTemplate:'<ul class="<%=name.toLowerCase()%>-legend"><% for (var i=0; i<datasets.length; i++){%><li><span style="background-color:<%=datasets[i].strokeColor%>"></span><%if(datasets[i].label){%><%=datasets[i].label%><%}%></li><%}%></ul>'};i.Type.extend({name:"Line",defaults:s,initialize:function(t){this.PointClass=i.Point.extend({strokeWidth:this.options.pointDotStrokeWidth,radius:this.options.pointDotRadius,display:this.options.pointDot,hitDetectionRadius:this.options.pointHitDetectionRadius,ctx:this.chart.ctx,inRange:function(t){return Math.pow(t-this.x,2)<Math.pow(this.radius+this.hitDetectionRadius,2)}}),this.datasets=[],this.options.showTooltips&&e.bindEvents(this,this.options.tooltipEvents,function(t){var i="mouseout"!==t.type?this.getPointsAtEvent(t):[];this.eachPoints(function(t){t.restore(["fillColor","strokeColor"])}),e.each(i,function(t){t.fillColor=t.highlightFill,t.strokeColor=t.highlightStroke}),this.showTooltip(i)}),e.each(t.datasets,function(i){var s={label:i.label||null,fillColor:i.fillColor,strokeColor:i.strokeColor,pointColor:i.pointColor,pointStrokeColor:i.pointStrokeColor,points:[]};this.datasets.push(s),e.each(i.data,function(e,n){s.points.push(new this.PointClass({value:e,label:t.labels[n],datasetLabel:i.label,strokeColor:i.pointStrokeColor,fillColor:i.pointColor,highlightFill:i.pointHighlightFill||i.pointColor,highlightStroke:i.pointHighlightStroke||i.pointStrokeColor}))},this),this.buildScale(t.labels),this.eachPoints(function(t,i){e.extend(t,{x:this.scale.calculateX(i),y:this.scale.endPoint}),t.save()},this)},this),this.render()},update:function(){this.scale.update(),e.each(this.activeElements,function(t){t.restore(["fillColor","strokeColor"])}),this.eachPoints(function(t){t.save()}),this.render()},eachPoints:function(t){e.each(this.datasets,function(i){e.each(i.points,t,this)},this)},getPointsAtEvent:function(t){var i=[],s=e.getRelativePosition(t);return e.each(this.datasets,function(t){e.each(t.points,function(t){t.inRange(s.x,s.y)&&i.push(t)})},this),i},buildScale:function(t){var s=this,n=function(){var t=[];return s.eachPoints(function(i){t.push(i.value)}),t},o={templateString:this.options.scaleLabel,height:this.chart.height,width:this.chart.width,ctx:this.chart.ctx,textColor:this.options.scaleFontColor,fontSize:this.options.scaleFontSize,fontStyle:this.options.scaleFontStyle,fontFamily:this.options.scaleFontFamily,valuesCount:t.length,beginAtZero:this.options.scaleBeginAtZero,integersOnly:this.options.scaleIntegersOnly,calculateYRange:function(t){var i=e.calculateScaleRange(n(),t,this.fontSize,this.beginAtZero,this.integersOnly);e.extend(this,i)},xLabels:t,font:e.fontString(this.options.scaleFontSize,this.options.scaleFontStyle,this.options.scaleFontFamily),lineWidth:this.options.scaleLineWidth,lineColor:this.options.scaleLineColor,showHorizontalLines:this.options.scaleShowHorizontalLines,showVerticalLines:this.options.scaleShowVerticalLines,gridLineWidth:this.options.scaleShowGridLines?this.options.scaleGridLineWidth:0,gridLineColor:this.options.scaleShowGridLines?this.options.scaleGridLineColor:"rgba(0,0,0,0)",padding:this.options.showScale?0:this.options.pointDotRadius+this.options.pointDotStrokeWidth,showLabels:this.options.scaleShowLabels,display:this.options.showScale};this.options.scaleOverride&&e.extend(o,{calculateYRange:e.noop,steps:this.options.scaleSteps,stepValue:this.options.scaleStepWidth,min:this.options.scaleStartValue,max:this.options.scaleStartValue+this.options.scaleSteps*this.options.scaleStepWidth}),this.scale=new i.Scale(o)},addData:function(t,i){e.each(t,function(t,e){this.datasets[e].points.push(new this.PointClass({value:t,label:i,x:this.scale.calculateX(this.scale.valuesCount+1),y:this.scale.endPoint,strokeColor:this.datasets[e].pointStrokeColor,fillColor:this.datasets[e].pointColor}))},this),this.scale.addXLabel(i),this.update()},removeData:function(){this.scale.removeXLabel(),e.each(this.datasets,function(t){t.points.shift()},this),this.update()},reflow:function(){var t=e.extend({height:this.chart.height,width:this.chart.width});this.scale.update(t)},draw:function(t){var i=t||1;this.clear();var s=this.chart.ctx,n=function(t){return null!==t.value},o=function(t,i,s){return e.findNextWhere(i,n,s)||t},a=function(t,i,s){return e.findPreviousWhere(i,n,s)||t};this.scale.draw(i),e.each(this.datasets,function(t){var h=e.where(t.points,n);e.each(t.points,function(t,e){t.hasValue()&&t.transition({y:this.scale.calculateY(t.value),x:this.scale.calculateX(e)},i)},this),this.options.bezierCurve&&e.each(h,function(t,i){var s=i>0&&i<h.length-1?this.options.bezierCurveTension:0;t.controlPoints=e.splineCurve(a(t,h,i),t,o(t,h,i),s),t.controlPoints.outer.y>this.scale.endPoint?t.controlPoints.outer.y=this.scale.endPoint:t.controlPoints.outer.y<this.scale.startPoint&&(t.controlPoints.outer.y=this.scale.startPoint),t.controlPoints.inner.y>this.scale.endPoint?t.controlPoints.inner.y=this.scale.endPoint:t.controlPoints.inner.y<this.scale.startPoint&&(t.controlPoints.inner.y=this.scale.startPoint)},this),s.lineWidth=this.options.datasetStrokeWidth,s.strokeStyle=t.strokeColor,s.beginPath(),e.each(h,function(t,i){if(0===i)s.moveTo(t.x,t.y);else if(this.options.bezierCurve){var e=a(t,h,i);s.bezierCurveTo(e.controlPoints.outer.x,e.controlPoints.outer.y,t.controlPoints.inner.x,t.controlPoints.inner.y,t.x,t.y)}else s.lineTo(t.x,t.y)},this),s.stroke(),this.options.datasetFill&&h.length>0&&(s.lineTo(h[h.length-1].x,this.scale.endPoint),s.lineTo(h[0].x,this.scale.endPoint),s.fillStyle=t.fillColor,s.closePath(),s.fill()),e.each(h,function(t){t.draw()})},this)}})}.call(this),function(){"use strict";var t=this,i=t.Chart,e=i.helpers,s={scaleShowLabelBackdrop:!0,scaleBackdropColor:"rgba(255,255,255,0.75)",scaleBeginAtZero:!0,scaleBackdropPaddingY:2,scaleBackdropPaddingX:2,scaleShowLine:!0,segmentShowStroke:!0,segmentStrokeColor:"#fff",segmentStrokeWidth:2,animationSteps:100,animationEasing:"easeOutBounce",animateRotate:!0,animateScale:!1,legendTemplate:'<ul class="<%=name.toLowerCase()%>-legend"><% for (var i=0; i<segments.length; i++){%><li><span style="background-color:<%=segments[i].fillColor%>"></span><%if(segments[i].label){%><%=segments[i].label%><%}%></li><%}%></ul>'};i.Type.extend({name:"PolarArea",defaults:s,initialize:function(t){this.segments=[],this.SegmentArc=i.Arc.extend({showStroke:this.options.segmentShowStroke,strokeWidth:this.options.segmentStrokeWidth,strokeColor:this.options.segmentStrokeColor,ctx:this.chart.ctx,innerRadius:0,x:this.chart.width/2,y:this.chart.height/2}),this.scale=new i.RadialScale({display:this.options.showScale,fontStyle:this.options.scaleFontStyle,fontSize:this.options.scaleFontSize,fontFamily:this.options.scaleFontFamily,fontColor:this.options.scaleFontColor,showLabels:this.options.scaleShowLabels,showLabelBackdrop:this.options.scaleShowLabelBackdrop,backdropColor:this.options.scaleBackdropColor,backdropPaddingY:this.options.scaleBackdropPaddingY,backdropPaddingX:this.options.scaleBackdropPaddingX,lineWidth:this.options.scaleShowLine?this.options.scaleLineWidth:0,lineColor:this.options.scaleLineColor,lineArc:!0,width:this.chart.width,height:this.chart.height,xCenter:this.chart.width/2,yCenter:this.chart.height/2,ctx:this.chart.ctx,templateString:this.options.scaleLabel,valuesCount:t.length}),this.updateScaleRange(t),this.scale.update(),e.each(t,function(t,i){this.addData(t,i,!0)},this),this.options.showTooltips&&e.bindEvents(this,this.options.tooltipEvents,function(t){var i="mouseout"!==t.type?this.getSegmentsAtEvent(t):[];e.each(this.segments,function(t){t.restore(["fillColor"])}),e.each(i,function(t){t.fillColor=t.highlightColor}),this.showTooltip(i)}),this.render()},getSegmentsAtEvent:function(t){var i=[],s=e.getRelativePosition(t);return e.each(this.segments,function(t){t.inRange(s.x,s.y)&&i.push(t)},this),i},addData:function(t,i,e){var s=i||this.segments.length;this.segments.splice(s,0,new this.SegmentArc({fillColor:t.color,highlightColor:t.highlight||t.color,label:t.label,value:t.value,outerRadius:this.options.animateScale?0:this.scale.calculateCenterOffset(t.value),circumference:this.options.animateRotate?0:this.scale.getCircumference(),startAngle:1.5*Math.PI})),e||(this.reflow(),this.update())},removeData:function(t){var i=e.isNumber(t)?t:this.segments.length-1;this.segments.splice(i,1),this.reflow(),this.update()},calculateTotal:function(t){this.total=0,e.each(t,function(t){this.total+=t.value},this),this.scale.valuesCount=this.segments.length},updateScaleRange:function(t){var i=[];e.each(t,function(t){i.push(t.value)});var s=this.options.scaleOverride?{steps:this.options.scaleSteps,stepValue:this.options.scaleStepWidth,min:this.options.scaleStartValue,max:this.options.scaleStartValue+this.options.scaleSteps*this.options.scaleStepWidth}:e.calculateScaleRange(i,e.min([this.chart.width,this.chart.height])/2,this.options.scaleFontSize,this.options.scaleBeginAtZero,this.options.scaleIntegersOnly);e.extend(this.scale,s,{size:e.min([this.chart.width,this.chart.height]),xCenter:this.chart.width/2,yCenter:this.chart.height/2})},update:function(){this.calculateTotal(this.segments),e.each(this.segments,function(t){t.save()}),this.reflow(),this.render()},reflow:function(){e.extend(this.SegmentArc.prototype,{x:this.chart.width/2,y:this.chart.height/2}),this.updateScaleRange(this.segments),this.scale.update(),e.extend(this.scale,{xCenter:this.chart.width/2,yCenter:this.chart.height/2}),e.each(this.segments,function(t){t.update({outerRadius:this.scale.calculateCenterOffset(t.value)})},this)},draw:function(t){var i=t||1;this.clear(),e.each(this.segments,function(t,e){t.transition({circumference:this.scale.getCircumference(),outerRadius:this.scale.calculateCenterOffset(t.value)},i),t.endAngle=t.startAngle+t.circumference,0===e&&(t.startAngle=1.5*Math.PI),e<this.segments.length-1&&(this.segments[e+1].startAngle=t.endAngle),t.draw()},this),this.scale.draw()}})}.call(this),function(){"use strict";var t=this,i=t.Chart,e=i.helpers;i.Type.extend({name:"Radar",defaults:{scaleShowLine:!0,angleShowLineOut:!0,scaleShowLabels:!1,scaleBeginAtZero:!0,angleLineColor:"rgba(0,0,0,.1)",angleLineWidth:1,pointLabelFontFamily:"'Arial'",pointLabelFontStyle:"normal",pointLabelFontSize:10,pointLabelFontColor:"#666",pointDot:!0,pointDotRadius:3,pointDotStrokeWidth:1,pointHitDetectionRadius:20,datasetStroke:!0,datasetStrokeWidth:2,datasetFill:!0,legendTemplate:'<ul class="<%=name.toLowerCase()%>-legend"><% for (var i=0; i<datasets.length; i++){%><li><span style="background-color:<%=datasets[i].strokeColor%>"></span><%if(datasets[i].label){%><%=datasets[i].label%><%}%></li><%}%></ul>'},initialize:function(t){this.PointClass=i.Point.extend({strokeWidth:this.options.pointDotStrokeWidth,radius:this.options.pointDotRadius,display:this.options.pointDot,hitDetectionRadius:this.options.pointHitDetectionRadius,ctx:this.chart.ctx}),this.datasets=[],this.buildScale(t),this.options.showTooltips&&e.bindEvents(this,this.options.tooltipEvents,function(t){var i="mouseout"!==t.type?this.getPointsAtEvent(t):[];this.eachPoints(function(t){t.restore(["fillColor","strokeColor"])}),e.each(i,function(t){t.fillColor=t.highlightFill,t.strokeColor=t.highlightStroke}),this.showTooltip(i)}),e.each(t.datasets,function(i){var s={label:i.label||null,fillColor:i.fillColor,strokeColor:i.strokeColor,pointColor:i.pointColor,pointStrokeColor:i.pointStrokeColor,points:[]};this.datasets.push(s),e.each(i.data,function(e,n){var o;this.scale.animation||(o=this.scale.getPointPosition(n,this.scale.calculateCenterOffset(e))),s.points.push(new this.PointClass({value:e,label:t.labels[n],datasetLabel:i.label,x:this.options.animation?this.scale.xCenter:o.x,y:this.options.animation?this.scale.yCenter:o.y,strokeColor:i.pointStrokeColor,fillColor:i.pointColor,highlightFill:i.pointHighlightFill||i.pointColor,highlightStroke:i.pointHighlightStroke||i.pointStrokeColor}))},this)},this),this.render()},eachPoints:function(t){e.each(this.datasets,function(i){e.each(i.points,t,this)},this)},getPointsAtEvent:function(t){var i=e.getRelativePosition(t),s=e.getAngleFromPoint({x:this.scale.xCenter,y:this.scale.yCenter},i),n=2*Math.PI/this.scale.valuesCount,o=Math.round((s.angle-1.5*Math.PI)/n),a=[];return(o>=this.scale.valuesCount||0>o)&&(o=0),s.distance<=this.scale.drawingArea&&e.each(this.datasets,function(t){a.push(t.points[o])}),a},buildScale:function(t){this.scale=new i.RadialScale({display:this.options.showScale,fontStyle:this.options.scaleFontStyle,fontSize:this.options.scaleFontSize,fontFamily:this.options.scaleFontFamily,fontColor:this.options.scaleFontColor,showLabels:this.options.scaleShowLabels,showLabelBackdrop:this.options.scaleShowLabelBackdrop,backdropColor:this.options.scaleBackdropColor,backdropPaddingY:this.options.scaleBackdropPaddingY,backdropPaddingX:this.options.scaleBackdropPaddingX,lineWidth:this.options.scaleShowLine?this.options.scaleLineWidth:0,lineColor:this.options.scaleLineColor,angleLineColor:this.options.angleLineColor,angleLineWidth:this.options.angleShowLineOut?this.options.angleLineWidth:0,pointLabelFontColor:this.options.pointLabelFontColor,pointLabelFontSize:this.options.pointLabelFontSize,pointLabelFontFamily:this.options.pointLabelFontFamily,pointLabelFontStyle:this.options.pointLabelFontStyle,height:this.chart.height,width:this.chart.width,xCenter:this.chart.width/2,yCenter:this.chart.height/2,ctx:this.chart.ctx,templateString:this.options.scaleLabel,labels:t.labels,valuesCount:t.datasets[0].data.length}),this.scale.setScaleSize(),this.updateScaleRange(t.datasets),this.scale.buildYLabels()},updateScaleRange:function(t){var i=function(){var i=[];return e.each(t,function(t){t.data?i=i.concat(t.data):e.each(t.points,function(t){i.push(t.value)})}),i}(),s=this.options.scaleOverride?{steps:this.options.scaleSteps,stepValue:this.options.scaleStepWidth,min:this.options.scaleStartValue,max:this.options.scaleStartValue+this.options.scaleSteps*this.options.scaleStepWidth}:e.calculateScaleRange(i,e.min([this.chart.width,this.chart.height])/2,this.options.scaleFontSize,this.options.scaleBeginAtZero,this.options.scaleIntegersOnly);e.extend(this.scale,s)},addData:function(t,i){this.scale.valuesCount++,e.each(t,function(t,e){var s=this.scale.getPointPosition(this.scale.valuesCount,this.scale.calculateCenterOffset(t));this.datasets[e].points.push(new this.PointClass({value:t,label:i,x:s.x,y:s.y,strokeColor:this.datasets[e].pointStrokeColor,fillColor:this.datasets[e].pointColor}))},this),this.scale.labels.push(i),this.reflow(),this.update()},removeData:function(){this.scale.valuesCount--,this.scale.labels.shift(),e.each(this.datasets,function(t){t.points.shift()},this),this.reflow(),this.update()},update:function(){this.eachPoints(function(t){t.save()}),this.reflow(),this.render()},reflow:function(){e.extend(this.scale,{width:this.chart.width,height:this.chart.height,size:e.min([this.chart.width,this.chart.height]),xCenter:this.chart.width/2,yCenter:this.chart.height/2}),this.updateScaleRange(this.datasets),this.scale.setScaleSize(),this.scale.buildYLabels()},draw:function(t){var i=t||1,s=this.chart.ctx;this.clear(),this.scale.draw(),e.each(this.datasets,function(t){e.each(t.points,function(t,e){t.hasValue()&&t.transition(this.scale.getPointPosition(e,this.scale.calculateCenterOffset(t.value)),i)},this),s.lineWidth=this.options.datasetStrokeWidth,s.strokeStyle=t.strokeColor,s.beginPath(),e.each(t.points,function(t,i){0===i?s.moveTo(t.x,t.y):s.lineTo(t.x,t.y)},this),s.closePath(),s.stroke(),s.fillStyle=t.fillColor,s.fill(),e.each(t.points,function(t){t.hasValue()&&t.draw()})},this)}})}.call(this); \ No newline at end of file
+!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";const t="undefined"==typeof window?function(t){return t()}:window.requestAnimationFrame;function e(e,i,s){const n=s||(t=>Array.prototype.slice.call(t));let o=!1,a=[];return function(...s){a=n(s),o||(o=!0,t.call(window,(()=>{o=!1,e.apply(i,a)})))}}function i(t,e){let i;return function(...s){return e?(clearTimeout(i),i=setTimeout(t,e,s)):t.apply(this,s),e}}const s=t=>"start"===t?"left":"end"===t?"right":"center",n=(t,e,i)=>"start"===t?e:"end"===t?i:(e+i)/2,o=(t,e,i,s)=>t===(s?"left":"right")?i:"center"===t?(e+i)/2:e;var a=new class{constructor(){this._request=null,this._charts=new Map,this._running=!1,this._lastDate=void 0}_notify(t,e,i,s){const n=e.listeners[s],o=e.duration;n.forEach((s=>s({chart:t,initial:e.initial,numSteps:o,currentStep:Math.min(i-e.start,o)})))}_refresh(){this._request||(this._running=!0,this._request=t.call(window,(()=>{this._update(),this._request=null,this._running&&this._refresh()})))}_update(t=Date.now()){let e=0;this._charts.forEach(((i,s)=>{if(!i.running||!i.items.length)return;const n=i.items;let o,a=n.length-1,r=!1;for(;a>=0;--a)o=n[a],o._active?(o._total>i.duration&&(i.duration=o._total),o.tick(t),r=!0):(n[a]=n[n.length-1],n.pop());r&&(s.draw(),this._notify(s,i,t,"progress")),n.length||(i.running=!1,this._notify(s,i,t,"complete"),i.initial=!1),e+=n.length})),this._lastDate=t,0===e&&(this._running=!1)}_getAnims(t){const e=this._charts;let i=e.get(t);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,i)),i}listen(t,e,i){this._getAnims(t).listeners[e].push(i)}add(t,e){e&&e.length&&this._getAnims(t).items.push(...e)}has(t){return this._getAnims(t).items.length>0}start(t){const e=this._charts.get(t);e&&(e.running=!0,e.start=Date.now(),e.duration=e.items.reduce(((t,e)=>Math.max(t,e._duration)),0),this._refresh())}running(t){if(!this._running)return!1;const e=this._charts.get(t);return!!(e&&e.running&&e.items.length)}stop(t){const e=this._charts.get(t);if(!e||!e.items.length)return;const i=e.items;let s=i.length-1;for(;s>=0;--s)i[s].cancel();e.items=[],this._notify(t,e,Date.now(),"complete")}remove(t){return this._charts.delete(t)}};
+/*!
+ * @kurkle/color v0.2.1
+ * https://github.com/kurkle/color#readme
+ * (c) 2022 Jukka Kurkela
+ * Released under the MIT License
+ */function r(t){return t+.5|0}const l=(t,e,i)=>Math.max(Math.min(t,i),e);function h(t){return l(r(2.55*t),0,255)}function c(t){return l(r(255*t),0,255)}function d(t){return l(r(t/2.55)/100,0,1)}function u(t){return l(r(100*t),0,100)}const f={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},g=[..."0123456789ABCDEF"],p=t=>g[15&t],m=t=>g[(240&t)>>4]+g[15&t],b=t=>(240&t)>>4==(15&t);function x(t){var e=(t=>b(t.r)&&b(t.g)&&b(t.b)&&b(t.a))(t)?p:m;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const _=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function y(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function v(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function w(t,e,i){const s=y(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function M(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e<i?6:0):e===n?(i-t)/s+2:(t-e)/s+4}(e,i,s,h,n),r=60*r+.5),[0|r,l||0,a]}function k(t,e,i,s){return(Array.isArray(e)?t(e[0],e[1],e[2]):t(e,i,s)).map(c)}function S(t,e,i){return k(y,t,e,i)}function P(t){return(t%360+360)%360}function D(t){const e=_.exec(t);let i,s=255;if(!e)return;e[5]!==i&&(s=e[6]?h(+e[5]):c(+e[5]));const n=P(+e[2]),o=+e[3]/100,a=+e[4]/100;return i="hwb"===e[1]?function(t,e,i){return k(w,t,e,i)}(n,o,a):"hsv"===e[1]?function(t,e,i){return k(v,t,e,i)}(n,o,a):S(n,o,a),{r:i[0],g:i[1],b:i[2],a:s}}const C={x:"dark",Z:"light",Y:"re",X:"blu",W:"gr",V:"medium",U:"slate",A:"ee",T:"ol",S:"or",B:"ra",C:"lateg",D:"ights",R:"in",Q:"turquois",E:"hi",P:"ro",O:"al",N:"le",M:"de",L:"yello",F:"en",K:"ch",G:"arks",H:"ea",I:"ightg",J:"wh"},O={OiceXe:"f0f8ff",antiquewEte:"faebd7",aqua:"ffff",aquamarRe:"7fffd4",azuY:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"0",blanKedOmond:"ffebcd",Xe:"ff",XeviTet:"8a2be2",bPwn:"a52a2a",burlywood:"deb887",caMtXe:"5f9ea0",KartYuse:"7fff00",KocTate:"d2691e",cSO:"ff7f50",cSnflowerXe:"6495ed",cSnsilk:"fff8dc",crimson:"dc143c",cyan:"ffff",xXe:"8b",xcyan:"8b8b",xgTMnPd:"b8860b",xWay:"a9a9a9",xgYF:"6400",xgYy:"a9a9a9",xkhaki:"bdb76b",xmagFta:"8b008b",xTivegYF:"556b2f",xSange:"ff8c00",xScEd:"9932cc",xYd:"8b0000",xsOmon:"e9967a",xsHgYF:"8fbc8f",xUXe:"483d8b",xUWay:"2f4f4f",xUgYy:"2f4f4f",xQe:"ced1",xviTet:"9400d3",dAppRk:"ff1493",dApskyXe:"bfff",dimWay:"696969",dimgYy:"696969",dodgerXe:"1e90ff",fiYbrick:"b22222",flSOwEte:"fffaf0",foYstWAn:"228b22",fuKsia:"ff00ff",gaRsbSo:"dcdcdc",ghostwEte:"f8f8ff",gTd:"ffd700",gTMnPd:"daa520",Way:"808080",gYF:"8000",gYFLw:"adff2f",gYy:"808080",honeyMw:"f0fff0",hotpRk:"ff69b4",RdianYd:"cd5c5c",Rdigo:"4b0082",ivSy:"fffff0",khaki:"f0e68c",lavFMr:"e6e6fa",lavFMrXsh:"fff0f5",lawngYF:"7cfc00",NmoncEffon:"fffacd",ZXe:"add8e6",ZcSO:"f08080",Zcyan:"e0ffff",ZgTMnPdLw:"fafad2",ZWay:"d3d3d3",ZgYF:"90ee90",ZgYy:"d3d3d3",ZpRk:"ffb6c1",ZsOmon:"ffa07a",ZsHgYF:"20b2aa",ZskyXe:"87cefa",ZUWay:"778899",ZUgYy:"778899",ZstAlXe:"b0c4de",ZLw:"ffffe0",lime:"ff00",limegYF:"32cd32",lRF:"faf0e6",magFta:"ff00ff",maPon:"800000",VaquamarRe:"66cdaa",VXe:"cd",VScEd:"ba55d3",VpurpN:"9370db",VsHgYF:"3cb371",VUXe:"7b68ee",VsprRggYF:"fa9a",VQe:"48d1cc",VviTetYd:"c71585",midnightXe:"191970",mRtcYam:"f5fffa",mistyPse:"ffe4e1",moccasR:"ffe4b5",navajowEte:"ffdead",navy:"80",Tdlace:"fdf5e6",Tive:"808000",TivedBb:"6b8e23",Sange:"ffa500",SangeYd:"ff4500",ScEd:"da70d6",pOegTMnPd:"eee8aa",pOegYF:"98fb98",pOeQe:"afeeee",pOeviTetYd:"db7093",papayawEp:"ffefd5",pHKpuff:"ffdab9",peru:"cd853f",pRk:"ffc0cb",plum:"dda0dd",powMrXe:"b0e0e6",purpN:"800080",YbeccapurpN:"663399",Yd:"ff0000",Psybrown:"bc8f8f",PyOXe:"4169e1",saddNbPwn:"8b4513",sOmon:"fa8072",sandybPwn:"f4a460",sHgYF:"2e8b57",sHshell:"fff5ee",siFna:"a0522d",silver:"c0c0c0",skyXe:"87ceeb",UXe:"6a5acd",UWay:"708090",UgYy:"708090",snow:"fffafa",sprRggYF:"ff7f",stAlXe:"4682b4",tan:"d2b48c",teO:"8080",tEstN:"d8bfd8",tomato:"ff6347",Qe:"40e0d0",viTet:"ee82ee",JHt:"f5deb3",wEte:"ffffff",wEtesmoke:"f5f5f5",Lw:"ffff00",LwgYF:"9acd32"};let A;function T(t){A||(A=function(){const t={},e=Object.keys(O),i=Object.keys(C);let s,n,o,a,r;for(s=0;s<e.length;s++){for(a=r=e[s],n=0;n<i.length;n++)o=i[n],r=r.replace(o,C[o]);o=parseInt(O[a],16),t[r]=[o>>16&255,o>>8&255,255&o]}return t}(),A.transparent=[0,0,0,0]);const e=A[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const L=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const R=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,E=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function I(t,e,i){if(t){let s=M(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),s=S(s),t.r=s[0],t.g=s[1],t.b=s[2]}}function z(t,e){return t?Object.assign(e||{},t):t}function F(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=c(t[3]))):(e=z(t,{r:0,g:0,b:0,a:1})).a=c(e.a),e}function B(t){return"r"===t.charAt(0)?function(t){const e=L.exec(t);let i,s,n,o=255;if(e){if(e[7]!==i){const t=+e[7];o=e[8]?h(t):l(255*t,0,255)}return i=+e[1],s=+e[3],n=+e[5],i=255&(e[2]?h(i):l(i,0,255)),s=255&(e[4]?h(s):l(s,0,255)),n=255&(e[6]?h(n):l(n,0,255)),{r:i,g:s,b:n,a:o}}}(t):D(t)}class V{constructor(t){if(t instanceof V)return t;const e=typeof t;let i;var s,n,o;"object"===e?i=F(t):"string"===e&&(o=(s=t).length,"#"===s[0]&&(4===o||5===o?n={r:255&17*f[s[1]],g:255&17*f[s[2]],b:255&17*f[s[3]],a:5===o?17*f[s[4]]:255}:7!==o&&9!==o||(n={r:f[s[1]]<<4|f[s[2]],g:f[s[3]]<<4|f[s[4]],b:f[s[5]]<<4|f[s[6]],a:9===o?f[s[7]]<<4|f[s[8]]:255})),i=n||T(t)||B(t)),this._rgb=i,this._valid=!!i}get valid(){return this._valid}get rgb(){var t=z(this._rgb);return t&&(t.a=d(t.a)),t}set rgb(t){this._rgb=F(t)}rgbString(){return this._valid?(t=this._rgb)&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${d(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`):void 0;var t}hexString(){return this._valid?x(this._rgb):void 0}hslString(){return this._valid?function(t){if(!t)return;const e=M(t),i=e[0],s=u(e[1]),n=u(e[2]);return t.a<255?`hsla(${i}, ${s}%, ${n}%, ${d(t.a)})`:`hsl(${i}, ${s}%, ${n}%)`}(this._rgb):void 0}mix(t,e){if(t){const i=this.rgb,s=t.rgb;let n;const o=e===n?.5:e,a=2*o-1,r=i.a-s.a,l=((a*r==-1?a:(a+r)/(1+a*r))+1)/2;n=1-l,i.r=255&l*i.r+n*s.r+.5,i.g=255&l*i.g+n*s.g+.5,i.b=255&l*i.b+n*s.b+.5,i.a=o*i.a+(1-o)*s.a,this.rgb=i}return this}interpolate(t,e){return t&&(this._rgb=function(t,e,i){const s=E(d(t.r)),n=E(d(t.g)),o=E(d(t.b));return{r:c(R(s+i*(E(d(e.r))-s))),g:c(R(n+i*(E(d(e.g))-n))),b:c(R(o+i*(E(d(e.b))-o))),a:t.a+i*(e.a-t.a)}}(this._rgb,t._rgb,e)),this}clone(){return new V(this.rgb)}alpha(t){return this._rgb.a=c(t),this}clearer(t){return this._rgb.a*=1-t,this}greyscale(){const t=this._rgb,e=r(.3*t.r+.59*t.g+.11*t.b);return t.r=t.g=t.b=e,this}opaquer(t){return this._rgb.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return I(this._rgb,2,t),this}darken(t){return I(this._rgb,2,-t),this}saturate(t){return I(this._rgb,1,t),this}desaturate(t){return I(this._rgb,1,-t),this}rotate(t){return function(t,e){var i=M(t);i[0]=P(i[0]+e),i=S(i),t.r=i[0],t.g=i[1],t.b=i[2]}(this._rgb,t),this}}function N(t){return new V(t)}function W(t){if(t&&"object"==typeof t){const e=t.toString();return"[object CanvasPattern]"===e||"[object CanvasGradient]"===e}return!1}function H(t){return W(t)?t:N(t)}function j(t){return W(t)?t:N(t).saturate(.5).darken(.1).hexString()}function $(){}const Y=function(){let t=0;return function(){return t++}}();function U(t){return null==t}function X(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function q(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}const K=t=>("number"==typeof t||t instanceof Number)&&isFinite(+t);function G(t,e){return K(t)?t:e}function Z(t,e){return void 0===t?e:t}const J=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:t/e,Q=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function tt(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function et(t,e,i,s){let n,o,a;if(X(t))if(o=t.length,s)for(n=o-1;n>=0;n--)e.call(i,t[n],n);else for(n=0;n<o;n++)e.call(i,t[n],n);else if(q(t))for(a=Object.keys(t),o=a.length,n=0;n<o;n++)e.call(i,t[a[n]],a[n])}function it(t,e){let i,s,n,o;if(!t||!e||t.length!==e.length)return!1;for(i=0,s=t.length;i<s;++i)if(n=t[i],o=e[i],n.datasetIndex!==o.datasetIndex||n.index!==o.index)return!1;return!0}function st(t){if(X(t))return t.map(st);if(q(t)){const e=Object.create(null),i=Object.keys(t),s=i.length;let n=0;for(;n<s;++n)e[i[n]]=st(t[i[n]]);return e}return t}function nt(t){return-1===["__proto__","prototype","constructor"].indexOf(t)}function ot(t,e,i,s){if(!nt(t))return;const n=e[t],o=i[t];q(n)&&q(o)?at(n,o,s):e[t]=st(o)}function at(t,e,i){const s=X(e)?e:[e],n=s.length;if(!q(t))return t;const o=(i=i||{}).merger||ot;for(let a=0;a<n;++a){if(!q(e=s[a]))continue;const n=Object.keys(e);for(let s=0,a=n.length;s<a;++s)o(n[s],t,e,i)}return t}function rt(t,e){return at(t,e,{merger:lt})}function lt(t,e,i){if(!nt(t))return;const s=e[t],n=i[t];q(s)&&q(n)?rt(s,n):Object.prototype.hasOwnProperty.call(e,t)||(e[t]=st(n))}function ht(t,e){const i=t.indexOf(".",e);return-1===i?t.length:i}function ct(t,e){if(""===e)return t;let i=0,s=ht(e,i);for(;t&&s>i;)t=t[e.slice(i,s)],i=s+1,s=ht(e,i);return t}function dt(t){return t.charAt(0).toUpperCase()+t.slice(1)}const ut=t=>void 0!==t,ft=t=>"function"==typeof t,gt=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function pt(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const mt=Object.create(null),bt=Object.create(null);function xt(t,e){if(!e)return t;const i=e.split(".");for(let e=0,s=i.length;e<s;++e){const s=i[e];t=t[s]||(t[s]=Object.create(null))}return t}function _t(t,e,i){return"string"==typeof e?at(xt(t,e),i):at(xt(t,""),e)}var yt=new class{constructor(t){this.animation=void 0,this.backgroundColor="rgba(0,0,0,0.1)",this.borderColor="rgba(0,0,0,0.1)",this.color="#666",this.datasets={},this.devicePixelRatio=t=>t.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(t,e)=>j(e.backgroundColor),this.hoverBorderColor=(t,e)=>j(e.borderColor),this.hoverColor=(t,e)=>j(e.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t)}set(t,e){return _t(this,t,e)}get(t){return xt(this,t)}describe(t,e){return _t(bt,t,e)}override(t,e){return _t(mt,t,e)}route(t,e,i,s){const n=xt(this,t),o=xt(this,i),a="_"+e;Object.defineProperties(n,{[a]:{value:n[e],writable:!0},[e]:{enumerable:!0,get(){const t=this[a],e=o[s];return q(t)?Object.assign({},e,t):Z(t,e)},set(t){this[a]=t}}})}}({_scriptable:t=>!t.startsWith("on"),_indexable:t=>"events"!==t,hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}});function vt(t,e,i){i=i||(i=>t[i]<e);let s,n=t.length-1,o=0;for(;n-o>1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const wt=(t,e,i)=>vt(t,i,(s=>t[s][e]<i)),Mt=(t,e,i)=>vt(t,i,(s=>t[s][e]>=i));function kt(t,e,i){let s=0,n=t.length;for(;s<n&&t[s]<e;)s++;for(;n>s&&t[n-1]>i;)n--;return s>0||n<t.length?t.slice(s,n):t}const St=["push","pop","shift","splice","unshift"];function Pt(t,e){t._chartjs?t._chartjs.listeners.push(e):(Object.defineProperty(t,"_chartjs",{configurable:!0,enumerable:!1,value:{listeners:[e]}}),St.forEach((e=>{const i="_onData"+dt(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...e){const n=s.apply(this,e);return t._chartjs.listeners.forEach((t=>{"function"==typeof t[i]&&t[i](...e)})),n}})})))}function Dt(t,e){const i=t._chartjs;if(!i)return;const s=i.listeners,n=s.indexOf(e);-1!==n&&s.splice(n,1),s.length>0||(St.forEach((e=>{delete t[e]})),delete t._chartjs)}function Ct(t){const e=new Set;let i,s;for(i=0,s=t.length;i<s;++i)e.add(t[i]);return e.size===s?t:Array.from(e)}const Ot=Math.PI,At=2*Ot,Tt=At+Ot,Lt=Number.POSITIVE_INFINITY,Rt=Ot/180,Et=Ot/2,It=Ot/4,zt=2*Ot/3,Ft=Math.log10,Bt=Math.sign;function Vt(t){const e=Math.round(t);t=Ht(t,e,t/1e3)?e:t;const i=Math.pow(10,Math.floor(Ft(t))),s=t/i;return(s<=1?1:s<=2?2:s<=5?5:10)*i}function Nt(t){const e=[],i=Math.sqrt(t);let s;for(s=1;s<i;s++)t%s==0&&(e.push(s),e.push(t/s));return i===(0|i)&&e.push(i),e.sort(((t,e)=>t-e)).pop(),e}function Wt(t){return!isNaN(parseFloat(t))&&isFinite(t)}function Ht(t,e,i){return Math.abs(t-e)<i}function jt(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function $t(t,e,i){let s,n,o;for(s=0,n=t.length;s<n;s++)o=t[s][i],isNaN(o)||(e.min=Math.min(e.min,o),e.max=Math.max(e.max,o))}function Yt(t){return t*(Ot/180)}function Ut(t){return t*(180/Ot)}function Xt(t){if(!K(t))return;let e=1,i=0;for(;Math.round(t*e)/e!==t;)e*=10,i++;return i}function qt(t,e){const i=e.x-t.x,s=e.y-t.y,n=Math.sqrt(i*i+s*s);let o=Math.atan2(s,i);return o<-.5*Ot&&(o+=At),{angle:o,distance:n}}function Kt(t,e){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function Gt(t,e){return(t-e+Tt)%At-Ot}function Zt(t){return(t%At+At)%At}function Jt(t,e,i,s){const n=Zt(t),o=Zt(e),a=Zt(i),r=Zt(o-n),l=Zt(a-n),h=Zt(n-o),c=Zt(n-a);return n===o||n===a||s&&o===a||r>l&&h<c}function Qt(t,e,i){return Math.max(e,Math.min(i,t))}function te(t){return Qt(t,-32768,32767)}function ee(t,e,i,s=1e-6){return t>=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function ie(){return"undefined"!=typeof window&&"undefined"!=typeof document}function se(t){let e=t.parentNode;return e&&"[object ShadowRoot]"===e.toString()&&(e=e.host),e}function ne(t,e,i){let s;return"string"==typeof t?(s=parseInt(t,10),-1!==t.indexOf("%")&&(s=s/100*e.parentNode[i])):s=t,s}const oe=t=>window.getComputedStyle(t,null);function ae(t,e){return oe(t).getPropertyValue(e)}const re=["top","right","bottom","left"];function le(t,e,i){const s={};i=i?"-"+i:"";for(let n=0;n<4;n++){const o=re[n];s[o]=parseFloat(t[e+"-"+o+i])||0}return s.width=s.left+s.right,s.height=s.top+s.bottom,s}function he(t,e){if("native"in t)return t;const{canvas:i,currentDevicePixelRatio:s}=e,n=oe(i),o="border-box"===n.boxSizing,a=le(n,"padding"),r=le(n,"border","width"),{x:l,y:h,box:c}=function(t,e){const i=t.touches,s=i&&i.length?i[0]:t,{offsetX:n,offsetY:o}=s;let a,r,l=!1;if(((t,e,i)=>(t>0||e>0)&&(!i||!i.shadowRoot))(n,o,t.target))a=n,r=o;else{const t=e.getBoundingClientRect();a=s.clientX-t.left,r=s.clientY-t.top,l=!0}return{x:a,y:r,box:l}}(t,i),d=a.left+(c&&r.left),u=a.top+(c&&r.top);let{width:f,height:g}=e;return o&&(f-=a.width+r.width,g-=a.height+r.height),{x:Math.round((l-d)/f*i.width/s),y:Math.round((h-u)/g*i.height/s)}}const ce=t=>Math.round(10*t)/10;function de(t,e,i,s){const n=oe(t),o=le(n,"margin"),a=ne(n.maxWidth,t,"clientWidth")||Lt,r=ne(n.maxHeight,t,"clientHeight")||Lt,l=function(t,e,i){let s,n;if(void 0===e||void 0===i){const o=se(t);if(o){const t=o.getBoundingClientRect(),a=oe(o),r=le(a,"border","width"),l=le(a,"padding");e=t.width-l.width-r.width,i=t.height-l.height-r.height,s=ne(a.maxWidth,o,"clientWidth"),n=ne(a.maxHeight,o,"clientHeight")}else e=t.clientWidth,i=t.clientHeight}return{width:e,height:i,maxWidth:s||Lt,maxHeight:n||Lt}}(t,e,i);let{width:h,height:c}=l;if("content-box"===n.boxSizing){const t=le(n,"border","width"),e=le(n,"padding");h-=e.width+t.width,c-=e.height+t.height}return h=Math.max(0,h-o.width),c=Math.max(0,s?Math.floor(h/s):c-o.height),h=ce(Math.min(h,a,l.maxWidth)),c=ce(Math.min(c,r,l.maxHeight)),h&&!c&&(c=ce(h/2)),{width:h,height:c}}function ue(t,e,i){const s=e||1,n=Math.floor(t.height*s),o=Math.floor(t.width*s);t.height=n/s,t.width=o/s;const a=t.canvas;return a.style&&(i||!a.style.height&&!a.style.width)&&(a.style.height=`${t.height}px`,a.style.width=`${t.width}px`),(t.currentDevicePixelRatio!==s||a.height!==n||a.width!==o)&&(t.currentDevicePixelRatio=s,a.height=n,a.width=o,t.ctx.setTransform(s,0,0,s,0,0),!0)}const fe=function(){let t=!1;try{const e={get passive(){return t=!0,!1}};window.addEventListener("test",null,e),window.removeEventListener("test",null,e)}catch(t){}return t}();function ge(t,e){const i=ae(t,e),s=i&&i.match(/^(\d+)(\.\d+)?px$/);return s?+s[1]:void 0}function pe(t){return!t||U(t.size)||U(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function me(t,e,i,s,n){let o=e[n];return o||(o=e[n]=t.measureText(n).width,i.push(n)),o>s&&(s=o),s}function be(t,e,i,s){let n=(s=s||{}).data=s.data||{},o=s.garbageCollect=s.garbageCollect||[];s.font!==e&&(n=s.data={},o=s.garbageCollect=[],s.font=e),t.save(),t.font=e;let a=0;const r=i.length;let l,h,c,d,u;for(l=0;l<r;l++)if(d=i[l],null!=d&&!0!==X(d))a=me(t,n,o,a,d);else if(X(d))for(h=0,c=d.length;h<c;h++)u=d[h],null==u||X(u)||(a=me(t,n,o,a,u));t.restore();const f=o.length/2;if(f>i.length){for(l=0;l<f;l++)delete n[o[l]];o.splice(0,f)}return a}function xe(t,e,i){const s=t.currentDevicePixelRatio,n=0!==i?Math.max(i/2,.5):0;return Math.round((e-n)*s)/s+n}function _e(t,e){(e=e||t.getContext("2d")).save(),e.resetTransform(),e.clearRect(0,0,t.width,t.height),e.restore()}function ye(t,e,i,s){let n,o,a,r,l;const h=e.pointStyle,c=e.rotation,d=e.radius;let u=(c||0)*Rt;if(h&&"object"==typeof h&&(n=h.toString(),"[object HTMLImageElement]"===n||"[object HTMLCanvasElement]"===n))return t.save(),t.translate(i,s),t.rotate(u),t.drawImage(h,-h.width/2,-h.height/2,h.width,h.height),void t.restore();if(!(isNaN(d)||d<=0)){switch(t.beginPath(),h){default:t.arc(i,s,d,0,At),t.closePath();break;case"triangle":t.moveTo(i+Math.sin(u)*d,s-Math.cos(u)*d),u+=zt,t.lineTo(i+Math.sin(u)*d,s-Math.cos(u)*d),u+=zt,t.lineTo(i+Math.sin(u)*d,s-Math.cos(u)*d),t.closePath();break;case"rectRounded":l=.516*d,r=d-l,o=Math.cos(u+It)*r,a=Math.sin(u+It)*r,t.arc(i-o,s-a,l,u-Ot,u-Et),t.arc(i+a,s-o,l,u-Et,u),t.arc(i+o,s+a,l,u,u+Et),t.arc(i-a,s+o,l,u+Et,u+Ot),t.closePath();break;case"rect":if(!c){r=Math.SQRT1_2*d,t.rect(i-r,s-r,2*r,2*r);break}u+=It;case"rectRot":o=Math.cos(u)*d,a=Math.sin(u)*d,t.moveTo(i-o,s-a),t.lineTo(i+a,s-o),t.lineTo(i+o,s+a),t.lineTo(i-a,s+o),t.closePath();break;case"crossRot":u+=It;case"cross":o=Math.cos(u)*d,a=Math.sin(u)*d,t.moveTo(i-o,s-a),t.lineTo(i+o,s+a),t.moveTo(i+a,s-o),t.lineTo(i-a,s+o);break;case"star":o=Math.cos(u)*d,a=Math.sin(u)*d,t.moveTo(i-o,s-a),t.lineTo(i+o,s+a),t.moveTo(i+a,s-o),t.lineTo(i-a,s+o),u+=It,o=Math.cos(u)*d,a=Math.sin(u)*d,t.moveTo(i-o,s-a),t.lineTo(i+o,s+a),t.moveTo(i+a,s-o),t.lineTo(i-a,s+o);break;case"line":o=Math.cos(u)*d,a=Math.sin(u)*d,t.moveTo(i-o,s-a),t.lineTo(i+o,s+a);break;case"dash":t.moveTo(i,s),t.lineTo(i+Math.cos(u)*d,s+Math.sin(u)*d)}t.fill(),e.borderWidth>0&&t.stroke()}}function ve(t,e,i){return i=i||.5,!e||t&&t.x>e.left-i&&t.x<e.right+i&&t.y>e.top-i&&t.y<e.bottom+i}function we(t,e){t.save(),t.beginPath(),t.rect(e.left,e.top,e.right-e.left,e.bottom-e.top),t.clip()}function Me(t){t.restore()}function ke(t,e,i,s,n){if(!e)return t.lineTo(i.x,i.y);if("middle"===n){const s=(e.x+i.x)/2;t.lineTo(s,e.y),t.lineTo(s,i.y)}else"after"===n!=!!s?t.lineTo(e.x,i.y):t.lineTo(i.x,e.y);t.lineTo(i.x,i.y)}function Se(t,e,i,s){if(!e)return t.lineTo(i.x,i.y);t.bezierCurveTo(s?e.cp1x:e.cp2x,s?e.cp1y:e.cp2y,s?i.cp2x:i.cp1x,s?i.cp2y:i.cp1y,i.x,i.y)}function Pe(t,e,i,s,n,o={}){const a=X(e)?e:[e],r=o.strokeWidth>0&&""!==o.strokeColor;let l,h;for(t.save(),t.font=n.string,function(t,e){e.translation&&t.translate(e.translation[0],e.translation[1]);U(e.rotation)||t.rotate(e.rotation);e.color&&(t.fillStyle=e.color);e.textAlign&&(t.textAlign=e.textAlign);e.textBaseline&&(t.textBaseline=e.textBaseline)}(t,o),l=0;l<a.length;++l)h=a[l],r&&(o.strokeColor&&(t.strokeStyle=o.strokeColor),U(o.strokeWidth)||(t.lineWidth=o.strokeWidth),t.strokeText(h,i,s,o.maxWidth)),t.fillText(h,i,s,o.maxWidth),De(t,i,s,h,o),s+=n.lineHeight;t.restore()}function De(t,e,i,s,n){if(n.strikethrough||n.underline){const o=t.measureText(s),a=e-o.actualBoundingBoxLeft,r=e+o.actualBoundingBoxRight,l=i-o.actualBoundingBoxAscent,h=i+o.actualBoundingBoxDescent,c=n.strikethrough?(l+h)/2:h;t.strokeStyle=t.fillStyle,t.beginPath(),t.lineWidth=n.decorationWidth||2,t.moveTo(a,c),t.lineTo(r,c),t.stroke()}}function Ce(t,e){const{x:i,y:s,w:n,h:o,radius:a}=e;t.arc(i+a.topLeft,s+a.topLeft,a.topLeft,-Et,Ot,!0),t.lineTo(i,s+o-a.bottomLeft),t.arc(i+a.bottomLeft,s+o-a.bottomLeft,a.bottomLeft,Ot,Et,!0),t.lineTo(i+n-a.bottomRight,s+o),t.arc(i+n-a.bottomRight,s+o-a.bottomRight,a.bottomRight,Et,0,!0),t.lineTo(i+n,s+a.topRight),t.arc(i+n-a.topRight,s+a.topRight,a.topRight,0,-Et,!0),t.lineTo(i+a.topLeft,s)}function Oe(t,e=[""],i=t,s,n=(()=>t[0])){ut(s)||(s=Ne("_fallback",t));const o={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:t,_rootScopes:i,_fallback:s,_getTarget:n,override:n=>Oe([n,...t],e,i,s)};return new Proxy(o,{deleteProperty:(e,i)=>(delete e[i],delete e._keys,delete t[0][i],!0),get:(i,s)=>Ee(i,s,(()=>function(t,e,i,s){let n;for(const o of e)if(n=Ne(Le(o,t),i),ut(n))return Re(t,n)?Be(i,s,t,n):n}(s,e,t,i))),getOwnPropertyDescriptor:(t,e)=>Reflect.getOwnPropertyDescriptor(t._scopes[0],e),getPrototypeOf:()=>Reflect.getPrototypeOf(t[0]),has:(t,e)=>We(t).includes(e),ownKeys:t=>We(t),set(t,e,i){const s=t._storage||(t._storage=n());return t[e]=s[e]=i,delete t._keys,!0}})}function Ae(t,e,i,s){const n={_cacheable:!1,_proxy:t,_context:e,_subProxy:i,_stack:new Set,_descriptors:Te(t,s),setContext:e=>Ae(t,e,i,s),override:n=>Ae(t.override(n),e,i,s)};return new Proxy(n,{deleteProperty:(e,i)=>(delete e[i],delete t[i],!0),get:(t,e,i)=>Ee(t,e,(()=>function(t,e,i){const{_proxy:s,_context:n,_subProxy:o,_descriptors:a}=t;let r=s[e];ft(r)&&a.isScriptable(e)&&(r=function(t,e,i,s){const{_proxy:n,_context:o,_subProxy:a,_stack:r}=i;if(r.has(t))throw new Error("Recursion detected: "+Array.from(r).join("->")+"->"+t);r.add(t),e=e(o,a||s),r.delete(t),Re(t,e)&&(e=Be(n._scopes,n,t,e));return e}(e,r,t,i));X(r)&&r.length&&(r=function(t,e,i,s){const{_proxy:n,_context:o,_subProxy:a,_descriptors:r}=i;if(ut(o.index)&&s(t))e=e[o.index%e.length];else if(q(e[0])){const i=e,s=n._scopes.filter((t=>t!==i));e=[];for(const l of i){const i=Be(s,n,t,l);e.push(Ae(i,o,a&&a[t],r))}}return e}(e,r,t,a.isIndexable));Re(e,r)&&(r=Ae(r,n,o&&o[e],a));return r}(t,e,i))),getOwnPropertyDescriptor:(e,i)=>e._descriptors.allKeys?Reflect.has(t,i)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(t,i),getPrototypeOf:()=>Reflect.getPrototypeOf(t),has:(e,i)=>Reflect.has(t,i),ownKeys:()=>Reflect.ownKeys(t),set:(e,i,s)=>(t[i]=s,delete e[i],!0)})}function Te(t,e={scriptable:!0,indexable:!0}){const{_scriptable:i=e.scriptable,_indexable:s=e.indexable,_allKeys:n=e.allKeys}=t;return{allKeys:n,scriptable:i,indexable:s,isScriptable:ft(i)?i:()=>i,isIndexable:ft(s)?s:()=>s}}const Le=(t,e)=>t?t+dt(e):e,Re=(t,e)=>q(e)&&"adapters"!==t&&(null===Object.getPrototypeOf(e)||e.constructor===Object);function Ee(t,e,i){if(Object.prototype.hasOwnProperty.call(t,e))return t[e];const s=i();return t[e]=s,s}function Ie(t,e,i){return ft(t)?t(e,i):t}const ze=(t,e)=>!0===t?e:"string"==typeof t?ct(e,t):void 0;function Fe(t,e,i,s,n){for(const o of e){const e=ze(i,o);if(e){t.add(e);const o=Ie(e._fallback,i,n);if(ut(o)&&o!==i&&o!==s)return o}else if(!1===e&&ut(s)&&i!==s)return null}return!1}function Be(t,e,i,s){const n=e._rootScopes,o=Ie(e._fallback,i,s),a=[...t,...n],r=new Set;r.add(s);let l=Ve(r,a,i,o||i,s);return null!==l&&((!ut(o)||o===i||(l=Ve(r,a,o,l,s),null!==l))&&Oe(Array.from(r),[""],n,o,(()=>function(t,e,i){const s=t._getTarget();e in s||(s[e]={});const n=s[e];if(X(n)&&q(i))return i;return n}(e,i,s))))}function Ve(t,e,i,s,n){for(;i;)i=Fe(t,e,i,s,n);return i}function Ne(t,e){for(const i of e){if(!i)continue;const e=i[t];if(ut(e))return e}}function We(t){let e=t._keys;return e||(e=t._keys=function(t){const e=new Set;for(const i of t)for(const t of Object.keys(i).filter((t=>!t.startsWith("_"))))e.add(t);return Array.from(e)}(t._scopes)),e}function He(t,e,i,s){const{iScale:n}=t,{key:o="r"}=this._parsing,a=new Array(s);let r,l,h,c;for(r=0,l=s;r<l;++r)h=r+i,c=e[h],a[r]={r:n.parse(ct(c,o),h)};return a}const je=Number.EPSILON||1e-14,$e=(t,e)=>e<t.length&&!t[e].skip&&t[e],Ye=t=>"x"===t?"y":"x";function Ue(t,e,i,s){const n=t.skip?e:t,o=e,a=i.skip?e:i,r=Kt(o,n),l=Kt(a,o);let h=r/(r+l),c=l/(r+l);h=isNaN(h)?0:h,c=isNaN(c)?0:c;const d=s*h,u=s*c;return{previous:{x:o.x-d*(a.x-n.x),y:o.y-d*(a.y-n.y)},next:{x:o.x+u*(a.x-n.x),y:o.y+u*(a.y-n.y)}}}function Xe(t,e="x"){const i=Ye(e),s=t.length,n=Array(s).fill(0),o=Array(s);let a,r,l,h=$e(t,0);for(a=0;a<s;++a)if(r=l,l=h,h=$e(t,a+1),l){if(h){const t=h[e]-l[e];n[a]=0!==t?(h[i]-l[i])/t:0}o[a]=r?h?Bt(n[a-1])!==Bt(n[a])?0:(n[a-1]+n[a])/2:n[a-1]:n[a]}!function(t,e,i){const s=t.length;let n,o,a,r,l,h=$e(t,0);for(let c=0;c<s-1;++c)l=h,h=$e(t,c+1),l&&h&&(Ht(e[c],0,je)?i[c]=i[c+1]=0:(n=i[c]/e[c],o=i[c+1]/e[c],r=Math.pow(n,2)+Math.pow(o,2),r<=9||(a=3/Math.sqrt(r),i[c]=n*a*e[c],i[c+1]=o*a*e[c])))}(t,n,o),function(t,e,i="x"){const s=Ye(i),n=t.length;let o,a,r,l=$e(t,0);for(let h=0;h<n;++h){if(a=r,r=l,l=$e(t,h+1),!r)continue;const n=r[i],c=r[s];a&&(o=(n-a[i])/3,r[`cp1${i}`]=n-o,r[`cp1${s}`]=c-o*e[h]),l&&(o=(l[i]-n)/3,r[`cp2${i}`]=n+o,r[`cp2${s}`]=c+o*e[h])}}(t,o,e)}function qe(t,e,i){return Math.max(Math.min(t,i),e)}function Ke(t,e,i,s,n){let o,a,r,l;if(e.spanGaps&&(t=t.filter((t=>!t.skip))),"monotone"===e.cubicInterpolationMode)Xe(t,n);else{let i=s?t[t.length-1]:t[0];for(o=0,a=t.length;o<a;++o)r=t[o],l=Ue(i,r,t[Math.min(o+1,a-(s?0:1))%a],e.tension),r.cp1x=l.previous.x,r.cp1y=l.previous.y,r.cp2x=l.next.x,r.cp2y=l.next.y,i=r}e.capBezierPoints&&function(t,e){let i,s,n,o,a,r=ve(t[0],e);for(i=0,s=t.length;i<s;++i)a=o,o=r,r=i<s-1&&ve(t[i+1],e),o&&(n=t[i],a&&(n.cp1x=qe(n.cp1x,e.left,e.right),n.cp1y=qe(n.cp1y,e.top,e.bottom)),r&&(n.cp2x=qe(n.cp2x,e.left,e.right),n.cp2y=qe(n.cp2y,e.top,e.bottom)))}(t,i)}const Ge=t=>0===t||1===t,Ze=(t,e,i)=>-Math.pow(2,10*(t-=1))*Math.sin((t-e)*At/i),Je=(t,e,i)=>Math.pow(2,-10*t)*Math.sin((t-e)*At/i)+1,Qe={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>(t-=1)*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-((t-=1)*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>(t-=1)*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>1-Math.cos(t*Et),easeOutSine:t=>Math.sin(t*Et),easeInOutSine:t=>-.5*(Math.cos(Ot*t)-1),easeInExpo:t=>0===t?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>1===t?1:1-Math.pow(2,-10*t),easeInOutExpo:t=>Ge(t)?t:t<.5?.5*Math.pow(2,10*(2*t-1)):.5*(2-Math.pow(2,-10*(2*t-1))),easeInCirc:t=>t>=1?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1-(t-=1)*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>Ge(t)?t:Ze(t,.075,.3),easeOutElastic:t=>Ge(t)?t:Je(t,.075,.3),easeInOutElastic(t){const e=.1125;return Ge(t)?t:t<.5?.5*Ze(2*t,e,.45):.5+.5*Je(2*t-1,e,.45)},easeInBack(t){const e=1.70158;return t*t*((e+1)*t-e)},easeOutBack(t){const e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:t=>1-Qe.easeOutBounce(1-t),easeOutBounce(t){const e=7.5625,i=2.75;return t<1/i?e*t*t:t<2/i?e*(t-=1.5/i)*t+.75:t<2.5/i?e*(t-=2.25/i)*t+.9375:e*(t-=2.625/i)*t+.984375},easeInOutBounce:t=>t<.5?.5*Qe.easeInBounce(2*t):.5*Qe.easeOutBounce(2*t-1)+.5};function ti(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:t.y+i*(e.y-t.y)}}function ei(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:"middle"===s?i<.5?t.y:e.y:"after"===s?i<1?t.y:e.y:i>0?e.y:t.y}}function ii(t,e,i,s){const n={x:t.cp2x,y:t.cp2y},o={x:e.cp1x,y:e.cp1y},a=ti(t,n,i),r=ti(n,o,i),l=ti(o,e,i),h=ti(a,r,i),c=ti(r,l,i);return ti(h,c,i)}const si=new Map;function ni(t,e,i){return function(t,e){e=e||{};const i=t+JSON.stringify(e);let s=si.get(i);return s||(s=new Intl.NumberFormat(t,e),si.set(i,s)),s}(e,i).format(t)}const oi=new RegExp(/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/),ai=new RegExp(/^(normal|italic|initial|inherit|unset|(oblique( -?[0-9]?[0-9]deg)?))$/);function ri(t,e){const i=(""+t).match(oi);if(!i||"normal"===i[1])return 1.2*e;switch(t=+i[2],i[3]){case"px":return t;case"%":t/=100}return e*t}function li(t,e){const i={},s=q(e),n=s?Object.keys(e):e,o=q(t)?s?i=>Z(t[i],t[e[i]]):e=>t[e]:()=>t;for(const t of n)i[t]=+o(t)||0;return i}function hi(t){return li(t,{top:"y",right:"x",bottom:"y",left:"x"})}function ci(t){return li(t,["topLeft","topRight","bottomLeft","bottomRight"])}function di(t){const e=hi(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function ui(t,e){t=t||{},e=e||yt.font;let i=Z(t.size,e.size);"string"==typeof i&&(i=parseInt(i,10));let s=Z(t.style,e.style);s&&!(""+s).match(ai)&&(console.warn('Invalid font style specified: "'+s+'"'),s="");const n={family:Z(t.family,e.family),lineHeight:ri(Z(t.lineHeight,e.lineHeight),i),size:i,style:s,weight:Z(t.weight,e.weight),string:""};return n.string=pe(n),n}function fi(t,e,i,s){let n,o,a,r=!0;for(n=0,o=t.length;n<o;++n)if(a=t[n],void 0!==a&&(void 0!==e&&"function"==typeof a&&(a=a(e),r=!1),void 0!==i&&X(a)&&(a=a[i%a.length],r=!1),void 0!==a))return s&&!r&&(s.cacheable=!1),a}function gi(t,e,i){const{min:s,max:n}=t,o=Q(e,(n-s)/2),a=(t,e)=>i&&0===t?0:t+e;return{min:a(s,-Math.abs(o)),max:a(n,o)}}function pi(t,e){return Object.assign(Object.create(t),e)}function mi(t,e,i){return t?function(t,e){return{x:i=>t+t+e-i,setWidth(t){e=t},textAlign:t=>"center"===t?t:"right"===t?"left":"right",xPlus:(t,e)=>t-e,leftForLtr:(t,e)=>t-e}}(e,i):{x:t=>t,setWidth(t){},textAlign:t=>t,xPlus:(t,e)=>t+e,leftForLtr:(t,e)=>t}}function bi(t,e){let i,s;"ltr"!==e&&"rtl"!==e||(i=t.canvas.style,s=[i.getPropertyValue("direction"),i.getPropertyPriority("direction")],i.setProperty("direction",e,"important"),t.prevTextDirection=s)}function xi(t,e){void 0!==e&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function _i(t){return"angle"===t?{between:Jt,compare:Gt,normalize:Zt}:{between:ee,compare:(t,e)=>t-e,normalize:t=>t}}function yi({start:t,end:e,count:i,loop:s,style:n}){return{start:t%i,end:e%i,loop:s&&(e-t+1)%i==0,style:n}}function vi(t,e,i){if(!i)return[t];const{property:s,start:n,end:o}=i,a=e.length,{compare:r,between:l,normalize:h}=_i(s),{start:c,end:d,loop:u,style:f}=function(t,e,i){const{property:s,start:n,end:o}=i,{between:a,normalize:r}=_i(s),l=e.length;let h,c,{start:d,end:u,loop:f}=t;if(f){for(d+=l,u+=l,h=0,c=l;h<c&&a(r(e[d%l][s]),n,o);++h)d--,u--;d%=l,u%=l}return u<d&&(u+=l),{start:d,end:u,loop:f,style:t.style}}(t,e,i),g=[];let p,m,b,x=!1,_=null;const y=()=>x||l(n,b,p)&&0!==r(n,b),v=()=>!x||0===r(o,p)||l(o,b,p);for(let t=c,i=c;t<=d;++t)m=e[t%a],m.skip||(p=h(m[s]),p!==b&&(x=l(p,n,o),null===_&&y()&&(_=0===r(p,n)?t:i),null!==_&&v()&&(g.push(yi({start:_,end:t,loop:u,count:a,style:f})),_=null),i=t,b=p));return null!==_&&g.push(yi({start:_,end:d,loop:u,count:a,style:f})),g}function wi(t,e){const i=[],s=t.segments;for(let n=0;n<s.length;n++){const o=vi(s[n],t.points,e);o.length&&i.push(...o)}return i}function Mi(t,e){const i=t.points,s=t.options.spanGaps,n=i.length;if(!n)return[];const o=!!t._loop,{start:a,end:r}=function(t,e,i,s){let n=0,o=e-1;if(i&&!s)for(;n<e&&!t[n].skip;)n++;for(;n<e&&t[n].skip;)n++;for(n%=e,i&&(o+=n);o>n&&t[o%e].skip;)o--;return o%=e,{start:n,end:o}}(i,n,o,s);if(!0===s)return ki(t,[{start:a,end:r,loop:o}],i,e);return ki(t,function(t,e,i,s){const n=t.length,o=[];let a,r=e,l=t[e];for(a=e+1;a<=i;++a){const i=t[a%n];i.skip||i.stop?l.skip||(s=!1,o.push({start:e%n,end:(a-1)%n,loop:s}),e=r=i.stop?a:null):(r=a,l.skip&&(e=a)),l=i}return null!==r&&o.push({start:e%n,end:r%n,loop:s}),o}(i,a,r<a?r+n:r,!!t._fullLoop&&0===a&&r===n-1),i,e)}function ki(t,e,i,s){return s&&s.setContext&&i?function(t,e,i,s){const n=t._chart.getContext(),o=Si(t.options),{_datasetIndex:a,options:{spanGaps:r}}=t,l=i.length,h=[];let c=o,d=e[0].start,u=d;function f(t,e,s,n){const o=r?-1:1;if(t!==e){for(t+=l;i[t%l].skip;)t-=o;for(;i[e%l].skip;)e+=o;t%l!=e%l&&(h.push({start:t%l,end:e%l,loop:s,style:n}),c=n,d=e%l)}}for(const t of e){d=r?d:t.start;let e,o=i[d%l];for(u=d+1;u<=t.end;u++){const r=i[u%l];e=Si(s.setContext(pi(n,{type:"segment",p0:o,p1:r,p0DataIndex:(u-1)%l,p1DataIndex:u%l,datasetIndex:a}))),Pi(e,c)&&f(d,u-1,t.loop,c),o=r,c=e}d<u-1&&f(d,u-1,t.loop,c)}return h}(t,e,i,s):e}function Si(t){return{backgroundColor:t.backgroundColor,borderCapStyle:t.borderCapStyle,borderDash:t.borderDash,borderDashOffset:t.borderDashOffset,borderJoinStyle:t.borderJoinStyle,borderWidth:t.borderWidth,borderColor:t.borderColor}}function Pi(t,e){return e&&JSON.stringify(t)!==JSON.stringify(e)}var Di=Object.freeze({__proto__:null,easingEffects:Qe,isPatternOrGradient:W,color:H,getHoverColor:j,noop:$,uid:Y,isNullOrUndef:U,isArray:X,isObject:q,isFinite:K,finiteOrDefault:G,valueOrDefault:Z,toPercentage:J,toDimension:Q,callback:tt,each:et,_elementsEqual:it,clone:st,_merger:ot,merge:at,mergeIf:rt,_mergerIf:lt,_deprecated:function(t,e,i,s){void 0!==e&&console.warn(t+': "'+i+'" is deprecated. Please use "'+s+'" instead')},resolveObjectKey:ct,_capitalize:dt,defined:ut,isFunction:ft,setsEqual:gt,_isClickEvent:pt,toFontString:pe,_measureText:me,_longestText:be,_alignPixel:xe,clearCanvas:_e,drawPoint:ye,_isPointInArea:ve,clipArea:we,unclipArea:Me,_steppedLineTo:ke,_bezierCurveTo:Se,renderText:Pe,addRoundedRectPath:Ce,_lookup:vt,_lookupByKey:wt,_rlookupByKey:Mt,_filterBetween:kt,listenArrayEvents:Pt,unlistenArrayEvents:Dt,_arrayUnique:Ct,_createResolver:Oe,_attachContext:Ae,_descriptors:Te,_parseObjectDataRadialScale:He,splineCurve:Ue,splineCurveMonotone:Xe,_updateBezierControlPoints:Ke,_isDomSupported:ie,_getParentNode:se,getStyle:ae,getRelativePosition:he,getMaximumSize:de,retinaScale:ue,supportsEventListenerOptions:fe,readUsedSize:ge,fontString:function(t,e,i){return e+" "+t+"px "+i},requestAnimFrame:t,throttled:e,debounce:i,_toLeftRightCenter:s,_alignStartEnd:n,_textX:o,_pointInLine:ti,_steppedInterpolation:ei,_bezierInterpolation:ii,formatNumber:ni,toLineHeight:ri,_readValueToProps:li,toTRBL:hi,toTRBLCorners:ci,toPadding:di,toFont:ui,resolve:fi,_addGrace:gi,createContext:pi,PI:Ot,TAU:At,PITAU:Tt,INFINITY:Lt,RAD_PER_DEG:Rt,HALF_PI:Et,QUARTER_PI:It,TWO_THIRDS_PI:zt,log10:Ft,sign:Bt,niceNum:Vt,_factorize:Nt,isNumber:Wt,almostEquals:Ht,almostWhole:jt,_setMinAndMaxByKey:$t,toRadians:Yt,toDegrees:Ut,_decimalPlaces:Xt,getAngleFromPoint:qt,distanceBetweenPoints:Kt,_angleDiff:Gt,_normalizeAngle:Zt,_angleBetween:Jt,_limitValue:Qt,_int16Range:te,_isBetween:ee,getRtlAdapter:mi,overrideTextDirection:bi,restoreTextDirection:xi,_boundSegment:vi,_boundSegments:wi,_computeSegments:Mi});function Ci(t,e,i,s){const{controller:n,data:o,_sorted:a}=t,r=n._cachedMeta.iScale;if(r&&e===r.axis&&"r"!==e&&a&&o.length){const t=r._reversePixels?Mt:wt;if(!s)return t(o,e,i);if(n._sharedOptions){const s=o[0],n="function"==typeof s.getRange&&s.getRange(e);if(n){const s=t(o,e,i-n),a=t(o,e,i+n);return{lo:s.lo,hi:a.hi}}}}return{lo:0,hi:o.length-1}}function Oi(t,e,i,s,n){const o=t.getSortedVisibleDatasetMetas(),a=i[e];for(let t=0,i=o.length;t<i;++t){const{index:i,data:r}=o[t],{lo:l,hi:h}=Ci(o[t],e,a,n);for(let t=l;t<=h;++t){const e=r[t];e.skip||s(e,i,t)}}}function Ai(t,e,i,s,n){const o=[];if(!n&&!t.isPointInArea(e))return o;return Oi(t,i,e,(function(i,a,r){(n||ve(i,t.chartArea,0))&&i.inRange(e.x,e.y,s)&&o.push({element:i,datasetIndex:a,index:r})}),!0),o}function Ti(t,e,i,s,n,o){let a=[];const r=function(t){const e=-1!==t.indexOf("x"),i=-1!==t.indexOf("y");return function(t,s){const n=e?Math.abs(t.x-s.x):0,o=i?Math.abs(t.y-s.y):0;return Math.sqrt(Math.pow(n,2)+Math.pow(o,2))}}(i);let l=Number.POSITIVE_INFINITY;return Oi(t,i,e,(function(i,h,c){const d=i.inRange(e.x,e.y,n);if(s&&!d)return;const u=i.getCenterPoint(n);if(!(!!o||t.isPointInArea(u))&&!d)return;const f=r(e,u);f<l?(a=[{element:i,datasetIndex:h,index:c}],l=f):f===l&&a.push({element:i,datasetIndex:h,index:c})})),a}function Li(t,e,i,s,n,o){return o||t.isPointInArea(e)?"r"!==i||s?Ti(t,e,i,s,n,o):function(t,e,i,s){let n=[];return Oi(t,i,e,(function(t,i,o){const{startAngle:a,endAngle:r}=t.getProps(["startAngle","endAngle"],s),{angle:l}=qt(t,{x:e.x,y:e.y});Jt(l,a,r)&&n.push({element:t,datasetIndex:i,index:o})})),n}(t,e,i,n):[]}function Ri(t,e,i,s,n){const o=[],a="x"===i?"inXRange":"inYRange";let r=!1;return Oi(t,i,e,((t,s,l)=>{t[a](e[i],n)&&(o.push({element:t,datasetIndex:s,index:l}),r=r||t.inRange(e.x,e.y,n))})),s&&!r?[]:o}var Ei={evaluateInteractionItems:Oi,modes:{index(t,e,i,s){const n=he(e,t),o=i.axis||"x",a=i.includeInvisible||!1,r=i.intersect?Ai(t,n,o,s,a):Li(t,n,o,!1,s,a),l=[];return r.length?(t.getSortedVisibleDatasetMetas().forEach((t=>{const e=r[0].index,i=t.data[e];i&&!i.skip&&l.push({element:i,datasetIndex:t.index,index:e})})),l):[]},dataset(t,e,i,s){const n=he(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;let r=i.intersect?Ai(t,n,o,s,a):Li(t,n,o,!1,s,a);if(r.length>0){const e=r[0].datasetIndex,i=t.getDatasetMeta(e).data;r=[];for(let t=0;t<i.length;++t)r.push({element:i[t],datasetIndex:e,index:t})}return r},point:(t,e,i,s)=>Ai(t,he(e,t),i.axis||"xy",s,i.includeInvisible||!1),nearest(t,e,i,s){const n=he(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;return Li(t,n,o,i.intersect,s,a)},x:(t,e,i,s)=>Ri(t,he(e,t),"x",i.intersect,s),y:(t,e,i,s)=>Ri(t,he(e,t),"y",i.intersect,s)}};const Ii=["left","top","right","bottom"];function zi(t,e){return t.filter((t=>t.pos===e))}function Fi(t,e){return t.filter((t=>-1===Ii.indexOf(t.pos)&&t.box.axis===e))}function Bi(t,e){return t.sort(((t,i)=>{const s=e?i:t,n=e?t:i;return s.weight===n.weight?s.index-n.index:s.weight-n.weight}))}function Vi(t,e){const i=function(t){const e={};for(const i of t){const{stack:t,pos:s,stackWeight:n}=i;if(!t||!Ii.includes(s))continue;const o=e[t]||(e[t]={count:0,placed:0,weight:0,size:0});o.count++,o.weight+=n}return e}(t),{vBoxMaxWidth:s,hBoxMaxHeight:n}=e;let o,a,r;for(o=0,a=t.length;o<a;++o){r=t[o];const{fullSize:a}=r.box,l=i[r.stack],h=l&&r.stackWeight/l.weight;r.horizontal?(r.width=h?h*s:a&&e.availableWidth,r.height=n):(r.width=s,r.height=h?h*n:a&&e.availableHeight)}return i}function Ni(t,e,i,s){return Math.max(t[i],e[i])+Math.max(t[s],e[s])}function Wi(t,e){t.top=Math.max(t.top,e.top),t.left=Math.max(t.left,e.left),t.bottom=Math.max(t.bottom,e.bottom),t.right=Math.max(t.right,e.right)}function Hi(t,e,i,s){const{pos:n,box:o}=i,a=t.maxPadding;if(!q(n)){i.size&&(t[n]-=i.size);const e=s[i.stack]||{size:0,count:1};e.size=Math.max(e.size,i.horizontal?o.height:o.width),i.size=e.size/e.count,t[n]+=i.size}o.getPadding&&Wi(a,o.getPadding());const r=Math.max(0,e.outerWidth-Ni(a,t,"left","right")),l=Math.max(0,e.outerHeight-Ni(a,t,"top","bottom")),h=r!==t.w,c=l!==t.h;return t.w=r,t.h=l,i.horizontal?{same:h,other:c}:{same:c,other:h}}function ji(t,e){const i=e.maxPadding;function s(t){const s={left:0,top:0,right:0,bottom:0};return t.forEach((t=>{s[t]=Math.max(e[t],i[t])})),s}return s(t?["left","right"]:["top","bottom"])}function $i(t,e,i,s){const n=[];let o,a,r,l,h,c;for(o=0,a=t.length,h=0;o<a;++o){r=t[o],l=r.box,l.update(r.width||e.w,r.height||e.h,ji(r.horizontal,e));const{same:a,other:d}=Hi(e,i,r,s);h|=a&&n.length,c=c||d,l.fullSize||n.push(r)}return h&&$i(n,e,i,s)||c}function Yi(t,e,i,s,n){t.top=i,t.left=e,t.right=e+s,t.bottom=i+n,t.width=s,t.height=n}function Ui(t,e,i,s){const n=i.padding;let{x:o,y:a}=e;for(const r of t){const t=r.box,l=s[r.stack]||{count:1,placed:0,weight:1},h=r.stackWeight/l.weight||1;if(r.horizontal){const s=e.w*h,o=l.size||t.height;ut(l.start)&&(a=l.start),t.fullSize?Yi(t,n.left,a,i.outerWidth-n.right-n.left,o):Yi(t,e.left+l.placed,a,s,o),l.start=a,l.placed+=s,a=t.bottom}else{const s=e.h*h,a=l.size||t.width;ut(l.start)&&(o=l.start),t.fullSize?Yi(t,o,n.top,a,i.outerHeight-n.bottom-n.top):Yi(t,o,e.top+l.placed,a,s),l.start=o,l.placed+=s,o=t.right}}e.x=o,e.y=a}yt.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}});var Xi={addBox(t,e){t.boxes||(t.boxes=[]),e.fullSize=e.fullSize||!1,e.position=e.position||"top",e.weight=e.weight||0,e._layers=e._layers||function(){return[{z:0,draw(t){e.draw(t)}}]},t.boxes.push(e)},removeBox(t,e){const i=t.boxes?t.boxes.indexOf(e):-1;-1!==i&&t.boxes.splice(i,1)},configure(t,e,i){e.fullSize=i.fullSize,e.position=i.position,e.weight=i.weight},update(t,e,i,s){if(!t)return;const n=di(t.options.layout.padding),o=Math.max(e-n.width,0),a=Math.max(i-n.height,0),r=function(t){const e=function(t){const e=[];let i,s,n,o,a,r;for(i=0,s=(t||[]).length;i<s;++i)n=t[i],({position:o,options:{stack:a,stackWeight:r=1}}=n),e.push({index:i,box:n,pos:o,horizontal:n.isHorizontal(),weight:n.weight,stack:a&&o+a,stackWeight:r});return e}(t),i=Bi(e.filter((t=>t.box.fullSize)),!0),s=Bi(zi(e,"left"),!0),n=Bi(zi(e,"right")),o=Bi(zi(e,"top"),!0),a=Bi(zi(e,"bottom")),r=Fi(e,"x"),l=Fi(e,"y");return{fullSize:i,leftAndTop:s.concat(o),rightAndBottom:n.concat(l).concat(a).concat(r),chartArea:zi(e,"chartArea"),vertical:s.concat(n).concat(l),horizontal:o.concat(a).concat(r)}}(t.boxes),l=r.vertical,h=r.horizontal;et(t.boxes,(t=>{"function"==typeof t.beforeLayout&&t.beforeLayout()}));const c=l.reduce(((t,e)=>e.box.options&&!1===e.box.options.display?t:t+1),0)||1,d=Object.freeze({outerWidth:e,outerHeight:i,padding:n,availableWidth:o,availableHeight:a,vBoxMaxWidth:o/2/c,hBoxMaxHeight:a/2}),u=Object.assign({},n);Wi(u,di(s));const f=Object.assign({maxPadding:u,w:o,h:a,x:n.left,y:n.top},n),g=Vi(l.concat(h),d);$i(r.fullSize,f,d,g),$i(l,f,d,g),$i(h,f,d,g)&&$i(l,f,d,g),function(t){const e=t.maxPadding;function i(i){const s=Math.max(e[i]-t[i],0);return t[i]+=s,s}t.y+=i("top"),t.x+=i("left"),i("right"),i("bottom")}(f),Ui(r.leftAndTop,f,d,g),f.x+=f.w,f.y+=f.h,Ui(r.rightAndBottom,f,d,g),t.chartArea={left:f.left,top:f.top,right:f.left+f.w,bottom:f.top+f.h,height:f.h,width:f.w},et(r.chartArea,(e=>{const i=e.box;Object.assign(i,t.chartArea),i.update(f.w,f.h,{left:0,top:0,right:0,bottom:0})}))}};class qi{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,i){}removeEventListener(t,e,i){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,i,s){return e=Math.max(0,e||t.width),i=i||t.height,{width:e,height:Math.max(0,s?Math.floor(e/s):i)}}isAttached(t){return!0}updateConfig(t){}}class Ki extends qi{acquireContext(t){return t&&t.getContext&&t.getContext("2d")||null}updateConfig(t){t.options.animation=!1}}const Gi={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},Zi=t=>null===t||""===t;const Ji=!!fe&&{passive:!0};function Qi(t,e,i){t.canvas.removeEventListener(e,i,Ji)}function ts(t,e){for(const i of t)if(i===e||i.contains(e))return!0}function es(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||ts(i.addedNodes,s),e=e&&!ts(i.removedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}function is(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||ts(i.removedNodes,s),e=e&&!ts(i.addedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}const ss=new Map;let ns=0;function os(){const t=window.devicePixelRatio;t!==ns&&(ns=t,ss.forEach(((e,i)=>{i.currentDevicePixelRatio!==t&&e()})))}function as(t,i,s){const n=t.canvas,o=n&&se(n);if(!o)return;const a=e(((t,e)=>{const i=o.clientWidth;s(t,e),i<o.clientWidth&&s()}),window),r=new ResizeObserver((t=>{const e=t[0],i=e.contentRect.width,s=e.contentRect.height;0===i&&0===s||a(i,s)}));return r.observe(o),function(t,e){ss.size||window.addEventListener("resize",os),ss.set(t,e)}(t,a),r}function rs(t,e,i){i&&i.disconnect(),"resize"===e&&function(t){ss.delete(t),ss.size||window.removeEventListener("resize",os)}(t)}function ls(t,i,s){const n=t.canvas,o=e((e=>{null!==t.ctx&&s(function(t,e){const i=Gi[t.type]||t.type,{x:s,y:n}=he(t,e);return{type:i,chart:e,native:t,x:void 0!==s?s:null,y:void 0!==n?n:null}}(e,t))}),t,(t=>{const e=t[0];return[e,e.offsetX,e.offsetY]}));return function(t,e,i){t.addEventListener(e,i,Ji)}(n,i,o),o}class hs extends qi{acquireContext(t,e){const i=t&&t.getContext&&t.getContext("2d");return i&&i.canvas===t?(function(t,e){const i=t.style,s=t.getAttribute("height"),n=t.getAttribute("width");if(t.$chartjs={initial:{height:s,width:n,style:{display:i.display,height:i.height,width:i.width}}},i.display=i.display||"block",i.boxSizing=i.boxSizing||"border-box",Zi(n)){const e=ge(t,"width");void 0!==e&&(t.width=e)}if(Zi(s))if(""===t.style.height)t.height=t.width/(e||2);else{const e=ge(t,"height");void 0!==e&&(t.height=e)}}(t,e),i):null}releaseContext(t){const e=t.canvas;if(!e.$chartjs)return!1;const i=e.$chartjs.initial;["height","width"].forEach((t=>{const s=i[t];U(s)?e.removeAttribute(t):e.setAttribute(t,s)}));const s=i.style||{};return Object.keys(s).forEach((t=>{e.style[t]=s[t]})),e.width=e.width,delete e.$chartjs,!0}addEventListener(t,e,i){this.removeEventListener(t,e);const s=t.$proxies||(t.$proxies={}),n={attach:es,detach:is,resize:as}[e]||ls;s[e]=n(t,e,i)}removeEventListener(t,e){const i=t.$proxies||(t.$proxies={}),s=i[e];if(!s)return;({attach:rs,detach:rs,resize:rs}[e]||Qi)(t,e,s),i[e]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,i,s){return de(t,e,i,s)}isAttached(t){const e=se(t);return!(!e||!e.isConnected)}}function cs(t){return!ie()||"undefined"!=typeof OffscreenCanvas&&t instanceof OffscreenCanvas?Ki:hs}var ds=Object.freeze({__proto__:null,_detectPlatform:cs,BasePlatform:qi,BasicPlatform:Ki,DomPlatform:hs});const us="transparent",fs={boolean:(t,e,i)=>i>.5?e:t,color(t,e,i){const s=H(t||us),n=s.valid&&H(e||us);return n&&n.valid?n.mix(s,i).hexString():e},number:(t,e,i)=>t+(e-t)*i};class gs{constructor(t,e,i,s){const n=e[i];s=fi([t.to,s,n,t.from]);const o=fi([t.from,n,s]);this._active=!0,this._fn=t.fn||fs[t.type||typeof o],this._easing=Qe[t.easing]||Qe.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=i,this._from=o,this._to=s,this._promises=void 0}active(){return this._active}update(t,e,i){if(this._active){this._notify(!1);const s=this._target[this._prop],n=i-this._start,o=this._duration-n;this._start=i,this._duration=Math.floor(Math.max(o,t.duration)),this._total+=n,this._loop=!!t.loop,this._to=fi([t.to,e,s,t.from]),this._from=fi([t.from,s,e])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){const e=t-this._start,i=this._duration,s=this._prop,n=this._from,o=this._loop,a=this._to;let r;if(this._active=n!==a&&(o||e<i),!this._active)return this._target[s]=a,void this._notify(!0);e<0?this._target[s]=n:(r=e/i%2,r=o&&r>1?2-r:r,r=this._easing(Math.min(1,Math.max(0,r))),this._target[s]=this._fn(n,a,r))}wait(){const t=this._promises||(this._promises=[]);return new Promise(((e,i)=>{t.push({res:e,rej:i})}))}_notify(t){const e=t?"res":"rej",i=this._promises||[];for(let t=0;t<i.length;t++)i[t][e]()}}yt.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0});const ps=Object.keys(yt.animation);yt.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:t=>"onProgress"!==t&&"onComplete"!==t&&"fn"!==t}),yt.set("animations",{colors:{type:"color",properties:["color","borderColor","backgroundColor"]},numbers:{type:"number",properties:["x","y","borderWidth","radius","tension"]}}),yt.describe("animations",{_fallback:"animation"}),yt.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:t=>0|t}}}});class ms{constructor(t,e){this._chart=t,this._properties=new Map,this.configure(e)}configure(t){if(!q(t))return;const e=this._properties;Object.getOwnPropertyNames(t).forEach((i=>{const s=t[i];if(!q(s))return;const n={};for(const t of ps)n[t]=s[t];(X(s.properties)&&s.properties||[i]).forEach((t=>{t!==i&&e.has(t)||e.set(t,n)}))}))}_animateOptions(t,e){const i=e.options,s=function(t,e){if(!e)return;let i=t.options;if(!i)return void(t.options=e);i.$shared&&(t.options=i=Object.assign({},i,{$shared:!1,$animations:{}}));return i}(t,i);if(!s)return[];const n=this._createAnimations(s,i);return i.$shared&&function(t,e){const i=[],s=Object.keys(e);for(let e=0;e<s.length;e++){const n=t[s[e]];n&&n.active()&&i.push(n.wait())}return Promise.all(i)}(t.options.$animations,i).then((()=>{t.options=i}),(()=>{})),n}_createAnimations(t,e){const i=this._properties,s=[],n=t.$animations||(t.$animations={}),o=Object.keys(e),a=Date.now();let r;for(r=o.length-1;r>=0;--r){const l=o[r];if("$"===l.charAt(0))continue;if("options"===l){s.push(...this._animateOptions(t,e));continue}const h=e[l];let c=n[l];const d=i.get(l);if(c){if(d&&c.active()){c.update(d,h,a);continue}c.cancel()}d&&d.duration?(n[l]=c=new gs(d,t,l,h),s.push(c)):t[l]=h}return s}update(t,e){if(0===this._properties.size)return void Object.assign(t,e);const i=this._createAnimations(t,e);return i.length?(a.add(this._chart,i),!0):void 0}}function bs(t,e){const i=t&&t.options||{},s=i.reverse,n=void 0===i.min?e:0,o=void 0===i.max?e:0;return{start:s?o:n,end:s?n:o}}function xs(t,e){const i=[],s=t._getSortedDatasetMetas(e);let n,o;for(n=0,o=s.length;n<o;++n)i.push(s[n].index);return i}function _s(t,e,i,s={}){const n=t.keys,o="single"===s.mode;let a,r,l,h;if(null!==e){for(a=0,r=n.length;a<r;++a){if(l=+n[a],l===i){if(s.all)continue;break}h=t.values[l],K(h)&&(o||0===e||Bt(e)===Bt(h))&&(e+=h)}return e}}function ys(t,e){const i=t&&t.options.stacked;return i||void 0===i&&void 0!==e.stack}function vs(t,e,i){const s=t[e]||(t[e]={});return s[i]||(s[i]={})}function ws(t,e,i,s){for(const n of e.getMatchingVisibleMetas(s).reverse()){const e=t[n.index];if(i&&e>0||!i&&e<0)return n.index}return null}function Ms(t,e){const{chart:i,_cachedMeta:s}=t,n=i._stacks||(i._stacks={}),{iScale:o,vScale:a,index:r}=s,l=o.axis,h=a.axis,c=function(t,e,i){return`${t.id}.${e.id}.${i.stack||i.type}`}(o,a,s),d=e.length;let u;for(let t=0;t<d;++t){const i=e[t],{[l]:o,[h]:d}=i;u=(i._stacks||(i._stacks={}))[h]=vs(n,c,o),u[r]=d,u._top=ws(u,a,!0,s.type),u._bottom=ws(u,a,!1,s.type)}}function ks(t,e){const i=t.scales;return Object.keys(i).filter((t=>i[t].axis===e)).shift()}function Ss(t,e){const i=t.controller.index,s=t.vScale&&t.vScale.axis;if(s){e=e||t._parsed;for(const t of e){const e=t._stacks;if(!e||void 0===e[s]||void 0===e[s][i])return;delete e[s][i]}}}const Ps=t=>"reset"===t||"none"===t,Ds=(t,e)=>e?t:Object.assign({},t);class Cs{constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=ys(t.vScale,t),this.addElements()}updateIndex(t){this.index!==t&&Ss(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,i=this.getDataset(),s=(t,e,i,s)=>"x"===t?e:"r"===t?s:i,n=e.xAxisID=Z(i.xAxisID,ks(t,"x")),o=e.yAxisID=Z(i.yAxisID,ks(t,"y")),a=e.rAxisID=Z(i.rAxisID,ks(t,"r")),r=e.indexAxis,l=e.iAxisID=s(r,n,o,a),h=e.vAxisID=s(r,o,n,a);e.xScale=this.getScaleForId(n),e.yScale=this.getScaleForId(o),e.rScale=this.getScaleForId(a),e.iScale=this.getScaleForId(l),e.vScale=this.getScaleForId(h)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){const e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update("reset")}_destroy(){const t=this._cachedMeta;this._data&&Dt(this._data,this),t._stacked&&Ss(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),i=this._data;if(q(e))this._data=function(t){const e=Object.keys(t),i=new Array(e.length);let s,n,o;for(s=0,n=e.length;s<n;++s)o=e[s],i[s]={x:o,y:t[o]};return i}(e);else if(i!==e){if(i){Dt(i,this);const t=this._cachedMeta;Ss(t),t._parsed=[]}e&&Object.isExtensible(e)&&Pt(e,this),this._syncList=[],this._data=e}}addElements(){const t=this._cachedMeta;this._dataCheck(),this.datasetElementType&&(t.dataset=new this.datasetElementType)}buildOrUpdateElements(t){const e=this._cachedMeta,i=this.getDataset();let s=!1;this._dataCheck();const n=e._stacked;e._stacked=ys(e.vScale,e),e.stack!==i.stack&&(s=!0,Ss(e),e.stack=i.stack),this._resyncElements(t),(s||n!==e._stacked)&&Ms(this,e._parsed)}configure(){const t=this.chart.config,e=t.datasetScopeKeys(this._type),i=t.getOptionScopes(this.getDataset(),e,!0);this.options=t.createResolver(i,this.getContext()),this._parsing=this.options.parsing,this._cachedDataOpts={}}parse(t,e){const{_cachedMeta:i,_data:s}=this,{iScale:n,_stacked:o}=i,a=n.axis;let r,l,h,c=0===t&&e===s.length||i._sorted,d=t>0&&i._parsed[t-1];if(!1===this._parsing)i._parsed=s,i._sorted=!0,h=s;else{h=X(s[t])?this.parseArrayData(i,s,t,e):q(s[t])?this.parseObjectData(i,s,t,e):this.parsePrimitiveData(i,s,t,e);const n=()=>null===l[a]||d&&l[a]<d[a];for(r=0;r<e;++r)i._parsed[r+t]=l=h[r],c&&(n()&&(c=!1),d=l);i._sorted=c}o&&Ms(this,h)}parsePrimitiveData(t,e,i,s){const{iScale:n,vScale:o}=t,a=n.axis,r=o.axis,l=n.getLabels(),h=n===o,c=new Array(s);let d,u,f;for(d=0,u=s;d<u;++d)f=d+i,c[d]={[a]:h||n.parse(l[f],f),[r]:o.parse(e[f],f)};return c}parseArrayData(t,e,i,s){const{xScale:n,yScale:o}=t,a=new Array(s);let r,l,h,c;for(r=0,l=s;r<l;++r)h=r+i,c=e[h],a[r]={x:n.parse(c[0],h),y:o.parse(c[1],h)};return a}parseObjectData(t,e,i,s){const{xScale:n,yScale:o}=t,{xAxisKey:a="x",yAxisKey:r="y"}=this._parsing,l=new Array(s);let h,c,d,u;for(h=0,c=s;h<c;++h)d=h+i,u=e[d],l[h]={x:n.parse(ct(u,a),d),y:o.parse(ct(u,r),d)};return l}getParsed(t){return this._cachedMeta._parsed[t]}getDataElement(t){return this._cachedMeta.data[t]}applyStack(t,e,i){const s=this.chart,n=this._cachedMeta,o=e[t.axis];return _s({keys:xs(s,!0),values:e._stacks[t.axis]},o,n.index,{mode:i})}updateRangeFromParsed(t,e,i,s){const n=i[e.axis];let o=null===n?NaN:n;const a=s&&i._stacks[e.axis];s&&a&&(s.values=a,o=_s(s,n,this._cachedMeta.index)),t.min=Math.min(t.min,o),t.max=Math.max(t.max,o)}getMinMax(t,e){const i=this._cachedMeta,s=i._parsed,n=i._sorted&&t===i.iScale,o=s.length,a=this._getOtherScale(t),r=((t,e,i)=>t&&!e.hidden&&e._stacked&&{keys:xs(i,!0),values:null})(e,i,this.chart),l={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY},{min:h,max:c}=function(t){const{min:e,max:i,minDefined:s,maxDefined:n}=t.getUserBounds();return{min:s?e:Number.NEGATIVE_INFINITY,max:n?i:Number.POSITIVE_INFINITY}}(a);let d,u;function f(){u=s[d];const e=u[a.axis];return!K(u[t.axis])||h>e||c<e}for(d=0;d<o&&(f()||(this.updateRangeFromParsed(l,t,u,r),!n));++d);if(n)for(d=o-1;d>=0;--d)if(!f()){this.updateRangeFromParsed(l,t,u,r);break}return l}getAllParsedValues(t){const e=this._cachedMeta._parsed,i=[];let s,n,o;for(s=0,n=e.length;s<n;++s)o=e[s][t.axis],K(o)&&i.push(o);return i}getMaxOverflow(){return!1}getLabelAndValue(t){const e=this._cachedMeta,i=e.iScale,s=e.vScale,n=this.getParsed(t);return{label:i?""+i.getLabelForValue(n[i.axis]):"",value:s?""+s.getLabelForValue(n[s.axis]):""}}_update(t){const e=this._cachedMeta;this.update(t||"default"),e._clip=function(t){let e,i,s,n;return q(t)?(e=t.top,i=t.right,s=t.bottom,n=t.left):e=i=s=n=t,{top:e,right:i,bottom:s,left:n,disabled:!1===t}}(Z(this.options.clip,function(t,e,i){if(!1===i)return!1;const s=bs(t,i),n=bs(e,i);return{top:n.end,right:s.end,bottom:n.start,left:s.start}}(e.xScale,e.yScale,this.getMaxOverflow())))}update(t){}draw(){const t=this._ctx,e=this.chart,i=this._cachedMeta,s=i.data||[],n=e.chartArea,o=[],a=this._drawStart||0,r=this._drawCount||s.length-a,l=this.options.drawActiveElementsOnTop;let h;for(i.dataset&&i.dataset.draw(t,n,a,r),h=a;h<a+r;++h){const e=s[h];e.hidden||(e.active&&l?o.push(e):e.draw(t,n))}for(h=0;h<o.length;++h)o[h].draw(t,n)}getStyle(t,e){const i=e?"active":"default";return void 0===t&&this._cachedMeta.dataset?this.resolveDatasetElementOptions(i):this.resolveDataElementOptions(t||0,i)}getContext(t,e,i){const s=this.getDataset();let n;if(t>=0&&t<this._cachedMeta.data.length){const e=this._cachedMeta.data[t];n=e.$context||(e.$context=function(t,e,i){return pi(t,{active:!1,dataIndex:e,parsed:void 0,raw:void 0,element:i,index:e,mode:"default",type:"data"})}(this.getContext(),t,e)),n.parsed=this.getParsed(t),n.raw=s.data[t],n.index=n.dataIndex=t}else n=this.$context||(this.$context=function(t,e){return pi(t,{active:!1,dataset:void 0,datasetIndex:e,index:e,mode:"default",type:"dataset"})}(this.chart.getContext(),this.index)),n.dataset=s,n.index=n.datasetIndex=this.index;return n.active=!!e,n.mode=i,n}resolveDatasetElementOptions(t){return this._resolveElementOptions(this.datasetElementType.id,t)}resolveDataElementOptions(t,e){return this._resolveElementOptions(this.dataElementType.id,e,t)}_resolveElementOptions(t,e="default",i){const s="active"===e,n=this._cachedDataOpts,o=t+"-"+e,a=n[o],r=this.enableOptionSharing&&ut(i);if(a)return Ds(a,r);const l=this.chart.config,h=l.datasetElementScopeKeys(this._type,t),c=s?[`${t}Hover`,"hover",t,""]:[t,""],d=l.getOptionScopes(this.getDataset(),h),u=Object.keys(yt.elements[t]),f=l.resolveNamedOptions(d,u,(()=>this.getContext(i,s)),c);return f.$shared&&(f.$shared=r,n[o]=Object.freeze(Ds(f,r))),f}_resolveAnimations(t,e,i){const s=this.chart,n=this._cachedDataOpts,o=`animation-${e}`,a=n[o];if(a)return a;let r;if(!1!==s.options.animation){const s=this.chart.config,n=s.datasetAnimationScopeKeys(this._type,e),o=s.getOptionScopes(this.getDataset(),n);r=s.createResolver(o,this.getContext(t,i,e))}const l=new ms(s,r&&r.animations);return r&&r._cacheable&&(n[o]=Object.freeze(l)),l}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||Ps(t)||this.chart._animationsDisabled}updateElement(t,e,i,s){Ps(s)?Object.assign(t,i):this._resolveAnimations(e,s).update(t,i)}updateSharedOptions(t,e,i){t&&!Ps(e)&&this._resolveAnimations(void 0,e).update(t,i)}_setStyle(t,e,i,s){t.active=s;const n=this.getStyle(e,s);this._resolveAnimations(e,i,s).update(t,{options:!s&&this.getSharedOptions(n)||n})}removeHoverStyle(t,e,i){this._setStyle(t,i,"active",!1)}setHoverStyle(t,e,i){this._setStyle(t,i,"active",!0)}_removeDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!1)}_setDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!0)}_resyncElements(t){const e=this._data,i=this._cachedMeta.data;for(const[t,e,i]of this._syncList)this[t](e,i);this._syncList=[];const s=i.length,n=e.length,o=Math.min(n,s);o&&this.parse(0,o),n>s?this._insertElements(s,n-s,t):n<s&&this._removeElements(n,s-n)}_insertElements(t,e,i=!0){const s=this._cachedMeta,n=s.data,o=t+e;let a;const r=t=>{for(t.length+=e,a=t.length-1;a>=o;a--)t[a]=t[a-e]};for(r(n),a=t;a<o;++a)n[a]=new this.dataElementType;this._parsing&&r(s._parsed),this.parse(t,e),i&&this.updateElements(n,t,e,"reset")}updateElements(t,e,i,s){}_removeElements(t,e){const i=this._cachedMeta;if(this._parsing){const s=i._parsed.splice(t,e);i._stacked&&Ss(i,s)}i.data.splice(t,e)}_sync(t){if(this._parsing)this._syncList.push(t);else{const[e,i,s]=t;this[e](i,s)}this.chart._dataChanges.push([this.index,...t])}_onDataPush(){const t=arguments.length;this._sync(["_insertElements",this.getDataset().data.length-t,t])}_onDataPop(){this._sync(["_removeElements",this._cachedMeta.data.length-1,1])}_onDataShift(){this._sync(["_removeElements",0,1])}_onDataSplice(t,e){e&&this._sync(["_removeElements",t,e]);const i=arguments.length-2;i&&this._sync(["_insertElements",t,i])}_onDataUnshift(){this._sync(["_insertElements",0,arguments.length])}}Cs.defaults={},Cs.prototype.datasetElementType=null,Cs.prototype.dataElementType=null;class Os{constructor(){this.x=void 0,this.y=void 0,this.active=!1,this.options=void 0,this.$animations=void 0}tooltipPosition(t){const{x:e,y:i}=this.getProps(["x","y"],t);return{x:e,y:i}}hasValue(){return Wt(this.x)&&Wt(this.y)}getProps(t,e){const i=this.$animations;if(!e||!i)return this;const s={};return t.forEach((t=>{s[t]=i[t]&&i[t].active()?i[t]._to:this[t]})),s}}Os.defaults={},Os.defaultRoutes=void 0;const As={values:t=>X(t)?t:""+t,numeric(t,e,i){if(0===t)return"0";const s=this.chart.options.locale;let n,o=t;if(i.length>1){const e=Math.max(Math.abs(i[0].value),Math.abs(i[i.length-1].value));(e<1e-4||e>1e15)&&(n="scientific"),o=function(t,e){let i=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;Math.abs(i)>=1&&t!==Math.floor(t)&&(i=t-Math.floor(t));return i}(t,i)}const a=Ft(Math.abs(o)),r=Math.max(Math.min(-1*Math.floor(a),20),0),l={notation:n,minimumFractionDigits:r,maximumFractionDigits:r};return Object.assign(l,this.options.ticks.format),ni(t,s,l)},logarithmic(t,e,i){if(0===t)return"0";const s=t/Math.pow(10,Math.floor(Ft(t)));return 1===s||2===s||5===s?As.numeric.call(this,t,e,i):""}};var Ts={formatters:As};function Ls(t,e){const i=t.options.ticks,s=i.maxTicksLimit||function(t){const e=t.options.offset,i=t._tickSize(),s=t._length/i+(e?0:1),n=t._maxLength/i;return Math.floor(Math.min(s,n))}(t),n=i.major.enabled?function(t){const e=[];let i,s;for(i=0,s=t.length;i<s;i++)t[i].major&&e.push(i);return e}(e):[],o=n.length,a=n[0],r=n[o-1],l=[];if(o>s)return function(t,e,i,s){let n,o=0,a=i[0];for(s=Math.ceil(s),n=0;n<t.length;n++)n===a&&(e.push(t[n]),o++,a=i[o*s])}(e,l,n,o/s),l;const h=function(t,e,i){const s=function(t){const e=t.length;let i,s;if(e<2)return!1;for(s=t[0],i=1;i<e;++i)if(t[i]-t[i-1]!==s)return!1;return s}(t),n=e.length/i;if(!s)return Math.max(n,1);const o=Nt(s);for(let t=0,e=o.length-1;t<e;t++){const e=o[t];if(e>n)return e}return Math.max(n,1)}(n,e,s);if(o>0){let t,i;const s=o>1?Math.round((r-a)/(o-1)):null;for(Rs(e,l,h,U(s)?0:a-s,a),t=0,i=o-1;t<i;t++)Rs(e,l,h,n[t],n[t+1]);return Rs(e,l,h,r,U(s)?e.length:r+s),l}return Rs(e,l,h),l}function Rs(t,e,i,s,n){const o=Z(s,0),a=Math.min(Z(n,t.length),t.length);let r,l,h,c=0;for(i=Math.ceil(i),n&&(r=n-s,i=r/Math.floor(r/i)),h=o;h<0;)c++,h=Math.round(o+c*i);for(l=Math.max(o,0);l<a;l++)l===h&&(e.push(t[l]),c++,h=Math.round(o+c*i))}yt.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",grace:0,grid:{display:!0,lineWidth:1,drawBorder:!0,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(t,e)=>e.lineWidth,tickColor:(t,e)=>e.color,offset:!1,borderDash:[],borderDashOffset:0,borderWidth:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:Ts.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),yt.route("scale.ticks","color","","color"),yt.route("scale.grid","color","","borderColor"),yt.route("scale.grid","borderColor","","borderColor"),yt.route("scale.title","color","","color"),yt.describe("scale",{_fallback:!1,_scriptable:t=>!t.startsWith("before")&&!t.startsWith("after")&&"callback"!==t&&"parser"!==t,_indexable:t=>"borderDash"!==t&&"tickBorderDash"!==t}),yt.describe("scales",{_fallback:"scale"}),yt.describe("scale.ticks",{_scriptable:t=>"backdropPadding"!==t&&"callback"!==t,_indexable:t=>"backdropPadding"!==t});const Es=(t,e,i)=>"top"===e||"left"===e?t[e]+i:t[e]-i;function Is(t,e){const i=[],s=t.length/e,n=t.length;let o=0;for(;o<n;o+=s)i.push(t[Math.floor(o)]);return i}function zs(t,e,i){const s=t.ticks.length,n=Math.min(e,s-1),o=t._startPixel,a=t._endPixel,r=1e-6;let l,h=t.getPixelForTick(n);if(!(i&&(l=1===s?Math.max(h-o,a-h):0===e?(t.getPixelForTick(1)-h)/2:(h-t.getPixelForTick(n-1))/2,h+=n<e?l:-l,h<o-r||h>a+r)))return h}function Fs(t){return t.drawTicks?t.tickLength:0}function Bs(t,e){if(!t.display)return 0;const i=ui(t.font,e),s=di(t.padding);return(X(t.text)?t.text.length:1)*i.lineHeight+s.height}function Vs(t,e,i){let n=s(t);return(i&&"right"!==e||!i&&"right"===e)&&(n=(t=>"left"===t?"right":"right"===t?"left":t)(n)),n}class Ns extends Os{constructor(t){super(),this.id=t.id,this.type=t.type,this.options=void 0,this.ctx=t.ctx,this.chart=t.chart,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this._margins={left:0,right:0,top:0,bottom:0},this.maxWidth=void 0,this.maxHeight=void 0,this.paddingTop=void 0,this.paddingBottom=void 0,this.paddingLeft=void 0,this.paddingRight=void 0,this.axis=void 0,this.labelRotation=void 0,this.min=void 0,this.max=void 0,this._range=void 0,this.ticks=[],this._gridLineItems=null,this._labelItems=null,this._labelSizes=null,this._length=0,this._maxLength=0,this._longestTextCache={},this._startPixel=void 0,this._endPixel=void 0,this._reversePixels=!1,this._userMax=void 0,this._userMin=void 0,this._suggestedMax=void 0,this._suggestedMin=void 0,this._ticksLength=0,this._borderValue=0,this._cache={},this._dataLimitsCached=!1,this.$context=void 0}init(t){this.options=t.setContext(this.getContext()),this.axis=t.axis,this._userMin=this.parse(t.min),this._userMax=this.parse(t.max),this._suggestedMin=this.parse(t.suggestedMin),this._suggestedMax=this.parse(t.suggestedMax)}parse(t,e){return t}getUserBounds(){let{_userMin:t,_userMax:e,_suggestedMin:i,_suggestedMax:s}=this;return t=G(t,Number.POSITIVE_INFINITY),e=G(e,Number.NEGATIVE_INFINITY),i=G(i,Number.POSITIVE_INFINITY),s=G(s,Number.NEGATIVE_INFINITY),{min:G(t,i),max:G(e,s),minDefined:K(t),maxDefined:K(e)}}getMinMax(t){let e,{min:i,max:s,minDefined:n,maxDefined:o}=this.getUserBounds();if(n&&o)return{min:i,max:s};const a=this.getMatchingVisibleMetas();for(let r=0,l=a.length;r<l;++r)e=a[r].controller.getMinMax(this,t),n||(i=Math.min(i,e.min)),o||(s=Math.max(s,e.max));return i=o&&i>s?s:i,s=n&&i>s?i:s,{min:G(i,G(s,i)),max:G(s,G(i,s))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){tt(this.options.beforeUpdate,[this])}update(t,e,i){const{beginAtZero:s,grace:n,ticks:o}=this.options,a=o.sampleSize;this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=gi(this,n,s),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const r=a<this.ticks.length;this._convertTicksToLabels(r?Is(this.ticks,a):this.ticks),this.configure(),this.beforeCalculateLabelRotation(),this.calculateLabelRotation(),this.afterCalculateLabelRotation(),o.display&&(o.autoSkip||"auto"===o.source)&&(this.ticks=Ls(this,this.ticks),this._labelSizes=null,this.afterAutoSkip()),r&&this._convertTicksToLabels(this.ticks),this.beforeFit(),this.fit(),this.afterFit(),this.afterUpdate()}configure(){let t,e,i=this.options.reverse;this.isHorizontal()?(t=this.left,e=this.right):(t=this.top,e=this.bottom,i=!i),this._startPixel=t,this._endPixel=e,this._reversePixels=i,this._length=e-t,this._alignToPixels=this.options.alignToPixels}afterUpdate(){tt(this.options.afterUpdate,[this])}beforeSetDimensions(){tt(this.options.beforeSetDimensions,[this])}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=0,this.right=this.width):(this.height=this.maxHeight,this.top=0,this.bottom=this.height),this.paddingLeft=0,this.paddingTop=0,this.paddingRight=0,this.paddingBottom=0}afterSetDimensions(){tt(this.options.afterSetDimensions,[this])}_callHooks(t){this.chart.notifyPlugins(t,this.getContext()),tt(this.options[t],[this])}beforeDataLimits(){this._callHooks("beforeDataLimits")}determineDataLimits(){}afterDataLimits(){this._callHooks("afterDataLimits")}beforeBuildTicks(){this._callHooks("beforeBuildTicks")}buildTicks(){return[]}afterBuildTicks(){this._callHooks("afterBuildTicks")}beforeTickToLabelConversion(){tt(this.options.beforeTickToLabelConversion,[this])}generateTickLabels(t){const e=this.options.ticks;let i,s,n;for(i=0,s=t.length;i<s;i++)n=t[i],n.label=tt(e.callback,[n.value,i,t],this)}afterTickToLabelConversion(){tt(this.options.afterTickToLabelConversion,[this])}beforeCalculateLabelRotation(){tt(this.options.beforeCalculateLabelRotation,[this])}calculateLabelRotation(){const t=this.options,e=t.ticks,i=this.ticks.length,s=e.minRotation||0,n=e.maxRotation;let o,a,r,l=s;if(!this._isVisible()||!e.display||s>=n||i<=1||!this.isHorizontal())return void(this.labelRotation=s);const h=this._getLabelSizes(),c=h.widest.width,d=h.highest.height,u=Qt(this.chart.width-c,0,this.maxWidth);o=t.offset?this.maxWidth/i:u/(i-1),c+6>o&&(o=u/(i-(t.offset?.5:1)),a=this.maxHeight-Fs(t.grid)-e.padding-Bs(t.title,this.chart.options.font),r=Math.sqrt(c*c+d*d),l=Ut(Math.min(Math.asin(Qt((h.highest.height+6)/o,-1,1)),Math.asin(Qt(a/r,-1,1))-Math.asin(Qt(d/r,-1,1)))),l=Math.max(s,Math.min(n,l))),this.labelRotation=l}afterCalculateLabelRotation(){tt(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){tt(this.options.beforeFit,[this])}fit(){const t={width:0,height:0},{chart:e,options:{ticks:i,title:s,grid:n}}=this,o=this._isVisible(),a=this.isHorizontal();if(o){const o=Bs(s,e.options.font);if(a?(t.width=this.maxWidth,t.height=Fs(n)+o):(t.height=this.maxHeight,t.width=Fs(n)+o),i.display&&this.ticks.length){const{first:e,last:s,widest:n,highest:o}=this._getLabelSizes(),r=2*i.padding,l=Yt(this.labelRotation),h=Math.cos(l),c=Math.sin(l);if(a){const e=i.mirror?0:c*n.width+h*o.height;t.height=Math.min(this.maxHeight,t.height+e+r)}else{const e=i.mirror?0:h*n.width+c*o.height;t.width=Math.min(this.maxWidth,t.width+e+r)}this._calculatePadding(e,s,c,h)}}this._handleMargins(),a?(this.width=this._length=e.width-this._margins.left-this._margins.right,this.height=t.height):(this.width=t.width,this.height=this._length=e.height-this._margins.top-this._margins.bottom)}_calculatePadding(t,e,i,s){const{ticks:{align:n,padding:o},position:a}=this.options,r=0!==this.labelRotation,l="top"!==a&&"x"===this.axis;if(this.isHorizontal()){const a=this.getPixelForTick(0)-this.left,h=this.right-this.getPixelForTick(this.ticks.length-1);let c=0,d=0;r?l?(c=s*t.width,d=i*e.height):(c=i*t.height,d=s*e.width):"start"===n?d=e.width:"end"===n?c=t.width:"inner"!==n&&(c=t.width/2,d=e.width/2),this.paddingLeft=Math.max((c-a+o)*this.width/(this.width-a),0),this.paddingRight=Math.max((d-h+o)*this.width/(this.width-h),0)}else{let i=e.height/2,s=t.height/2;"start"===n?(i=0,s=t.height):"end"===n&&(i=e.height,s=0),this.paddingTop=i+o,this.paddingBottom=s+o}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){tt(this.options.afterFit,[this])}isHorizontal(){const{axis:t,position:e}=this.options;return"top"===e||"bottom"===e||"x"===t}isFullSize(){return this.options.fullSize}_convertTicksToLabels(t){let e,i;for(this.beforeTickToLabelConversion(),this.generateTickLabels(t),e=0,i=t.length;e<i;e++)U(t[e].label)&&(t.splice(e,1),i--,e--);this.afterTickToLabelConversion()}_getLabelSizes(){let t=this._labelSizes;if(!t){const e=this.options.ticks.sampleSize;let i=this.ticks;e<i.length&&(i=Is(i,e)),this._labelSizes=t=this._computeLabelSizes(i,i.length)}return t}_computeLabelSizes(t,e){const{ctx:i,_longestTextCache:s}=this,n=[],o=[];let a,r,l,h,c,d,u,f,g,p,m,b=0,x=0;for(a=0;a<e;++a){if(h=t[a].label,c=this._resolveTickFontOptions(a),i.font=d=c.string,u=s[d]=s[d]||{data:{},gc:[]},f=c.lineHeight,g=p=0,U(h)||X(h)){if(X(h))for(r=0,l=h.length;r<l;++r)m=h[r],U(m)||X(m)||(g=me(i,u.data,u.gc,g,m),p+=f)}else g=me(i,u.data,u.gc,g,h),p=f;n.push(g),o.push(p),b=Math.max(g,b),x=Math.max(p,x)}!function(t,e){et(t,(t=>{const i=t.gc,s=i.length/2;let n;if(s>e){for(n=0;n<s;++n)delete t.data[i[n]];i.splice(0,s)}}))}(s,e);const _=n.indexOf(b),y=o.indexOf(x),v=t=>({width:n[t]||0,height:o[t]||0});return{first:v(0),last:v(e-1),widest:v(_),highest:v(y),widths:n,heights:o}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);const e=this._startPixel+t*this._length;return te(this._alignToPixels?xe(this.chart,e,0):e)}getDecimalForPixel(t){const e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:t,max:e}=this;return t<0&&e<0?e:t>0&&e>0?t:0}getContext(t){const e=this.ticks||[];if(t>=0&&t<e.length){const i=e[t];return i.$context||(i.$context=function(t,e,i){return pi(t,{tick:i,index:e,type:"tick"})}(this.getContext(),t,i))}return this.$context||(this.$context=pi(this.chart.getContext(),{scale:this,type:"scale"}))}_tickSize(){const t=this.options.ticks,e=Yt(this.labelRotation),i=Math.abs(Math.cos(e)),s=Math.abs(Math.sin(e)),n=this._getLabelSizes(),o=t.autoSkipPadding||0,a=n?n.widest.width+o:0,r=n?n.highest.height+o:0;return this.isHorizontal()?r*i>a*s?a/i:r/s:r*s<a*i?r/i:a/s}_isVisible(){const t=this.options.display;return"auto"!==t?!!t:this.getMatchingVisibleMetas().length>0}_computeGridLineItems(t){const e=this.axis,i=this.chart,s=this.options,{grid:n,position:o}=s,a=n.offset,r=this.isHorizontal(),l=this.ticks.length+(a?1:0),h=Fs(n),c=[],d=n.setContext(this.getContext()),u=d.drawBorder?d.borderWidth:0,f=u/2,g=function(t){return xe(i,t,u)};let p,m,b,x,_,y,v,w,M,k,S,P;if("top"===o)p=g(this.bottom),y=this.bottom-h,w=p-f,k=g(t.top)+f,P=t.bottom;else if("bottom"===o)p=g(this.top),k=t.top,P=g(t.bottom)-f,y=p+f,w=this.top+h;else if("left"===o)p=g(this.right),_=this.right-h,v=p-f,M=g(t.left)+f,S=t.right;else if("right"===o)p=g(this.left),M=t.left,S=g(t.right)-f,_=p+f,v=this.left+h;else if("x"===e){if("center"===o)p=g((t.top+t.bottom)/2+.5);else if(q(o)){const t=Object.keys(o)[0],e=o[t];p=g(this.chart.scales[t].getPixelForValue(e))}k=t.top,P=t.bottom,y=p+f,w=y+h}else if("y"===e){if("center"===o)p=g((t.left+t.right)/2);else if(q(o)){const t=Object.keys(o)[0],e=o[t];p=g(this.chart.scales[t].getPixelForValue(e))}_=p-f,v=_-h,M=t.left,S=t.right}const D=Z(s.ticks.maxTicksLimit,l),C=Math.max(1,Math.ceil(l/D));for(m=0;m<l;m+=C){const t=n.setContext(this.getContext(m)),e=t.lineWidth,s=t.color,o=n.borderDash||[],l=t.borderDashOffset,h=t.tickWidth,d=t.tickColor,u=t.tickBorderDash||[],f=t.tickBorderDashOffset;b=zs(this,m,a),void 0!==b&&(x=xe(i,b,e),r?_=v=M=S=x:y=w=k=P=x,c.push({tx1:_,ty1:y,tx2:v,ty2:w,x1:M,y1:k,x2:S,y2:P,width:e,color:s,borderDash:o,borderDashOffset:l,tickWidth:h,tickColor:d,tickBorderDash:u,tickBorderDashOffset:f}))}return this._ticksLength=l,this._borderValue=p,c}_computeLabelItems(t){const e=this.axis,i=this.options,{position:s,ticks:n}=i,o=this.isHorizontal(),a=this.ticks,{align:r,crossAlign:l,padding:h,mirror:c}=n,d=Fs(i.grid),u=d+h,f=c?-h:u,g=-Yt(this.labelRotation),p=[];let m,b,x,_,y,v,w,M,k,S,P,D,C="middle";if("top"===s)v=this.bottom-f,w=this._getXAxisLabelAlignment();else if("bottom"===s)v=this.top+f,w=this._getXAxisLabelAlignment();else if("left"===s){const t=this._getYAxisLabelAlignment(d);w=t.textAlign,y=t.x}else if("right"===s){const t=this._getYAxisLabelAlignment(d);w=t.textAlign,y=t.x}else if("x"===e){if("center"===s)v=(t.top+t.bottom)/2+u;else if(q(s)){const t=Object.keys(s)[0],e=s[t];v=this.chart.scales[t].getPixelForValue(e)+u}w=this._getXAxisLabelAlignment()}else if("y"===e){if("center"===s)y=(t.left+t.right)/2-u;else if(q(s)){const t=Object.keys(s)[0],e=s[t];y=this.chart.scales[t].getPixelForValue(e)}w=this._getYAxisLabelAlignment(d).textAlign}"y"===e&&("start"===r?C="top":"end"===r&&(C="bottom"));const O=this._getLabelSizes();for(m=0,b=a.length;m<b;++m){x=a[m],_=x.label;const t=n.setContext(this.getContext(m));M=this.getPixelForTick(m)+n.labelOffset,k=this._resolveTickFontOptions(m),S=k.lineHeight,P=X(_)?_.length:1;const e=P/2,i=t.color,r=t.textStrokeColor,h=t.textStrokeWidth;let d,u=w;if(o?(y=M,"inner"===w&&(u=m===b-1?this.options.reverse?"left":"right":0===m?this.options.reverse?"right":"left":"center"),D="top"===s?"near"===l||0!==g?-P*S+S/2:"center"===l?-O.highest.height/2-e*S+S:-O.highest.height+S/2:"near"===l||0!==g?S/2:"center"===l?O.highest.height/2-e*S:O.highest.height-P*S,c&&(D*=-1)):(v=M,D=(1-P)*S/2),t.showLabelBackdrop){const e=di(t.backdropPadding),i=O.heights[m],s=O.widths[m];let n=v+D-e.top,o=y-e.left;switch(C){case"middle":n-=i/2;break;case"bottom":n-=i}switch(w){case"center":o-=s/2;break;case"right":o-=s}d={left:o,top:n,width:s+e.width,height:i+e.height,color:t.backdropColor}}p.push({rotation:g,label:_,font:k,color:i,strokeColor:r,strokeWidth:h,textOffset:D,textAlign:u,textBaseline:C,translation:[y,v],backdrop:d})}return p}_getXAxisLabelAlignment(){const{position:t,ticks:e}=this.options;if(-Yt(this.labelRotation))return"top"===t?"left":"right";let i="center";return"start"===e.align?i="left":"end"===e.align?i="right":"inner"===e.align&&(i="inner"),i}_getYAxisLabelAlignment(t){const{position:e,ticks:{crossAlign:i,mirror:s,padding:n}}=this.options,o=t+n,a=this._getLabelSizes().widest.width;let r,l;return"left"===e?s?(l=this.right+n,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l+=a)):(l=this.right-o,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l=this.left)):"right"===e?s?(l=this.left+n,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l-=a)):(l=this.left+o,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l=this.right)):r="right",{textAlign:r,x:l}}_computeLabelArea(){if(this.options.ticks.mirror)return;const t=this.chart,e=this.options.position;return"left"===e||"right"===e?{top:0,left:this.left,bottom:t.height,right:this.right}:"top"===e||"bottom"===e?{top:this.top,left:0,bottom:this.bottom,right:t.width}:void 0}drawBackground(){const{ctx:t,options:{backgroundColor:e},left:i,top:s,width:n,height:o}=this;e&&(t.save(),t.fillStyle=e,t.fillRect(i,s,n,o),t.restore())}getLineWidthForValue(t){const e=this.options.grid;if(!this._isVisible()||!e.display)return 0;const i=this.ticks.findIndex((e=>e.value===t));if(i>=0){return e.setContext(this.getContext(i)).lineWidth}return 0}drawGrid(t){const e=this.options.grid,i=this.ctx,s=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let n,o;const a=(t,e,s)=>{s.width&&s.color&&(i.save(),i.lineWidth=s.width,i.strokeStyle=s.color,i.setLineDash(s.borderDash||[]),i.lineDashOffset=s.borderDashOffset,i.beginPath(),i.moveTo(t.x,t.y),i.lineTo(e.x,e.y),i.stroke(),i.restore())};if(e.display)for(n=0,o=s.length;n<o;++n){const t=s[n];e.drawOnChartArea&&a({x:t.x1,y:t.y1},{x:t.x2,y:t.y2},t),e.drawTicks&&a({x:t.tx1,y:t.ty1},{x:t.tx2,y:t.ty2},{color:t.tickColor,width:t.tickWidth,borderDash:t.tickBorderDash,borderDashOffset:t.tickBorderDashOffset})}}drawBorder(){const{chart:t,ctx:e,options:{grid:i}}=this,s=i.setContext(this.getContext()),n=i.drawBorder?s.borderWidth:0;if(!n)return;const o=i.setContext(this.getContext(0)).lineWidth,a=this._borderValue;let r,l,h,c;this.isHorizontal()?(r=xe(t,this.left,n)-n/2,l=xe(t,this.right,o)+o/2,h=c=a):(h=xe(t,this.top,n)-n/2,c=xe(t,this.bottom,o)+o/2,r=l=a),e.save(),e.lineWidth=s.borderWidth,e.strokeStyle=s.borderColor,e.beginPath(),e.moveTo(r,h),e.lineTo(l,c),e.stroke(),e.restore()}drawLabels(t){if(!this.options.ticks.display)return;const e=this.ctx,i=this._computeLabelArea();i&&we(e,i);const s=this._labelItems||(this._labelItems=this._computeLabelItems(t));let n,o;for(n=0,o=s.length;n<o;++n){const t=s[n],i=t.font,o=t.label;t.backdrop&&(e.fillStyle=t.backdrop.color,e.fillRect(t.backdrop.left,t.backdrop.top,t.backdrop.width,t.backdrop.height)),Pe(e,o,0,t.textOffset,i,t)}i&&Me(e)}drawTitle(){const{ctx:t,options:{position:e,title:i,reverse:s}}=this;if(!i.display)return;const o=ui(i.font),a=di(i.padding),r=i.align;let l=o.lineHeight/2;"bottom"===e||"center"===e||q(e)?(l+=a.bottom,X(i.text)&&(l+=o.lineHeight*(i.text.length-1))):l+=a.top;const{titleX:h,titleY:c,maxWidth:d,rotation:u}=function(t,e,i,s){const{top:o,left:a,bottom:r,right:l,chart:h}=t,{chartArea:c,scales:d}=h;let u,f,g,p=0;const m=r-o,b=l-a;if(t.isHorizontal()){if(f=n(s,a,l),q(i)){const t=Object.keys(i)[0],s=i[t];g=d[t].getPixelForValue(s)+m-e}else g="center"===i?(c.bottom+c.top)/2+m-e:Es(t,i,e);u=l-a}else{if(q(i)){const t=Object.keys(i)[0],s=i[t];f=d[t].getPixelForValue(s)-b+e}else f="center"===i?(c.left+c.right)/2-b+e:Es(t,i,e);g=n(s,r,o),p="left"===i?-Et:Et}return{titleX:f,titleY:g,maxWidth:u,rotation:p}}(this,l,e,r);Pe(t,i.text,0,0,o,{color:i.color,maxWidth:d,rotation:u,textAlign:Vs(r,e,s),textBaseline:"middle",translation:[h,c]})}draw(t){this._isVisible()&&(this.drawBackground(),this.drawGrid(t),this.drawBorder(),this.drawTitle(),this.drawLabels(t))}_layers(){const t=this.options,e=t.ticks&&t.ticks.z||0,i=Z(t.grid&&t.grid.z,-1);return this._isVisible()&&this.draw===Ns.prototype.draw?[{z:i,draw:t=>{this.drawBackground(),this.drawGrid(t),this.drawTitle()}},{z:i+1,draw:()=>{this.drawBorder()}},{z:e,draw:t=>{this.drawLabels(t)}}]:[{z:e,draw:t=>{this.draw(t)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+"AxisID",s=[];let n,o;for(n=0,o=e.length;n<o;++n){const o=e[n];o[i]!==this.id||t&&o.type!==t||s.push(o)}return s}_resolveTickFontOptions(t){return ui(this.options.ticks.setContext(this.getContext(t)).font)}_maxDigits(){const t=this._resolveTickFontOptions(0).lineHeight;return(this.isHorizontal()?this.width:this.height)/t}}class Ws{constructor(t,e,i){this.type=t,this.scope=e,this.override=i,this.items=Object.create(null)}isForType(t){return Object.prototype.isPrototypeOf.call(this.type.prototype,t.prototype)}register(t){const e=Object.getPrototypeOf(t);let i;(function(t){return"id"in t&&"defaults"in t})(e)&&(i=this.register(e));const s=this.items,n=t.id,o=this.scope+"."+n;if(!n)throw new Error("class does not have id: "+t);return n in s||(s[n]=t,function(t,e,i){const s=at(Object.create(null),[i?yt.get(i):{},yt.get(e),t.defaults]);yt.set(e,s),t.defaultRoutes&&function(t,e){Object.keys(e).forEach((i=>{const s=i.split("."),n=s.pop(),o=[t].concat(s).join("."),a=e[i].split("."),r=a.pop(),l=a.join(".");yt.route(o,n,l,r)}))}(e,t.defaultRoutes);t.descriptors&&yt.describe(e,t.descriptors)}(t,o,i),this.override&&yt.override(t.id,t.overrides)),o}get(t){return this.items[t]}unregister(t){const e=this.items,i=t.id,s=this.scope;i in e&&delete e[i],s&&i in yt[s]&&(delete yt[s][i],this.override&&delete mt[i])}}var Hs=new class{constructor(){this.controllers=new Ws(Cs,"datasets",!0),this.elements=new Ws(Os,"elements"),this.plugins=new Ws(Object,"plugins"),this.scales=new Ws(Ns,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each("register",t)}remove(...t){this._each("unregister",t)}addControllers(...t){this._each("register",t,this.controllers)}addElements(...t){this._each("register",t,this.elements)}addPlugins(...t){this._each("register",t,this.plugins)}addScales(...t){this._each("register",t,this.scales)}getController(t){return this._get(t,this.controllers,"controller")}getElement(t){return this._get(t,this.elements,"element")}getPlugin(t){return this._get(t,this.plugins,"plugin")}getScale(t){return this._get(t,this.scales,"scale")}removeControllers(...t){this._each("unregister",t,this.controllers)}removeElements(...t){this._each("unregister",t,this.elements)}removePlugins(...t){this._each("unregister",t,this.plugins)}removeScales(...t){this._each("unregister",t,this.scales)}_each(t,e,i){[...e].forEach((e=>{const s=i||this._getRegistryForType(e);i||s.isForType(e)||s===this.plugins&&e.id?this._exec(t,s,e):et(e,(e=>{const s=i||this._getRegistryForType(e);this._exec(t,s,e)}))}))}_exec(t,e,i){const s=dt(t);tt(i["before"+s],[],i),e[t](i),tt(i["after"+s],[],i)}_getRegistryForType(t){for(let e=0;e<this._typedRegistries.length;e++){const i=this._typedRegistries[e];if(i.isForType(t))return i}return this.plugins}_get(t,e,i){const s=e.get(t);if(void 0===s)throw new Error('"'+t+'" is not a registered '+i+".");return s}};class js{constructor(){this._init=[]}notify(t,e,i,s){"beforeInit"===e&&(this._init=this._createDescriptors(t,!0),this._notify(this._init,t,"install"));const n=s?this._descriptors(t).filter(s):this._descriptors(t),o=this._notify(n,t,e,i);return"afterDestroy"===e&&(this._notify(n,t,"stop"),this._notify(this._init,t,"uninstall")),o}_notify(t,e,i,s){s=s||{};for(const n of t){const t=n.plugin;if(!1===tt(t[i],[e,s,n.options],t)&&s.cancelable)return!1}return!0}invalidate(){U(this._cache)||(this._oldCache=this._cache,this._cache=void 0)}_descriptors(t){if(this._cache)return this._cache;const e=this._cache=this._createDescriptors(t);return this._notifyStateChanges(t),e}_createDescriptors(t,e){const i=t&&t.config,s=Z(i.options&&i.options.plugins,{}),n=function(t){const e=[],i=Object.keys(Hs.plugins.items);for(let t=0;t<i.length;t++)e.push(Hs.getPlugin(i[t]));const s=t.plugins||[];for(let t=0;t<s.length;t++){const i=s[t];-1===e.indexOf(i)&&e.push(i)}return e}(i);return!1!==s||e?function(t,e,i,s){const n=[],o=t.getContext();for(let a=0;a<e.length;a++){const r=e[a],l=$s(i[r.id],s);null!==l&&n.push({plugin:r,options:Ys(t.config,r,l,o)})}return n}(t,n,s,e):[]}_notifyStateChanges(t){const e=this._oldCache||[],i=this._cache,s=(t,e)=>t.filter((t=>!e.some((e=>t.plugin.id===e.plugin.id))));this._notify(s(e,i),t,"stop"),this._notify(s(i,e),t,"start")}}function $s(t,e){return e||!1!==t?!0===t?{}:t:null}function Ys(t,e,i,s){const n=t.pluginScopeKeys(e),o=t.getOptionScopes(i,n);return t.createResolver(o,s,[""],{scriptable:!1,indexable:!1,allKeys:!0})}function Us(t,e){const i=yt.datasets[t]||{};return((e.datasets||{})[t]||{}).indexAxis||e.indexAxis||i.indexAxis||"x"}function Xs(t,e){return"x"===t||"y"===t?t:e.axis||("top"===(i=e.position)||"bottom"===i?"x":"left"===i||"right"===i?"y":void 0)||t.charAt(0).toLowerCase();var i}function qs(t){const e=t.options||(t.options={});e.plugins=Z(e.plugins,{}),e.scales=function(t,e){const i=mt[t.type]||{scales:{}},s=e.scales||{},n=Us(t.type,e),o=Object.create(null),a=Object.create(null);return Object.keys(s).forEach((t=>{const e=s[t];if(!q(e))return console.error(`Invalid scale configuration for scale: ${t}`);if(e._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${t}`);const r=Xs(t,e),l=function(t,e){return t===e?"_index_":"_value_"}(r,n),h=i.scales||{};o[r]=o[r]||t,a[t]=rt(Object.create(null),[{axis:r},e,h[r],h[l]])})),t.data.datasets.forEach((i=>{const n=i.type||t.type,r=i.indexAxis||Us(n,e),l=(mt[n]||{}).scales||{};Object.keys(l).forEach((t=>{const e=function(t,e){let i=t;return"_index_"===t?i=e:"_value_"===t&&(i="x"===e?"y":"x"),i}(t,r),n=i[e+"AxisID"]||o[e]||e;a[n]=a[n]||Object.create(null),rt(a[n],[{axis:e},s[n],l[t]])}))})),Object.keys(a).forEach((t=>{const e=a[t];rt(e,[yt.scales[e.type],yt.scale])})),a}(t,e)}function Ks(t){return(t=t||{}).datasets=t.datasets||[],t.labels=t.labels||[],t}const Gs=new Map,Zs=new Set;function Js(t,e){let i=Gs.get(t);return i||(i=e(),Gs.set(t,i),Zs.add(i)),i}const Qs=(t,e,i)=>{const s=ct(e,i);void 0!==s&&t.add(s)};class tn{constructor(t){this._config=function(t){return(t=t||{}).data=Ks(t.data),qs(t),t}(t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=Ks(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){const t=this._config;this.clearCache(),qs(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return Js(t,(()=>[[`datasets.${t}`,""]]))}datasetAnimationScopeKeys(t,e){return Js(`${t}.transition.${e}`,(()=>[[`datasets.${t}.transitions.${e}`,`transitions.${e}`],[`datasets.${t}`,""]]))}datasetElementScopeKeys(t,e){return Js(`${t}-${e}`,(()=>[[`datasets.${t}.elements.${e}`,`datasets.${t}`,`elements.${e}`,""]]))}pluginScopeKeys(t){const e=t.id;return Js(`${this.type}-plugin-${e}`,(()=>[[`plugins.${e}`,...t.additionalOptionScopes||[]]]))}_cachedScopes(t,e){const i=this._scopeCache;let s=i.get(t);return s&&!e||(s=new Map,i.set(t,s)),s}getOptionScopes(t,e,i){const{options:s,type:n}=this,o=this._cachedScopes(t,i),a=o.get(e);if(a)return a;const r=new Set;e.forEach((e=>{t&&(r.add(t),e.forEach((e=>Qs(r,t,e)))),e.forEach((t=>Qs(r,s,t))),e.forEach((t=>Qs(r,mt[n]||{},t))),e.forEach((t=>Qs(r,yt,t))),e.forEach((t=>Qs(r,bt,t)))}));const l=Array.from(r);return 0===l.length&&l.push(Object.create(null)),Zs.has(e)&&o.set(e,l),l}chartOptionScopes(){const{options:t,type:e}=this;return[t,mt[e]||{},yt.datasets[e]||{},{type:e},yt,bt]}resolveNamedOptions(t,e,i,s=[""]){const n={$shared:!0},{resolver:o,subPrefixes:a}=en(this._resolverCache,t,s);let r=o;if(function(t,e){const{isScriptable:i,isIndexable:s}=Te(t);for(const n of e){const e=i(n),o=s(n),a=(o||e)&&t[n];if(e&&(ft(a)||sn(a))||o&&X(a))return!0}return!1}(o,e)){n.$shared=!1;r=Ae(o,i=ft(i)?i():i,this.createResolver(t,i,a))}for(const t of e)n[t]=r[t];return n}createResolver(t,e,i=[""],s){const{resolver:n}=en(this._resolverCache,t,i);return q(e)?Ae(n,e,void 0,s):n}}function en(t,e,i){let s=t.get(e);s||(s=new Map,t.set(e,s));const n=i.join();let o=s.get(n);if(!o){o={resolver:Oe(e,i),subPrefixes:i.filter((t=>!t.toLowerCase().includes("hover")))},s.set(n,o)}return o}const sn=t=>q(t)&&Object.getOwnPropertyNames(t).reduce(((e,i)=>e||ft(t[i])),!1);const nn=["top","bottom","left","right","chartArea"];function on(t,e){return"top"===t||"bottom"===t||-1===nn.indexOf(t)&&"x"===e}function an(t,e){return function(i,s){return i[t]===s[t]?i[e]-s[e]:i[t]-s[t]}}function rn(t){const e=t.chart,i=e.options.animation;e.notifyPlugins("afterRender"),tt(i&&i.onComplete,[t],e)}function ln(t){const e=t.chart,i=e.options.animation;tt(i&&i.onProgress,[t],e)}function hn(t){return ie()&&"string"==typeof t?t=document.getElementById(t):t&&t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas),t}const cn={},dn=t=>{const e=hn(t);return Object.values(cn).filter((t=>t.canvas===e)).pop()};function un(t,e,i){const s=Object.keys(t);for(const n of s){const s=+n;if(s>=e){const o=t[n];delete t[n],(i>0||s>e)&&(t[s+i]=o)}}}class fn{constructor(t,e){const s=this.config=new tn(e),n=hn(t),o=dn(n);if(o)throw new Error("Canvas is already in use. Chart with ID '"+o.id+"' must be destroyed before the canvas can be reused.");const r=s.createResolver(s.chartOptionScopes(),this.getContext());this.platform=new(s.platform||cs(n)),this.platform.updateConfig(s);const l=this.platform.acquireContext(n,r.aspectRatio),h=l&&l.canvas,c=h&&h.height,d=h&&h.width;this.id=Y(),this.ctx=l,this.canvas=h,this.width=d,this.height=c,this._options=r,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new js,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=i((t=>this.update(t)),r.resizeDelay||0),this._dataChanges=[],cn[this.id]=this,l&&h?(a.listen(this,"complete",rn),a.listen(this,"progress",ln),this._initialize(),this.attached&&this.update()):console.error("Failed to create chart: can't acquire context from the given item")}get aspectRatio(){const{options:{aspectRatio:t,maintainAspectRatio:e},width:i,height:s,_aspectRatio:n}=this;return U(t)?e&&n?n:s?i/s:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():ue(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return _e(this.canvas,this.ctx),this}stop(){return a.stop(this),this}resize(t,e){a.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){const i=this.options,s=this.canvas,n=i.maintainAspectRatio&&this.aspectRatio,o=this.platform.getMaximumSize(s,t,e,n),a=i.devicePixelRatio||this.platform.getDevicePixelRatio(),r=this.width?"resize":"attach";this.width=o.width,this.height=o.height,this._aspectRatio=this.aspectRatio,ue(this,a,!0)&&(this.notifyPlugins("resize",{size:o}),tt(i.onResize,[this,o],this),this.attached&&this._doResize(r)&&this.render())}ensureScalesHaveIDs(){et(this.options.scales||{},((t,e)=>{t.id=e}))}buildOrUpdateScales(){const t=this.options,e=t.scales,i=this.scales,s=Object.keys(i).reduce(((t,e)=>(t[e]=!1,t)),{});let n=[];e&&(n=n.concat(Object.keys(e).map((t=>{const i=e[t],s=Xs(t,i),n="r"===s,o="x"===s;return{options:i,dposition:n?"chartArea":o?"bottom":"left",dtype:n?"radialLinear":o?"category":"linear"}})))),et(n,(e=>{const n=e.options,o=n.id,a=Xs(o,n),r=Z(n.type,e.dtype);void 0!==n.position&&on(n.position,a)===on(e.dposition)||(n.position=e.dposition),s[o]=!0;let l=null;if(o in i&&i[o].type===r)l=i[o];else{l=new(Hs.getScale(r))({id:o,type:r,ctx:this.ctx,chart:this}),i[l.id]=l}l.init(n,t)})),et(s,((t,e)=>{t||delete i[e]})),et(i,(t=>{Xi.configure(this,t,t.options),Xi.addBox(this,t)}))}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,i=t.length;if(t.sort(((t,e)=>t.index-e.index)),i>e){for(let t=e;t<i;++t)this._destroyDatasetMeta(t);t.splice(e,i-e)}this._sortedMetasets=t.slice(0).sort(an("order","index"))}_removeUnreferencedMetasets(){const{_metasets:t,data:{datasets:e}}=this;t.length>e.length&&delete this._stacks,t.forEach(((t,i)=>{0===e.filter((e=>e===t._dataset)).length&&this._destroyDatasetMeta(i)}))}buildOrUpdateControllers(){const t=[],e=this.data.datasets;let i,s;for(this._removeUnreferencedMetasets(),i=0,s=e.length;i<s;i++){const s=e[i];let n=this.getDatasetMeta(i);const o=s.type||this.config.type;if(n.type&&n.type!==o&&(this._destroyDatasetMeta(i),n=this.getDatasetMeta(i)),n.type=o,n.indexAxis=s.indexAxis||Us(o,this.options),n.order=s.order||0,n.index=i,n.label=""+s.label,n.visible=this.isDatasetVisible(i),n.controller)n.controller.updateIndex(i),n.controller.linkScales();else{const e=Hs.getController(o),{datasetElementType:s,dataElementType:a}=yt.datasets[o];Object.assign(e.prototype,{dataElementType:Hs.getElement(a),datasetElementType:s&&Hs.getElement(s)}),n.controller=new e(this,i),t.push(n.controller)}}return this._updateMetasets(),t}_resetElements(){et(this.data.datasets,((t,e)=>{this.getDatasetMeta(e).controller.reset()}),this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(t){const e=this.config;e.update();const i=this._options=e.createResolver(e.chartOptionScopes(),this.getContext()),s=this._animationsDisabled=!i.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),!1===this.notifyPlugins("beforeUpdate",{mode:t,cancelable:!0}))return;const n=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let o=0;for(let t=0,e=this.data.datasets.length;t<e;t++){const{controller:e}=this.getDatasetMeta(t),i=!s&&-1===n.indexOf(e);e.buildOrUpdateElements(i),o=Math.max(+e.getMaxOverflow(),o)}o=this._minPadding=i.layout.autoPadding?o:0,this._updateLayout(o),s||et(n,(t=>{t.reset()})),this._updateDatasets(t),this.notifyPlugins("afterUpdate",{mode:t}),this._layers.sort(an("z","_idx"));const{_active:a,_lastEvent:r}=this;r?this._eventHandler(r,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){et(this.scales,(t=>{Xi.removeBox(this,t)})),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const t=this.options,e=new Set(Object.keys(this._listeners)),i=new Set(t.events);gt(e,i)&&!!this._responsiveListeners===t.responsive||(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:t}=this,e=this._getUniformDataChanges()||[];for(const{method:i,start:s,count:n}of e){un(t,s,"_removeElements"===i?-n:n)}}_getUniformDataChanges(){const t=this._dataChanges;if(!t||!t.length)return;this._dataChanges=[];const e=this.data.datasets.length,i=e=>new Set(t.filter((t=>t[0]===e)).map(((t,e)=>e+","+t.splice(1).join(",")))),s=i(0);for(let t=1;t<e;t++)if(!gt(s,i(t)))return;return Array.from(s).map((t=>t.split(","))).map((t=>({method:t[1],start:+t[2],count:+t[3]})))}_updateLayout(t){if(!1===this.notifyPlugins("beforeLayout",{cancelable:!0}))return;Xi.update(this,this.width,this.height,t);const e=this.chartArea,i=e.width<=0||e.height<=0;this._layers=[],et(this.boxes,(t=>{i&&"chartArea"===t.position||(t.configure&&t.configure(),this._layers.push(...t._layers()))}),this),this._layers.forEach(((t,e)=>{t._idx=e})),this.notifyPlugins("afterLayout")}_updateDatasets(t){if(!1!==this.notifyPlugins("beforeDatasetsUpdate",{mode:t,cancelable:!0})){for(let t=0,e=this.data.datasets.length;t<e;++t)this.getDatasetMeta(t).controller.configure();for(let e=0,i=this.data.datasets.length;e<i;++e)this._updateDataset(e,ft(t)?t({datasetIndex:e}):t);this.notifyPlugins("afterDatasetsUpdate",{mode:t})}}_updateDataset(t,e){const i=this.getDatasetMeta(t),s={meta:i,index:t,mode:e,cancelable:!0};!1!==this.notifyPlugins("beforeDatasetUpdate",s)&&(i.controller._update(e),s.cancelable=!1,this.notifyPlugins("afterDatasetUpdate",s))}render(){!1!==this.notifyPlugins("beforeRender",{cancelable:!0})&&(a.has(this)?this.attached&&!a.running(this)&&a.start(this):(this.draw(),rn({chart:this})))}draw(){let t;if(this._resizeBeforeDraw){const{width:t,height:e}=this._resizeBeforeDraw;this._resize(t,e),this._resizeBeforeDraw=null}if(this.clear(),this.width<=0||this.height<=0)return;if(!1===this.notifyPlugins("beforeDraw",{cancelable:!0}))return;const e=this._layers;for(t=0;t<e.length&&e[t].z<=0;++t)e[t].draw(this.chartArea);for(this._drawDatasets();t<e.length;++t)e[t].draw(this.chartArea);this.notifyPlugins("afterDraw")}_getSortedDatasetMetas(t){const e=this._sortedMetasets,i=[];let s,n;for(s=0,n=e.length;s<n;++s){const n=e[s];t&&!n.visible||i.push(n)}return i}getSortedVisibleDatasetMetas(){return this._getSortedDatasetMetas(!0)}_drawDatasets(){if(!1===this.notifyPlugins("beforeDatasetsDraw",{cancelable:!0}))return;const t=this.getSortedVisibleDatasetMetas();for(let e=t.length-1;e>=0;--e)this._drawDataset(t[e]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(t){const e=this.ctx,i=t._clip,s=!i.disabled,n=this.chartArea,o={meta:t,index:t.index,cancelable:!0};!1!==this.notifyPlugins("beforeDatasetDraw",o)&&(s&&we(e,{left:!1===i.left?0:n.left-i.left,right:!1===i.right?this.width:n.right+i.right,top:!1===i.top?0:n.top-i.top,bottom:!1===i.bottom?this.height:n.bottom+i.bottom}),t.controller.draw(),s&&Me(e),o.cancelable=!1,this.notifyPlugins("afterDatasetDraw",o))}isPointInArea(t){return ve(t,this.chartArea,this._minPadding)}getElementsAtEventForMode(t,e,i,s){const n=Ei.modes[e];return"function"==typeof n?n(this,t,i,s):[]}getDatasetMeta(t){const e=this.data.datasets[t],i=this._metasets;let s=i.filter((t=>t&&t._dataset===e)).pop();return s||(s={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},i.push(s)),s}getContext(){return this.$context||(this.$context=pi(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){const e=this.data.datasets[t];if(!e)return!1;const i=this.getDatasetMeta(t);return"boolean"==typeof i.hidden?!i.hidden:!e.hidden}setDatasetVisibility(t,e){this.getDatasetMeta(t).hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(t,e,i){const s=i?"show":"hide",n=this.getDatasetMeta(t),o=n.controller._resolveAnimations(void 0,s);ut(e)?(n.data[e].hidden=!i,this.update()):(this.setDatasetVisibility(t,i),o.update(n,{visible:i}),this.update((e=>e.datasetIndex===t?s:void 0)))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),a.remove(this),t=0,e=this.data.datasets.length;t<e;++t)this._destroyDatasetMeta(t)}destroy(){this.notifyPlugins("beforeDestroy");const{canvas:t,ctx:e}=this;this._stop(),this.config.clearCache(),t&&(this.unbindEvents(),_e(t,e),this.platform.releaseContext(e),this.canvas=null,this.ctx=null),this.notifyPlugins("destroy"),delete cn[this.id],this.notifyPlugins("afterDestroy")}toBase64Image(...t){return this.canvas.toDataURL(...t)}bindEvents(){this.bindUserEvents(),this.options.responsive?this.bindResponsiveEvents():this.attached=!0}bindUserEvents(){const t=this._listeners,e=this.platform,i=(i,s)=>{e.addEventListener(this,i,s),t[i]=s},s=(t,e,i)=>{t.offsetX=e,t.offsetY=i,this._eventHandler(t)};et(this.options.events,(t=>i(t,s)))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const t=this._responsiveListeners,e=this.platform,i=(i,s)=>{e.addEventListener(this,i,s),t[i]=s},s=(i,s)=>{t[i]&&(e.removeEventListener(this,i,s),delete t[i])},n=(t,e)=>{this.canvas&&this.resize(t,e)};let o;const a=()=>{s("attach",a),this.attached=!0,this.resize(),i("resize",n),i("detach",o)};o=()=>{this.attached=!1,s("resize",n),this._stop(),this._resize(0,0),i("attach",a)},e.isAttached(this.canvas)?a():o()}unbindEvents(){et(this._listeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._listeners={},et(this._responsiveListeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._responsiveListeners=void 0}updateHoverStyle(t,e,i){const s=i?"set":"remove";let n,o,a,r;for("dataset"===e&&(n=this.getDatasetMeta(t[0].datasetIndex),n.controller["_"+s+"DatasetHoverStyle"]()),a=0,r=t.length;a<r;++a){o=t[a];const e=o&&this.getDatasetMeta(o.datasetIndex).controller;e&&e[s+"HoverStyle"](o.element,o.datasetIndex,o.index)}}getActiveElements(){return this._active||[]}setActiveElements(t){const e=this._active||[],i=t.map((({datasetIndex:t,index:e})=>{const i=this.getDatasetMeta(t);if(!i)throw new Error("No dataset found at index "+t);return{datasetIndex:t,element:i.data[e],index:e}}));!it(i,e)&&(this._active=i,this._lastEvent=null,this._updateHoverStyles(i,e))}notifyPlugins(t,e,i){return this._plugins.notify(this,t,e,i)}_updateHoverStyles(t,e,i){const s=this.options.hover,n=(t,e)=>t.filter((t=>!e.some((e=>t.datasetIndex===e.datasetIndex&&t.index===e.index)))),o=n(e,t),a=i?t:n(t,e);o.length&&this.updateHoverStyle(o,s.mode,!1),a.length&&s.mode&&this.updateHoverStyle(a,s.mode,!0)}_eventHandler(t,e){const i={event:t,replay:e,cancelable:!0,inChartArea:this.isPointInArea(t)},s=e=>(e.options.events||this.options.events).includes(t.native.type);if(!1===this.notifyPlugins("beforeEvent",i,s))return;const n=this._handleEvent(t,e,i.inChartArea);return i.cancelable=!1,this.notifyPlugins("afterEvent",i,s),(n||i.changed)&&this.render(),this}_handleEvent(t,e,i){const{_active:s=[],options:n}=this,o=e,a=this._getActiveElements(t,s,i,o),r=pt(t),l=function(t,e,i,s){return i&&"mouseout"!==t.type?s?e:t:null}(t,this._lastEvent,i,r);i&&(this._lastEvent=null,tt(n.onHover,[t,a,this],this),r&&tt(n.onClick,[t,a,this],this));const h=!it(a,s);return(h||e)&&(this._active=a,this._updateHoverStyles(a,s,e)),this._lastEvent=l,h}_getActiveElements(t,e,i,s){if("mouseout"===t.type)return[];if(!i)return e;const n=this.options.hover;return this.getElementsAtEventForMode(t,n.mode,n,s)}}const gn=()=>et(fn.instances,(t=>t._plugins.invalidate())),pn=!0;function mn(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}Object.defineProperties(fn,{defaults:{enumerable:pn,value:yt},instances:{enumerable:pn,value:cn},overrides:{enumerable:pn,value:mt},registry:{enumerable:pn,value:Hs},version:{enumerable:pn,value:"3.8.0"},getChart:{enumerable:pn,value:dn},register:{enumerable:pn,value:(...t)=>{Hs.add(...t),gn()}},unregister:{enumerable:pn,value:(...t)=>{Hs.remove(...t),gn()}}});class bn{constructor(t){this.options=t||{}}formats(){return mn()}parse(t,e){return mn()}format(t,e){return mn()}add(t,e,i){return mn()}diff(t,e,i){return mn()}startOf(t,e,i){return mn()}endOf(t,e){return mn()}}bn.override=function(t){Object.assign(bn.prototype,t)};var xn={_date:bn};function _n(t){const e=t.iScale,i=function(t,e){if(!t._cache.$bar){const i=t.getMatchingVisibleMetas(e);let s=[];for(let e=0,n=i.length;e<n;e++)s=s.concat(i[e].controller.getAllParsedValues(t));t._cache.$bar=Ct(s.sort(((t,e)=>t-e)))}return t._cache.$bar}(e,t.type);let s,n,o,a,r=e._length;const l=()=>{32767!==o&&-32768!==o&&(ut(a)&&(r=Math.min(r,Math.abs(o-a)||r)),a=o)};for(s=0,n=i.length;s<n;++s)o=e.getPixelForValue(i[s]),l();for(a=void 0,s=0,n=e.ticks.length;s<n;++s)o=e.getPixelForTick(s),l();return r}function yn(t,e,i,s){return X(t)?function(t,e,i,s){const n=i.parse(t[0],s),o=i.parse(t[1],s),a=Math.min(n,o),r=Math.max(n,o);let l=a,h=r;Math.abs(a)>Math.abs(r)&&(l=r,h=a),e[i.axis]=h,e._custom={barStart:l,barEnd:h,start:n,end:o,min:a,max:r}}(t,e,i,s):e[i.axis]=i.parse(t,s),e}function vn(t,e,i,s){const n=t.iScale,o=t.vScale,a=n.getLabels(),r=n===o,l=[];let h,c,d,u;for(h=i,c=i+s;h<c;++h)u=e[h],d={},d[n.axis]=r||n.parse(a[h],h),l.push(yn(u,d,o,h));return l}function wn(t){return t&&void 0!==t.barStart&&void 0!==t.barEnd}function Mn(t,e,i,s){let n=e.borderSkipped;const o={};if(!n)return void(t.borderSkipped=o);const{start:a,end:r,reverse:l,top:h,bottom:c}=function(t){let e,i,s,n,o;return t.horizontal?(e=t.base>t.x,i="left",s="right"):(e=t.base<t.y,i="bottom",s="top"),e?(n="end",o="start"):(n="start",o="end"),{start:i,end:s,reverse:e,top:n,bottom:o}}(t);"middle"===n&&i&&(t.enableBorderRadius=!0,(i._top||0)===s?n=h:(i._bottom||0)===s?n=c:(o[kn(c,a,r,l)]=!0,n=h)),o[kn(n,a,r,l)]=!0,t.borderSkipped=o}function kn(t,e,i,s){var n,o,a;return s?(a=i,t=Sn(t=(n=t)===(o=e)?a:n===a?o:n,i,e)):t=Sn(t,e,i),t}function Sn(t,e,i){return"start"===t?e:"end"===t?i:t}function Pn(t,{inflateAmount:e},i){t.inflateAmount="auto"===e?1===i?.33:0:e}class Dn extends Cs{parsePrimitiveData(t,e,i,s){return vn(t,e,i,s)}parseArrayData(t,e,i,s){return vn(t,e,i,s)}parseObjectData(t,e,i,s){const{iScale:n,vScale:o}=t,{xAxisKey:a="x",yAxisKey:r="y"}=this._parsing,l="x"===n.axis?a:r,h="x"===o.axis?a:r,c=[];let d,u,f,g;for(d=i,u=i+s;d<u;++d)g=e[d],f={},f[n.axis]=n.parse(ct(g,l),d),c.push(yn(ct(g,h),f,o,d));return c}updateRangeFromParsed(t,e,i,s){super.updateRangeFromParsed(t,e,i,s);const n=i._custom;n&&e===this._cachedMeta.vScale&&(t.min=Math.min(t.min,n.min),t.max=Math.max(t.max,n.max))}getMaxOverflow(){return 0}getLabelAndValue(t){const e=this._cachedMeta,{iScale:i,vScale:s}=e,n=this.getParsed(t),o=n._custom,a=wn(o)?"["+o.start+", "+o.end+"]":""+s.getLabelForValue(n[s.axis]);return{label:""+i.getLabelForValue(n[i.axis]),value:a}}initialize(){this.enableOptionSharing=!0,super.initialize();this._cachedMeta.stack=this.getDataset().stack}update(t){const e=this._cachedMeta;this.updateElements(e.data,0,e.data.length,t)}updateElements(t,e,i,s){const n="reset"===s,{index:o,_cachedMeta:{vScale:a}}=this,r=a.getBasePixel(),l=a.isHorizontal(),h=this._getRuler(),c=this.resolveDataElementOptions(e,s),d=this.getSharedOptions(c),u=this.includeOptions(s,d);this.updateSharedOptions(d,s,c);for(let c=e;c<e+i;c++){const e=this.getParsed(c),i=n||U(e[a.axis])?{base:r,head:r}:this._calculateBarValuePixels(c),f=this._calculateBarIndexPixels(c,h),g=(e._stacks||{})[a.axis],p={horizontal:l,base:i.base,enableBorderRadius:!g||wn(e._custom)||o===g._top||o===g._bottom,x:l?i.head:f.center,y:l?f.center:i.head,height:l?f.size:Math.abs(i.size),width:l?Math.abs(i.size):f.size};u&&(p.options=d||this.resolveDataElementOptions(c,t[c].active?"active":s));const m=p.options||t[c].options;Mn(p,m,g,o),Pn(p,m,h.ratio),this.updateElement(t[c],c,p,s)}}_getStacks(t,e){const i=this._cachedMeta.iScale,s=i.getMatchingVisibleMetas(this._type),n=i.options.stacked,o=s.length,a=[];let r,l;for(r=0;r<o;++r)if(l=s[r],l.controller.options.grouped){if(void 0!==e){const t=l.controller.getParsed(e)[l.controller._cachedMeta.vScale.axis];if(U(t)||isNaN(t))continue}if((!1===n||-1===a.indexOf(l.stack)||void 0===n&&void 0===l.stack)&&a.push(l.stack),l.index===t)break}return a.length||a.push(void 0),a}_getStackCount(t){return this._getStacks(void 0,t).length}_getStackIndex(t,e,i){const s=this._getStacks(t,i),n=void 0!==e?s.indexOf(e):-1;return-1===n?s.length-1:n}_getRuler(){const t=this.options,e=this._cachedMeta,i=e.iScale,s=[];let n,o;for(n=0,o=e.data.length;n<o;++n)s.push(i.getPixelForValue(this.getParsed(n)[i.axis],n));const a=t.barThickness;return{min:a||_n(e),pixels:s,start:i._startPixel,end:i._endPixel,stackCount:this._getStackCount(),scale:i,grouped:t.grouped,ratio:a?1:t.categoryPercentage*t.barPercentage}}_calculateBarValuePixels(t){const{_cachedMeta:{vScale:e,_stacked:i},options:{base:s,minBarLength:n}}=this,o=s||0,a=this.getParsed(t),r=a._custom,l=wn(r);let h,c,d=a[e.axis],u=0,f=i?this.applyStack(e,a,i):d;f!==d&&(u=f-d,f=d),l&&(d=r.barStart,f=r.barEnd-r.barStart,0!==d&&Bt(d)!==Bt(r.barEnd)&&(u=0),u+=d);const g=U(s)||l?u:s;let p=e.getPixelForValue(g);if(h=this.chart.getDataVisibility(t)?e.getPixelForValue(u+f):p,c=h-p,Math.abs(c)<n){c=function(t,e,i){return 0!==t?Bt(t):(e.isHorizontal()?1:-1)*(e.min>=i?1:-1)}(c,e,o)*n,d===o&&(p-=c/2);const t=e.getPixelForDecimal(0),i=e.getPixelForDecimal(1),s=Math.min(t,i),a=Math.max(t,i);p=Math.max(Math.min(p,a),s),h=p+c}if(p===e.getPixelForValue(o)){const t=Bt(c)*e.getLineWidthForValue(o)/2;p+=t,c-=t}return{size:c,base:p,head:h,center:h+c/2}}_calculateBarIndexPixels(t,e){const i=e.scale,s=this.options,n=s.skipNull,o=Z(s.maxBarThickness,1/0);let a,r;if(e.grouped){const i=n?this._getStackCount(t):e.stackCount,l="flex"===s.barThickness?function(t,e,i,s){const n=e.pixels,o=n[t];let a=t>0?n[t-1]:null,r=t<n.length-1?n[t+1]:null;const l=i.categoryPercentage;null===a&&(a=o-(null===r?e.end-e.start:r-o)),null===r&&(r=o+o-a);const h=o-(o-Math.min(a,r))/2*l;return{chunk:Math.abs(r-a)/2*l/s,ratio:i.barPercentage,start:h}}(t,e,s,i):function(t,e,i,s){const n=i.barThickness;let o,a;return U(n)?(o=e.min*i.categoryPercentage,a=i.barPercentage):(o=n*s,a=1),{chunk:o/s,ratio:a,start:e.pixels[t]-o/2}}(t,e,s,i),h=this._getStackIndex(this.index,this._cachedMeta.stack,n?t:void 0);a=l.start+l.chunk*h+l.chunk/2,r=Math.min(o,l.chunk*l.ratio)}else a=i.getPixelForValue(this.getParsed(t)[i.axis],t),r=Math.min(o,e.min*e.ratio);return{base:a-r/2,head:a+r/2,center:a,size:r}}draw(){const t=this._cachedMeta,e=t.vScale,i=t.data,s=i.length;let n=0;for(;n<s;++n)null!==this.getParsed(n)[e.axis]&&i[n].draw(this._ctx)}}Dn.id="bar",Dn.defaults={datasetElementType:!1,dataElementType:"bar",categoryPercentage:.8,barPercentage:.9,grouped:!0,animations:{numbers:{type:"number",properties:["x","y","base","width","height"]}}},Dn.overrides={scales:{_index_:{type:"category",offset:!0,grid:{offset:!0}},_value_:{type:"linear",beginAtZero:!0}}};class Cn extends Cs{initialize(){this.enableOptionSharing=!0,super.initialize()}parsePrimitiveData(t,e,i,s){const n=super.parsePrimitiveData(t,e,i,s);for(let t=0;t<n.length;t++)n[t]._custom=this.resolveDataElementOptions(t+i).radius;return n}parseArrayData(t,e,i,s){const n=super.parseArrayData(t,e,i,s);for(let t=0;t<n.length;t++){const s=e[i+t];n[t]._custom=Z(s[2],this.resolveDataElementOptions(t+i).radius)}return n}parseObjectData(t,e,i,s){const n=super.parseObjectData(t,e,i,s);for(let t=0;t<n.length;t++){const s=e[i+t];n[t]._custom=Z(s&&s.r&&+s.r,this.resolveDataElementOptions(t+i).radius)}return n}getMaxOverflow(){const t=this._cachedMeta.data;let e=0;for(let i=t.length-1;i>=0;--i)e=Math.max(e,t[i].size(this.resolveDataElementOptions(i))/2);return e>0&&e}getLabelAndValue(t){const e=this._cachedMeta,{xScale:i,yScale:s}=e,n=this.getParsed(t),o=i.getLabelForValue(n.x),a=s.getLabelForValue(n.y),r=n._custom;return{label:e.label,value:"("+o+", "+a+(r?", "+r:"")+")"}}update(t){const e=this._cachedMeta.data;this.updateElements(e,0,e.length,t)}updateElements(t,e,i,s){const n="reset"===s,{iScale:o,vScale:a}=this._cachedMeta,r=this.resolveDataElementOptions(e,s),l=this.getSharedOptions(r),h=this.includeOptions(s,l),c=o.axis,d=a.axis;for(let r=e;r<e+i;r++){const e=t[r],i=!n&&this.getParsed(r),l={},u=l[c]=n?o.getPixelForDecimal(.5):o.getPixelForValue(i[c]),f=l[d]=n?a.getBasePixel():a.getPixelForValue(i[d]);l.skip=isNaN(u)||isNaN(f),h&&(l.options=this.resolveDataElementOptions(r,e.active?"active":s),n&&(l.options.radius=0)),this.updateElement(e,r,l,s)}this.updateSharedOptions(l,s,r)}resolveDataElementOptions(t,e){const i=this.getParsed(t);let s=super.resolveDataElementOptions(t,e);s.$shared&&(s=Object.assign({},s,{$shared:!1}));const n=s.radius;return"active"!==e&&(s.radius=0),s.radius+=Z(i&&i._custom,n),s}}Cn.id="bubble",Cn.defaults={datasetElementType:!1,dataElementType:"point",animations:{numbers:{type:"number",properties:["x","y","borderWidth","radius"]}}},Cn.overrides={scales:{x:{type:"linear"},y:{type:"linear"}},plugins:{tooltip:{callbacks:{title:()=>""}}}};class On extends Cs{constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(t,e){const i=this.getDataset().data,s=this._cachedMeta;if(!1===this._parsing)s._parsed=i;else{let n,o,a=t=>+i[t];if(q(i[t])){const{key:t="value"}=this._parsing;a=e=>+ct(i[e],t)}for(n=t,o=t+e;n<o;++n)s._parsed[n]=a(n)}}_getRotation(){return Yt(this.options.rotation-90)}_getCircumference(){return Yt(this.options.circumference)}_getRotationExtents(){let t=At,e=-At;for(let i=0;i<this.chart.data.datasets.length;++i)if(this.chart.isDatasetVisible(i)){const s=this.chart.getDatasetMeta(i).controller,n=s._getRotation(),o=s._getCircumference();t=Math.min(t,n),e=Math.max(e,n+o)}return{rotation:t,circumference:e-t}}update(t){const e=this.chart,{chartArea:i}=e,s=this._cachedMeta,n=s.data,o=this.getMaxBorderWidth()+this.getMaxOffset(n)+this.options.spacing,a=Math.max((Math.min(i.width,i.height)-o)/2,0),r=Math.min(J(this.options.cutout,a),1),l=this._getRingWeight(this.index),{circumference:h,rotation:c}=this._getRotationExtents(),{ratioX:d,ratioY:u,offsetX:f,offsetY:g}=function(t,e,i){let s=1,n=1,o=0,a=0;if(e<At){const r=t,l=r+e,h=Math.cos(r),c=Math.sin(r),d=Math.cos(l),u=Math.sin(l),f=(t,e,s)=>Jt(t,r,l,!0)?1:Math.max(e,e*i,s,s*i),g=(t,e,s)=>Jt(t,r,l,!0)?-1:Math.min(e,e*i,s,s*i),p=f(0,h,d),m=f(Et,c,u),b=g(Ot,h,d),x=g(Ot+Et,c,u);s=(p-b)/2,n=(m-x)/2,o=-(p+b)/2,a=-(m+x)/2}return{ratioX:s,ratioY:n,offsetX:o,offsetY:a}}(c,h,r),p=(i.width-o)/d,m=(i.height-o)/u,b=Math.max(Math.min(p,m)/2,0),x=Q(this.options.radius,b),_=(x-Math.max(x*r,0))/this._getVisibleDatasetWeightTotal();this.offsetX=f*x,this.offsetY=g*x,s.total=this.calculateTotal(),this.outerRadius=x-_*this._getRingWeightOffset(this.index),this.innerRadius=Math.max(this.outerRadius-_*l,0),this.updateElements(n,0,n.length,t)}_circumference(t,e){const i=this.options,s=this._cachedMeta,n=this._getCircumference();return e&&i.animation.animateRotate||!this.chart.getDataVisibility(t)||null===s._parsed[t]||s.data[t].hidden?0:this.calculateCircumference(s._parsed[t]*n/At)}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.chartArea,r=o.options.animation,l=(a.left+a.right)/2,h=(a.top+a.bottom)/2,c=n&&r.animateScale,d=c?0:this.innerRadius,u=c?0:this.outerRadius,f=this.resolveDataElementOptions(e,s),g=this.getSharedOptions(f),p=this.includeOptions(s,g);let m,b=this._getRotation();for(m=0;m<e;++m)b+=this._circumference(m,n);for(m=e;m<e+i;++m){const e=this._circumference(m,n),i=t[m],o={x:l+this.offsetX,y:h+this.offsetY,startAngle:b,endAngle:b+e,circumference:e,outerRadius:u,innerRadius:d};p&&(o.options=g||this.resolveDataElementOptions(m,i.active?"active":s)),b+=e,this.updateElement(i,m,o,s)}this.updateSharedOptions(g,s,f)}calculateTotal(){const t=this._cachedMeta,e=t.data;let i,s=0;for(i=0;i<e.length;i++){const n=t._parsed[i];null===n||isNaN(n)||!this.chart.getDataVisibility(i)||e[i].hidden||(s+=Math.abs(n))}return s}calculateCircumference(t){const e=this._cachedMeta.total;return e>0&&!isNaN(t)?At*(Math.abs(t)/e):0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ni(e._parsed[t],i.options.locale);return{label:s[t]||"",value:n}}getMaxBorderWidth(t){let e=0;const i=this.chart;let s,n,o,a,r;if(!t)for(s=0,n=i.data.datasets.length;s<n;++s)if(i.isDatasetVisible(s)){o=i.getDatasetMeta(s),t=o.data,a=o.controller;break}if(!t)return 0;for(s=0,n=t.length;s<n;++s)r=a.resolveDataElementOptions(s),"inner"!==r.borderAlign&&(e=Math.max(e,r.borderWidth||0,r.hoverBorderWidth||0));return e}getMaxOffset(t){let e=0;for(let i=0,s=t.length;i<s;++i){const t=this.resolveDataElementOptions(i);e=Math.max(e,t.offset||0,t.hoverOffset||0)}return e}_getRingWeightOffset(t){let e=0;for(let i=0;i<t;++i)this.chart.isDatasetVisible(i)&&(e+=this._getRingWeight(i));return e}_getRingWeight(t){return Math.max(Z(this.chart.data.datasets[t].weight,1),0)}_getVisibleDatasetWeightTotal(){return this._getRingWeightOffset(this.chart.data.datasets.length)||1}}On.id="doughnut",On.defaults={datasetElementType:!1,dataElementType:"arc",animation:{animateRotate:!0,animateScale:!1},animations:{numbers:{type:"number",properties:["circumference","endAngle","innerRadius","outerRadius","startAngle","x","y","offset","borderWidth","spacing"]}},cutout:"50%",rotation:0,circumference:360,radius:"100%",spacing:0,indexAxis:"r"},On.descriptors={_scriptable:t=>"spacing"!==t,_indexable:t=>"spacing"!==t},On.overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(t){const e=t.data;if(e.labels.length&&e.datasets.length){const{labels:{pointStyle:i}}=t.legend.options;return e.labels.map(((e,s)=>{const n=t.getDatasetMeta(0).controller.getStyle(s);return{text:e,fillStyle:n.backgroundColor,strokeStyle:n.borderColor,lineWidth:n.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(s),index:s}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}},tooltip:{callbacks:{title:()=>"",label(t){let e=t.label;const i=": "+t.formattedValue;return X(e)?(e=e.slice(),e[0]+=i):e+=i,e}}}}};class An extends Cs{initialize(){this.enableOptionSharing=!0,this.supportsDecimation=!0,super.initialize()}update(t){const e=this._cachedMeta,{dataset:i,data:s=[],_dataset:n}=e,o=this.chart._animationsDisabled;let{start:a,count:r}=function(t,e,i){const s=e.length;let n=0,o=s;if(t._sorted){const{iScale:a,_parsed:r}=t,l=a.axis,{min:h,max:c,minDefined:d,maxDefined:u}=a.getUserBounds();d&&(n=Qt(Math.min(wt(r,a.axis,h).lo,i?s:wt(e,l,a.getPixelForValue(h)).lo),0,s-1)),o=u?Qt(Math.max(wt(r,a.axis,c).hi+1,i?0:wt(e,l,a.getPixelForValue(c)).hi+1),n,s)-n:s-n}return{start:n,count:o}}(e,s,o);this._drawStart=a,this._drawCount=r,function(t){const{xScale:e,yScale:i,_scaleRanges:s}=t,n={xmin:e.min,xmax:e.max,ymin:i.min,ymax:i.max};if(!s)return t._scaleRanges=n,!0;const o=s.xmin!==e.min||s.xmax!==e.max||s.ymin!==i.min||s.ymax!==i.max;return Object.assign(s,n),o}(e)&&(a=0,r=s.length),i._chart=this.chart,i._datasetIndex=this.index,i._decimated=!!n._decimated,i.points=s;const l=this.resolveDatasetElementOptions(t);this.options.showLine||(l.borderWidth=0),l.segment=this.options.segment,this.updateElement(i,void 0,{animated:!o,options:l},t),this.updateElements(s,a,r,t)}updateElements(t,e,i,s){const n="reset"===s,{iScale:o,vScale:a,_stacked:r,_dataset:l}=this._cachedMeta,h=this.resolveDataElementOptions(e,s),c=this.getSharedOptions(h),d=this.includeOptions(s,c),u=o.axis,f=a.axis,{spanGaps:g,segment:p}=this.options,m=Wt(g)?g:Number.POSITIVE_INFINITY,b=this.chart._animationsDisabled||n||"none"===s;let x=e>0&&this.getParsed(e-1);for(let h=e;h<e+i;++h){const e=t[h],i=this.getParsed(h),g=b?e:{},_=U(i[f]),y=g[u]=o.getPixelForValue(i[u],h),v=g[f]=n||_?a.getBasePixel():a.getPixelForValue(r?this.applyStack(a,i,r):i[f],h);g.skip=isNaN(y)||isNaN(v)||_,g.stop=h>0&&Math.abs(i[u]-x[u])>m,p&&(g.parsed=i,g.raw=l.data[h]),d&&(g.options=c||this.resolveDataElementOptions(h,e.active?"active":s)),b||this.updateElement(e,h,g,s),x=i}this.updateSharedOptions(c,s,h)}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,i=e.options&&e.options.borderWidth||0,s=t.data||[];if(!s.length)return i;const n=s[0].size(this.resolveDataElementOptions(0)),o=s[s.length-1].size(this.resolveDataElementOptions(s.length-1));return Math.max(i,n,o)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}}An.id="line",An.defaults={datasetElementType:"line",dataElementType:"point",showLine:!0,spanGaps:!1},An.overrides={scales:{_index_:{type:"category"},_value_:{type:"linear"}}};class Tn extends Cs{constructor(t,e){super(t,e),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ni(e._parsed[t].r,i.options.locale);return{label:s[t]||"",value:n}}parseObjectData(t,e,i,s){return He.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta.data;this._updateRadius(),this.updateElements(e,0,e.length,t)}getMinMax(){const t=this._cachedMeta,e={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return t.data.forEach(((t,i)=>{const s=this.getParsed(i).r;!isNaN(s)&&this.chart.getDataVisibility(i)&&(s<e.min&&(e.min=s),s>e.max&&(e.max=s))})),e}_updateRadius(){const t=this.chart,e=t.chartArea,i=t.options,s=Math.min(e.right-e.left,e.bottom-e.top),n=Math.max(s/2,0),o=(n-Math.max(i.cutoutPercentage?n/100*i.cutoutPercentage:1,0))/t.getVisibleDatasetCount();this.outerRadius=n-o*this.index,this.innerRadius=this.outerRadius-o}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.options.animation,r=this._cachedMeta.rScale,l=r.xCenter,h=r.yCenter,c=r.getIndexAngle(0)-.5*Ot;let d,u=c;const f=360/this.countVisibleElements();for(d=0;d<e;++d)u+=this._computeAngle(d,s,f);for(d=e;d<e+i;d++){const e=t[d];let i=u,g=u+this._computeAngle(d,s,f),p=o.getDataVisibility(d)?r.getDistanceFromCenterForValue(this.getParsed(d).r):0;u=g,n&&(a.animateScale&&(p=0),a.animateRotate&&(i=g=c));const m={x:l,y:h,innerRadius:0,outerRadius:p,startAngle:i,endAngle:g,options:this.resolveDataElementOptions(d,e.active?"active":s)};this.updateElement(e,d,m,s)}}countVisibleElements(){const t=this._cachedMeta;let e=0;return t.data.forEach(((t,i)=>{!isNaN(this.getParsed(i).r)&&this.chart.getDataVisibility(i)&&e++})),e}_computeAngle(t,e,i){return this.chart.getDataVisibility(t)?Yt(this.resolveDataElementOptions(t,e).angle||i):0}}Tn.id="polarArea",Tn.defaults={dataElementType:"arc",animation:{animateRotate:!0,animateScale:!0},animations:{numbers:{type:"number",properties:["x","y","startAngle","endAngle","innerRadius","outerRadius"]}},indexAxis:"r",startAngle:0},Tn.overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(t){const e=t.data;if(e.labels.length&&e.datasets.length){const{labels:{pointStyle:i}}=t.legend.options;return e.labels.map(((e,s)=>{const n=t.getDatasetMeta(0).controller.getStyle(s);return{text:e,fillStyle:n.backgroundColor,strokeStyle:n.borderColor,lineWidth:n.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(s),index:s}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}},tooltip:{callbacks:{title:()=>"",label:t=>t.chart.data.labels[t.dataIndex]+": "+t.formattedValue}}},scales:{r:{type:"radialLinear",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};class Ln extends On{}Ln.id="pie",Ln.defaults={cutout:0,rotation:0,circumference:360,radius:"100%"};class Rn extends Cs{getLabelAndValue(t){const e=this._cachedMeta.vScale,i=this.getParsed(t);return{label:e.getLabels()[t],value:""+e.getLabelForValue(i[e.axis])}}parseObjectData(t,e,i,s){return He.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta,i=e.dataset,s=e.data||[],n=e.iScale.getLabels();if(i.points=s,"resize"!==t){const e=this.resolveDatasetElementOptions(t);this.options.showLine||(e.borderWidth=0);const o={_loop:!0,_fullLoop:n.length===s.length,options:e};this.updateElement(i,void 0,o,t)}this.updateElements(s,0,s.length,t)}updateElements(t,e,i,s){const n=this._cachedMeta.rScale,o="reset"===s;for(let a=e;a<e+i;a++){const e=t[a],i=this.resolveDataElementOptions(a,e.active?"active":s),r=n.getPointPositionForValue(a,this.getParsed(a).r),l=o?n.xCenter:r.x,h=o?n.yCenter:r.y,c={x:l,y:h,angle:r.angle,skip:isNaN(l)||isNaN(h),options:i};this.updateElement(e,a,c,s)}}}Rn.id="radar",Rn.defaults={datasetElementType:"line",dataElementType:"point",indexAxis:"r",showLine:!0,elements:{line:{fill:"start"}}},Rn.overrides={aspectRatio:1,scales:{r:{type:"radialLinear"}}};class En extends An{}En.id="scatter",En.defaults={showLine:!1,fill:!1},En.overrides={interaction:{mode:"point"},plugins:{tooltip:{callbacks:{title:()=>"",label:t=>"("+t.label+", "+t.formattedValue+")"}}},scales:{x:{type:"linear"},y:{type:"linear"}}};var In=Object.freeze({__proto__:null,BarController:Dn,BubbleController:Cn,DoughnutController:On,LineController:An,PolarAreaController:Tn,PieController:Ln,RadarController:Rn,ScatterController:En});function zn(t,e,i){const{startAngle:s,pixelMargin:n,x:o,y:a,outerRadius:r,innerRadius:l}=e;let h=n/r;t.beginPath(),t.arc(o,a,r,s-h,i+h),l>n?(h=n/l,t.arc(o,a,l,i+h,s-h,!0)):t.arc(o,a,n,i+Et,s-Et),t.closePath(),t.clip()}function Fn(t,e,i,s){const n=li(t.options.borderRadius,["outerStart","outerEnd","innerStart","innerEnd"]);const o=(i-e)/2,a=Math.min(o,s*e/2),r=t=>{const e=(i-Math.min(o,t))*s/2;return Qt(t,0,Math.min(o,e))};return{outerStart:r(n.outerStart),outerEnd:r(n.outerEnd),innerStart:Qt(n.innerStart,0,a),innerEnd:Qt(n.innerEnd,0,a)}}function Bn(t,e,i,s){return{x:i+t*Math.cos(e),y:s+t*Math.sin(e)}}function Vn(t,e,i,s,n){const{x:o,y:a,startAngle:r,pixelMargin:l,innerRadius:h}=e,c=Math.max(e.outerRadius+s+i-l,0),d=h>0?h+s+i+l:0;let u=0;const f=n-r;if(s){const t=((h>0?h-s:0)+(c>0?c-s:0))/2;u=(f-(0!==t?f*t/(t+s):f))/2}const g=(f-Math.max(.001,f*c-i/Ot)/c)/2,p=r+g+u,m=n-g-u,{outerStart:b,outerEnd:x,innerStart:_,innerEnd:y}=Fn(e,d,c,m-p),v=c-b,w=c-x,M=p+b/v,k=m-x/w,S=d+_,P=d+y,D=p+_/S,C=m-y/P;if(t.beginPath(),t.arc(o,a,c,M,k),x>0){const e=Bn(w,k,o,a);t.arc(e.x,e.y,x,k,m+Et)}const O=Bn(P,m,o,a);if(t.lineTo(O.x,O.y),y>0){const e=Bn(P,C,o,a);t.arc(e.x,e.y,y,m+Et,C+Math.PI)}if(t.arc(o,a,d,m-y/d,p+_/d,!0),_>0){const e=Bn(S,D,o,a);t.arc(e.x,e.y,_,D+Math.PI,p-Et)}const A=Bn(v,p,o,a);if(t.lineTo(A.x,A.y),b>0){const e=Bn(v,M,o,a);t.arc(e.x,e.y,b,p-Et,M)}t.closePath()}function Nn(t,e,i,s,n){const{options:o}=e,{borderWidth:a,borderJoinStyle:r}=o,l="inner"===o.borderAlign;a&&(l?(t.lineWidth=2*a,t.lineJoin=r||"round"):(t.lineWidth=a,t.lineJoin=r||"bevel"),e.fullCircles&&function(t,e,i){const{x:s,y:n,startAngle:o,pixelMargin:a,fullCircles:r}=e,l=Math.max(e.outerRadius-a,0),h=e.innerRadius+a;let c;for(i&&zn(t,e,o+At),t.beginPath(),t.arc(s,n,h,o+At,o,!0),c=0;c<r;++c)t.stroke();for(t.beginPath(),t.arc(s,n,l,o,o+At),c=0;c<r;++c)t.stroke()}(t,e,l),l&&zn(t,e,n),Vn(t,e,i,s,n),t.stroke())}class Wn extends Os{constructor(t){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,t&&Object.assign(this,t)}inRange(t,e,i){const s=this.getProps(["x","y"],i),{angle:n,distance:o}=qt(s,{x:t,y:e}),{startAngle:a,endAngle:r,innerRadius:l,outerRadius:h,circumference:c}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],i),d=this.options.spacing/2,u=Z(c,r-a)>=At||Jt(n,a,r),f=ee(o,l+d,h+d);return u&&f}getCenterPoint(t){const{x:e,y:i,startAngle:s,endAngle:n,innerRadius:o,outerRadius:a}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius","circumference"],t),{offset:r,spacing:l}=this.options,h=(s+n)/2,c=(o+a+l+r)/2;return{x:e+Math.cos(h)*c,y:i+Math.sin(h)*c}}tooltipPosition(t){return this.getCenterPoint(t)}draw(t){const{options:e,circumference:i}=this,s=(e.offset||0)/2,n=(e.spacing||0)/2;if(this.pixelMargin="inner"===e.borderAlign?.33:0,this.fullCircles=i>At?Math.floor(i/At):0,0===i||this.innerRadius<0||this.outerRadius<0)return;t.save();let o=0;if(s){o=s/2;const e=(this.startAngle+this.endAngle)/2;t.translate(Math.cos(e)*o,Math.sin(e)*o),this.circumference>=Ot&&(o=s)}t.fillStyle=e.backgroundColor,t.strokeStyle=e.borderColor;const a=function(t,e,i,s){const{fullCircles:n,startAngle:o,circumference:a}=e;let r=e.endAngle;if(n){Vn(t,e,i,s,o+At);for(let e=0;e<n;++e)t.fill();isNaN(a)||(r=o+a%At,a%At==0&&(r+=At))}return Vn(t,e,i,s,r),t.fill(),r}(t,this,o,n);Nn(t,this,o,n,a),t.restore()}}function Hn(t,e,i=e){t.lineCap=Z(i.borderCapStyle,e.borderCapStyle),t.setLineDash(Z(i.borderDash,e.borderDash)),t.lineDashOffset=Z(i.borderDashOffset,e.borderDashOffset),t.lineJoin=Z(i.borderJoinStyle,e.borderJoinStyle),t.lineWidth=Z(i.borderWidth,e.borderWidth),t.strokeStyle=Z(i.borderColor,e.borderColor)}function jn(t,e,i){t.lineTo(i.x,i.y)}function $n(t,e,i={}){const s=t.length,{start:n=0,end:o=s-1}=i,{start:a,end:r}=e,l=Math.max(n,a),h=Math.min(o,r),c=n<a&&o<a||n>r&&o>r;return{count:s,start:l,loop:e.loop,ilen:h<l&&!c?s+h-l:h-l}}function Yn(t,e,i,s){const{points:n,options:o}=e,{count:a,start:r,loop:l,ilen:h}=$n(n,i,s),c=function(t){return t.stepped?ke:t.tension||"monotone"===t.cubicInterpolationMode?Se:jn}(o);let d,u,f,{move:g=!0,reverse:p}=s||{};for(d=0;d<=h;++d)u=n[(r+(p?h-d:d))%a],u.skip||(g?(t.moveTo(u.x,u.y),g=!1):c(t,f,u,p,o.stepped),f=u);return l&&(u=n[(r+(p?h:0))%a],c(t,f,u,p,o.stepped)),!!l}function Un(t,e,i,s){const n=e.points,{count:o,start:a,ilen:r}=$n(n,i,s),{move:l=!0,reverse:h}=s||{};let c,d,u,f,g,p,m=0,b=0;const x=t=>(a+(h?r-t:t))%o,_=()=>{f!==g&&(t.lineTo(m,g),t.lineTo(m,f),t.lineTo(m,p))};for(l&&(d=n[x(0)],t.moveTo(d.x,d.y)),c=0;c<=r;++c){if(d=n[x(c)],d.skip)continue;const e=d.x,i=d.y,s=0|e;s===u?(i<f?f=i:i>g&&(g=i),m=(b*m+e)/++b):(_(),t.lineTo(e,i),u=s,b=0,f=g=i),p=i}_()}function Xn(t){const e=t.options,i=e.borderDash&&e.borderDash.length;return!(t._decimated||t._loop||e.tension||"monotone"===e.cubicInterpolationMode||e.stepped||i)?Un:Yn}Wn.id="arc",Wn.defaults={borderAlign:"center",borderColor:"#fff",borderJoinStyle:void 0,borderRadius:0,borderWidth:2,offset:0,spacing:0,angle:void 0},Wn.defaultRoutes={backgroundColor:"backgroundColor"};const qn="function"==typeof Path2D;function Kn(t,e,i,s){qn&&!e.options.segment?function(t,e,i,s){let n=e._path;n||(n=e._path=new Path2D,e.path(n,i,s)&&n.closePath()),Hn(t,e.options),t.stroke(n)}(t,e,i,s):function(t,e,i,s){const{segments:n,options:o}=e,a=Xn(e);for(const r of n)Hn(t,o,r.style),t.beginPath(),a(t,e,r,{start:i,end:i+s-1})&&t.closePath(),t.stroke()}(t,e,i,s)}class Gn extends Os{constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){const i=this.options;if((i.tension||"monotone"===i.cubicInterpolationMode)&&!i.stepped&&!this._pointsUpdated){const s=i.spanGaps?this._loop:this._fullLoop;Ke(this._points,i,t,s,e),this._pointsUpdated=!0}}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=Mi(this,this.options.segment))}first(){const t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){const t=this.segments,e=this.points,i=t.length;return i&&e[t[i-1].end]}interpolate(t,e){const i=this.options,s=t[e],n=this.points,o=wi(this,{property:e,start:s,end:s});if(!o.length)return;const a=[],r=function(t){return t.stepped?ei:t.tension||"monotone"===t.cubicInterpolationMode?ii:ti}(i);let l,h;for(l=0,h=o.length;l<h;++l){const{start:h,end:c}=o[l],d=n[h],u=n[c];if(d===u){a.push(d);continue}const f=r(d,u,Math.abs((s-d[e])/(u[e]-d[e])),i.stepped);f[e]=t[e],a.push(f)}return 1===a.length?a[0]:a}pathSegment(t,e,i){return Xn(this)(t,this,e,i)}path(t,e,i){const s=this.segments,n=Xn(this);let o=this._loop;e=e||0,i=i||this.points.length-e;for(const a of s)o&=n(t,this,a,{start:e,end:e+i-1});return!!o}draw(t,e,i,s){const n=this.options||{};(this.points||[]).length&&n.borderWidth&&(t.save(),Kn(t,this,i,s),t.restore()),this.animated&&(this._pointsUpdated=!1,this._path=void 0)}}function Zn(t,e,i,s){const n=t.options,{[i]:o}=t.getProps([i],s);return Math.abs(e-o)<n.radius+n.hitRadius}Gn.id="line",Gn.defaults={borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:"default",fill:!1,spanGaps:!1,stepped:!1,tension:0},Gn.defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"},Gn.descriptors={_scriptable:!0,_indexable:t=>"borderDash"!==t&&"fill"!==t};class Jn extends Os{constructor(t){super(),this.options=void 0,this.parsed=void 0,this.skip=void 0,this.stop=void 0,t&&Object.assign(this,t)}inRange(t,e,i){const s=this.options,{x:n,y:o}=this.getProps(["x","y"],i);return Math.pow(t-n,2)+Math.pow(e-o,2)<Math.pow(s.hitRadius+s.radius,2)}inXRange(t,e){return Zn(this,t,"x",e)}inYRange(t,e){return Zn(this,t,"y",e)}getCenterPoint(t){const{x:e,y:i}=this.getProps(["x","y"],t);return{x:e,y:i}}size(t){let e=(t=t||this.options||{}).radius||0;e=Math.max(e,e&&t.hoverRadius||0);return 2*(e+(e&&t.borderWidth||0))}draw(t,e){const i=this.options;this.skip||i.radius<.1||!ve(this,e,this.size(i)/2)||(t.strokeStyle=i.borderColor,t.lineWidth=i.borderWidth,t.fillStyle=i.backgroundColor,ye(t,i,this.x,this.y))}getRange(){const t=this.options||{};return t.radius+t.hitRadius}}function Qn(t,e){const{x:i,y:s,base:n,width:o,height:a}=t.getProps(["x","y","base","width","height"],e);let r,l,h,c,d;return t.horizontal?(d=a/2,r=Math.min(i,n),l=Math.max(i,n),h=s-d,c=s+d):(d=o/2,r=i-d,l=i+d,h=Math.min(s,n),c=Math.max(s,n)),{left:r,top:h,right:l,bottom:c}}function to(t,e,i,s){return t?0:Qt(e,i,s)}function eo(t){const e=Qn(t),i=e.right-e.left,s=e.bottom-e.top,n=function(t,e,i){const s=t.options.borderWidth,n=t.borderSkipped,o=hi(s);return{t:to(n.top,o.top,0,i),r:to(n.right,o.right,0,e),b:to(n.bottom,o.bottom,0,i),l:to(n.left,o.left,0,e)}}(t,i/2,s/2),o=function(t,e,i){const{enableBorderRadius:s}=t.getProps(["enableBorderRadius"]),n=t.options.borderRadius,o=ci(n),a=Math.min(e,i),r=t.borderSkipped,l=s||q(n);return{topLeft:to(!l||r.top||r.left,o.topLeft,0,a),topRight:to(!l||r.top||r.right,o.topRight,0,a),bottomLeft:to(!l||r.bottom||r.left,o.bottomLeft,0,a),bottomRight:to(!l||r.bottom||r.right,o.bottomRight,0,a)}}(t,i/2,s/2);return{outer:{x:e.left,y:e.top,w:i,h:s,radius:o},inner:{x:e.left+n.l,y:e.top+n.t,w:i-n.l-n.r,h:s-n.t-n.b,radius:{topLeft:Math.max(0,o.topLeft-Math.max(n.t,n.l)),topRight:Math.max(0,o.topRight-Math.max(n.t,n.r)),bottomLeft:Math.max(0,o.bottomLeft-Math.max(n.b,n.l)),bottomRight:Math.max(0,o.bottomRight-Math.max(n.b,n.r))}}}}function io(t,e,i,s){const n=null===e,o=null===i,a=t&&!(n&&o)&&Qn(t,s);return a&&(n||ee(e,a.left,a.right))&&(o||ee(i,a.top,a.bottom))}function so(t,e){t.rect(e.x,e.y,e.w,e.h)}function no(t,e,i={}){const s=t.x!==i.x?-e:0,n=t.y!==i.y?-e:0,o=(t.x+t.w!==i.x+i.w?e:0)-s,a=(t.y+t.h!==i.y+i.h?e:0)-n;return{x:t.x+s,y:t.y+n,w:t.w+o,h:t.h+a,radius:t.radius}}Jn.id="point",Jn.defaults={borderWidth:1,hitRadius:1,hoverBorderWidth:1,hoverRadius:4,pointStyle:"circle",radius:3,rotation:0},Jn.defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};class oo extends Os{constructor(t){super(),this.options=void 0,this.horizontal=void 0,this.base=void 0,this.width=void 0,this.height=void 0,this.inflateAmount=void 0,t&&Object.assign(this,t)}draw(t){const{inflateAmount:e,options:{borderColor:i,backgroundColor:s}}=this,{inner:n,outer:o}=eo(this),a=(r=o.radius).topLeft||r.topRight||r.bottomLeft||r.bottomRight?Ce:so;var r;t.save(),o.w===n.w&&o.h===n.h||(t.beginPath(),a(t,no(o,e,n)),t.clip(),a(t,no(n,-e,o)),t.fillStyle=i,t.fill("evenodd")),t.beginPath(),a(t,no(n,e)),t.fillStyle=s,t.fill(),t.restore()}inRange(t,e,i){return io(this,t,e,i)}inXRange(t,e){return io(this,t,null,e)}inYRange(t,e){return io(this,null,t,e)}getCenterPoint(t){const{x:e,y:i,base:s,horizontal:n}=this.getProps(["x","y","base","horizontal"],t);return{x:n?(e+s)/2:e,y:n?i:(i+s)/2}}getRange(t){return"x"===t?this.width/2:this.height/2}}oo.id="bar",oo.defaults={borderSkipped:"start",borderWidth:0,borderRadius:0,inflateAmount:"auto",pointStyle:void 0},oo.defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};var ao=Object.freeze({__proto__:null,ArcElement:Wn,LineElement:Gn,PointElement:Jn,BarElement:oo});function ro(t){if(t._decimated){const e=t._data;delete t._decimated,delete t._data,Object.defineProperty(t,"data",{value:e})}}function lo(t){t.data.datasets.forEach((t=>{ro(t)}))}var ho={id:"decimation",defaults:{algorithm:"min-max",enabled:!1},beforeElementsUpdate:(t,e,i)=>{if(!i.enabled)return void lo(t);const s=t.width;t.data.datasets.forEach(((e,n)=>{const{_data:o,indexAxis:a}=e,r=t.getDatasetMeta(n),l=o||e.data;if("y"===fi([a,t.options.indexAxis]))return;if(!r.controller.supportsDecimation)return;const h=t.scales[r.xAxisID];if("linear"!==h.type&&"time"!==h.type)return;if(t.options.parsing)return;let{start:c,count:d}=function(t,e){const i=e.length;let s,n=0;const{iScale:o}=t,{min:a,max:r,minDefined:l,maxDefined:h}=o.getUserBounds();return l&&(n=Qt(wt(e,o.axis,a).lo,0,i-1)),s=h?Qt(wt(e,o.axis,r).hi+1,n,i)-n:i-n,{start:n,count:s}}(r,l);if(d<=(i.threshold||4*s))return void ro(e);let u;switch(U(o)&&(e._data=l,delete e.data,Object.defineProperty(e,"data",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(t){this._data=t}})),i.algorithm){case"lttb":u=function(t,e,i,s,n){const o=n.samples||s;if(o>=i)return t.slice(e,e+i);const a=[],r=(i-2)/(o-2);let l=0;const h=e+i-1;let c,d,u,f,g,p=e;for(a[l++]=t[p],c=0;c<o-2;c++){let s,n=0,o=0;const h=Math.floor((c+1)*r)+1+e,m=Math.min(Math.floor((c+2)*r)+1,i)+e,b=m-h;for(s=h;s<m;s++)n+=t[s].x,o+=t[s].y;n/=b,o/=b;const x=Math.floor(c*r)+1+e,_=Math.min(Math.floor((c+1)*r)+1,i)+e,{x:y,y:v}=t[p];for(u=f=-1,s=x;s<_;s++)f=.5*Math.abs((y-n)*(t[s].y-v)-(y-t[s].x)*(o-v)),f>u&&(u=f,d=t[s],g=s);a[l++]=d,p=g}return a[l++]=t[h],a}(l,c,d,s,i);break;case"min-max":u=function(t,e,i,s){let n,o,a,r,l,h,c,d,u,f,g=0,p=0;const m=[],b=e+i-1,x=t[e].x,_=t[b].x-x;for(n=e;n<e+i;++n){o=t[n],a=(o.x-x)/_*s,r=o.y;const e=0|a;if(e===l)r<u?(u=r,h=n):r>f&&(f=r,c=n),g=(p*g+o.x)/++p;else{const i=n-1;if(!U(h)&&!U(c)){const e=Math.min(h,c),s=Math.max(h,c);e!==d&&e!==i&&m.push({...t[e],x:g}),s!==d&&s!==i&&m.push({...t[s],x:g})}n>0&&i!==d&&m.push(t[i]),m.push(o),l=e,p=0,u=f=r,h=c=d=n}}return m}(l,c,d,s);break;default:throw new Error(`Unsupported decimation algorithm '${i.algorithm}'`)}e._decimated=u}))},destroy(t){lo(t)}};function co(t,e,i,s){if(s)return;let n=e[t],o=i[t];return"angle"===t&&(n=Zt(n),o=Zt(o)),{property:t,start:n,end:o}}function uo(t,e,i){for(;e>t;e--){const t=i[e];if(!isNaN(t.x)&&!isNaN(t.y))break}return e}function fo(t,e,i,s){return t&&e?s(t[i],e[i]):t?t[i]:e?e[i]:0}function go(t,e){let i=[],s=!1;return X(t)?(s=!0,i=t):i=function(t,e){const{x:i=null,y:s=null}=t||{},n=e.points,o=[];return e.segments.forEach((({start:t,end:e})=>{e=uo(t,e,n);const a=n[t],r=n[e];null!==s?(o.push({x:a.x,y:s}),o.push({x:r.x,y:s})):null!==i&&(o.push({x:i,y:a.y}),o.push({x:i,y:r.y}))})),o}(t,e),i.length?new Gn({points:i,options:{tension:0},_loop:s,_fullLoop:s}):null}function po(t,e,i){let s=t[e].fill;const n=[e];let o;if(!i)return s;for(;!1!==s&&-1===n.indexOf(s);){if(!K(s))return s;if(o=t[s],!o)return!1;if(o.visible)return s;n.push(s),s=o.fill}return!1}function mo(t,e,i){const s=function(t){const e=t.options,i=e.fill;let s=Z(i&&i.target,i);void 0===s&&(s=!!e.backgroundColor);if(!1===s||null===s)return!1;if(!0===s)return"origin";return s}(t);if(q(s))return!isNaN(s.value)&&s;let n=parseFloat(s);return K(n)&&Math.floor(n)===n?function(t,e,i,s){"-"!==t&&"+"!==t||(i=e+i);if(i===e||i<0||i>=s)return!1;return i}(s[0],e,n,i):["origin","start","end","stack","shape"].indexOf(s)>=0&&s}function bo(t,e,i){const s=[];for(let n=0;n<i.length;n++){const o=i[n],{first:a,last:r,point:l}=xo(o,e,"x");if(!(!l||a&&r))if(a)s.unshift(l);else if(t.push(l),!r)break}t.push(...s)}function xo(t,e,i){const s=t.interpolate(e,i);if(!s)return{};const n=s[i],o=t.segments,a=t.points;let r=!1,l=!1;for(let t=0;t<o.length;t++){const e=o[t],s=a[e.start][i],h=a[e.end][i];if(ee(n,s,h)){r=n===s,l=n===h;break}}return{first:r,last:l,point:s}}class _o{constructor(t){this.x=t.x,this.y=t.y,this.radius=t.radius}pathSegment(t,e,i){const{x:s,y:n,radius:o}=this;return e=e||{start:0,end:At},t.arc(s,n,o,e.end,e.start,!0),!i.bounds}interpolate(t){const{x:e,y:i,radius:s}=this,n=t.angle;return{x:e+Math.cos(n)*s,y:i+Math.sin(n)*s,angle:n}}}function yo(t){const{chart:e,fill:i,line:s}=t;if(K(i))return function(t,e){const i=t.getDatasetMeta(e);return i&&t.isDatasetVisible(e)?i.dataset:null}(e,i);if("stack"===i)return function(t){const{scale:e,index:i,line:s}=t,n=[],o=s.segments,a=s.points,r=function(t,e){const i=[],s=t.getMatchingVisibleMetas("line");for(let t=0;t<s.length;t++){const n=s[t];if(n.index===e)break;n.hidden||i.unshift(n.dataset)}return i}(e,i);r.push(go({x:null,y:e.bottom},s));for(let t=0;t<o.length;t++){const e=o[t];for(let t=e.start;t<=e.end;t++)bo(n,a[t],r)}return new Gn({points:n,options:{}})}(t);if("shape"===i)return!0;const n=function(t){if((t.scale||{}).getPointPositionForValue)return function(t){const{scale:e,fill:i}=t,s=e.options,n=e.getLabels().length,o=s.reverse?e.max:e.min,a=function(t,e,i){let s;return s="start"===t?i:"end"===t?e.options.reverse?e.min:e.max:q(t)?t.value:e.getBaseValue(),s}(i,e,o),r=[];if(s.grid.circular){const t=e.getPointPositionForValue(0,o);return new _o({x:t.x,y:t.y,radius:e.getDistanceFromCenterForValue(a)})}for(let t=0;t<n;++t)r.push(e.getPointPositionForValue(t,a));return r}(t);return function(t){const{scale:e={},fill:i}=t,s=function(t,e){let i=null;return"start"===t?i=e.bottom:"end"===t?i=e.top:q(t)?i=e.getPixelForValue(t.value):e.getBasePixel&&(i=e.getBasePixel()),i}(i,e);if(K(s)){const t=e.isHorizontal();return{x:t?s:null,y:t?null:s}}return null}(t)}(t);return n instanceof _o?n:go(n,s)}function vo(t,e,i){const s=yo(e),{line:n,scale:o,axis:a}=e,r=n.options,l=r.fill,h=r.backgroundColor,{above:c=h,below:d=h}=l||{};s&&n.points.length&&(we(t,i),function(t,e){const{line:i,target:s,above:n,below:o,area:a,scale:r}=e,l=i._loop?"angle":e.axis;t.save(),"x"===l&&o!==n&&(wo(t,s,a.top),Mo(t,{line:i,target:s,color:n,scale:r,property:l}),t.restore(),t.save(),wo(t,s,a.bottom));Mo(t,{line:i,target:s,color:o,scale:r,property:l}),t.restore()}(t,{line:n,target:s,above:c,below:d,area:i,scale:o,axis:a}),Me(t))}function wo(t,e,i){const{segments:s,points:n}=e;let o=!0,a=!1;t.beginPath();for(const r of s){const{start:s,end:l}=r,h=n[s],c=n[uo(s,l,n)];o?(t.moveTo(h.x,h.y),o=!1):(t.lineTo(h.x,i),t.lineTo(h.x,h.y)),a=!!e.pathSegment(t,r,{move:a}),a?t.closePath():t.lineTo(c.x,i)}t.lineTo(e.first().x,i),t.closePath(),t.clip()}function Mo(t,e){const{line:i,target:s,property:n,color:o,scale:a}=e,r=function(t,e,i){const s=t.segments,n=t.points,o=e.points,a=[];for(const t of s){let{start:s,end:r}=t;r=uo(s,r,n);const l=co(i,n[s],n[r],t.loop);if(!e.segments){a.push({source:t,target:l,start:n[s],end:n[r]});continue}const h=wi(e,l);for(const e of h){const s=co(i,o[e.start],o[e.end],e.loop),r=vi(t,n,s);for(const t of r)a.push({source:t,target:e,start:{[i]:fo(l,s,"start",Math.max)},end:{[i]:fo(l,s,"end",Math.min)}})}}return a}(i,s,n);for(const{source:e,target:l,start:h,end:c}of r){const{style:{backgroundColor:r=o}={}}=e,d=!0!==s;t.save(),t.fillStyle=r,ko(t,a,d&&co(n,h,c)),t.beginPath();const u=!!i.pathSegment(t,e);let f;if(d){u?t.closePath():So(t,s,c,n);const e=!!s.pathSegment(t,l,{move:u,reverse:!0});f=u&&e,f||So(t,s,h,n)}t.closePath(),t.fill(f?"evenodd":"nonzero"),t.restore()}}function ko(t,e,i){const{top:s,bottom:n}=e.chart.chartArea,{property:o,start:a,end:r}=i||{};"x"===o&&(t.beginPath(),t.rect(a,s,r-a,n-s),t.clip())}function So(t,e,i,s){const n=e.interpolate(i,s);n&&t.lineTo(n.x,n.y)}var Po={id:"filler",afterDatasetsUpdate(t,e,i){const s=(t.data.datasets||[]).length,n=[];let o,a,r,l;for(a=0;a<s;++a)o=t.getDatasetMeta(a),r=o.dataset,l=null,r&&r.options&&r instanceof Gn&&(l={visible:t.isDatasetVisible(a),index:a,fill:mo(r,a,s),chart:t,axis:o.controller.options.indexAxis,scale:o.vScale,line:r}),o.$filler=l,n.push(l);for(a=0;a<s;++a)l=n[a],l&&!1!==l.fill&&(l.fill=po(n,a,i.propagate))},beforeDraw(t,e,i){const s="beforeDraw"===i.drawTime,n=t.getSortedVisibleDatasetMetas(),o=t.chartArea;for(let e=n.length-1;e>=0;--e){const i=n[e].$filler;i&&(i.line.updateControlPoints(o,i.axis),s&&vo(t.ctx,i,o))}},beforeDatasetsDraw(t,e,i){if("beforeDatasetsDraw"!==i.drawTime)return;const s=t.getSortedVisibleDatasetMetas();for(let e=s.length-1;e>=0;--e){const i=s[e].$filler;i&&vo(t.ctx,i,t.chartArea)}},beforeDatasetDraw(t,e,i){const s=e.meta.$filler;s&&!1!==s.fill&&"beforeDatasetDraw"===i.drawTime&&vo(t.ctx,s,t.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}};const Do=(t,e)=>{let{boxHeight:i=e,boxWidth:s=e}=t;return t.usePointStyle&&(i=Math.min(i,e),s=Math.min(s,e)),{boxWidth:s,boxHeight:i,itemHeight:Math.max(e,i)}};class Co extends Os{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,i){this.maxWidth=t,this.maxHeight=e,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const t=this.options.labels||{};let e=tt(t.generateLabels,[this.chart],this)||[];t.filter&&(e=e.filter((e=>t.filter(e,this.chart.data)))),t.sort&&(e=e.sort(((e,i)=>t.sort(e,i,this.chart.data)))),this.options.reverse&&e.reverse(),this.legendItems=e}fit(){const{options:t,ctx:e}=this;if(!t.display)return void(this.width=this.height=0);const i=t.labels,s=ui(i.font),n=s.size,o=this._computeTitleHeight(),{boxWidth:a,itemHeight:r}=Do(i,n);let l,h;e.font=s.string,this.isHorizontal()?(l=this.maxWidth,h=this._fitRows(o,n,a,r)+10):(h=this.maxHeight,l=this._fitCols(o,n,a,r)+10),this.width=Math.min(l,t.maxWidth||this.maxWidth),this.height=Math.min(h,t.maxHeight||this.maxHeight)}_fitRows(t,e,i,s){const{ctx:n,maxWidth:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.lineWidths=[0],h=s+a;let c=t;n.textAlign="left",n.textBaseline="middle";let d=-1,u=-h;return this.legendItems.forEach(((t,f)=>{const g=i+e/2+n.measureText(t.text).width;(0===f||l[l.length-1]+g+2*a>o)&&(c+=h,l[l.length-(f>0?0:1)]=0,u+=h,d++),r[f]={left:0,top:u,row:d,width:g,height:s},l[l.length-1]+=g+a})),c}_fitCols(t,e,i,s){const{ctx:n,maxHeight:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.columnSizes=[],h=o-t;let c=a,d=0,u=0,f=0,g=0;return this.legendItems.forEach(((t,o)=>{const p=i+e/2+n.measureText(t.text).width;o>0&&u+s+2*a>h&&(c+=d+a,l.push({width:d,height:u}),f+=d+a,g++,d=u=0),r[o]={left:f,top:u,col:g,width:p,height:s},d=Math.max(d,p),u+=s+a})),c+=d,l.push({width:d,height:u}),c}adjustHitBoxes(){if(!this.options.display)return;const t=this._computeTitleHeight(),{legendHitBoxes:e,options:{align:i,labels:{padding:s},rtl:o}}=this,a=mi(o,this.left,this.width);if(this.isHorizontal()){let o=0,r=n(i,this.left+s,this.right-this.lineWidths[o]);for(const l of e)o!==l.row&&(o=l.row,r=n(i,this.left+s,this.right-this.lineWidths[o])),l.top+=this.top+t+s,l.left=a.leftForLtr(a.x(r),l.width),r+=l.width+s}else{let o=0,r=n(i,this.top+t+s,this.bottom-this.columnSizes[o].height);for(const l of e)l.col!==o&&(o=l.col,r=n(i,this.top+t+s,this.bottom-this.columnSizes[o].height)),l.top=r,l.left+=this.left+s,l.left=a.leftForLtr(a.x(l.left),l.width),r+=l.height+s}}isHorizontal(){return"top"===this.options.position||"bottom"===this.options.position}draw(){if(this.options.display){const t=this.ctx;we(t,this),this._draw(),Me(t)}}_draw(){const{options:t,columnSizes:e,lineWidths:i,ctx:s}=this,{align:a,labels:r}=t,l=yt.color,h=mi(t.rtl,this.left,this.width),c=ui(r.font),{color:d,padding:u}=r,f=c.size,g=f/2;let p;this.drawTitle(),s.textAlign=h.textAlign("left"),s.textBaseline="middle",s.lineWidth=.5,s.font=c.string;const{boxWidth:m,boxHeight:b,itemHeight:x}=Do(r,f),_=this.isHorizontal(),y=this._computeTitleHeight();p=_?{x:n(a,this.left+u,this.right-i[0]),y:this.top+u+y,line:0}:{x:this.left+u,y:n(a,this.top+y+u,this.bottom-e[0].height),line:0},bi(this.ctx,t.textDirection);const v=x+u;this.legendItems.forEach(((w,M)=>{s.strokeStyle=w.fontColor||d,s.fillStyle=w.fontColor||d;const k=s.measureText(w.text).width,S=h.textAlign(w.textAlign||(w.textAlign=r.textAlign)),P=m+g+k;let D=p.x,C=p.y;h.setWidth(this.width),_?M>0&&D+P+u>this.right&&(C=p.y+=v,p.line++,D=p.x=n(a,this.left+u,this.right-i[p.line])):M>0&&C+v>this.bottom&&(D=p.x=D+e[p.line].width+u,p.line++,C=p.y=n(a,this.top+y+u,this.bottom-e[p.line].height));!function(t,e,i){if(isNaN(m)||m<=0||isNaN(b)||b<0)return;s.save();const n=Z(i.lineWidth,1);if(s.fillStyle=Z(i.fillStyle,l),s.lineCap=Z(i.lineCap,"butt"),s.lineDashOffset=Z(i.lineDashOffset,0),s.lineJoin=Z(i.lineJoin,"miter"),s.lineWidth=n,s.strokeStyle=Z(i.strokeStyle,l),s.setLineDash(Z(i.lineDash,[])),r.usePointStyle){const o={radius:m*Math.SQRT2/2,pointStyle:i.pointStyle,rotation:i.rotation,borderWidth:n},a=h.xPlus(t,m/2);ye(s,o,a,e+g)}else{const o=e+Math.max((f-b)/2,0),a=h.leftForLtr(t,m),r=ci(i.borderRadius);s.beginPath(),Object.values(r).some((t=>0!==t))?Ce(s,{x:a,y:o,w:m,h:b,radius:r}):s.rect(a,o,m,b),s.fill(),0!==n&&s.stroke()}s.restore()}(h.x(D),C,w),D=o(S,D+m+g,_?D+P:this.right,t.rtl),function(t,e,i){Pe(s,i.text,t,e+x/2,c,{strikethrough:i.hidden,textAlign:h.textAlign(i.textAlign)})}(h.x(D),C,w),_?p.x+=P+u:p.y+=v})),xi(this.ctx,t.textDirection)}drawTitle(){const t=this.options,e=t.title,i=ui(e.font),o=di(e.padding);if(!e.display)return;const a=mi(t.rtl,this.left,this.width),r=this.ctx,l=e.position,h=i.size/2,c=o.top+h;let d,u=this.left,f=this.width;if(this.isHorizontal())f=Math.max(...this.lineWidths),d=this.top+c,u=n(t.align,u,this.right-f);else{const e=this.columnSizes.reduce(((t,e)=>Math.max(t,e.height)),0);d=c+n(t.align,this.top,this.bottom-e-t.labels.padding-this._computeTitleHeight())}const g=n(l,u,u+f);r.textAlign=a.textAlign(s(l)),r.textBaseline="middle",r.strokeStyle=e.color,r.fillStyle=e.color,r.font=i.string,Pe(r,e.text,g,d,i)}_computeTitleHeight(){const t=this.options.title,e=ui(t.font),i=di(t.padding);return t.display?e.lineHeight+i.height:0}_getLegendItemAt(t,e){let i,s,n;if(ee(t,this.left,this.right)&&ee(e,this.top,this.bottom))for(n=this.legendHitBoxes,i=0;i<n.length;++i)if(s=n[i],ee(t,s.left,s.left+s.width)&&ee(e,s.top,s.top+s.height))return this.legendItems[i];return null}handleEvent(t){const e=this.options;if(!function(t,e){if(("mousemove"===t||"mouseout"===t)&&(e.onHover||e.onLeave))return!0;if(e.onClick&&("click"===t||"mouseup"===t))return!0;return!1}(t.type,e))return;const i=this._getLegendItemAt(t.x,t.y);if("mousemove"===t.type||"mouseout"===t.type){const o=this._hoveredItem,a=(n=i,null!==(s=o)&&null!==n&&s.datasetIndex===n.datasetIndex&&s.index===n.index);o&&!a&&tt(e.onLeave,[t,o,this],this),this._hoveredItem=i,i&&!a&&tt(e.onHover,[t,i,this],this)}else i&&tt(e.onClick,[t,i,this],this);var s,n}}var Oo={id:"legend",_element:Co,start(t,e,i){const s=t.legend=new Co({ctx:t.ctx,options:i,chart:t});Xi.configure(t,s,i),Xi.addBox(t,s)},stop(t){Xi.removeBox(t,t.legend),delete t.legend},beforeUpdate(t,e,i){const s=t.legend;Xi.configure(t,s,i),s.options=i},afterUpdate(t){const e=t.legend;e.buildLabels(),e.adjustHitBoxes()},afterEvent(t,e){e.replay||t.legend.handleEvent(e.event)},defaults:{display:!0,position:"top",align:"center",fullSize:!0,reverse:!1,weight:1e3,onClick(t,e,i){const s=e.datasetIndex,n=i.chart;n.isDatasetVisible(s)?(n.hide(s),e.hidden=!0):(n.show(s),e.hidden=!1)},onHover:null,onLeave:null,labels:{color:t=>t.chart.options.color,boxWidth:40,padding:10,generateLabels(t){const e=t.data.datasets,{labels:{usePointStyle:i,pointStyle:s,textAlign:n,color:o}}=t.legend.options;return t._getSortedDatasetMetas().map((t=>{const a=t.controller.getStyle(i?0:void 0),r=di(a.borderWidth);return{text:e[t.index].label,fillStyle:a.backgroundColor,fontColor:o,hidden:!t.visible,lineCap:a.borderCapStyle,lineDash:a.borderDash,lineDashOffset:a.borderDashOffset,lineJoin:a.borderJoinStyle,lineWidth:(r.width+r.height)/4,strokeStyle:a.borderColor,pointStyle:s||a.pointStyle,rotation:a.rotation,textAlign:n||a.textAlign,borderRadius:0,datasetIndex:t.index}}),this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}};class Ao extends Os{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){const i=this.options;if(this.left=0,this.top=0,!i.display)return void(this.width=this.height=this.right=this.bottom=0);this.width=this.right=t,this.height=this.bottom=e;const s=X(i.text)?i.text.length:1;this._padding=di(i.padding);const n=s*ui(i.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=n:this.width=n}isHorizontal(){const t=this.options.position;return"top"===t||"bottom"===t}_drawArgs(t){const{top:e,left:i,bottom:s,right:o,options:a}=this,r=a.align;let l,h,c,d=0;return this.isHorizontal()?(h=n(r,i,o),c=e+t,l=o-i):("left"===a.position?(h=i+t,c=n(r,s,e),d=-.5*Ot):(h=o-t,c=n(r,e,s),d=.5*Ot),l=s-e),{titleX:h,titleY:c,maxWidth:l,rotation:d}}draw(){const t=this.ctx,e=this.options;if(!e.display)return;const i=ui(e.font),n=i.lineHeight/2+this._padding.top,{titleX:o,titleY:a,maxWidth:r,rotation:l}=this._drawArgs(n);Pe(t,e.text,0,0,i,{color:e.color,maxWidth:r,rotation:l,textAlign:s(e.align),textBaseline:"middle",translation:[o,a]})}}var To={id:"title",_element:Ao,start(t,e,i){!function(t,e){const i=new Ao({ctx:t.ctx,options:e,chart:t});Xi.configure(t,i,e),Xi.addBox(t,i),t.titleBlock=i}(t,i)},stop(t){const e=t.titleBlock;Xi.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,i){const s=t.titleBlock;Xi.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Lo=new WeakMap;var Ro={id:"subtitle",start(t,e,i){const s=new Ao({ctx:t.ctx,options:i,chart:t});Xi.configure(t,s,i),Xi.addBox(t,s),Lo.set(t,s)},stop(t){Xi.removeBox(t,Lo.get(t)),Lo.delete(t)},beforeUpdate(t,e,i){const s=Lo.get(t);Xi.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"normal"},fullSize:!0,padding:0,position:"top",text:"",weight:1500},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Eo={average(t){if(!t.length)return!1;let e,i,s=0,n=0,o=0;for(e=0,i=t.length;e<i;++e){const i=t[e].element;if(i&&i.hasValue()){const t=i.tooltipPosition();s+=t.x,n+=t.y,++o}}return{x:s/o,y:n/o}},nearest(t,e){if(!t.length)return!1;let i,s,n,o=e.x,a=e.y,r=Number.POSITIVE_INFINITY;for(i=0,s=t.length;i<s;++i){const s=t[i].element;if(s&&s.hasValue()){const t=Kt(e,s.getCenterPoint());t<r&&(r=t,n=s)}}if(n){const t=n.tooltipPosition();o=t.x,a=t.y}return{x:o,y:a}}};function Io(t,e){return e&&(X(e)?Array.prototype.push.apply(t,e):t.push(e)),t}function zo(t){return("string"==typeof t||t instanceof String)&&t.indexOf("\n")>-1?t.split("\n"):t}function Fo(t,e){const{element:i,datasetIndex:s,index:n}=e,o=t.getDatasetMeta(s).controller,{label:a,value:r}=o.getLabelAndValue(n);return{chart:t,label:a,parsed:o.getParsed(n),raw:t.data.datasets[s].data[n],formattedValue:r,dataset:o.getDataset(),dataIndex:n,datasetIndex:s,element:i}}function Bo(t,e){const i=t.chart.ctx,{body:s,footer:n,title:o}=t,{boxWidth:a,boxHeight:r}=e,l=ui(e.bodyFont),h=ui(e.titleFont),c=ui(e.footerFont),d=o.length,u=n.length,f=s.length,g=di(e.padding);let p=g.height,m=0,b=s.reduce(((t,e)=>t+e.before.length+e.lines.length+e.after.length),0);if(b+=t.beforeBody.length+t.afterBody.length,d&&(p+=d*h.lineHeight+(d-1)*e.titleSpacing+e.titleMarginBottom),b){p+=f*(e.displayColors?Math.max(r,l.lineHeight):l.lineHeight)+(b-f)*l.lineHeight+(b-1)*e.bodySpacing}u&&(p+=e.footerMarginTop+u*c.lineHeight+(u-1)*e.footerSpacing);let x=0;const _=function(t){m=Math.max(m,i.measureText(t).width+x)};return i.save(),i.font=h.string,et(t.title,_),i.font=l.string,et(t.beforeBody.concat(t.afterBody),_),x=e.displayColors?a+2+e.boxPadding:0,et(s,(t=>{et(t.before,_),et(t.lines,_),et(t.after,_)})),x=0,i.font=c.string,et(t.footer,_),i.restore(),m+=g.width,{width:m,height:p}}function Vo(t,e,i,s){const{x:n,width:o}=i,{width:a,chartArea:{left:r,right:l}}=t;let h="center";return"center"===s?h=n<=(r+l)/2?"left":"right":n<=o/2?h="left":n>=a-o/2&&(h="right"),function(t,e,i,s){const{x:n,width:o}=s,a=i.caretSize+i.caretPadding;return"left"===t&&n+o+a>e.width||"right"===t&&n-o-a<0||void 0}(h,t,e,i)&&(h="center"),h}function No(t,e,i){const s=i.yAlign||e.yAlign||function(t,e){const{y:i,height:s}=e;return i<s/2?"top":i>t.height-s/2?"bottom":"center"}(t,i);return{xAlign:i.xAlign||e.xAlign||Vo(t,e,i,s),yAlign:s}}function Wo(t,e,i,s){const{caretSize:n,caretPadding:o,cornerRadius:a}=t,{xAlign:r,yAlign:l}=i,h=n+o,{topLeft:c,topRight:d,bottomLeft:u,bottomRight:f}=ci(a);let g=function(t,e){let{x:i,width:s}=t;return"right"===e?i-=s:"center"===e&&(i-=s/2),i}(e,r);const p=function(t,e,i){let{y:s,height:n}=t;return"top"===e?s+=i:s-="bottom"===e?n+i:n/2,s}(e,l,h);return"center"===l?"left"===r?g+=h:"right"===r&&(g-=h):"left"===r?g-=Math.max(c,u)+n:"right"===r&&(g+=Math.max(d,f)+n),{x:Qt(g,0,s.width-e.width),y:Qt(p,0,s.height-e.height)}}function Ho(t,e,i){const s=di(i.padding);return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-s.right:t.x+s.left}function jo(t){return Io([],zo(t))}function $o(t,e){const i=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return i?t.override(i):t}class Yo extends Os{constructor(t){super(),this.opacity=0,this._active=[],this._eventPosition=void 0,this._size=void 0,this._cachedAnimations=void 0,this._tooltipItems=[],this.$animations=void 0,this.$context=void 0,this.chart=t.chart||t._chart,this._chart=this.chart,this.options=t.options,this.dataPoints=void 0,this.title=void 0,this.beforeBody=void 0,this.body=void 0,this.afterBody=void 0,this.footer=void 0,this.xAlign=void 0,this.yAlign=void 0,this.x=void 0,this.y=void 0,this.height=void 0,this.width=void 0,this.caretX=void 0,this.caretY=void 0,this.labelColors=void 0,this.labelPointStyles=void 0,this.labelTextColors=void 0}initialize(t){this.options=t,this._cachedAnimations=void 0,this.$context=void 0}_resolveAnimations(){const t=this._cachedAnimations;if(t)return t;const e=this.chart,i=this.options.setContext(this.getContext()),s=i.enabled&&e.options.animation&&i.animations,n=new ms(this.chart,s);return s._cacheable&&(this._cachedAnimations=Object.freeze(n)),n}getContext(){return this.$context||(this.$context=(t=this.chart.getContext(),e=this,i=this._tooltipItems,pi(t,{tooltip:e,tooltipItems:i,type:"tooltip"})));var t,e,i}getTitle(t,e){const{callbacks:i}=e,s=i.beforeTitle.apply(this,[t]),n=i.title.apply(this,[t]),o=i.afterTitle.apply(this,[t]);let a=[];return a=Io(a,zo(s)),a=Io(a,zo(n)),a=Io(a,zo(o)),a}getBeforeBody(t,e){return jo(e.callbacks.beforeBody.apply(this,[t]))}getBody(t,e){const{callbacks:i}=e,s=[];return et(t,(t=>{const e={before:[],lines:[],after:[]},n=$o(i,t);Io(e.before,zo(n.beforeLabel.call(this,t))),Io(e.lines,n.label.call(this,t)),Io(e.after,zo(n.afterLabel.call(this,t))),s.push(e)})),s}getAfterBody(t,e){return jo(e.callbacks.afterBody.apply(this,[t]))}getFooter(t,e){const{callbacks:i}=e,s=i.beforeFooter.apply(this,[t]),n=i.footer.apply(this,[t]),o=i.afterFooter.apply(this,[t]);let a=[];return a=Io(a,zo(s)),a=Io(a,zo(n)),a=Io(a,zo(o)),a}_createItems(t){const e=this._active,i=this.chart.data,s=[],n=[],o=[];let a,r,l=[];for(a=0,r=e.length;a<r;++a)l.push(Fo(this.chart,e[a]));return t.filter&&(l=l.filter(((e,s,n)=>t.filter(e,s,n,i)))),t.itemSort&&(l=l.sort(((e,s)=>t.itemSort(e,s,i)))),et(l,(e=>{const i=$o(t.callbacks,e);s.push(i.labelColor.call(this,e)),n.push(i.labelPointStyle.call(this,e)),o.push(i.labelTextColor.call(this,e))})),this.labelColors=s,this.labelPointStyles=n,this.labelTextColors=o,this.dataPoints=l,l}update(t,e){const i=this.options.setContext(this.getContext()),s=this._active;let n,o=[];if(s.length){const t=Eo[i.position].call(this,s,this._eventPosition);o=this._createItems(i),this.title=this.getTitle(o,i),this.beforeBody=this.getBeforeBody(o,i),this.body=this.getBody(o,i),this.afterBody=this.getAfterBody(o,i),this.footer=this.getFooter(o,i);const e=this._size=Bo(this,i),a=Object.assign({},t,e),r=No(this.chart,i,a),l=Wo(i,a,r,this.chart);this.xAlign=r.xAlign,this.yAlign=r.yAlign,n={opacity:1,x:l.x,y:l.y,width:e.width,height:e.height,caretX:t.x,caretY:t.y}}else 0!==this.opacity&&(n={opacity:0});this._tooltipItems=o,this.$context=void 0,n&&this._resolveAnimations().update(this,n),t&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,i,s){const n=this.getCaretPosition(t,i,s);e.lineTo(n.x1,n.y1),e.lineTo(n.x2,n.y2),e.lineTo(n.x3,n.y3)}getCaretPosition(t,e,i){const{xAlign:s,yAlign:n}=this,{caretSize:o,cornerRadius:a}=i,{topLeft:r,topRight:l,bottomLeft:h,bottomRight:c}=ci(a),{x:d,y:u}=t,{width:f,height:g}=e;let p,m,b,x,_,y;return"center"===n?(_=u+g/2,"left"===s?(p=d,m=p-o,x=_+o,y=_-o):(p=d+f,m=p+o,x=_-o,y=_+o),b=p):(m="left"===s?d+Math.max(r,h)+o:"right"===s?d+f-Math.max(l,c)-o:this.caretX,"top"===n?(x=u,_=x-o,p=m-o,b=m+o):(x=u+g,_=x+o,p=m+o,b=m-o),y=x),{x1:p,x2:m,x3:b,y1:x,y2:_,y3:y}}drawTitle(t,e,i){const s=this.title,n=s.length;let o,a,r;if(n){const l=mi(i.rtl,this.x,this.width);for(t.x=Ho(this,i.titleAlign,i),e.textAlign=l.textAlign(i.titleAlign),e.textBaseline="middle",o=ui(i.titleFont),a=i.titleSpacing,e.fillStyle=i.titleColor,e.font=o.string,r=0;r<n;++r)e.fillText(s[r],l.x(t.x),t.y+o.lineHeight/2),t.y+=o.lineHeight+a,r+1===n&&(t.y+=i.titleMarginBottom-a)}}_drawColorBox(t,e,i,s,n){const o=this.labelColors[i],a=this.labelPointStyles[i],{boxHeight:r,boxWidth:l,boxPadding:h}=n,c=ui(n.bodyFont),d=Ho(this,"left",n),u=s.x(d),f=r<c.lineHeight?(c.lineHeight-r)/2:0,g=e.y+f;if(n.usePointStyle){const e={radius:Math.min(l,r)/2,pointStyle:a.pointStyle,rotation:a.rotation,borderWidth:1},i=s.leftForLtr(u,l)+l/2,h=g+r/2;t.strokeStyle=n.multiKeyBackground,t.fillStyle=n.multiKeyBackground,ye(t,e,i,h),t.strokeStyle=o.borderColor,t.fillStyle=o.backgroundColor,ye(t,e,i,h)}else{t.lineWidth=o.borderWidth||1,t.strokeStyle=o.borderColor,t.setLineDash(o.borderDash||[]),t.lineDashOffset=o.borderDashOffset||0;const e=s.leftForLtr(u,l-h),i=s.leftForLtr(s.xPlus(u,1),l-h-2),a=ci(o.borderRadius);Object.values(a).some((t=>0!==t))?(t.beginPath(),t.fillStyle=n.multiKeyBackground,Ce(t,{x:e,y:g,w:l,h:r,radius:a}),t.fill(),t.stroke(),t.fillStyle=o.backgroundColor,t.beginPath(),Ce(t,{x:i,y:g+1,w:l-2,h:r-2,radius:a}),t.fill()):(t.fillStyle=n.multiKeyBackground,t.fillRect(e,g,l,r),t.strokeRect(e,g,l,r),t.fillStyle=o.backgroundColor,t.fillRect(i,g+1,l-2,r-2))}t.fillStyle=this.labelTextColors[i]}drawBody(t,e,i){const{body:s}=this,{bodySpacing:n,bodyAlign:o,displayColors:a,boxHeight:r,boxWidth:l,boxPadding:h}=i,c=ui(i.bodyFont);let d=c.lineHeight,u=0;const f=mi(i.rtl,this.x,this.width),g=function(i){e.fillText(i,f.x(t.x+u),t.y+d/2),t.y+=d+n},p=f.textAlign(o);let m,b,x,_,y,v,w;for(e.textAlign=o,e.textBaseline="middle",e.font=c.string,t.x=Ho(this,p,i),e.fillStyle=i.bodyColor,et(this.beforeBody,g),u=a&&"right"!==p?"center"===o?l/2+h:l+2+h:0,_=0,v=s.length;_<v;++_){for(m=s[_],b=this.labelTextColors[_],e.fillStyle=b,et(m.before,g),x=m.lines,a&&x.length&&(this._drawColorBox(e,t,_,f,i),d=Math.max(c.lineHeight,r)),y=0,w=x.length;y<w;++y)g(x[y]),d=c.lineHeight;et(m.after,g)}u=0,d=c.lineHeight,et(this.afterBody,g),t.y-=n}drawFooter(t,e,i){const s=this.footer,n=s.length;let o,a;if(n){const r=mi(i.rtl,this.x,this.width);for(t.x=Ho(this,i.footerAlign,i),t.y+=i.footerMarginTop,e.textAlign=r.textAlign(i.footerAlign),e.textBaseline="middle",o=ui(i.footerFont),e.fillStyle=i.footerColor,e.font=o.string,a=0;a<n;++a)e.fillText(s[a],r.x(t.x),t.y+o.lineHeight/2),t.y+=o.lineHeight+i.footerSpacing}}drawBackground(t,e,i,s){const{xAlign:n,yAlign:o}=this,{x:a,y:r}=t,{width:l,height:h}=i,{topLeft:c,topRight:d,bottomLeft:u,bottomRight:f}=ci(s.cornerRadius);e.fillStyle=s.backgroundColor,e.strokeStyle=s.borderColor,e.lineWidth=s.borderWidth,e.beginPath(),e.moveTo(a+c,r),"top"===o&&this.drawCaret(t,e,i,s),e.lineTo(a+l-d,r),e.quadraticCurveTo(a+l,r,a+l,r+d),"center"===o&&"right"===n&&this.drawCaret(t,e,i,s),e.lineTo(a+l,r+h-f),e.quadraticCurveTo(a+l,r+h,a+l-f,r+h),"bottom"===o&&this.drawCaret(t,e,i,s),e.lineTo(a+u,r+h),e.quadraticCurveTo(a,r+h,a,r+h-u),"center"===o&&"left"===n&&this.drawCaret(t,e,i,s),e.lineTo(a,r+c),e.quadraticCurveTo(a,r,a+c,r),e.closePath(),e.fill(),s.borderWidth>0&&e.stroke()}_updateAnimationTarget(t){const e=this.chart,i=this.$animations,s=i&&i.x,n=i&&i.y;if(s||n){const i=Eo[t.position].call(this,this._active,this._eventPosition);if(!i)return;const o=this._size=Bo(this,t),a=Object.assign({},i,this._size),r=No(e,t,a),l=Wo(t,a,r,e);s._to===l.x&&n._to===l.y||(this.xAlign=r.xAlign,this.yAlign=r.yAlign,this.width=o.width,this.height=o.height,this.caretX=i.x,this.caretY=i.y,this._resolveAnimations().update(this,l))}}_willRender(){return!!this.opacity}draw(t){const e=this.options.setContext(this.getContext());let i=this.opacity;if(!i)return;this._updateAnimationTarget(e);const s={width:this.width,height:this.height},n={x:this.x,y:this.y};i=Math.abs(i)<.001?0:i;const o=di(e.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;e.enabled&&a&&(t.save(),t.globalAlpha=i,this.drawBackground(n,t,s,e),bi(t,e.textDirection),n.y+=o.top,this.drawTitle(n,t,e),this.drawBody(n,t,e),this.drawFooter(n,t,e),xi(t,e.textDirection),t.restore())}getActiveElements(){return this._active||[]}setActiveElements(t,e){const i=this._active,s=t.map((({datasetIndex:t,index:e})=>{const i=this.chart.getDatasetMeta(t);if(!i)throw new Error("Cannot find a dataset at index "+t);return{datasetIndex:t,element:i.data[e],index:e}})),n=!it(i,s),o=this._positionChanged(s,e);(n||o)&&(this._active=s,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,i=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const s=this.options,n=this._active||[],o=this._getActiveElements(t,n,e,i),a=this._positionChanged(o,t),r=e||!it(o,n)||a;return r&&(this._active=o,(s.enabled||s.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),r}_getActiveElements(t,e,i,s){const n=this.options;if("mouseout"===t.type)return[];if(!s)return e;const o=this.chart.getElementsAtEventForMode(t,n.mode,n,i);return n.reverse&&o.reverse(),o}_positionChanged(t,e){const{caretX:i,caretY:s,options:n}=this,o=Eo[n.position].call(this,t,e);return!1!==o&&(i!==o.x||s!==o.y)}}Yo.positioners=Eo;var Uo={id:"tooltip",_element:Yo,positioners:Eo,afterInit(t,e,i){i&&(t.tooltip=new Yo({chart:t,options:i}))},beforeUpdate(t,e,i){t.tooltip&&t.tooltip.initialize(i)},reset(t,e,i){t.tooltip&&t.tooltip.initialize(i)},afterDraw(t){const e=t.tooltip;if(e&&e._willRender()){const i={tooltip:e};if(!1===t.notifyPlugins("beforeTooltipDraw",i))return;e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",i)}},afterEvent(t,e){if(t.tooltip){const i=e.replay;t.tooltip.handleEvent(e.event,i,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:{beforeTitle:$,title(t){if(t.length>0){const e=t[0],i=e.chart.data.labels,s=i?i.length:0;if(this&&this.options&&"dataset"===this.options.mode)return e.dataset.label||"";if(e.label)return e.label;if(s>0&&e.dataIndex<s)return i[e.dataIndex]}return""},afterTitle:$,beforeBody:$,beforeLabel:$,label(t){if(this&&this.options&&"dataset"===this.options.mode)return t.label+": "+t.formattedValue||t.formattedValue;let e=t.dataset.label||"";e&&(e+=": ");const i=t.formattedValue;return U(i)||(e+=i),e},labelColor(t){const e=t.chart.getDatasetMeta(t.datasetIndex).controller.getStyle(t.dataIndex);return{borderColor:e.borderColor,backgroundColor:e.backgroundColor,borderWidth:e.borderWidth,borderDash:e.borderDash,borderDashOffset:e.borderDashOffset,borderRadius:0}},labelTextColor(){return this.options.bodyColor},labelPointStyle(t){const e=t.chart.getDatasetMeta(t.datasetIndex).controller.getStyle(t.dataIndex);return{pointStyle:e.pointStyle,rotation:e.rotation}},afterLabel:$,afterBody:$,beforeFooter:$,footer:$,afterFooter:$}},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>"filter"!==t&&"itemSort"!==t&&"external"!==t,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]},Xo=Object.freeze({__proto__:null,Decimation:ho,Filler:Po,Legend:Oo,SubTitle:Ro,Title:To,Tooltip:Uo});function qo(t,e,i,s){const n=t.indexOf(e);if(-1===n)return((t,e,i,s)=>("string"==typeof e?(i=t.push(e)-1,s.unshift({index:i,label:e})):isNaN(e)&&(i=null),i))(t,e,i,s);return n!==t.lastIndexOf(e)?i:n}class Ko extends Ns{constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){const e=this._addedLabels;if(e.length){const t=this.getLabels();for(const{index:i,label:s}of e)t[i]===s&&t.splice(i,1);this._addedLabels=[]}super.init(t)}parse(t,e){if(U(t))return null;const i=this.getLabels();return((t,e)=>null===t?null:Qt(Math.round(t),0,e))(e=isFinite(e)&&i[e]===t?e:qo(i,t,Z(e,t),this._addedLabels),i.length-1)}determineDataLimits(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:i,max:s}=this.getMinMax(!0);"ticks"===this.options.bounds&&(t||(i=0),e||(s=this.getLabels().length-1)),this.min=i,this.max=s}buildTicks(){const t=this.min,e=this.max,i=this.options.offset,s=[];let n=this.getLabels();n=0===t&&e===n.length-1?n:n.slice(t,e+1),this._valueRange=Math.max(n.length-(i?0:1),1),this._startValue=this.min-(i?.5:0);for(let i=t;i<=e;i++)s.push({value:i});return s}getLabelForValue(t){const e=this.getLabels();return t>=0&&t<e.length?e[t]:t}configure(){super.configure(),this.isHorizontal()||(this._reversePixels=!this._reversePixels)}getPixelForValue(t){return"number"!=typeof t&&(t=this.parse(t)),null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}}function Go(t,e,{horizontal:i,minRotation:s}){const n=Yt(s),o=(i?Math.sin(n):Math.cos(n))||.001,a=.75*e*(""+t).length;return Math.min(e/o,a)}Ko.id="category",Ko.defaults={ticks:{callback:Ko.prototype.getLabelForValue}};class Zo extends Ns{constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._endValue=void 0,this._valueRange=0}parse(t,e){return U(t)||("number"==typeof t||t instanceof Number)&&!isFinite(+t)?null:+t}handleTickRangeOptions(){const{beginAtZero:t}=this.options,{minDefined:e,maxDefined:i}=this.getUserBounds();let{min:s,max:n}=this;const o=t=>s=e?s:t,a=t=>n=i?n:t;if(t){const t=Bt(s),e=Bt(n);t<0&&e<0?a(0):t>0&&e>0&&o(0)}if(s===n){let e=1;(n>=Number.MAX_SAFE_INTEGER||s<=Number.MIN_SAFE_INTEGER)&&(e=Math.abs(.05*n)),a(n+e),t||o(s-e)}this.min=s,this.max=n}getTickLimit(){const t=this.options.ticks;let e,{maxTicksLimit:i,stepSize:s}=t;return s?(e=Math.ceil(this.max/s)-Math.floor(this.min/s)+1,e>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${s} would result generating up to ${e} ticks. Limiting to 1000.`),e=1e3)):(e=this.computeTickLimit(),i=i||11),i&&(e=Math.min(i,e)),e}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const t=this.options,e=t.ticks;let i=this.getTickLimit();i=Math.max(2,i);const s=function(t,e){const i=[],{bounds:s,step:n,min:o,max:a,precision:r,count:l,maxTicks:h,maxDigits:c,includeBounds:d}=t,u=n||1,f=h-1,{min:g,max:p}=e,m=!U(o),b=!U(a),x=!U(l),_=(p-g)/(c+1);let y,v,w,M,k=Vt((p-g)/f/u)*u;if(k<1e-14&&!m&&!b)return[{value:g},{value:p}];M=Math.ceil(p/k)-Math.floor(g/k),M>f&&(k=Vt(M*k/f/u)*u),U(r)||(y=Math.pow(10,r),k=Math.ceil(k*y)/y),"ticks"===s?(v=Math.floor(g/k)*k,w=Math.ceil(p/k)*k):(v=g,w=p),m&&b&&n&&jt((a-o)/n,k/1e3)?(M=Math.round(Math.min((a-o)/k,h)),k=(a-o)/M,v=o,w=a):x?(v=m?o:v,w=b?a:w,M=l-1,k=(w-v)/M):(M=(w-v)/k,M=Ht(M,Math.round(M),k/1e3)?Math.round(M):Math.ceil(M));const S=Math.max(Xt(k),Xt(v));y=Math.pow(10,U(r)?S:r),v=Math.round(v*y)/y,w=Math.round(w*y)/y;let P=0;for(m&&(d&&v!==o?(i.push({value:o}),v<o&&P++,Ht(Math.round((v+P*k)*y)/y,o,Go(o,_,t))&&P++):v<o&&P++);P<M;++P)i.push({value:Math.round((v+P*k)*y)/y});return b&&d&&w!==a?i.length&&Ht(i[i.length-1].value,a,Go(a,_,t))?i[i.length-1].value=a:i.push({value:a}):b&&w!==a||i.push({value:w}),i}({maxTicks:i,bounds:t.bounds,min:t.min,max:t.max,precision:e.precision,step:e.stepSize,count:e.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:e.minRotation||0,includeBounds:!1!==e.includeBounds},this._range||this);return"ticks"===t.bounds&&$t(s,this,"value"),t.reverse?(s.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),s}configure(){const t=this.ticks;let e=this.min,i=this.max;if(super.configure(),this.options.offset&&t.length){const s=(i-e)/Math.max(t.length-1,1)/2;e-=s,i+=s}this._startValue=e,this._endValue=i,this._valueRange=i-e}getLabelForValue(t){return ni(t,this.chart.options.locale,this.options.ticks.format)}}class Jo extends Zo{determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=K(t)?t:0,this.max=K(e)?e:1,this.handleTickRangeOptions()}computeTickLimit(){const t=this.isHorizontal(),e=t?this.width:this.height,i=Yt(this.options.ticks.minRotation),s=(t?Math.sin(i):Math.cos(i))||.001,n=this._resolveTickFontOptions(0);return Math.ceil(e/Math.min(40,n.lineHeight/s))}getPixelForValue(t){return null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getValueForPixel(t){return this._startValue+this.getDecimalForPixel(t)*this._valueRange}}function Qo(t){return 1===t/Math.pow(10,Math.floor(Ft(t)))}Jo.id="linear",Jo.defaults={ticks:{callback:Ts.formatters.numeric}};class ta extends Ns{constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(t,e){const i=Zo.prototype.parse.apply(this,[t,e]);if(0!==i)return K(i)&&i>0?i:null;this._zero=!0}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=K(t)?Math.max(0,t):null,this.max=K(e)?Math.max(0,e):null,this.options.beginAtZero&&(this._zero=!0),this.handleTickRangeOptions()}handleTickRangeOptions(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let i=this.min,s=this.max;const n=e=>i=t?i:e,o=t=>s=e?s:t,a=(t,e)=>Math.pow(10,Math.floor(Ft(t))+e);i===s&&(i<=0?(n(1),o(10)):(n(a(i,-1)),o(a(s,1)))),i<=0&&n(a(s,-1)),s<=0&&o(a(i,1)),this._zero&&this.min!==this._suggestedMin&&i===a(this.min,0)&&n(a(i,-1)),this.min=i,this.max=s}buildTicks(){const t=this.options,e=function(t,e){const i=Math.floor(Ft(e.max)),s=Math.ceil(e.max/Math.pow(10,i)),n=[];let o=G(t.min,Math.pow(10,Math.floor(Ft(e.min)))),a=Math.floor(Ft(o)),r=Math.floor(o/Math.pow(10,a)),l=a<0?Math.pow(10,Math.abs(a)):1;do{n.push({value:o,major:Qo(o)}),++r,10===r&&(r=1,++a,l=a>=0?1:l),o=Math.round(r*Math.pow(10,a)*l)/l}while(a<i||a===i&&r<s);const h=G(t.max,o);return n.push({value:h,major:Qo(o)}),n}({min:this._userMin,max:this._userMax},this);return"ticks"===t.bounds&&$t(e,this,"value"),t.reverse?(e.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),e}getLabelForValue(t){return void 0===t?"0":ni(t,this.chart.options.locale,this.options.ticks.format)}configure(){const t=this.min;super.configure(),this._startValue=Ft(t),this._valueRange=Ft(this.max)-Ft(t)}getPixelForValue(t){return void 0!==t&&0!==t||(t=this.min),null===t||isNaN(t)?NaN:this.getPixelForDecimal(t===this.min?0:(Ft(t)-this._startValue)/this._valueRange)}getValueForPixel(t){const e=this.getDecimalForPixel(t);return Math.pow(10,this._startValue+e*this._valueRange)}}function ea(t){const e=t.ticks;if(e.display&&t.display){const t=di(e.backdropPadding);return Z(e.font&&e.font.size,yt.font.size)+t.height}return 0}function ia(t,e,i,s,n){return t===s||t===n?{start:e-i/2,end:e+i/2}:t<s||t>n?{start:e-i,end:e}:{start:e,end:e+i}}function sa(t){const e={l:t.left+t._padding.left,r:t.right-t._padding.right,t:t.top+t._padding.top,b:t.bottom-t._padding.bottom},i=Object.assign({},e),s=[],n=[],o=t._pointLabels.length,a=t.options.pointLabels,r=a.centerPointLabels?Ot/o:0;for(let d=0;d<o;d++){const o=a.setContext(t.getPointLabelContext(d));n[d]=o.padding;const u=t.getPointPosition(d,t.drawingArea+n[d],r),f=ui(o.font),g=(l=t.ctx,h=f,c=X(c=t._pointLabels[d])?c:[c],{w:be(l,h.string,c),h:c.length*h.lineHeight});s[d]=g;const p=Zt(t.getIndexAngle(d)+r),m=Math.round(Ut(p));na(i,e,p,ia(m,u.x,g.w,0,180),ia(m,u.y,g.h,90,270))}var l,h,c;t.setCenterPoint(e.l-i.l,i.r-e.r,e.t-i.t,i.b-e.b),t._pointLabelItems=function(t,e,i){const s=[],n=t._pointLabels.length,o=t.options,a=ea(o)/2,r=t.drawingArea,l=o.pointLabels.centerPointLabels?Ot/n:0;for(let o=0;o<n;o++){const n=t.getPointPosition(o,r+a+i[o],l),h=Math.round(Ut(Zt(n.angle+Et))),c=e[o],d=ra(n.y,c.h,h),u=oa(h),f=aa(n.x,c.w,u);s.push({x:n.x,y:d,textAlign:u,left:f,top:d,right:f+c.w,bottom:d+c.h})}return s}(t,s,n)}function na(t,e,i,s,n){const o=Math.abs(Math.sin(i)),a=Math.abs(Math.cos(i));let r=0,l=0;s.start<e.l?(r=(e.l-s.start)/o,t.l=Math.min(t.l,e.l-r)):s.end>e.r&&(r=(s.end-e.r)/o,t.r=Math.max(t.r,e.r+r)),n.start<e.t?(l=(e.t-n.start)/a,t.t=Math.min(t.t,e.t-l)):n.end>e.b&&(l=(n.end-e.b)/a,t.b=Math.max(t.b,e.b+l))}function oa(t){return 0===t||180===t?"center":t<180?"left":"right"}function aa(t,e,i){return"right"===i?t-=e:"center"===i&&(t-=e/2),t}function ra(t,e,i){return 90===i||270===i?t-=e/2:(i>270||i<90)&&(t-=e),t}function la(t,e,i,s){const{ctx:n}=t;if(i)n.arc(t.xCenter,t.yCenter,e,0,At);else{let i=t.getPointPosition(0,e);n.moveTo(i.x,i.y);for(let o=1;o<s;o++)i=t.getPointPosition(o,e),n.lineTo(i.x,i.y)}}ta.id="logarithmic",ta.defaults={ticks:{callback:Ts.formatters.logarithmic,major:{enabled:!0}}};class ha extends Zo{constructor(t){super(t),this.xCenter=void 0,this.yCenter=void 0,this.drawingArea=void 0,this._pointLabels=[],this._pointLabelItems=[]}setDimensions(){const t=this._padding=di(ea(this.options)/2),e=this.width=this.maxWidth-t.width,i=this.height=this.maxHeight-t.height;this.xCenter=Math.floor(this.left+e/2+t.left),this.yCenter=Math.floor(this.top+i/2+t.top),this.drawingArea=Math.floor(Math.min(e,i)/2)}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!1);this.min=K(t)&&!isNaN(t)?t:0,this.max=K(e)&&!isNaN(e)?e:0,this.handleTickRangeOptions()}computeTickLimit(){return Math.ceil(this.drawingArea/ea(this.options))}generateTickLabels(t){Zo.prototype.generateTickLabels.call(this,t),this._pointLabels=this.getLabels().map(((t,e)=>{const i=tt(this.options.pointLabels.callback,[t,e],this);return i||0===i?i:""})).filter(((t,e)=>this.chart.getDataVisibility(e)))}fit(){const t=this.options;t.display&&t.pointLabels.display?sa(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(t,e,i,s){this.xCenter+=Math.floor((t-e)/2),this.yCenter+=Math.floor((i-s)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(t,e,i,s))}getIndexAngle(t){return Zt(t*(At/(this._pointLabels.length||1))+Yt(this.options.startAngle||0))}getDistanceFromCenterForValue(t){if(U(t))return NaN;const e=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-t)*e:(t-this.min)*e}getValueForDistanceFromCenter(t){if(U(t))return NaN;const e=t/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-e:this.min+e}getPointLabelContext(t){const e=this._pointLabels||[];if(t>=0&&t<e.length){const i=e[t];return function(t,e,i){return pi(t,{label:i,index:e,type:"pointLabel"})}(this.getContext(),t,i)}}getPointPosition(t,e,i=0){const s=this.getIndexAngle(t)-Et+i;return{x:Math.cos(s)*e+this.xCenter,y:Math.sin(s)*e+this.yCenter,angle:s}}getPointPositionForValue(t,e){return this.getPointPosition(t,this.getDistanceFromCenterForValue(e))}getBasePosition(t){return this.getPointPositionForValue(t||0,this.getBaseValue())}getPointLabelPosition(t){const{left:e,top:i,right:s,bottom:n}=this._pointLabelItems[t];return{left:e,top:i,right:s,bottom:n}}drawBackground(){const{backgroundColor:t,grid:{circular:e}}=this.options;if(t){const i=this.ctx;i.save(),i.beginPath(),la(this,this.getDistanceFromCenterForValue(this._endValue),e,this._pointLabels.length),i.closePath(),i.fillStyle=t,i.fill(),i.restore()}}drawGrid(){const t=this.ctx,e=this.options,{angleLines:i,grid:s}=e,n=this._pointLabels.length;let o,a,r;if(e.pointLabels.display&&function(t,e){const{ctx:i,options:{pointLabels:s}}=t;for(let n=e-1;n>=0;n--){const e=s.setContext(t.getPointLabelContext(n)),o=ui(e.font),{x:a,y:r,textAlign:l,left:h,top:c,right:d,bottom:u}=t._pointLabelItems[n],{backdropColor:f}=e;if(!U(f)){const t=ci(e.borderRadius),s=di(e.backdropPadding);i.fillStyle=f;const n=h-s.left,o=c-s.top,a=d-h+s.width,r=u-c+s.height;Object.values(t).some((t=>0!==t))?(i.beginPath(),Ce(i,{x:n,y:o,w:a,h:r,radius:t}),i.fill()):i.fillRect(n,o,a,r)}Pe(i,t._pointLabels[n],a,r+o.lineHeight/2,o,{color:e.color,textAlign:l,textBaseline:"middle"})}}(this,n),s.display&&this.ticks.forEach(((t,e)=>{if(0!==e){a=this.getDistanceFromCenterForValue(t.value);!function(t,e,i,s){const n=t.ctx,o=e.circular,{color:a,lineWidth:r}=e;!o&&!s||!a||!r||i<0||(n.save(),n.strokeStyle=a,n.lineWidth=r,n.setLineDash(e.borderDash),n.lineDashOffset=e.borderDashOffset,n.beginPath(),la(t,i,o,s),n.closePath(),n.stroke(),n.restore())}(this,s.setContext(this.getContext(e-1)),a,n)}})),i.display){for(t.save(),o=n-1;o>=0;o--){const s=i.setContext(this.getPointLabelContext(o)),{color:n,lineWidth:l}=s;l&&n&&(t.lineWidth=l,t.strokeStyle=n,t.setLineDash(s.borderDash),t.lineDashOffset=s.borderDashOffset,a=this.getDistanceFromCenterForValue(e.ticks.reverse?this.min:this.max),r=this.getPointPosition(o,a),t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(r.x,r.y),t.stroke())}t.restore()}}drawBorder(){}drawLabels(){const t=this.ctx,e=this.options,i=e.ticks;if(!i.display)return;const s=this.getIndexAngle(0);let n,o;t.save(),t.translate(this.xCenter,this.yCenter),t.rotate(s),t.textAlign="center",t.textBaseline="middle",this.ticks.forEach(((s,a)=>{if(0===a&&!e.reverse)return;const r=i.setContext(this.getContext(a)),l=ui(r.font);if(n=this.getDistanceFromCenterForValue(this.ticks[a].value),r.showLabelBackdrop){t.font=l.string,o=t.measureText(s.label).width,t.fillStyle=r.backdropColor;const e=di(r.backdropPadding);t.fillRect(-o/2-e.left,-n-l.size/2-e.top,o+e.width,l.size+e.height)}Pe(t,s.label,0,-n,l,{color:r.color})})),t.restore()}drawTitle(){}}ha.id="radialLinear",ha.defaults={display:!0,animate:!0,position:"chartArea",angleLines:{display:!0,lineWidth:1,borderDash:[],borderDashOffset:0},grid:{circular:!1},startAngle:0,ticks:{showLabelBackdrop:!0,callback:Ts.formatters.numeric},pointLabels:{backdropColor:void 0,backdropPadding:2,display:!0,font:{size:10},callback:t=>t,padding:5,centerPointLabels:!1}},ha.defaultRoutes={"angleLines.color":"borderColor","pointLabels.color":"color","ticks.color":"color"},ha.descriptors={angleLines:{_fallback:"grid"}};const ca={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},da=Object.keys(ca);function ua(t,e){return t-e}function fa(t,e){if(U(e))return null;const i=t._adapter,{parser:s,round:n,isoWeekday:o}=t._parseOpts;let a=e;return"function"==typeof s&&(a=s(a)),K(a)||(a="string"==typeof s?i.parse(a,s):i.parse(a)),null===a?null:(n&&(a="week"!==n||!Wt(o)&&!0!==o?i.startOf(a,n):i.startOf(a,"isoWeek",o)),+a)}function ga(t,e,i,s){const n=da.length;for(let o=da.indexOf(t);o<n-1;++o){const t=ca[da[o]],n=t.steps?t.steps:Number.MAX_SAFE_INTEGER;if(t.common&&Math.ceil((i-e)/(n*t.size))<=s)return da[o]}return da[n-1]}function pa(t,e,i){if(i){if(i.length){const{lo:s,hi:n}=vt(i,e);t[i[s]>=e?i[s]:i[n]]=!0}}else t[e]=!0}function ma(t,e,i){const s=[],n={},o=e.length;let a,r;for(a=0;a<o;++a)r=e[a],n[r]=a,s.push({value:r,major:!1});return 0!==o&&i?function(t,e,i,s){const n=t._adapter,o=+n.startOf(e[0].value,s),a=e[e.length-1].value;let r,l;for(r=o;r<=a;r=+n.add(r,1,s))l=i[r],l>=0&&(e[l].major=!0);return e}(t,s,n,i):s}class ba extends Ns{constructor(t){super(t),this._cache={data:[],labels:[],all:[]},this._unit="day",this._majorUnit=void 0,this._offsets={},this._normalized=!1,this._parseOpts=void 0}init(t,e){const i=t.time||(t.time={}),s=this._adapter=new xn._date(t.adapters.date);rt(i.displayFormats,s.formats()),this._parseOpts={parser:i.parser,round:i.round,isoWeekday:i.isoWeekday},super.init(t),this._normalized=e.normalized}parse(t,e){return void 0===t?null:fa(this,t)}beforeLayout(){super.beforeLayout(),this._cache={data:[],labels:[],all:[]}}determineDataLimits(){const t=this.options,e=this._adapter,i=t.time.unit||"day";let{min:s,max:n,minDefined:o,maxDefined:a}=this.getUserBounds();function r(t){o||isNaN(t.min)||(s=Math.min(s,t.min)),a||isNaN(t.max)||(n=Math.max(n,t.max))}o&&a||(r(this._getLabelBounds()),"ticks"===t.bounds&&"labels"===t.ticks.source||r(this.getMinMax(!1))),s=K(s)&&!isNaN(s)?s:+e.startOf(Date.now(),i),n=K(n)&&!isNaN(n)?n:+e.endOf(Date.now(),i)+1,this.min=Math.min(s,n-1),this.max=Math.max(s+1,n)}_getLabelBounds(){const t=this.getLabelTimestamps();let e=Number.POSITIVE_INFINITY,i=Number.NEGATIVE_INFINITY;return t.length&&(e=t[0],i=t[t.length-1]),{min:e,max:i}}buildTicks(){const t=this.options,e=t.time,i=t.ticks,s="labels"===i.source?this.getLabelTimestamps():this._generate();"ticks"===t.bounds&&s.length&&(this.min=this._userMin||s[0],this.max=this._userMax||s[s.length-1]);const n=this.min,o=kt(s,n,this.max);return this._unit=e.unit||(i.autoSkip?ga(e.minUnit,this.min,this.max,this._getLabelCapacity(n)):function(t,e,i,s,n){for(let o=da.length-1;o>=da.indexOf(i);o--){const i=da[o];if(ca[i].common&&t._adapter.diff(n,s,i)>=e-1)return i}return da[i?da.indexOf(i):0]}(this,o.length,e.minUnit,this.min,this.max)),this._majorUnit=i.major.enabled&&"year"!==this._unit?function(t){for(let e=da.indexOf(t)+1,i=da.length;e<i;++e)if(ca[da[e]].common)return da[e]}(this._unit):void 0,this.initOffsets(s),t.reverse&&o.reverse(),ma(this,o,this._majorUnit)}afterAutoSkip(){this.options.offsetAfterAutoskip&&this.initOffsets(this.ticks.map((t=>+t.value)))}initOffsets(t){let e,i,s=0,n=0;this.options.offset&&t.length&&(e=this.getDecimalForValue(t[0]),s=1===t.length?1-e:(this.getDecimalForValue(t[1])-e)/2,i=this.getDecimalForValue(t[t.length-1]),n=1===t.length?i:(i-this.getDecimalForValue(t[t.length-2]))/2);const o=t.length<3?.5:.25;s=Qt(s,0,o),n=Qt(n,0,o),this._offsets={start:s,end:n,factor:1/(s+1+n)}}_generate(){const t=this._adapter,e=this.min,i=this.max,s=this.options,n=s.time,o=n.unit||ga(n.minUnit,e,i,this._getLabelCapacity(e)),a=Z(n.stepSize,1),r="week"===o&&n.isoWeekday,l=Wt(r)||!0===r,h={};let c,d,u=e;if(l&&(u=+t.startOf(u,"isoWeek",r)),u=+t.startOf(u,l?"day":o),t.diff(i,e,o)>1e5*a)throw new Error(e+" and "+i+" are too far apart with stepSize of "+a+" "+o);const f="data"===s.ticks.source&&this.getDataTimestamps();for(c=u,d=0;c<i;c=+t.add(c,a,o),d++)pa(h,c,f);return c!==i&&"ticks"!==s.bounds&&1!==d||pa(h,c,f),Object.keys(h).sort(((t,e)=>t-e)).map((t=>+t))}getLabelForValue(t){const e=this._adapter,i=this.options.time;return i.tooltipFormat?e.format(t,i.tooltipFormat):e.format(t,i.displayFormats.datetime)}_tickFormatFunction(t,e,i,s){const n=this.options,o=n.time.displayFormats,a=this._unit,r=this._majorUnit,l=a&&o[a],h=r&&o[r],c=i[e],d=r&&h&&c&&c.major,u=this._adapter.format(t,s||(d?h:l)),f=n.ticks.callback;return f?tt(f,[u,e,i],this):u}generateTickLabels(t){let e,i,s;for(e=0,i=t.length;e<i;++e)s=t[e],s.label=this._tickFormatFunction(s.value,e,t)}getDecimalForValue(t){return null===t?NaN:(t-this.min)/(this.max-this.min)}getPixelForValue(t){const e=this._offsets,i=this.getDecimalForValue(t);return this.getPixelForDecimal((e.start+i)*e.factor)}getValueForPixel(t){const e=this._offsets,i=this.getDecimalForPixel(t)/e.factor-e.end;return this.min+i*(this.max-this.min)}_getLabelSize(t){const e=this.options.ticks,i=this.ctx.measureText(t).width,s=Yt(this.isHorizontal()?e.maxRotation:e.minRotation),n=Math.cos(s),o=Math.sin(s),a=this._resolveTickFontOptions(0).size;return{w:i*n+a*o,h:i*o+a*n}}_getLabelCapacity(t){const e=this.options.time,i=e.displayFormats,s=i[e.unit]||i.millisecond,n=this._tickFormatFunction(t,0,ma(this,[t],this._majorUnit),s),o=this._getLabelSize(n),a=Math.floor(this.isHorizontal()?this.width/o.w:this.height/o.h)-1;return a>0?a:1}getDataTimestamps(){let t,e,i=this._cache.data||[];if(i.length)return i;const s=this.getMatchingVisibleMetas();if(this._normalized&&s.length)return this._cache.data=s[0].controller.getAllParsedValues(this);for(t=0,e=s.length;t<e;++t)i=i.concat(s[t].controller.getAllParsedValues(this));return this._cache.data=this.normalize(i)}getLabelTimestamps(){const t=this._cache.labels||[];let e,i;if(t.length)return t;const s=this.getLabels();for(e=0,i=s.length;e<i;++e)t.push(fa(this,s[e]));return this._cache.labels=this._normalized?t:this.normalize(t)}normalize(t){return Ct(t.sort(ua))}}function xa(t,e,i){let s,n,o,a,r=0,l=t.length-1;i?(e>=t[r].pos&&e<=t[l].pos&&({lo:r,hi:l}=wt(t,"pos",e)),({pos:s,time:o}=t[r]),({pos:n,time:a}=t[l])):(e>=t[r].time&&e<=t[l].time&&({lo:r,hi:l}=wt(t,"time",e)),({time:s,pos:o}=t[r]),({time:n,pos:a}=t[l]));const h=n-s;return h?o+(a-o)*(e-s)/h:o}ba.id="time",ba.defaults={bounds:"data",adapters:{},time:{parser:!1,unit:!1,round:!1,isoWeekday:!1,minUnit:"millisecond",displayFormats:{}},ticks:{source:"auto",major:{enabled:!1}}};class _a extends ba{constructor(t){super(t),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const t=this._getTimestampsForTable(),e=this._table=this.buildLookupTable(t);this._minPos=xa(e,this.min),this._tableRange=xa(e,this.max)-this._minPos,super.initOffsets(t)}buildLookupTable(t){const{min:e,max:i}=this,s=[],n=[];let o,a,r,l,h;for(o=0,a=t.length;o<a;++o)l=t[o],l>=e&&l<=i&&s.push(l);if(s.length<2)return[{time:e,pos:0},{time:i,pos:1}];for(o=0,a=s.length;o<a;++o)h=s[o+1],r=s[o-1],l=s[o],Math.round((h+r)/2)!==l&&n.push({time:l,pos:o/(a-1)});return n}_getTimestampsForTable(){let t=this._cache.all||[];if(t.length)return t;const e=this.getDataTimestamps(),i=this.getLabelTimestamps();return t=e.length&&i.length?this.normalize(e.concat(i)):e.length?e:i,t=this._cache.all=t,t}getDecimalForValue(t){return(xa(this._table,t)-this._minPos)/this._tableRange}getValueForPixel(t){const e=this._offsets,i=this.getDecimalForPixel(t)/e.factor-e.end;return xa(this._table,i*this._tableRange+this._minPos,!0)}}_a.id="timeseries",_a.defaults=ba.defaults;var ya=Object.freeze({__proto__:null,CategoryScale:Ko,LinearScale:Jo,LogarithmicScale:ta,RadialLinearScale:ha,TimeScale:ba,TimeSeriesScale:_a});return fn.register(In,ya,ao,Xo),fn.helpers={...Di},fn._adapters=xn,fn.Animation=gs,fn.Animations=ms,fn.animator=a,fn.controllers=Hs.controllers.items,fn.DatasetController=Cs,fn.Element=Os,fn.elements=ao,fn.Interaction=Ei,fn.layouts=Xi,fn.platforms=ds,fn.Scale=Ns,fn.Ticks=Ts,Object.assign(fn,In,ya,ao,Xo,ds),fn.Chart=fn,"undefined"!=typeof window&&(window.Chart=fn),fn}));
diff --git a/modules-available/locationinfo/api.inc.php b/modules-available/locationinfo/api.inc.php
index d3ff9ebd..24919ba1 100644
--- a/modules-available/locationinfo/api.inc.php
+++ b/modules-available/locationinfo/api.inc.php
@@ -14,7 +14,7 @@ function HandleParameters()
$get = Request::get('get', 0, 'string');
$uuid = Request::get('uuid', false, 'string');
- $output = false;
+ $output = null;
if ($get === "timestamp") {
$output = array('ts' => getLastChangeTs($uuid));
} elseif ($get === "machines") {
@@ -24,7 +24,7 @@ function HandleParameters()
$output = array_values($output);
} elseif ($get === "config") {
$type = InfoPanel::getConfig($uuid, $output);
- if ($type === false) {
+ if ($type === null) {
http_response_code(404);
die('Panel not found');
}
@@ -36,9 +36,9 @@ function HandleParameters()
$output = getLocationTree($locationIds);
} elseif ($get === "calendar") {
$locationIds = LocationInfo::getLocationsOr404($uuid);
- $output = getCalendar($locationIds);
+ $output = LocationInfo::getCalendar($locationIds);
}
- if ($output !== false) {
+ if ($output !== null) {
Header('Content-Type: application/json; charset=utf-8');
echo json_encode($output);
} else {
@@ -59,7 +59,7 @@ function HandleParameters()
* @param string $paneluuid panels uuid
* @return int UNIX_TIMESTAMP
*/
-function getLastChangeTs($paneluuid)
+function getLastChangeTs(string $paneluuid): int
{
$panel = Database::queryFirst('SELECT lastchange, locationids FROM locationinfo_panel WHERE paneluuid = :paneluuid',
compact('paneluuid'));
@@ -84,7 +84,7 @@ function getLastChangeTs($paneluuid)
* @param int[] $idList list of the location ids.
* @return array aggregated PC states
*/
-function getPcStates($idList, $paneluuid)
+function getPcStates(array $idList, string $paneluuid): array
{
$pcStates = array();
foreach ($idList as $id) {
@@ -130,18 +130,17 @@ function getPcStates($idList, $paneluuid)
* @param int[] $idList Array list of the locations.
* @return array location tree data
*/
-function getLocationTree($idList)
+function getLocationTree(array $idList): array
{
if (in_array(0, $idList)) {
return array_values(Location::getTree());
}
$locations = Location::getTree();
- $ret = findLocations($locations, $idList);
- return $ret;
+ return findLocations($locations, $idList);
}
-function findLocations($locations, $idList)
+function findLocations(array $locations, array $idList): array
{
$ret = array();
foreach ($locations as $location) {
@@ -153,69 +152,3 @@ function findLocations($locations, $idList)
}
return $ret;
}
-
-// ########## <Calendar> ###########
-/**
- * Gets the calendar of the given ids.
- *
- * @param int[] $idList list with the location ids.
- * @return array Calendar.
- */
-function getCalendar($idList)
-{
- if (empty($idList))
- return [];
-
- // Build SQL query for multiple ids.
- $query = "SELECT l.locationid, l.serverid, l.serverlocationid, s.servertype, s.credentials
- FROM `locationinfo_locationconfig` AS l
- INNER JOIN locationinfo_coursebackend AS s ON (s.serverid = l.serverid)
- WHERE l.locationid IN (:idlist)
- ORDER BY s.servertype ASC";
- $dbquery = Database::simpleQuery($query, array('idlist' => array_values($idList)));
-
- $serverList = array();
- while ($dbresult = $dbquery->fetch(PDO::FETCH_ASSOC)) {
- if (!isset($serverList[$dbresult['serverid']])) {
- $serverList[$dbresult['serverid']] = array(
- 'credentials' => json_decode($dbresult['credentials'], true),
- 'type' => $dbresult['servertype'],
- 'idlist' => array()
- );
- }
- $serverList[$dbresult['serverid']]['idlist'][] = $dbresult['locationid'];
- }
-
- $resultArray = array();
- foreach ($serverList as $serverid => $server) {
- $serverInstance = CourseBackend::getInstance($server['type']);
- if ($serverInstance === false) {
- EventLog::warning('Cannot fetch schedule for location (' . implode(', ', $server['idlist']) . ')'
- . ': Backend type ' . $server['type'] . ' unknown. Disabling location.');
- Database::exec("UPDATE locationinfo_locationconfig SET serverid = NULL WHERE locationid IN (:lid)",
- array('lid' => $server['idlist']));
- continue;
- }
- $credentialsOk = $serverInstance->setCredentials($serverid, $server['credentials']);
-
- if ($credentialsOk) {
- $calendarFromBackend = $serverInstance->fetchSchedule($server['idlist']);
- } else {
- $calendarFromBackend = array();
- }
-
- LocationInfo::setServerError($serverid, $serverInstance->getErrors());
-
- if (is_array($calendarFromBackend)) {
- foreach ($calendarFromBackend as $key => $value) {
- $resultArray[] = array(
- 'id' => $key,
- 'calendar' => $value,
- );
- }
- }
- }
- return $resultArray;
-}
-
-// ########## </Calendar> ##########
diff --git a/modules-available/locationinfo/exchange-includes/jamesiarmes/PhpEws/ArrayType/ArrayOfStringsType.php b/modules-available/locationinfo/exchange-includes/jamesiarmes/PhpEws/ArrayType/ArrayOfStringsType.php
index 6443d31d..28792929 100644
--- a/modules-available/locationinfo/exchange-includes/jamesiarmes/PhpEws/ArrayType/ArrayOfStringsType.php
+++ b/modules-available/locationinfo/exchange-includes/jamesiarmes/PhpEws/ArrayType/ArrayOfStringsType.php
@@ -32,6 +32,6 @@ class ArrayOfStringsType extends ArrayType
*/
public function __toString()
{
- return $this->String;
+ return implode(' + ', $this->String);
}
}
diff --git a/modules-available/locationinfo/exchange-includes/jamesiarmes/PhpEws/Autodiscover.php b/modules-available/locationinfo/exchange-includes/jamesiarmes/PhpEws/Autodiscover.php
index 8198137d..8c60a4c8 100644
--- a/modules-available/locationinfo/exchange-includes/jamesiarmes/PhpEws/Autodiscover.php
+++ b/modules-available/locationinfo/exchange-includes/jamesiarmes/PhpEws/Autodiscover.php
@@ -891,6 +891,6 @@ class Autodiscover
protected function tryViaUrl($url, $timeout = 6)
{
$result = $this->doNTLMPost($url, $timeout);
- return ($result ? true : false);
+ return (bool)$result;
}
}
diff --git a/modules-available/locationinfo/exchange-includes/jamesiarmes/PhpNtlm/SoapClient.php b/modules-available/locationinfo/exchange-includes/jamesiarmes/PhpNtlm/SoapClient.php
index 21c77cbf..98f23dfa 100644
--- a/modules-available/locationinfo/exchange-includes/jamesiarmes/PhpNtlm/SoapClient.php
+++ b/modules-available/locationinfo/exchange-includes/jamesiarmes/PhpNtlm/SoapClient.php
@@ -26,6 +26,9 @@ class SoapClient extends \SoapClient
*/
protected $options;
+ protected $__last_response;
+ protected $__last_request_headers;
+
/**
* {@inheritdoc}
*
@@ -68,7 +71,7 @@ class SoapClient extends \SoapClient
/**
* {@inheritdoc}
*/
- public function __doRequest($request, $location, $action, $version, $one_way = 0)
+ public function __doRequest($request, $location, $action, $version, $oneWay = 0)
{
$headers = $this->buildHeaders($action);
$this->__last_request = $request;
diff --git a/modules-available/locationinfo/inc/coursebackend.inc.php b/modules-available/locationinfo/inc/coursebackend.inc.php
index 6e4d77ac..ea1bebac 100644
--- a/modules-available/locationinfo/inc/coursebackend.inc.php
+++ b/modules-available/locationinfo/inc/coursebackend.inc.php
@@ -39,7 +39,7 @@ abstract class CourseBackend
$this->errors = [];
}
- protected final function addError($message, $fatal)
+ protected final function addError(string $message, bool $fatal)
{
$this->errors[] = ['time' => time(), 'message' => $message, 'fatal' => $fatal];
}
@@ -55,7 +55,7 @@ abstract class CourseBackend
self::$backendTypes = array();
foreach (glob(dirname(__FILE__) . '/coursebackend/coursebackend_*.inc.php', GLOB_NOSORT) as $file) {
require_once $file;
- preg_match('#coursebackend_([^/\.]+)\.inc\.php$#i', $file, $out);
+ preg_match('#coursebackend_([^/.]+)\.inc\.php$#i', $file, $out);
$className = 'CourseBackend_' . $out[1];
if (!class_exists($className)) {
trigger_error("Backend type source unit $file doesn't seem to define class $className", E_USER_ERROR);
@@ -71,13 +71,13 @@ abstract class CourseBackend
*
* @return array list of backends
*/
- public static function getList()
+ public static function getList(): array
{
self::loadDb();
return array_keys(self::$backendTypes);
}
- public static function exists($backendType)
+ public static function exists($backendType): bool
{
self::loadDb();
return isset(self::$backendTypes[$backendType]);
@@ -89,7 +89,7 @@ abstract class CourseBackend
* @param string $backendType name of module type
* @return \CourseBackend|false module instance
*/
- public static function getInstance($backendType)
+ public static function getInstance(string $backendType)
{
self::loadDb();
if (!isset(self::$backendTypes[$backendType])) {
@@ -106,18 +106,18 @@ abstract class CourseBackend
/**
* @return string return display name of backend
*/
- public abstract function getDisplayName();
+ public abstract function getDisplayName(): string;
/**
* @returns \BackendProperty[] list of properties that need to be set
*/
- public abstract function getCredentialDefinitions();
+ public abstract function getCredentialDefinitions(): array;
/**
* @return boolean true if the connection works, false otherwise
*/
- public abstract function checkConnection();
+ public abstract function checkConnection(): bool;
/**
* uses json to setCredentials, the json must follow the form given in
@@ -126,42 +126,39 @@ abstract class CourseBackend
* @param array $data assoc array with data required by backend
* @returns bool if the credentials were in the correct format
*/
- public abstract function setCredentialsInternal($data);
+ public abstract function setCredentialsInternal(array $data): bool;
/**
* @return int desired caching time of results, in seconds. 0 = no caching
*/
- public abstract function getCacheTime();
+ public abstract function getCacheTime(): int;
/**
* @return int age after which timetables are no longer refreshed should be
* greater then CacheTime
*/
- public abstract function getRefreshTime();
+ public abstract function getRefreshTime(): int;
/**
* Internal version of fetch, to be overridden by subclasses.
*
- * @param $roomIds array with remote IDs for wanted rooms
+ * @param $requestedRoomIds array with remote IDs for wanted rooms
* @return array a recursive array that uses the roomID as key
* and has the schedule array as value. A schedule array contains an array in this format:
* ["start"=>'JJJJ-MM-DD"T"HH:MM:SS',"end"=>'JJJJ-MM-DD"T"HH:MM:SS',"title"=>string]
*/
- protected abstract function fetchSchedulesInternal($roomId);
+ protected abstract function fetchSchedulesInternal(array $requestedRoomIds): array;
/**
* In case you want to sanitize or otherwise mangle a property for your backend,
* override this.
- * @param string $prop
- * @param $value
- * @return mixed
*/
- public function mangleProperty($prop, $value)
+ public function mangleProperty(string $prop, $value)
{
return $value;
}
- private static function fixTime(&$start, &$end)
+ private static function fixTime(string &$start, string &$end): bool
{
if (!preg_match('/^(\d{2}|\d{4})-?\d{2}-?\d{2}-?T\d{1,2}:?\d{2}:?(\d{2})?$/', $start))
return false;
@@ -178,14 +175,10 @@ abstract class CourseBackend
* Method for fetching the schedule of the given rooms on a server.
*
* @param array $requestedLocationIds array of room ID to fetch
- * @return array|bool array containing the timetables as value and roomid as key as result, or false on error
+ * @return array array containing the timetables as value and roomid as key as result, or false on error
*/
- public final function fetchSchedule($requestedLocationIds)
+ public final function fetchSchedule(array $requestedLocationIds): array
{
- if (!is_array($requestedLocationIds)) {
- $this->addError('No array of roomids was given to fetchSchedule', false);
- return false;
- }
if (empty($requestedLocationIds))
return array();
$requestedLocationIds = array_values($requestedLocationIds);
@@ -195,7 +188,7 @@ abstract class CourseBackend
array('locations' => $requestedLocationIds));
$returnValue = [];
$remoteIds = [];
- while ($row = $dbquery1->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($dbquery1 as $row) {
// Check if in cache - if lastUpdate is null then it is interpreted as 1970
if ($row['lastcalendarupdate'] + $this->getCacheTime() < $NOW) {
$remoteIds[$row['locationid']] = $row['serverlocationid'];
@@ -222,7 +215,7 @@ abstract class CourseBackend
'lastuse' => $NOW - $this->getRefreshTime(),
'minage' => $NOW - $this->getCacheTime(),
));
- while ($row = $dbquery4->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($dbquery4 as $row) {
$remoteIds[$row['locationid']] = $row['serverlocationid'];
}
}
@@ -239,9 +232,6 @@ abstract class CourseBackend
]);
}
$backendResponse = $this->fetchSchedulesInternal(array_unique($remoteIds));
- if ($backendResponse === false) {
- return false;
- }
// Fetching might have taken a while, get current time again
$NOW = time();
@@ -282,7 +272,7 @@ abstract class CourseBackend
return $returnValue;
}
- public final function setCredentials($serverId, $data)
+ public final function setCredentials(int $serverId, array $data): bool
{
foreach ($this->getCredentialDefinitions() as $prop) {
if (!isset($data[$prop->property])) {
@@ -302,18 +292,9 @@ abstract class CourseBackend
}
/**
- * @return false if there was no error string with error message if there was one
- */
- public final function getError()
- {
- trigger_error('getError() is legacy; use getErrors()');
- return $this->error;
- }
-
- /**
* @return array list of errors that occurred during processing.
*/
- public final function getErrors()
+ public final function getErrors(): array
{
return $this->errors;
}
@@ -378,7 +359,7 @@ abstract class CourseBackend
* @param string $response xml document to convert
* @return bool|array array representation of the xml if possible, false otherwise
*/
- protected function xmlStringToArray($response, &$error)
+ protected function xmlStringToArray(string $response, &$error)
{
$cleanresponse = preg_replace('/(<\/?)(\w+):([^>]*>)/', '$1$2$3', $response);
try {
@@ -390,8 +371,7 @@ abstract class CourseBackend
}
return false;
}
- $array = json_decode(json_encode((array)$xml), true);
- return $array;
+ return json_decode(json_encode((array)$xml), true);
}
}
@@ -414,7 +394,6 @@ class BackendProperty {
* Initialize additional fields of this class that are only required
* for rendering the server configuration dialog.
*
- * @param string $backendId target backend id
* @param mixed $current current value of this property.
*/
public function initForRender($current = null) {
diff --git a/modules-available/locationinfo/inc/coursebackend/coursebackend_davinci.inc.php b/modules-available/locationinfo/inc/coursebackend/coursebackend_davinci.inc.php
index 07c8457d..786ab459 100644
--- a/modules-available/locationinfo/inc/coursebackend/coursebackend_davinci.inc.php
+++ b/modules-available/locationinfo/inc/coursebackend/coursebackend_davinci.inc.php
@@ -11,7 +11,7 @@ class CourseBackend_Davinci extends CourseBackend
*/
private $curlHandle = false;
- public function setCredentialsInternal($data)
+ public function setCredentialsInternal(array $data): bool
{
if (empty($data['baseUrl'])) {
$this->addError("No url is given", true);
@@ -24,7 +24,7 @@ class CourseBackend_Davinci extends CourseBackend
return true;
}
- public function checkConnection()
+ public function checkConnection(): bool
{
if (empty($this->location)) {
$this->addError("Credentials are not set", true);
@@ -40,7 +40,7 @@ class CourseBackend_Davinci extends CourseBackend
return true;
}
- public function getCredentialDefinitions()
+ public function getCredentialDefinitions(): array
{
return [
new BackendProperty('baseUrl', 'string'),
@@ -49,17 +49,17 @@ class CourseBackend_Davinci extends CourseBackend
];
}
- public function getDisplayName()
+ public function getDisplayName(): string
{
return 'Davinci';
}
- public function getCacheTime()
+ public function getCacheTime(): int
{
return 30 * 60;
}
- public function getRefreshTime()
+ public function getRefreshTime(): int
{
return 0;
}
@@ -68,9 +68,9 @@ class CourseBackend_Davinci extends CourseBackend
* @param string $roomId unique name of the room, as used by davinci
* @param \DateTime $startDate start date to fetch
* @param \DateTime $endDate end date of range to fetch
- * @return array|bool if successful the arrayrepresentation of the timetable
+ * @return false|string if successful the array representation of the timetable
*/
- private function fetchRoomRaw($roomId, $startDate, $endDate)
+ private function fetchRoomRaw(string $roomId, DateTime $startDate, DateTime $endDate)
{
$url = $this->location . "content=xml&type=room&name=" . urlencode($roomId)
. "&startdate=" . $startDate->format('d.m.Y') . "&enddate=" . $endDate->format('d.m.Y');
@@ -97,7 +97,7 @@ class CourseBackend_Davinci extends CourseBackend
}
- public function fetchSchedulesInternal($requestedRoomIds)
+ public function fetchSchedulesInternal(array $requestedRoomIds): array
{
$startDate = new DateTime('last Monday 0:00');
$endDate = new DateTime('+14 days 0:00');
@@ -134,7 +134,7 @@ class CourseBackend_Davinci extends CourseBackend
$start = substr($start, 0, 2) . ':' . substr($start, 2, 2);
$end = $lesson['Finish'];
$end = substr($end, 0, 2) . ':' . substr($end, 2, 2);
- $subject = isset($lesson['Subject']) ? $lesson['Subject'] : '???';
+ $subject = $lesson['Subject'] ?? '???';
$timetable[] = array(
'title' => $subject,
'start' => $date . "T" . $start . ':00',
diff --git a/modules-available/locationinfo/inc/coursebackend/coursebackend_dummy.inc.php b/modules-available/locationinfo/inc/coursebackend/coursebackend_dummy.inc.php
index 2cb2be18..4588bf7c 100644
--- a/modules-available/locationinfo/inc/coursebackend/coursebackend_dummy.inc.php
+++ b/modules-available/locationinfo/inc/coursebackend/coursebackend_dummy.inc.php
@@ -15,9 +15,9 @@ class CourseBackend_Dummy extends CourseBackend
* @param int $serverId ID of the server
* @returns bool if the credentials were in the correct format
*/
- public function setCredentialsInternal($json)
+ public function setCredentialsInternal(array $data): bool
{
- $x = $json;
+ $x = $data;
$this->pw = $x['password'];
if ($this->pw === "mfg") {
@@ -30,7 +30,7 @@ class CourseBackend_Dummy extends CourseBackend
/**
* @return boolean true if the connection works, false otherwise
*/
- public function checkConnection()
+ public function checkConnection(): bool
{
if ($this->pw == "mfg") {
return true;
@@ -42,7 +42,7 @@ class CourseBackend_Dummy extends CourseBackend
/**
* @returns array with parameter name as key and and an array with type, help text and mask as value
*/
- public function getCredentialDefinitions()
+ public function getCredentialDefinitions(): array
{
$options = ["opt1", "opt2", "opt3", "opt4", "opt5", "opt6", "opt7", "opt8"];
return [
@@ -58,7 +58,7 @@ class CourseBackend_Dummy extends CourseBackend
/**
* @return string return display name of backend
*/
- public function getDisplayName()
+ public function getDisplayName(): string
{
return 'Dummy with array';
}
@@ -66,7 +66,7 @@ class CourseBackend_Dummy extends CourseBackend
/**
* @return int desired caching time of results, in seconds. 0 = no caching
*/
- public function getCacheTime()
+ public function getCacheTime(): int
{
return 0;
}
@@ -75,7 +75,7 @@ class CourseBackend_Dummy extends CourseBackend
* @return int age after which timetables are no longer refreshed should be
* greater then CacheTime
*/
- public function getRefreshTime()
+ public function getRefreshTime(): int
{
return 0;
}
@@ -83,15 +83,15 @@ class CourseBackend_Dummy extends CourseBackend
/**
* Internal version of fetch, to be overridden by subclasses.
*
- * @param $roomIds array with local ID as key and serverId as value
+ * @param $requestedRoomIds array with local ID as key and serverId as value
* @return array a recursive array that uses the roomID as key
* and has the schedule array as value. A schedule array contains an array in this format:
* ["start"=>'YYYY-MM-DD<T>HH:MM:SS',"end"=>'YYYY-MM-DD<T>HH:MM:SS',"title"=>string]
*/
- public function fetchSchedulesInternal($roomId)
+ public function fetchSchedulesInternal(array $requestedRoomIds): array
{
$a = array();
- foreach ($roomId as $id) {
+ foreach ($requestedRoomIds as $id) {
if ($id == 1) {
$now = time();
return array($id => array(
diff --git a/modules-available/locationinfo/inc/coursebackend/coursebackend_exchange.inc.php b/modules-available/locationinfo/inc/coursebackend/coursebackend_exchange.inc.php
index 44847ce2..df33dadd 100755
--- a/modules-available/locationinfo/inc/coursebackend/coursebackend_exchange.inc.php
+++ b/modules-available/locationinfo/inc/coursebackend/coursebackend_exchange.inc.php
@@ -12,6 +12,7 @@ spl_autoload_register(function ($class) {
require_once $file;
});
+use jamesiarmes\PhpEws\ArrayType\NonEmptyArrayOfBaseFolderIdsType;
use jamesiarmes\PhpEws\Client;
use jamesiarmes\PhpEws\Enumeration\DefaultShapeNamesType;
use jamesiarmes\PhpEws\Enumeration\DistinguishedFolderIdNameType;
@@ -38,7 +39,7 @@ class CourseBackend_Exchange extends CourseBackend
/**
* @return string return display name of backend
*/
- public function getDisplayName()
+ public function getDisplayName(): string
{
return "Microsoft Exchange";
}
@@ -46,7 +47,7 @@ class CourseBackend_Exchange extends CourseBackend
/**
* @returns \BackendProperty[] list of properties that need to be set
*/
- public function getCredentialDefinitions()
+ public function getCredentialDefinitions(): array
{
$options = [
Client::VERSION_2007,
@@ -72,7 +73,7 @@ class CourseBackend_Exchange extends CourseBackend
/**
* @return boolean true if the connection works, false otherwise
*/
- public function checkConnection()
+ public function checkConnection(): bool
{
$client = $this->getClient();
$request = new ResolveNamesType();
@@ -104,7 +105,7 @@ class CourseBackend_Exchange extends CourseBackend
* @param array $data assoc array with data required by backend
* @returns bool if the credentials were in the correct format
*/
- public function setCredentialsInternal($data)
+ public function setCredentialsInternal(array $data): bool
{
foreach (['username', 'password'] as $field) {
if (empty($data[$field])) {
@@ -133,7 +134,7 @@ class CourseBackend_Exchange extends CourseBackend
/**
* @return int desired caching time of results, in seconds. 0 = no caching
*/
- public function getCacheTime()
+ public function getCacheTime(): int
{
return 15 * 60;
}
@@ -142,7 +143,7 @@ class CourseBackend_Exchange extends CourseBackend
* @return int age after which timetables are no longer refreshed. should be
* greater than CacheTime.
*/
- public function getRefreshTime()
+ public function getRefreshTime(): int
{
return 30 * 60;
}
@@ -150,12 +151,12 @@ class CourseBackend_Exchange extends CourseBackend
/**
* Internal version of fetch, to be overridden by subclasses.
*
- * @param $roomIds array with local ID as key and serverId as value
+ * @param $requestedRoomIds array with local ID as key and serverId as value
* @return array a recursive array that uses the roomID as key
* and has the schedule array as value. A schedule array contains an array in this format:
* ["start"=>'JJJJ-MM-DD HH:MM:SS',"end"=>'JJJJ-MM-DD HH:MM:SS',"title"=>string]
*/
- protected function fetchSchedulesInternal($requestedRoomIds)
+ protected function fetchSchedulesInternal(array $requestedRoomIds): array
{
$startDate = new DateTime('last Monday 0:00');
$endDate = new DateTime('+14 days 0:00');
@@ -172,8 +173,13 @@ class CourseBackend_Exchange extends CourseBackend
// Iterate over the events that were found, printing some data for each.
foreach ($items as $item) {
- $start = new DateTime($item->Start);
- $end = new DateTime($item->End);
+ try {
+ $start = new DateTime($item->Start);
+ $end = new DateTime($item->End);
+ } catch (Exception $e) {
+ $this->addError("Invalid date range: '{$item->Start}' -> '{$item->End}'", false);
+ continue;
+ }
$schedules[$roomId][] = array(
'title' => $item->Subject,
@@ -186,13 +192,9 @@ class CourseBackend_Exchange extends CourseBackend
}
/**
- * @param \jamesiarmes\PhpEws\Client $client
- * @param \DateTime $startDate
- * @param \DateTime $endDate
- * @param string $roomAddress
* @return \jamesiarmes\PhpEws\Type\CalendarItemType[]
*/
- public function findEventsForRoom($client, $startDate, $endDate, $roomAddress)
+ public function findEventsForRoom(Client $client, DateTime $startDate, DateTime $endDate, string $roomAddress): array
{
$request = new FindItemType();
$request->Traversal = ItemQueryTraversalType::SHALLOW;
@@ -206,12 +208,20 @@ class CourseBackend_Exchange extends CourseBackend
$folderId->Id = DistinguishedFolderIdNameType::CALENDAR;
$folderId->Mailbox = new EmailAddressType();
$folderId->Mailbox->EmailAddress = $roomAddress;
+ $request->ParentFolderIds = new NonEmptyArrayOfBaseFolderIdsType();
$request->ParentFolderIds->DistinguishedFolderId[] = $folderId;
- $response = $client->FindItem($request);
- $response_messages = $response->ResponseMessages->FindItemResponseMessage;
-
+ try {
+ $response = $client->FindItem($request);
+ } catch (Exception $e) {
+ $this->addError('Exception calling FindItem: ' . $e->getMessage(), true);
+ return [];
+ }
+ if (!is_object($response->ResponseMessages)) {
+ $this->addError('FindItem returned response without ResponseMessages', true);
+ return [];
+ }
$items = [];
- foreach ($response_messages as $response_message) {
+ foreach ($response->ResponseMessages->FindItemResponseMessage as $response_message) {
// Make sure the request succeeded.
if ($response_message->ResponseClass !== ResponseClassType::SUCCESS) {
$code = $response_message->ResponseCode;
@@ -224,10 +234,7 @@ class CourseBackend_Exchange extends CourseBackend
return $items;
}
- /**
- * @return \jamesiarmes\PhpEws\Client
- */
- public function getClient()
+ public function getClient(): Client
{
$client = new Client($this->serverAddress, $this->username, $this->password, $this->clientVersion);
$client->setTimezone($this->timezone);
diff --git a/modules-available/locationinfo/inc/coursebackend/coursebackend_hisinone.inc.php b/modules-available/locationinfo/inc/coursebackend/coursebackend_hisinone.inc.php
index 8bd18169..55d5ed4b 100644
--- a/modules-available/locationinfo/inc/coursebackend/coursebackend_hisinone.inc.php
+++ b/modules-available/locationinfo/inc/coursebackend/coursebackend_hisinone.inc.php
@@ -3,7 +3,7 @@
class CourseBackend_HisInOne extends ICalCourseBackend
{
- public function setCredentialsInternal($data)
+ public function setCredentialsInternal(array $data): bool
{
if (empty($data['baseUrl'])) {
$this->addError("No url is given", true);
@@ -16,7 +16,7 @@ class CourseBackend_HisInOne extends ICalCourseBackend
return true;
}
- public function getCredentialDefinitions()
+ public function getCredentialDefinitions(): array
{
return [
new BackendProperty('baseUrl', 'string'),
@@ -25,7 +25,7 @@ class CourseBackend_HisInOne extends ICalCourseBackend
];
}
- public function mangleProperty($prop, $value)
+ public function mangleProperty(string $prop, $value)
{
if ($prop === 'baseUrl') {
// Update form SOAP to iCal url
@@ -43,7 +43,15 @@ class CourseBackend_HisInOne extends ICalCourseBackend
return $value;
}
- public function checkConnection()
+ protected function toTitle(ICalEvent $event): string
+ {
+ $title = parent::toTitle($event);
+ // His in one seems to prefix *some* (but *not* all) of the lectures by their ID/("Nummer")
+ // No clue what that format is supposed to be, this regex is some guesswork after observing this for a while
+ return preg_replace('#^[0-9][0-9A-ZÄÖÜ]{3,9}-[A-Za-z0-9/_ÄÖÜäöüß.-]{4,30}\s+#u', '', $title);
+ }
+
+ public function checkConnection(): bool
{
if (!$this->isOK())
return false;
@@ -57,18 +65,18 @@ class CourseBackend_HisInOne extends ICalCourseBackend
return false;
}
- public function getCacheTime()
+ public function getCacheTime(): int
{
return 30 * 60;
}
- public function getRefreshTime()
+ public function getRefreshTime(): int
{
return 60 * 60;
}
- public function getDisplayName()
+ public function getDisplayName(): string
{
return "HisInOne";
}
diff --git a/modules-available/locationinfo/inc/coursebackend/coursebackend_ical.inc.php b/modules-available/locationinfo/inc/coursebackend/coursebackend_ical.inc.php
index 98dca1cb..f1791c4e 100644
--- a/modules-available/locationinfo/inc/coursebackend/coursebackend_ical.inc.php
+++ b/modules-available/locationinfo/inc/coursebackend/coursebackend_ical.inc.php
@@ -6,7 +6,7 @@ class CourseBackend_ICal extends ICalCourseBackend
/** @var string room ID for testing connection */
private $testId;
- public function setCredentialsInternal($data)
+ public function setCredentialsInternal(array $data): bool
{
if (empty($data['baseUrl'])) {
$this->addError("No url is given", true);
@@ -20,7 +20,7 @@ class CourseBackend_ICal extends ICalCourseBackend
return true;
}
- public function getCredentialDefinitions()
+ public function getCredentialDefinitions(): array
{
return [
new BackendProperty('baseUrl', 'string'),
@@ -33,7 +33,7 @@ class CourseBackend_ICal extends ICalCourseBackend
];
}
- public function checkConnection()
+ public function checkConnection(): bool
{
if (!$this->isOK())
return false;
@@ -42,18 +42,18 @@ class CourseBackend_ICal extends ICalCourseBackend
return ($this->downloadIcal($this->testId) !== null);
}
- public function getCacheTime()
+ public function getCacheTime(): int
{
return 30 * 60;
}
- public function getRefreshTime()
+ public function getRefreshTime(): int
{
return 60 * 60;
}
- public function getDisplayName()
+ public function getDisplayName(): string
{
return "iCal";
}
diff --git a/modules-available/locationinfo/inc/icalcoursebackend.inc.php b/modules-available/locationinfo/inc/icalcoursebackend.inc.php
index fba0866c..838d18b7 100644
--- a/modules-available/locationinfo/inc/icalcoursebackend.inc.php
+++ b/modules-available/locationinfo/inc/icalcoursebackend.inc.php
@@ -18,18 +18,9 @@ abstract class ICalCourseBackend extends CourseBackend
/** @var bool|resource */
private $curlHandle = false;
- /**
- * Initialize values
- *
- * @param string $location
- * @param bool $verifyCert
- * @param bool $verifyHostname
- * @param string $authMethod
- * @param string $user
- * @param string $pass
- */
- protected function init($location, $verifyCert, $verifyHostname,
- $authMethod = 'NONE', $user = '', $pass = '')
+ protected function init(
+ string $location, bool $verifyCert, bool $verifyHostname,
+ string $authMethod = 'NONE', string $user = '', string $pass = '')
{
$this->verifyCert = $verifyCert;
$this->verifyHostname = $verifyHostname;
@@ -43,19 +34,26 @@ abstract class ICalCourseBackend extends CourseBackend
}
/**
- * @param int $roomId room id
+ * @param string $roomId room id
* @param callable $errorFunc
* @return ICalEvent[]|null all events for this room in the range -7 days to +7 days, or NULL on error
*/
- protected function downloadIcal($roomId)
+ protected function downloadIcal(string $roomId): ?array
{
if (!$this->isOK())
return null;
+
+ try {
+ $ical = new ICalParser(['filterDaysBefore' => 7, 'filterDaysAfter' => 7]);
+ } catch (Exception $e) {
+ $this->addError('Error instantiating ICalParser: ' . $e->getMessage(), true);
+ return null;
+ }
+
if ($this->curlHandle === false) {
$this->curlHandle = curl_init();
}
- $ical = new ICalParser(['filterDaysBefore' => 7, 'filterDaysAfter' => 7]);
$options = [
CURLOPT_WRITEFUNCTION => function ($ch, $data) use ($ical) {
$ical->feedData($data);
@@ -92,7 +90,7 @@ abstract class ICalCourseBackend extends CourseBackend
return $ical->events();
}
- public function fetchSchedulesInternal($requestedRoomIds): array
+ public function fetchSchedulesInternal(array $requestedRoomIds): array
{
if (empty($requestedRoomIds) || !$this->isOK()) {
return array();
@@ -118,9 +116,8 @@ abstract class ICalCourseBackend extends CourseBackend
/**
* Get a usable title from either SUMMARY or DESCRIPTION
- * @param ICalEvent $event
*/
- private function toTitle($event): string
+ protected function toTitle(ICalEvent $event): string
{
$title = $event->summary;
if (empty($title)) {
diff --git a/modules-available/locationinfo/inc/icalevent.inc.php b/modules-available/locationinfo/inc/icalevent.inc.php
index 1fb586ee..c5aea349 100644
--- a/modules-available/locationinfo/inc/icalevent.inc.php
+++ b/modules-available/locationinfo/inc/icalevent.inc.php
@@ -2,199 +2,253 @@
class ICalEvent
{
- // phpcs:disable Generic.Arrays.DisallowLongArraySyntax
-
- const HTML_TEMPLATE = '<p>%s: %s</p>';
-
- /**
- * https://www.kanzaki.com/docs/ical/summary.html
- *
- * @var $summary
- */
- public $summary;
-
- /**
- * https://www.kanzaki.com/docs/ical/dtstart.html
- *
- * @var $dtstart
- */
- public $dtstart;
-
- /**
- * https://www.kanzaki.com/docs/ical/dtend.html
- *
- * @var $dtend
- */
- public $dtend;
-
- /**
- * https://www.kanzaki.com/docs/ical/duration.html
- *
- * @var $duration
- */
- public $duration;
-
- /**
- * https://www.kanzaki.com/docs/ical/dtstamp.html
- *
- * @var $dtstamp
- */
- public $dtstamp;
-
- /**
- * https://www.kanzaki.com/docs/ical/uid.html
- *
- * @var $uid
- */
- public $uid;
-
- /**
- * https://www.kanzaki.com/docs/ical/created.html
- *
- * @var $created
- */
- public $created;
-
- /**
- * https://www.kanzaki.com/docs/ical/lastModified.html
- *
- * @var $lastmodified
- */
- public $lastmodified;
-
- /**
- * https://www.kanzaki.com/docs/ical/description.html
- *
- * @var $description
- */
- public $description;
-
- /**
- * https://www.kanzaki.com/docs/ical/location.html
- *
- * @var $location
- */
- public $location;
-
- /**
- * https://www.kanzaki.com/docs/ical/sequence.html
- *
- * @var $sequence
- */
- public $sequence;
-
- /**
- * https://www.kanzaki.com/docs/ical/status.html
- *
- * @var $status
- */
- public $status;
-
- /**
- * https://www.kanzaki.com/docs/ical/transp.html
- *
- * @var $transp
- */
- public $transp;
-
- /**
- * https://www.kanzaki.com/docs/ical/organizer.html
- *
- * @var $organizer
- */
- public $organizer;
-
- /**
- * https://www.kanzaki.com/docs/ical/attendee.html
- *
- * @var $attendee
- */
- public $attendee;
-
- /**
- * Creates the Event object
- *
- * @param array $data
- * @return void
- */
- public function __construct(array $data = array())
- {
- foreach ($data as $key => $value) {
- $variable = self::snakeCase($key);
- $this->{$variable} = self::prepareData($value);
- }
- }
-
- /**
- * Prepares the data for output
- *
- * @param mixed $value
- * @return mixed
- */
- protected function prepareData($value)
- {
- if (is_string($value)) {
- return stripslashes(trim(str_replace('\n', "\n", $value)));
- } elseif (is_array($value)) {
- return array_map('self::prepareData', $value);
- }
-
- return $value;
- }
-
- /**
- * Returns Event data excluding anything blank
- * within an HTML template
- *
- * @param string $html HTML template to use
- * @return string
- */
- public function printData($html = self::HTML_TEMPLATE)
- {
- $data = array(
- 'SUMMARY' => $this->summary,
- 'DTSTART' => $this->dtstart,
- 'DTEND' => $this->dtend,
- 'DURATION' => $this->duration,
- 'DTSTAMP' => $this->dtstamp,
- 'UID' => $this->uid,
- 'CREATED' => $this->created,
- 'LAST-MODIFIED' => $this->lastmodified,
- 'DESCRIPTION' => $this->description,
- 'LOCATION' => $this->location,
- 'SEQUENCE' => $this->sequence,
- 'STATUS' => $this->status,
- 'TRANSP' => $this->transp,
- 'ORGANISER' => $this->organizer,
- 'ATTENDEE(S)' => $this->attendee,
- );
-
- // Remove any blank values
- $data = array_filter($data);
-
- $output = '';
-
- foreach ($data as $key => $value) {
- $output .= sprintf($html, $key, $value);
- }
-
- return $output;
- }
-
- /**
- * Converts the given input to snake_case
- *
- * @param string $input
- * @param string $glue
- * @param string $separator
- * @return string
- */
- protected static function snakeCase($input, $glue = '_', $separator = '-')
- {
- $input = preg_split('/(?<=[a-z])(?=[A-Z])/x', $input);
- $input = implode($glue, $input);
- $input = str_replace($separator, $glue, $input);
-
- return strtolower($input);
- }
+ // phpcs:disable Generic.Arrays.DisallowLongArraySyntax
+
+ const HTML_TEMPLATE = '<p>%s: %s</p>';
+
+ /**
+ * https://www.kanzaki.com/docs/ical/summary.html
+ *
+ * @var string
+ */
+ public $summary;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/dtstart.html
+ *
+ * @var string
+ */
+ public $dtstart;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/dtend.html
+ *
+ * @var string
+ */
+ public $dtend;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/duration.html
+ *
+ * @var string
+ */
+ public $duration;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/dtstamp.html
+ *
+ * @var string
+ */
+ public $dtstamp;
+
+ /**
+ * When the event starts, represented as a timezone-adjusted string
+ *
+ * @var string
+ */
+ public $dtstart_tz;
+
+ /**
+ * When the event ends, represented as a timezone-adjusted string
+ *
+ * @var string
+ */
+ public $dtend_tz;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/uid.html
+ *
+ * @var string
+ */
+ public $uid;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/created.html
+ *
+ * @var string
+ */
+ public $created;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/lastModified.html
+ *
+ * @var string
+ */
+ public $last_modified;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/description.html
+ *
+ * @var string
+ */
+ public $description;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/location.html
+ *
+ * @var string
+ */
+ public $location;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/sequence.html
+ *
+ * @var string
+ */
+ public $sequence;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/status.html
+ *
+ * @var string
+ */
+ public $status;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/transp.html
+ *
+ * @var string
+ */
+ public $transp;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/organizer.html
+ *
+ * @var string
+ */
+ public $organizer;
+
+ /**
+ * https://www.kanzaki.com/docs/ical/attendee.html
+ *
+ * @var string
+ */
+ public $attendee;
+
+ /**
+ * Manage additional properties
+ *
+ * @var array<string, mixed>
+ */
+ private $additionalProperties = array();
+
+ /**
+ * Creates the Event object
+ *
+ * @param array $data
+ * @return void
+ */
+ public function __construct(array $data = array())
+ {
+ foreach ($data as $key => $value) {
+ $variable = self::snakeCase($key);
+ if (property_exists($this, $variable)) {
+ $this->{$variable} = $this->prepareData($value);
+ } else {
+ $this->additionalProperties[$variable] = $this->prepareData($value);
+ }
+ }
+ }
+
+ /**
+ * Magic getter method
+ *
+ * @param string $additionalPropertyName
+ * @return mixed
+ */
+ public function __get(string $additionalPropertyName)
+ {
+ if (array_key_exists($additionalPropertyName, $this->additionalProperties)) {
+ return $this->additionalProperties[$additionalPropertyName];
+ }
+
+ return null;
+ }
+
+ /**
+ * Magic isset method
+ */
+ public function __isset(string $name): bool
+ {
+ return is_null($this->$name) === false;
+ }
+
+ /**
+ * Prepares the data for output
+ *
+ * @param mixed $value
+ * @return mixed
+ */
+ protected function prepareData($value)
+ {
+ if (is_string($value)) {
+ return stripslashes(trim(str_replace('\n', "\n", $value)));
+ }
+
+ if (is_array($value)) {
+ return array_map(function ($value) {
+ return $this->prepareData($value);
+ }, $value);
+ }
+
+ return $value;
+ }
+
+ /**
+ * Returns Event data excluding anything blank
+ * within an HTML template
+ *
+ * @param string $html HTML template to use
+ * @return string
+ */
+ public function printData($html = self::HTML_TEMPLATE)
+ {
+ $data = array(
+ 'SUMMARY' => $this->summary,
+ 'DTSTART' => $this->dtstart,
+ 'DTEND' => $this->dtend,
+ 'DTSTART_TZ' => $this->dtstart_tz,
+ 'DTEND_TZ' => $this->dtend_tz,
+ 'DURATION' => $this->duration,
+ 'DTSTAMP' => $this->dtstamp,
+ 'UID' => $this->uid,
+ 'CREATED' => $this->created,
+ 'LAST-MODIFIED' => $this->last_modified,
+ 'DESCRIPTION' => $this->description,
+ 'LOCATION' => $this->location,
+ 'SEQUENCE' => $this->sequence,
+ 'STATUS' => $this->status,
+ 'TRANSP' => $this->transp,
+ 'ORGANISER' => $this->organizer,
+ 'ATTENDEE(S)' => $this->attendee,
+ );
+
+ // Remove any blank values
+ $data = array_filter($data);
+
+ $output = '';
+
+ foreach ($data as $key => $value) {
+ $output .= sprintf($html, $key, $value);
+ }
+
+ return $output;
+ }
+
+ /**
+ * Converts the given input to snake_case
+ *
+ * @param string $input
+ * @param string $glue
+ * @param string $separator
+ * @return string
+ */
+ protected static function snakeCase($input, $glue = '_', $separator = '-')
+ {
+ $input = preg_split('/(?<=[a-z])(?=[A-Z])/x', $input);
+ $input = implode($glue, $input);
+ $input = str_replace($separator, $glue, $input);
+
+ return strtolower($input);
+ }
}
diff --git a/modules-available/locationinfo/inc/icalparser.inc.php b/modules-available/locationinfo/inc/icalparser.inc.php
index 0be8777b..eacb67b1 100644
--- a/modules-available/locationinfo/inc/icalparser.inc.php
+++ b/modules-available/locationinfo/inc/icalparser.inc.php
@@ -23,7 +23,6 @@ class ICalParser
const ICAL_DATE_TIME_TEMPLATE = 'TZID=%s:';
const ISO_8601_WEEK_START = 'MO';
const RECURRENCE_EVENT = 'Generated recurrence event';
- const TIME_FORMAT = 'His';
const TIME_ZONE_UTC = 'UTC';
const UNIX_FORMAT = 'U';
@@ -481,15 +480,12 @@ class ICalParser
/**
* Creates the ICal object
*
- * @param mixed $files
* @param array $options
* @return void
* @throws Exception
*/
public function __construct(array $options = array())
{
- ini_set('auto_detect_line_endings', '1');
-
foreach ($options as $option => $value) {
if (in_array($option, self::$configurableOptions)) {
$this->{$option} = $value;
@@ -501,10 +497,7 @@ class ICalParser
$this->defaultTimeZone = date_default_timezone_get();
}
- // Ideally you would use `PHP_INT_MIN` from PHP 7
- $php_int_min = -2147483648;
-
- $this->windowMinTimestamp = is_null($this->filterDaysBefore) ? $php_int_min : (new DateTime('now'))->sub(new DateInterval('P' . $this->filterDaysBefore . 'D'))->getTimestamp();
+ $this->windowMinTimestamp = is_null($this->filterDaysBefore) ? PHP_INT_MIN : (new DateTime('now'))->sub(new DateInterval('P' . $this->filterDaysBefore . 'D'))->getTimestamp();
$this->windowMaxTimestamp = is_null($this->filterDaysAfter) ? PHP_INT_MAX : (new DateTime('now'))->add(new DateInterval('P' . $this->filterDaysAfter . 'D'))->getTimestamp();
$this->shouldFilterByWindow = !is_null($this->filterDaysBefore) || !is_null($this->filterDaysAfter);
@@ -516,7 +509,7 @@ class ICalParser
*
* @param string $data
*/
- public function feedData($data)
+ public function feedData(string $data)
{
$this->feedBuffer .= $data;
$start = 0;
@@ -581,7 +574,7 @@ class ICalParser
*
* @return bool
*/
- public function isValid()
+ public function isValid(): bool
{
return $this->hasSeenStart;
}
@@ -591,7 +584,7 @@ class ICalParser
*
* @param string $line
*/
- protected function handleLine($line)
+ protected function handleLine(string $line)
{
$line = rtrim($line); // Trim trailing whitespace
$line = $this->removeUnprintableChars($line);
@@ -602,18 +595,18 @@ class ICalParser
$add = $this->keyValueFromString($line);
- if ($add === false) {
+ if ($add === null) {
return;
}
- $keyword = $add[0];
+ $keyword = $add[0]; // string
$values = $add[1]; // May be an array containing multiple values
if (!is_array($values)) {
if (!empty($values)) {
$values = array($values); // Make an array as not already
$blankArray = array(); // Empty placeholder array
- array_push($values, $blankArray);
+ $values[] = $blankArray;
} else {
$values = array(); // Use blank array to ignore this line
}
@@ -752,16 +745,9 @@ class ICalParser
foreach ($events as $key => $anEvent) {
if ($anEvent === null) {
unset($events[$key]);
-
- continue;
- }
-
- if ($this->doesEventStartOutsideWindow($anEvent)) {
+ } elseif ($this->doesEventStartOutsideWindow($anEvent)) {
$this->eventCount--;
-
unset($events[$key]);
-
- continue;
}
}
@@ -776,7 +762,7 @@ class ICalParser
* @param array $event
* @return boolean
*/
- protected function doesEventStartOutsideWindow(array $event)
+ protected function doesEventStartOutsideWindow(array $event): bool
{
return !isset($event['DTSTART']) || !$this->isValidDate($event['DTSTART'])
|| $this->isOutOfRange($event['DTSTART'], $this->windowMinTimestamp, $this->windowMaxTimestamp);
@@ -790,7 +776,7 @@ class ICalParser
* @param integer $maxTimestamp
* @return boolean
*/
- protected function isOutOfRange($calendarDate, $minTimestamp, $maxTimestamp)
+ protected function isOutOfRange(string $calendarDate, int $minTimestamp, int $maxTimestamp): bool
{
$timestamp = strtotime(explode('T', $calendarDate)[0]);
@@ -798,36 +784,15 @@ class ICalParser
}
/**
- * Unfolds an iCal file in preparation for parsing
- * (https://icalendar.org/iCalendar-RFC-5545/3-1-content-lines.html)
- *
- * @param array $lines
- * @return array
- */
- protected function unfold(array $lines)
- {
- $string = implode(PHP_EOL, $lines);
- $string = preg_replace('/' . PHP_EOL . '[ \t]/', '', $string);
-
- $lines = explode(PHP_EOL, $string);
-
- return $lines;
- }
-
- /**
* Add one key and value pair to the `$this->cal` array
*
* @param string $component
- * @param string|boolean $keyword
- * @param string $value
+ * @param string $keyword
+ * @param string|string[] $value
* @return void
*/
- protected function addCalendarComponentWithKeyAndValue($component, $keyword, $value)
+ protected function addCalendarComponentWithKeyAndValue(string $component, string $keyword, $value)
{
- if ($keyword == false) {
- $keyword = $this->lastKeyword;
- }
-
switch ($component) {
case 'VALARM':
$key1 = 'VEVENT';
@@ -840,7 +805,7 @@ class ICalParser
if (is_array($value)) {
// Add array of properties to the end
- array_push($this->cal[$key1][$key2][$key3]["{$keyword}_array"], $value);
+ $this->cal[$key1][$key2][$key3]["{$keyword}_array"][] = $value;
} else {
if (!isset($this->cal[$key1][$key2][$key3][$keyword])) {
$this->cal[$key1][$key2][$key3][$keyword] = $value;
@@ -862,7 +827,7 @@ class ICalParser
if (is_array($value)) {
// Add array of properties to the end
- array_push($this->cal[$key1][$key2]["{$keyword}_array"], $value);
+ $this->cal[$key1][$key2]["{$keyword}_array"][] = $value;
} else {
if (!isset($this->cal[$key1][$key2][$keyword])) {
$this->cal[$key1][$key2][$keyword] = $value;
@@ -882,7 +847,7 @@ class ICalParser
if ($keyword === 'DURATION') {
try {
$duration = new DateInterval($value);
- array_push($this->cal[$key1][$key2]["{$keyword}_array"], $duration);
+ $this->cal[$key1][$key2]["{$keyword}_array"][] = $duration;
} catch (Exception $e) {
error_log('Ignoring invalid duration ' . $value);
}
@@ -928,6 +893,7 @@ class ICalParser
break;
}
+ // Remove?
$this->lastKeyword = $keyword;
}
@@ -935,9 +901,9 @@ class ICalParser
* Gets the key value pair from an iCal string
*
* @param string $text
- * @return array|boolean
+ * @return ?array
*/
- protected function keyValueFromString($text)
+ protected function keyValueFromString(string $text): ?array
{
$text = htmlspecialchars($text, ENT_NOQUOTES, 'UTF-8');
@@ -974,14 +940,14 @@ class ICalParser
}
if (count($matches) === 0) {
- return false;
+ return null;
}
- if (preg_match('/^([A-Z-]+)([;][\w\W]*)?$/', $matches[1])) {
+ if (preg_match('/^([A-Z-]+)(;[\w\W]*)?$/', $matches[1])) {
$matches = array_splice($matches, 1, 2); // Remove first match and re-align ordering
// Process properties
- if (preg_match('/([A-Z-]+)[;]([\w\W]*)/', $matches[0], $properties)) {
+ if (preg_match('/([A-Z-]+);([\w\W]*)/', $matches[0], $properties)) {
// Remove first match
array_shift($properties);
// Fix to ignore everything in keyword after a ; (e.g. Language, TZID, etc.)
@@ -1023,9 +989,8 @@ class ICalParser
}
return $matches;
- } else {
- return false; // Ignore this match
}
+ return null; // Ignore this match
}
/**
@@ -1034,7 +999,7 @@ class ICalParser
* @param string $icalDate
* @return DateTime
*/
- public function iCalDateToDateTime($icalDate)
+ public function iCalDateToDateTime(string $icalDate): DateTime
{
/**
* iCal times may be in 3 formats, (https://www.kanzaki.com/docs/ical/dateTime.html)
@@ -1091,7 +1056,7 @@ class ICalParser
* @param string $icalDate
* @return integer
*/
- public function iCalDateToUnixTimestamp($icalDate)
+ public function iCalDateToUnixTimestamp(string $icalDate): int
{
return $this->iCalDateToDateTime($icalDate)->getTimestamp();
}
@@ -1104,7 +1069,7 @@ class ICalParser
* @param string $format
* @return string|boolean
*/
- public function iCalDateWithTimeZone(array $event, $key, $format = self::DATE_TIME_FORMAT)
+ public function iCalDateWithTimeZone(array $event, string $key, string $format = self::DATE_TIME_FORMAT)
{
if (!isset($event["{$key}_array"]) || !isset($event[$key])) {
return false;
@@ -1141,7 +1106,6 @@ class ICalParser
if (empty($this->cal['VEVENT']))
return;
$events =& $this->cal['VEVENT'];
- $checks = null;
foreach ($events as $key => $anEvent) {
foreach (array('DTSTART', 'DTEND', 'RECURRENCE-ID') as $type) {
@@ -1175,23 +1139,20 @@ class ICalParser
$eventKeysToRemove = array();
foreach ($events as $key => $event) {
- $checks[] = !isset($event['RECURRENCE-ID']);
- $checks[] = isset($event['UID']);
- $checks[] = isset($event['UID']) && isset($this->alteredRecurrenceInstances[$event['UID']]);
+ $checks = !isset($event['RECURRENCE-ID'])
+ && isset($event['UID']) && isset($this->alteredRecurrenceInstances[$event['UID']]);
- if ((bool)array_product($checks)) {
+ if ($checks) {
$eventDtstartUnix = $this->iCalDateToUnixTimestamp($event['DTSTART_array'][3]);
// phpcs:ignore CustomPHPCS.ControlStructures.AssignmentInCondition
if (($alteredEventKey = array_search($eventDtstartUnix, $this->alteredRecurrenceInstances[$event['UID']])) !== false) {
$eventKeysToRemove[] = $alteredEventKey;
- $alteredEvent = array_replace_recursive($events[$key], $events[$alteredEventKey]);
+ $alteredEvent = array_replace_recursive($event, $events[$alteredEventKey]);
$this->alteredRecurrenceInstances[$event['UID']]['altered-event'] = array($key => $alteredEvent);
}
}
-
- unset($checks);
}
foreach ($eventKeysToRemove as $eventKeyToRemove) {
@@ -1568,7 +1529,7 @@ class ICalParser
* @param DateTime $initialDateTime
* @return array
*/
- protected function getDaysOfMonthMatchingByDayRRule(array $byDays, $initialDateTime)
+ protected function getDaysOfMonthMatchingByDayRRule(array $byDays, DateTime $initialDateTime): array
{
$matchingDays = array();
@@ -1628,7 +1589,7 @@ class ICalParser
* @param array $valuesList
* @return array
*/
- protected function filterValuesUsingBySetPosRRule(array $bySetPos, array $valuesList)
+ protected function filterValuesUsingBySetPosRRule(array $bySetPos, array $valuesList): array
{
$filteredMatches = array();
@@ -1688,7 +1649,7 @@ class ICalParser
*
* @return ICalEvent[]
*/
- public function events()
+ public function events(): array
{
if (empty($this->cal) || empty($this->cal['VEVENT']))
return [];
@@ -1706,9 +1667,9 @@ class ICalParser
*
* @return string
*/
- public function calendarName()
+ public function calendarName(): string
{
- return isset($this->cal['VCALENDAR']['X-WR-CALNAME']) ? $this->cal['VCALENDAR']['X-WR-CALNAME'] : '';
+ return $this->cal['VCALENDAR']['X-WR-CALNAME'] ?? '';
}
/**
@@ -1716,9 +1677,9 @@ class ICalParser
*
* @return string
*/
- public function calendarDescription()
+ public function calendarDescription(): string
{
- return isset($this->cal['VCALENDAR']['X-WR-CALDESC']) ? $this->cal['VCALENDAR']['X-WR-CALDESC'] : '';
+ return $this->cal['VCALENDAR']['X-WR-CALDESC'] ?? '';
}
/**
@@ -1727,7 +1688,7 @@ class ICalParser
* @param boolean $ignoreUtc
* @return string
*/
- public function calendarTimeZone($ignoreUtc = false)
+ public function calendarTimeZone(bool $ignoreUtc = false): ?string
{
if (isset($this->cal['VCALENDAR']['X-WR-TIMEZONE'])) {
$timeZone = $this->cal['VCALENDAR']['X-WR-TIMEZONE'];
@@ -1754,11 +1715,11 @@ class ICalParser
*
* @return array
*/
- public function freeBusyEvents()
+ public function freeBusyEvents(): array
{
$array = $this->cal;
- return isset($array['VFREEBUSY']) ? $array['VFREEBUSY'] : array();
+ return $array['VFREEBUSY'] ?? array();
}
/**
@@ -1784,7 +1745,7 @@ class ICalParser
* @return array
* @throws Exception
*/
- public function eventsFromRange($rangeStart = null, $rangeEnd = null)
+ public function eventsFromRange(string $rangeStart = null, string $rangeEnd = null): array
{
// Sort events before processing range
$events = $this->sortEventsWithOrder($this->events());
@@ -1857,7 +1818,7 @@ class ICalParser
* @param integer $sortOrder Either SORT_ASC, SORT_DESC, SORT_REGULAR, SORT_NUMERIC, SORT_STRING
* @return array
*/
- public function sortEventsWithOrder(array $events, $sortOrder = SORT_ASC)
+ public function sortEventsWithOrder(array $events, int $sortOrder = SORT_ASC): array
{
$extendedEvents = array();
$timestamp = array();
@@ -1878,7 +1839,7 @@ class ICalParser
* @param string $timeZone
* @return boolean
*/
- protected function isValidTimeZoneId($timeZone)
+ protected function isValidTimeZoneId(string $timeZone): bool
{
return $this->isValidIanaTimeZoneId($timeZone) !== false
|| $this->isValidCldrTimeZoneId($timeZone) !== false
@@ -1891,7 +1852,7 @@ class ICalParser
* @param string $timeZone
* @return boolean
*/
- protected function isValidIanaTimeZoneId($timeZone)
+ protected function isValidIanaTimeZoneId(string $timeZone): bool
{
if (in_array($timeZone, $this->validIanaTimeZones)) {
return true;
@@ -1923,7 +1884,7 @@ class ICalParser
* @param string $timeZone
* @return boolean
*/
- public function isValidCldrTimeZoneId($timeZone)
+ public function isValidCldrTimeZoneId(string $timeZone): bool
{
return array_key_exists(html_entity_decode($timeZone), self::$cldrTimeZonesMap);
}
@@ -1934,7 +1895,7 @@ class ICalParser
* @param string $timeZone
* @return boolean
*/
- public function isValidWindowsTimeZoneId($timeZone)
+ public function isValidWindowsTimeZoneId(string $timeZone): bool
{
return array_key_exists(html_entity_decode($timeZone), self::$windowsTimeZonesMap);
}
@@ -1942,12 +1903,9 @@ class ICalParser
/**
* Parses a duration and applies it to a date
*
- * @param string $date
- * @param DateInterval $duration
- * @param string $format
* @return integer|DateTime
*/
- protected function parseDuration($date, $duration, $format = self::UNIX_FORMAT)
+ protected function parseDuration(string $date, DateInterval $duration, ?string $format = self::UNIX_FORMAT)
{
$dateTime = date_create($date);
$dateTime->modify("{$duration->y} year");
@@ -1974,7 +1932,7 @@ class ICalParser
* @param string $data
* @return string
*/
- protected function removeUnprintableChars($data)
+ protected function removeUnprintableChars(string $data): string
{
return preg_replace('/[\x00-\x1F\x7F\xA0]/u', '', $data);
}
@@ -1986,7 +1944,7 @@ class ICalParser
* @param string $candidateText
* @return string
*/
- protected function escapeParamText($candidateText)
+ protected function escapeParamText(string $candidateText): string
{
if (strpbrk($candidateText, ':;,') !== false) {
return '"' . $candidateText . '"';
@@ -2002,13 +1960,12 @@ class ICalParser
* @param array $event
* @return array
*/
- public function parseExdates(array $event)
+ public function parseExdates(array $event): array
{
if (empty($event['EXDATE_array'])) {
return array();
- } else {
- $exdates = $event['EXDATE_array'];
}
+ $exdates = $event['EXDATE_array'];
$output = array();
$currentTimeZone = $this->defaultTimeZone;
@@ -2046,7 +2003,7 @@ class ICalParser
* @param string $value
* @return boolean
*/
- public function isValidDate($value)
+ public function isValidDate(string $value): bool
{
if (!$value) {
return false;
@@ -2065,10 +2022,10 @@ class ICalParser
* Returns a `DateTimeZone` object based on a string containing a time zone name.
* Falls back to the default time zone if string passed not a recognised time zone.
*
- * @param string $timeZoneString
+ * @param DateTimeZone|string $timeZoneString
* @return DateTimeZone
*/
- public function timeZoneStringToDateTimeZone($timeZoneString)
+ public function timeZoneStringToDateTimeZone($timeZoneString): DateTimeZone
{
if ($timeZoneString instanceof DateTimeZone)
return $timeZoneString;
diff --git a/modules-available/locationinfo/inc/infopanel.inc.php b/modules-available/locationinfo/inc/infopanel.inc.php
index 6deb9db5..1a0e9b67 100644
--- a/modules-available/locationinfo/inc/infopanel.inc.php
+++ b/modules-available/locationinfo/inc/infopanel.inc.php
@@ -7,16 +7,16 @@ class InfoPanel
* Gets the config of the location.
*
* @param int $locationID ID of the location
- * @param mixed $config the panel config will be returned here
- * @return string|bool paneltype, false if not exists
+ * @param ?array $config the panel config will be returned here
+ * @return ?string panel type, null if not exists
*/
- public static function getConfig($paneluuid, &$config)
+ public static function getConfig(string $paneluuid, ?array &$config): ?string
{
$panel = Database::queryFirst('SELECT panelname, panelconfig, paneltype, locationids FROM locationinfo_panel WHERE paneluuid = :paneluuid',
compact('paneluuid'));
if ($panel === false) {
- return false;
+ return null;
}
$config = LocationInfo::defaultPanelConfig($panel['paneltype']);
@@ -87,13 +87,10 @@ class InfoPanel
* @param array $array location list to populate with machine data
* @param bool $withPosition Defines if coords should be included or not.
*/
- public static function appendMachineData(&$array, $idList = false, $withPosition = false, $withHostname = false)
+ public static function appendMachineData(array &$array, array $idList, bool $withPosition = false, bool $withHostname = false): void
{
- if (empty($array) && $idList === false)
+ if (empty($idList))
return;
- if ($idList === false) {
- $idList = array_keys($array);
- }
$ignoreList = array();
if (Module::isAvailable('runmode')) {
@@ -115,7 +112,7 @@ class InfoPanel
$dbquery = Database::simpleQuery($query, array('idlist' => $idList));
// Iterate over matching machines
- while ($row = $dbquery->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($dbquery as $row) {
if (isset($ignoreList[$row['machineuuid']]))
continue;
settype($row['locationid'], 'int');
@@ -165,7 +162,7 @@ class InfoPanel
* @param array $array list of locations, indexed by locationId
* @param int[] $idList list of locations
*/
- public static function appendOpeningTimes(&$array, $idList)
+ public static function appendOpeningTimes(array &$array, array $idList): void
{
// First, lets get all the parent ids for the given locations
// in case we need to get inherited opening times
@@ -175,7 +172,7 @@ class InfoPanel
$res = Database::simpleQuery("SELECT locationid, openingtime FROM location
WHERE locationid IN (:lids)", array('lids' => $allIds));
$openingTimes = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$openingTimes[(int)$row['locationid']] = $row;
}
// Now we got all the calendars for locations and parents
@@ -207,7 +204,6 @@ class InfoPanel
$currentId = $locations[$currentId]['parentlocationid'];
}
}
- return;
}
@@ -218,12 +214,12 @@ class InfoPanel
* @param int[] $idList location ids
* @return int[] more location ids
*/
- private static function getLocationsWithParents($idList)
+ private static function getLocationsWithParents(array $idList): array
{
$locations = Location::getLocationsAssoc();
$allIds = $idList;
foreach ($idList as $id) {
- if (isset($locations[$id]) && isset($locations[$id]['parents'])) {
+ if (isset($locations[$id]['parents'])) {
$allIds = array_merge($allIds, $locations[$id]['parents']);
}
}
@@ -239,9 +235,9 @@ class InfoPanel
* 'HourClose' => hh, 'MinutesClose' => mm }
*
* @param array $openingtime The opening time in the db saved format.
- * @return mixed The opening time in the frontend needed format.
+ * @return array The opening time in the frontend needed format.
*/
- private static function formatOpeningtime($openingtime)
+ private static function formatOpeningtime(array $openingtime): array
{
$result = array();
foreach ($openingtime as $entry) {
diff --git a/modules-available/locationinfo/inc/locationinfo.inc.php b/modules-available/locationinfo/inc/locationinfo.inc.php
index 6427cc1a..42829a18 100644
--- a/modules-available/locationinfo/inc/locationinfo.inc.php
+++ b/modules-available/locationinfo/inc/locationinfo.inc.php
@@ -7,14 +7,14 @@ class LocationInfo
* Gets the pc data and returns it's state.
*
* @param array $pc The pc data from the db. Array('state' => xx, 'lastseen' => xxx)
- * @return int pc state
+ * @return string pc state
*/
- public static function getPcState($pc)
+ public static function getPcState(array $pc): string
{
$lastseen = (int)$pc['lastseen'];
$NOW = time();
- if ($pc['state'] === 'OFFLINE' && $NOW - $lastseen > 21 * 86400) {
+ if ($pc['state'] === 'OFFLINE' && $NOW - $lastseen > 30 * 86400) {
return "BROKEN";
}
return $pc['state'];
@@ -22,11 +22,12 @@ class LocationInfo
/**
* Return list of locationids associated with given panel.
+ *
* @param string $paneluuid panel
* @param bool $recursive if true and paneltype == SUMMARY the result is recursive with all child room ids.
* @return int[] locationIds
*/
- public static function getLocationsOr404($paneluuid, $recursive = true)
+ public static function getLocationsOr404(string $paneluuid, bool $recursive = true): array
{
$panel = Database::queryFirst('SELECT paneltype, locationids FROM locationinfo_panel WHERE paneluuid = :paneluuid',
compact('paneluuid'));
@@ -48,7 +49,7 @@ class LocationInfo
* @param int $serverId id of server
* @param string|array $message error message to set, array of error message struct, null or false clears error.
*/
- public static function setServerError($serverId, $message)
+ public static function setServerError(int $serverId, $message): void
{
if (is_array($message)) {
$fatal = false;
@@ -86,7 +87,7 @@ class LocationInfo
*
* @return array Return a default config.
*/
- public static function defaultPanelConfig($type)
+ public static function defaultPanelConfig(string $type): array
{
if ($type === 'DEFAULT') {
return array(
@@ -97,6 +98,7 @@ class LocationInfo
'prettytime' => true,
'roomplanner' => true,
'scaledaysauto' => true,
+ 'startday' => 0,
'daystoshow' => 7,
'rotation' => 0,
'scale' => 50,
@@ -127,9 +129,111 @@ class LocationInfo
'interactive' => 0,
'bookmarks' => '',
'allow-tty' => '',
+ 'url' => '',
+ 'zoom-factor' => 100,
);
}
return array();
}
+ /**
+ * Gets the calendar of the given ids.
+ *
+ * @param int[] $idList list with the location ids.
+ * @return array Calendar.
+ */
+ public static function getCalendar(array $idList, bool $forceCached = false): array
+ {
+ if (empty($idList))
+ return [];
+
+ $resultArray = array();
+
+ if ($forceCached) {
+ $res = Database::simpleQuery("SELECT locationid, calendar FROM locationinfo_locationconfig
+ WHERE Length(calendar) > 10 AND lastcalendarupdate > UNIX_TIMESTAMP() - 86400*3");
+ foreach ($res as $row) {
+ $resultArray[] = [
+ 'id' => (int)$row['locationid'],
+ 'calendar' => json_decode($row['calendar'], true),
+ ];
+ }
+ return $resultArray;
+ }
+
+ // Build SQL query for multiple ids.
+ $query = "SELECT l.locationid, l.serverid, l.serverlocationid, s.servertype, s.credentials
+ FROM `locationinfo_locationconfig` AS l
+ INNER JOIN locationinfo_coursebackend AS s ON (s.serverid = l.serverid)
+ WHERE l.locationid IN (:idlist)
+ ORDER BY s.servertype ASC";
+ $dbquery = Database::simpleQuery($query, array('idlist' => array_values($idList)));
+
+ $serverList = array();
+ foreach ($dbquery as $dbresult) {
+ if (!isset($serverList[$dbresult['serverid']])) {
+ $serverList[$dbresult['serverid']] = array(
+ 'credentials' => (array)json_decode($dbresult['credentials'], true),
+ 'type' => $dbresult['servertype'],
+ 'idlist' => array()
+ );
+ }
+ $serverList[$dbresult['serverid']]['idlist'][] = $dbresult['locationid'];
+ }
+
+ foreach ($serverList as $serverid => $server) {
+ $serverInstance = CourseBackend::getInstance($server['type']);
+ if ($serverInstance === false) {
+ EventLog::warning('Cannot fetch schedule for location (' . implode(', ', $server['idlist']) . ')'
+ . ': Backend type ' . $server['type'] . ' unknown. Disabling location.');
+ Database::exec("UPDATE locationinfo_locationconfig SET serverid = NULL WHERE locationid IN (:lid)",
+ array('lid' => $server['idlist']));
+ continue;
+ }
+ $credentialsOk = $serverInstance->setCredentials($serverid, $server['credentials']);
+
+ if ($credentialsOk) {
+ $calendarFromBackend = $serverInstance->fetchSchedule($server['idlist']);
+ } else {
+ $calendarFromBackend = array();
+ }
+
+ LocationInfo::setServerError($serverid, $serverInstance->getErrors());
+
+ if (is_array($calendarFromBackend)) {
+ foreach ($calendarFromBackend as $key => $value) {
+ $resultArray[] = array(
+ 'id' => (int)$key,
+ 'calendar' => $value,
+ );
+ }
+ }
+ }
+ return $resultArray;
+ }
+
+ public static function getAllCalendars(bool $forceCached): array
+ {
+ $locations = Database::queryColumnArray("SELECT locationid FROM location");
+ $calendars = [];
+ foreach (LocationInfo::getCalendar($locations, $forceCached) as $cal) {
+ if (empty($cal['calendar']))
+ continue;
+ $calendars[$cal['id']] = $cal['calendar'];
+ }
+ return $calendars;
+ }
+
+ public static function extractCurrentEvent(array $calendar): string
+ {
+ $NOW = time();
+ foreach ($calendar as $event) {
+ $start = strtotime($event['start']);
+ $end = strtotime($event['end']) + 60;
+ if ($NOW >= $start && $NOW <= $end)
+ return $event['title'];
+ }
+ return '';
+ }
+
}
diff --git a/modules-available/locationinfo/inc/locationinfohooks.inc.php b/modules-available/locationinfo/inc/locationinfohooks.inc.php
index 8e4975e9..8ec217cc 100644
--- a/modules-available/locationinfo/inc/locationinfohooks.inc.php
+++ b/modules-available/locationinfo/inc/locationinfohooks.inc.php
@@ -5,9 +5,9 @@ class LocationInfoHooks
/**
* @param string $uuid panel uuid
- * @return bool|string panel name if exists, false otherwise
+ * @return false|string panel name if exists, false otherwise
*/
- public static function getPanelName($uuid)
+ public static function getPanelName(string $uuid)
{
$ret = Database::queryFirst('SELECT panelname FROM locationinfo_panel WHERE paneluuid = :uuid', compact('uuid'));
if ($ret === false)
@@ -18,14 +18,11 @@ class LocationInfoHooks
/**
* Hook called by runmode module where we should modify the client config according to our
* needs. Disable standby/logout timeouts, enable autologin, set URL.
- *
- * @param $machineUuid
- * @param $panelUuid
*/
- public static function configHook($machineUuid, $panelUuid)
+ public static function configHook(string $machineUuid, string $panelUuid): void
{
$type = InfoPanel::getConfig($panelUuid, $data);
- if ($type === false)
+ if ($type === null)
return; // TODO: Invalid panel - what should we do?
if ($type === 'URL') {
// Check if we should set the insecure SSL mode (accept invalid/self signed certs etc.)
@@ -51,7 +48,7 @@ class LocationInfoHooks
RunMode::updateClientFlag($machineUuid, 'locationinfo', true);
} else { // Automatic login
RunMode::updateClientFlag($machineUuid, 'locationinfo', false);
- ConfigHolder::add('SLX_AUTOLOGIN', '1', 1000);
+ ConfigHolder::add('SLX_AUTOLOGIN', 'ON', 1000);
ConfigHolder::add('SLX_ADDONS', '', 1000);
}
if (!empty($data['browser'])) {
@@ -72,13 +69,19 @@ class LocationInfoHooks
if ($data['allow-tty'] === 'yes' || $data['allow-tty'] === 'no') {
ConfigHolder::add('SLX_TTY_SWITCH', $data['allow-tty'], 1000);
}
+ if (($data['zoom-factor'] ?? 100) != 100) {
+ ConfigHolder::add('SLX_BROWSER_ZOOM', $data['zoom-factor']);
+ }
} else {
// Not URL panel
ConfigHolder::add('SLX_BROWSER_URL', 'http://' . $_SERVER['SERVER_ADDR'] . '/panel/' . $panelUuid);
- ConfigHolder::add('SLX_BROWSER_INSECURE', '1'); // TODO: Sat server might redirect to HTTPS, which in turn could have a self-signed cert - push to client
- ConfigHolder::add('SLX_AUTOLOGIN', '1', 1000);
+ ConfigHolder::add('SLX_AUTOLOGIN', 'ON', 1000);
ConfigHolder::add('SLX_ADDONS', '', 1000);
}
+ $al = ConfigHolder::get('SLX_AUTOLOGIN');
+ if (!empty($al) && $al !== 'OFF' && $al != 0) {
+ ConfigHolder::add('SLX_SHUTDOWN_TIMEOUT', '', 1000);
+ }
ConfigHolder::add('SLX_LOGOUT_TIMEOUT', '', 1000);
ConfigHolder::add('SLX_SCREEN_STANDBY_TIMEOUT', '', 1000);
ConfigHolder::add('SLX_SYSTEM_STANDBY_TIMEOUT', '', 1000);
@@ -87,10 +90,8 @@ class LocationInfoHooks
/**
* Turn multiline list into space separated list, removing any
* comments (starting with #)
- * @param string $list
- * @return string
*/
- private static function mangleList($list)
+ private static function mangleList(string $list): string
{
return preg_replace('/\s*(#[^\n]*)?(\n|$)/', ' ', $list);
}
diff --git a/modules-available/locationinfo/lang/de/template-tags.json b/modules-available/locationinfo/lang/de/template-tags.json
index 1f781721..fe6a3e53 100644
--- a/modules-available/locationinfo/lang/de/template-tags.json
+++ b/modules-available/locationinfo/lang/de/template-tags.json
@@ -50,7 +50,7 @@
"lang_ignoreSslTooltip": "Akzeptiere ung\u00fcltige, abgelaufene oder selbstsignierte SSL-Zertifikate",
"lang_insecureSsl": "Unsicheres SSL",
"lang_interactive": "Interaktiver Browser",
- "lang_interactiveTooltip": "Aktivieren, um regul\u00e4res Surfen zuzulassen",
+ "lang_interactiveTooltip": "Volles UI anzeigen (tabs, bookmarks, ...)",
"lang_language": "Sprache",
"lang_languageTooltip": "Legt die Sprache der angezeigten Oberfl\u00e4che fest",
"lang_lastCalendarUpdate": "Kalender Update",
@@ -149,5 +149,7 @@
"lang_verticalTooltip": "Legt fest, ob Kalender und Raum \u00fcbereinander angezeigt werden sollen",
"lang_wednesday": "Mittwoch",
"lang_when": "Wann",
- "lang_whitelist": "Whitelist"
+ "lang_whitelist": "Whitelist",
+ "lang_zoomFactor": "Zoom-Faktor",
+ "lang_zoomFactorTooltip": "Initialer Zoom-Faktor beim Start des Panels. Je nach gew\u00e4hltem Browser kann der Faktor vom Benutzer angepasst werden."
} \ No newline at end of file
diff --git a/modules-available/locationinfo/lang/en/template-tags.json b/modules-available/locationinfo/lang/en/template-tags.json
index ce0eac98..5f612d16 100644
--- a/modules-available/locationinfo/lang/en/template-tags.json
+++ b/modules-available/locationinfo/lang/en/template-tags.json
@@ -50,7 +50,7 @@
"lang_ignoreSslTooltip": "Accept invalid, expired or self-signed ssl certificates",
"lang_insecureSsl": "Insecure SSL",
"lang_interactive": "Interactive Browser",
- "lang_interactiveTooltip": "Activate to allow regular websurfing",
+ "lang_interactiveTooltip": "Show full browser UI (tabs, bookmarks, ...)",
"lang_language": "Language",
"lang_languageTooltip": "The language the frontend uses",
"lang_lastCalendarUpdate": "Calendar update",
@@ -149,5 +149,7 @@
"lang_verticalTooltip": "Defines whether the room and calendar are shown above each other",
"lang_wednesday": "Wednesday",
"lang_when": "When",
- "lang_whitelist": "Whitelist"
+ "lang_whitelist": "Whitelist",
+ "lang_zoomFactor": "Zoom level",
+ "lang_zoomFactorTooltip": "Initial zoom level at startup. Depending on selected browser, this can later be changed by the user."
} \ No newline at end of file
diff --git a/modules-available/locationinfo/page.inc.php b/modules-available/locationinfo/page.inc.php
index 9e7a704e..63a02ba2 100644
--- a/modules-available/locationinfo/page.inc.php
+++ b/modules-available/locationinfo/page.inc.php
@@ -81,7 +81,7 @@ class Page_LocationInfo extends Page
$this->showLocationsTable();
break;
case 'backends':
- $this->showBackendsTable($backends);
+ $this->showBackendsTable($backends ?? []);
break;
case 'edit-panel':
$this->showPanelConfig();
@@ -100,7 +100,7 @@ class Page_LocationInfo extends Page
/**
* Deletes the server from the db.
*/
- private function deleteServer($id)
+ private function deleteServer($id): void
{
User::assertPermission('backend.edit');
if ($id === 0) {
@@ -113,7 +113,7 @@ class Page_LocationInfo extends Page
}
}
- private function deletePanel()
+ private function deletePanel(): void
{
$id = Request::post('uuid', false, 'string');
if ($id === false) {
@@ -130,25 +130,27 @@ class Page_LocationInfo extends Page
}
}
- private function getTime($str)
+ private static function getTime(string $str): ?int
{
$str = explode(':', $str);
- if (count($str) !== 2) return false;
- if ($str[0] < 0 || $str[0] > 23 || $str[1] < 0 || $str[1] > 59) return false;
+ if (count($str) !== 2)
+ return null;
+ if ($str[0] < 0 || $str[0] > 23 || $str[1] < 0 || $str[1] > 59)
+ return null;
return $str[0] * 60 + $str[1];
}
- private function writeLocationConfig()
+ private function writeLocationConfig(): void
{
// Check locations
$locationid = Request::post('locationid', false, 'int');
if ($locationid === false) {
Message::addError('main.parameter-missing', 'locationid');
- return false;
+ return;
}
if (Location::get($locationid) === false) {
Message::addError('location.invalid-location-id', $locationid);
- return false;
+ return;
}
User::assertPermission('location.edit', $locationid);
@@ -198,29 +200,23 @@ class Page_LocationInfo extends Page
));
}
}
-
- return true;
}
/**
* Get all location ids from the locationids parameter, which is comma separated, then split
- * and remove any ids that don't exist. The cleaned list will be returned
+ * and remove any ids that don't exist. The cleaned list will be returned.
+ * Will show error and redirect to main page if parameter is missing
+ *
* @param bool $failIfEmpty Show error and redirect to main page if parameter is missing or list is empty
* @return array list of locations from parameter
*/
- private function getLocationIdsFromRequest($failIfEmpty)
+ private function getLocationIdsFromRequest(): array
{
- $locationids = Request::post('locationids', false, 'string');
- if ($locationids === false) {
- if (!$failIfEmpty)
- return array();
- Message::addError('main.parameter-missing', 'locationids');
- Util::redirect('?do=locationinfo');
- }
+ $locationids = Request::post('locationids', Request::REQUIRED_EMPTY, 'string');
$locationids = explode(',', $locationids);
$all = array_map(function ($item) { return $item['locationid']; }, Location::queryLocations());
$locationids = array_filter($locationids, function ($item) use ($all) { return in_array($item, $all); });
- if ($failIfEmpty && empty($locationids)) {
+ if (empty($locationids)) {
Message::addError('main.parameter-empty', 'locationids');
Util::redirect('?do=locationinfo');
}
@@ -230,7 +226,7 @@ class Page_LocationInfo extends Page
/**
* Updated the config in the db.
*/
- private function writePanelConfig()
+ private function writePanelConfig(): void
{
// UUID - existing or new
$paneluuid = Request::post('uuid', false, 'string');
@@ -253,7 +249,7 @@ class Page_LocationInfo extends Page
}
// Permission
- $this->assertPanelPermission($paneluuid, 'panel.edit', $params['locationids']);
+ $this->assertPanelPermission($paneluuid, 'panel.edit', $params['locationids'] ?? []);
if ($paneluuid === 'new') {
$paneluuid = Util::randomUuid();
@@ -276,10 +272,13 @@ class Page_LocationInfo extends Page
Util::redirect('?do=locationinfo');
}
- private function preparePanelConfigDefault()
+ /**
+ * @return array{config: array, locationids: array}
+ */
+ private function preparePanelConfigDefault(): array
{
// Check locations
- $locationids = self::getLocationIdsFromRequest(true);
+ $locationids = self::getLocationIdsFromRequest();
if (count($locationids) > 4) {
$locationids = array_slice($locationids, 0, 4);
}
@@ -331,7 +330,10 @@ class Page_LocationInfo extends Page
return array('config' => $conf, 'locationids' => $locationids);
}
- private function preparePanelConfigUrl()
+ /**
+ * @return array{config: array, locationids: array}
+ */
+ private function preparePanelConfigUrl(): array
{
$bookmarkNames = Request::post('bookmarkNames', [], 'array');
$bookmarkUrls = Request::post('bookmarkUrls', [], 'array');
@@ -349,18 +351,22 @@ class Page_LocationInfo extends Page
'url' => Request::post('url', 'https://www.bwlehrpool.de/', 'string'),
'insecure-ssl' => Request::post('insecure-ssl', 0, 'int'),
'reload-minutes' => max(0, Request::post('reloadminutes', 0, 'int')),
- 'whitelist' => preg_replace("/[\r\n]+/ms", "\n", Request::post('whitelist', '', 'string')),
- 'blacklist' => preg_replace("/[\r\n]+/ms", "\n", Request::post('blacklist', '', 'string')),
+ 'whitelist' => preg_replace("/[\r\n]+/m", "\n", Request::post('whitelist', '', 'string')),
+ 'blacklist' => preg_replace("/[\r\n]+/m", "\n", Request::post('blacklist', '', 'string')),
'split-login' => Request::post('split-login', 0, 'bool'),
'browser' => Request::post('browser', 'slx-browser', 'string'),
'interactive' => Request::post('interactive', '0', 'bool'),
- 'bookmarks' => $bookmarkString ? $bookmarkString : '',
+ 'bookmarks' => $bookmarkString ?: '',
'allow-tty' => Request::post('allow-tty', '', 'string'),
+ 'zoom-factor' => Request::post('zoom-factor', 100, 'int'),
);
return array('config' => $conf, 'locationids' => []);
}
- private function preparePanelConfigSummary()
+ /**
+ * @return array{config: array, locationids: array}
+ */
+ private function preparePanelConfigSummary(): array
{
// Build json structure
$conf = array(
@@ -373,14 +379,14 @@ class Page_LocationInfo extends Page
$conf['panelupdate'] = 15;
}
// Check locations
- $locationids = self::getLocationIdsFromRequest(true);
+ $locationids = self::getLocationIdsFromRequest();
return array('config' => $conf, 'locationids' => $locationids);
}
/**
* Updates the server settings in the db.
*/
- private function updateServerSettings()
+ private function updateServerSettings(): void
{
User::assertPermission('backend.edit');
$serverid = Request::post('id', -1, 'int');
@@ -422,10 +428,10 @@ class Page_LocationInfo extends Page
*
* @param int $id Server id which connection should be checked.
*/
- private function checkConnection($serverid = 0)
+ private function checkConnection(int $serverid = 0): void
{
if ($serverid === 0) {
- Util::traceError('checkConnection called with no server id');
+ ErrorHandler::traceError('checkConnection called with no server id');
}
User::assertPermission('backend.check');
@@ -438,7 +444,8 @@ class Page_LocationInfo extends Page
LocationInfo::setServerError($serverid, 'Unknown backend type: ' . $dbresult['servertype']);
return;
}
- $credentialsOk = $serverInstance->setCredentials($serverid, json_decode($dbresult['credentials'], true));
+ $credentialsOk = $serverInstance->setCredentials($serverid,
+ (array)json_decode($dbresult['credentials'], true));
if ($credentialsOk) {
$serverInstance->checkConnection();
@@ -447,7 +454,7 @@ class Page_LocationInfo extends Page
LocationInfo::setServerError($serverid, $serverInstance->getErrors());
}
- private function loadBackends()
+ private function loadBackends(): array
{
// Get a list of all the backend types.
$servertypes = array();
@@ -459,7 +466,7 @@ class Page_LocationInfo extends Page
// Build list of defined backends
$serverlist = array();
$dbquery2 = Database::simpleQuery("SELECT * FROM `locationinfo_coursebackend` ORDER BY servername ASC");
- while ($row = $dbquery2->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($dbquery2 as $row) {
if (isset($servertypes[$row['servertype']])) {
$row['typename'] = $servertypes[$row['servertype']];
} else {
@@ -485,7 +492,7 @@ class Page_LocationInfo extends Page
/**
* Show the list of backends
*/
- private function showBackendsTable($serverlist)
+ private function showBackendsTable(array $serverlist): void
{
User::assertPermission('backend.*');
$data = array(
@@ -496,7 +503,7 @@ class Page_LocationInfo extends Page
Render::addTemplate('page-servers', $data);
}
- private function showBackendLog()
+ private function showBackendLog(): void
{
$id = Request::get('serverid', false, 'int');
if ($id === false) {
@@ -512,7 +519,7 @@ class Page_LocationInfo extends Page
$server['list'] = [];
$res = Database::simpleQuery('SELECT dateline, message FROM locationinfo_backendlog
WHERE serverid = :id ORDER BY logid DESC LIMIT 100', ['id' => $id]);
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$row['dateline_s'] = Util::prettyTime($row['dateline']);
$row['class'] = substr($row['message'], 0, 3) === '[F]' ? 'text-danger' : 'text-warning';
$row['message'] = Substr($row['message'], 3);
@@ -521,7 +528,7 @@ class Page_LocationInfo extends Page
Render::addTemplate('page-server-log', $server);
}
- private function showLocationsTable()
+ private function showLocationsTable(): void
{
$allowedLocations = User::getAllowedLocations('location.edit');
if (empty($allowedLocations)) {
@@ -536,7 +543,7 @@ class Page_LocationInfo extends Page
LEFT JOIN `locationinfo_coursebackend` AS cb USING (serverid)
LEFT JOIN `location` AS loc USING (locationid)");
- while ($row = $dbquery->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($dbquery as $row) {
$locid = (int)$row['locationid'];
if (!isset($locations[$locid]) || !in_array($locid, $allowedLocations))
continue;
@@ -547,6 +554,7 @@ class Page_LocationInfo extends Page
}
$locations[$locid] += array(
'openingGlyph' => $glyph,
+ 'strong' => $glyph === 'ok',
'backend' => $backend,
'lastCalendarUpdate' => Util::prettyTime($row['lastcalendarupdate']), // TODO
'backendMissing' => !CourseBackend::exists($row['servertype']),
@@ -575,11 +583,20 @@ class Page_LocationInfo extends Page
));
}
- private function showPanelsTable()
+ private function showPanelsTable(): void
{
$visibleLocations = User::getAllowedLocations('panel.list');
+ if (in_array(0, $visibleLocations)) {
+ $visibleLocations = true;
+ }
$editLocations = User::getAllowedLocations('panel.edit');
+ if (in_array(0, $editLocations)) {
+ $editLocations = true;
+ }
$assignLocations = USer::getAllowedLocations('panel.assign-client');
+ if (in_array(0, $assignLocations)) {
+ $assignLocations = true;
+ }
if (empty($visibleLocations)) {
Message::addError('main.no-permission');
return;
@@ -593,7 +610,7 @@ class Page_LocationInfo extends Page
}
$panels = array();
$locations = Location::getLocationsAssoc();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if ($row['paneltype'] === 'URL') {
$url = json_decode($row['panelconfig'], true)['url'];
$row['locations'] = $row['locationurl'] = $url;
@@ -602,14 +619,16 @@ class Page_LocationInfo extends Page
} else {
$lids = explode(',', $row['locationids']);
// Permissions
- if (!empty(array_diff($lids, $visibleLocations))) {
+ if ($visibleLocations !== true && !empty(array_diff($lids, $visibleLocations))) {
continue;
}
- $row['edit_disabled'] = !empty(array_diff($lids, $editLocations)) ? 'disabled' : '';
- $row['runmode_disabled'] = !empty(array_diff($lids, $assignLocations)) ? 'disabled' : '';
+ $row['edit_disabled'] = $editLocations !== true && !empty(array_diff($lids, $editLocations))
+ ? 'disabled' : '';
+ $row['runmode_disabled'] = $assignLocations !== true && !empty(array_diff($lids, $assignLocations))
+ ? 'disabled' : '';
// Locations
$locs = array_map(function ($id) use ($locations) {
- return isset($locations[$id]) ? $locations[$id]['locationname'] : $id;
+ return isset($locations[$id]) ? $locations[$id]['locationname'] : "<<deleted=$id>>";
}, $lids);
$row['locations'] = implode(', ', $locs);
}
@@ -648,7 +667,7 @@ class Page_LocationInfo extends Page
*
* @param int $id Serverid
*/
- private function ajaxServerSettings($id)
+ private function ajaxServerSettings(int $id): void
{
User::assertPermission('backend.edit');
$oldConfig = Database::queryFirst('SELECT servername, servertype, credentials
@@ -679,7 +698,7 @@ class Page_LocationInfo extends Page
} else {
$cred->initForRender();
}
- $cred->title = Dictionary::translateFile('backend-' . $s, $cred->property, true);
+ $cred->title = Dictionary::translateFile('backend-' . $s, $cred->property);
$cred->helptext = Dictionary::translateFile('backend-' . $s, $cred->property . "_helptext");
$cred->credentialsHtml = Render::parse('server-prop-' . $cred->template, (array)$cred);
}
@@ -697,7 +716,7 @@ class Page_LocationInfo extends Page
*
* @param int $id id of the location
*/
- private function ajaxConfigLocation($id)
+ private function ajaxConfigLocation(int $id): void
{
User::assertPermission('location.edit', $id);
$locConfig = Database::queryFirst("SELECT info.serverid, info.serverlocationid, loc.openingtime
@@ -721,7 +740,7 @@ class Page_LocationInfo extends Page
WHERE locationid IN (:locations) AND serverid IS NOT NULL", array('locations' => $chain));
$chain = array_flip($chain);
$best = false;
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if ($best === false || $chain[$row['locationid']] < $chain[$best['locationid']]) {
$best = $row;
}
@@ -735,7 +754,7 @@ class Page_LocationInfo extends Page
// get Server / ID list
$res = Database::simpleQuery("SELECT serverid, servername FROM locationinfo_coursebackend ORDER BY servername ASC");
$serverList = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if ($row['serverid'] == $locConfig['serverid']) {
$row['selected'] = 'selected';
}
@@ -752,12 +771,6 @@ class Page_LocationInfo extends Page
echo Render::parse('ajax-config-location', $data);
}
- private function fmtTime($time)
- {
- $t = explode(':', $time);
- return sprintf('%02d:%02d', $t[0], $t[1]);
- }
-
/**
* Checks if simple mode or expert mode is active.
* Tries to merge/compact the opening times schedule, and
@@ -766,7 +779,7 @@ class Page_LocationInfo extends Page
*
* @return array new optimized openingtimes
*/
- private function compressTimes(&$array)
+ private function compressTimes(array $array): array
{
if (empty($array))
return [];
@@ -774,9 +787,9 @@ class Page_LocationInfo extends Page
$DAYLIST = array_flip(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']);
$new = [];
foreach ($array as $row) {
- $s = $this->getTime($row['openingtime']);
- $e = $this->getTime($row['closingtime']);
- if ($s === false || $e === false || $e <= $s)
+ $s = Page_LocationInfo::getTime($row['openingtime']);
+ $e = Page_LocationInfo::getTime($row['closingtime']);
+ if ($s === null || $e === null || $e <= $s)
continue;
foreach ($row['days'] as $day) {
$day = $DAYLIST[$day] ?? -1;
@@ -811,9 +824,9 @@ class Page_LocationInfo extends Page
/**
* @param array $daysArray List of days, "Monday", "Tuesday" etc. Must not contain duplicates.
- * @return string Human readable representation of list of days
+ * @return string Human-readable representation of list of days
*/
- private function buildDaysString(array $daysArray)
+ private function buildDaysString(array $daysArray): string
{
/* Dictionary::translate('monday') Dictionary::translate('tuesday') Dictionary::translate('wednesday')
* Dictionary::translate('thursday') Dictionary::translate('friday') Dictionary::translate('saturday')
@@ -831,10 +844,10 @@ class Page_LocationInfo extends Page
// Chain
$last++;
} else {
- $string = Dictionary::translate($DAYLIST[$first], true);
+ $string = Dictionary::translate($DAYLIST[$first]);
if ($first !== $last) {
$string .= ($first + 1 === $last ? ",\xe2\x80\x89" : "\xe2\x80\x89-\xe2\x80\x89")
- . Dictionary::translate($DAYLIST[$last], true);
+ . Dictionary::translate($DAYLIST[$last]);
}
$output[] = $string;
$first = $last = $day;
@@ -870,7 +883,7 @@ class Page_LocationInfo extends Page
// $start must lie before range start, otherwise we'd have hit the case above
$e = $current[1];
unset($array[$day][$key]);
- continue;
+ //continue;
}
}
$array[$day][] = array($s, $e);
@@ -878,10 +891,8 @@ class Page_LocationInfo extends Page
/**
* Ajax the config of a panel.
- *
- * @param $id Location ID
*/
- private function showPanelConfig()
+ private function showPanelConfig(): void
{
$id = Request::get('uuid', false, 'string');
if ($id === false) {
@@ -1000,6 +1011,7 @@ class Page_LocationInfo extends Page
'uuid' => $id,
'panelname' => $panel['panelname'],
'url' => $config['url'],
+ 'zoom-factor' => $config['zoom-factor'],
'ssl_checked' => $config['insecure-ssl'] ? 'checked' : '',
'reloadminutes' => (int)$config['reload-minutes'],
'whitelist' => str_replace("\n", "\r\n", $config['whitelist']),
@@ -1025,7 +1037,7 @@ class Page_LocationInfo extends Page
}
}
- private function showPanel()
+ private function showPanel(): void
{
$uuid = Request::get('uuid', false, 'string');
if ($uuid === false) {
@@ -1033,7 +1045,7 @@ class Page_LocationInfo extends Page
die('Missing parameter uuid');
}
$type = InfoPanel::getConfig($uuid, $config);
- if ($type === false) {
+ if ($type === null) {
http_response_code(404);
die('Panel with given uuid not found');
}
@@ -1060,7 +1072,7 @@ class Page_LocationInfo extends Page
'language' => $config['language'],
);
- die(Render::parse('frontend-default', $data, $module = false, $lang = $config['language']));
+ die(Render::parse('frontend-default', $data, null, $config['language']));
}
if ($type === 'SUMMARY') {
@@ -1072,7 +1084,7 @@ class Page_LocationInfo extends Page
'language' => $config['language'],
);
- die(Render::parse('frontend-summary', $data, $module = false, $lang = $config['language']));
+ die(Render::parse('frontend-summary', $data, null, $config['language']));
}
http_response_code(500);
@@ -1081,10 +1093,9 @@ class Page_LocationInfo extends Page
/**
* @param string|array $panelOrUuid UUID of panel, or array with keys paneltype and locationds
- * @param string $permission
- * @param null|int[] $additionalLocations
+ * @param int[] $additionalLocations
*/
- private function assertPanelPermission($panelOrUuid, $permission, $additionalLocations = null)
+ private function assertPanelPermission($panelOrUuid, string $permission, array $additionalLocations = null): void
{
if (is_array($panelOrUuid)) {
$panel = $panelOrUuid;
diff --git a/modules-available/locationinfo/templates/frontend-default.html b/modules-available/locationinfo/templates/frontend-default.html
index 6e04550d..cc62075e 100755
--- a/modules-available/locationinfo/templates/frontend-default.html
+++ b/modules-available/locationinfo/templates/frontend-default.html
@@ -564,6 +564,8 @@ optional:
putInRange(config, 'rotation', 0, 3, 0);
}
+ var updateTimer = null;
+
/**
* generates the Room divs and calls the needed functions depending on the rooms mode
*/
@@ -651,7 +653,7 @@ optional:
}
mainUpdateLoop();
- setInterval(mainUpdateLoop, 10000);
+ updateTimer = setInterval(mainUpdateLoop, 10000);
setInterval(updateHeaders, globalConfig.eco ? 10000 : 1000);
}
@@ -679,7 +681,14 @@ optional:
var today = date.getDate();
if (lastDate !== false) {
if (lastDate !== today) {
- location.reload(true);
+ if (updateTimer !== null) {
+ clearInterval(updateTimer);
+ updateTimer = null;
+ }
+ // Delay by a minute, sometimes the calendar shows the previous day if we load too quickly.
+ setTimeout(function() {
+ location.reload(true);
+ }, 60000);
}
} else {
lastDate = today;
@@ -1326,7 +1335,6 @@ optional:
/========================================== Room Layout =============================================
*/
-
const picSizeX = 3.8;
const picSizeY = 3;
diff --git a/modules-available/locationinfo/templates/page-config-panel-url.html b/modules-available/locationinfo/templates/page-config-panel-url.html
index 365e15db..3aaf8620 100644
--- a/modules-available/locationinfo/templates/page-config-panel-url.html
+++ b/modules-available/locationinfo/templates/page-config-panel-url.html
@@ -188,10 +188,10 @@
</div>
<div class="col-sm-3">
<input class="form-control" name="bookmarkUrls[]" type="text" value=""
- placeholder="http://www.bwlehrpool.de/" pattern=".*://.*">
+ placeholder="https://www.bwlehrpool.de/" pattern=".*://.*">
</div>
<div class="col-sm-1">
- <button type="button" class="btn btn-danger" onclick="this.closest('.row').remove()">
+ <button type="button" class="btn btn-danger" onclick="$(this).closest('.row').remove()">
<span class="glyphicon glyphicon-minus"></span>
</button>
</div>
@@ -208,7 +208,7 @@
placeholder="http://www.bwlehrpool.de/" pattern=".*://.*" required>
</div>
<div class="col-sm-1">
- <button type="button" class="btn btn-danger" onclick="this.closest('.row').remove()">
+ <button type="button" class="btn btn-danger" onclick="$(this).closest('.row').remove()">
<span class="glyphicon glyphicon-minus"></span>
</button>
</div>
@@ -216,6 +216,24 @@
{{/bookmarks}}
</div>
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="zoom-factor">{{lang_zoomFactor}}</label>
+ </div>
+ <div class="col-sm-7 col-xs-10">
+ <input class="form-control" id="zoom-factor" type="range" min="50" max="300" step="5"
+ name="zoom-factor" value="{{zoom-factor}}">
+ </div>
+ <div class="col-sm-1 col-xs-2" id="zoom-value">
+
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_zoomFactorTooltip}}
+ </div>
+ </div>
+ </div>
+
</div>
</div>
<div class="text-right">
@@ -227,12 +245,19 @@
</div>
</form>
-<script type="text/javascript"><!--
+<script>
document.addEventListener("DOMContentLoaded", function () {
// load value to dropdown menus
- $('#browser option[value="{{browser}}"]').attr("selected", "selected");
+ $('#browser option[value="{{browser}}"]').prop("selected", true);
browserChange();
+ var $zv = $('#zoom-value');
+ var $zf = $('#zoom-factor');
+ var sliderUpdate = function() {
+ $zv.text($zf.val() + '%');
+ };
+ $zf.on('input', sliderUpdate);
+ sliderUpdate();
});
// Hide interactive-input if slx-browser is selected
@@ -257,4 +282,4 @@ function addBookmark() {
$('#bookmarks').append(rowCopy);
}
-//--></script>
+</script>
diff --git a/modules-available/locationinfo/templates/page-locations.html b/modules-available/locationinfo/templates/page-locations.html
index fa2e3a2d..c09b5336 100644
--- a/modules-available/locationinfo/templates/page-locations.html
+++ b/modules-available/locationinfo/templates/page-locations.html
@@ -35,7 +35,7 @@
{{/backend}}
</td>
<td class="text-center">
- <span class="glyphicon glyphicon-{{openingGlyph}}"></span>
+ <span class="glyphicon glyphicon-{{openingGlyph}} {{^strong}}text-muted{{/strong}}"></span>
</td>
</tr>
{{/list}}
diff --git a/modules-available/locations/baseconfig/getconfig.inc.php b/modules-available/locations/baseconfig/getconfig.inc.php
index 26e43ed8..1bed5de7 100644
--- a/modules-available/locations/baseconfig/getconfig.inc.php
+++ b/modules-available/locations/baseconfig/getconfig.inc.php
@@ -1,5 +1,8 @@
<?php
+/** @var ?string $uuid */
+/** @var ?string $ip */
+
// Location handling: figure out location
$locationId = false;
if (BaseConfig::hasOverride('locationid')) {
@@ -12,7 +15,7 @@ if (BaseConfig::hasOverride('locationid')) {
}
if ($locationId === false) {
- if (!$ip) // Required at this point, bail out if not given
+ if ($ip === null) // Required at this point, bail out if not given
return;
$locationId = Location::getFromIpAndUuid($ip, $uuid);
}
@@ -31,7 +34,7 @@ if (!empty($matchingLocations)) {
FROM setting_location WHERE locationid IN (:list)",
['list' => $matchingLocations]);
$tmp = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$tmp[(int)$row['locationid']][$row['setting']] = $row; // Put whole row so we have value and displayvalue
}
// Callback for pretty printing
diff --git a/modules-available/locations/inc/abstractlocationcolumn.inc.php b/modules-available/locations/inc/abstractlocationcolumn.inc.php
new file mode 100644
index 00000000..65224da9
--- /dev/null
+++ b/modules-available/locations/inc/abstractlocationcolumn.inc.php
@@ -0,0 +1,29 @@
+<?php
+
+abstract class AbstractLocationColumn
+{
+
+ public abstract function getColumnHtml(int $locationId): string;
+
+ public abstract function getEditUrl(int $locationId): string;
+
+ public abstract function header(): string;
+
+ public abstract function priority(): int;
+
+ public function propagateColumn(): bool
+ {
+ return false;
+ }
+
+ public function propagationOverride(string $parent, string $data): string
+ {
+ return $data;
+ }
+
+ public function propagateDefaultHtml(): string
+ {
+ return $this->getColumnHtml(0);
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/locations/inc/autolocation.inc.php b/modules-available/locations/inc/autolocation.inc.php
index 82c61251..f77cf714 100644
--- a/modules-available/locations/inc/autolocation.inc.php
+++ b/modules-available/locations/inc/autolocation.inc.php
@@ -22,7 +22,7 @@ class AutoLocation
}
$updates = array();
$nulls = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$loc = Location::mapIpToLocation($row['clientip']);
if ($loc === false) {
$nulls[] = $row['machineuuid'];
diff --git a/modules-available/locations/inc/location.inc.php b/modules-available/locations/inc/location.inc.php
index 700edaf8..807f8577 100644
--- a/modules-available/locations/inc/location.inc.php
+++ b/modules-available/locations/inc/location.inc.php
@@ -8,7 +8,7 @@ class Location
private static $treeCache = false;
private static $subnetMapCache = false;
- public static function getTree()
+ public static function getTree(): array
{
if (self::$treeCache === false) {
self::$treeCache = self::queryLocations();
@@ -17,11 +17,11 @@ class Location
return self::$treeCache;
}
- public static function queryLocations()
+ public static function queryLocations(): array
{
$res = Database::simpleQuery("SELECT locationid, parentlocationid, locationname FROM location");
$rows = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$rows[] = $row;
}
return $rows;
@@ -29,10 +29,9 @@ class Location
/**
* Return row from location table for $locationId.
- * @param $locationId
* @return array|bool row from DB, false if not found
*/
- public static function get($locationId)
+ public static function get(int $locationId)
{
return Database::queryFirst("SELECT * FROM location WHERE locationid = :locationId", compact('locationId'));
}
@@ -42,9 +41,8 @@ class Location
* @param int $locationId id of location to get name for
* @return string|false Name of location, false if locationId doesn't exist
*/
- public static function getName($locationId)
+ public static function getName(int $locationId)
{
- $locationId = (int)$locationId;
if (self::$assocLocationCache === false) {
self::getLocationsAssoc();
}
@@ -56,15 +54,13 @@ class Location
/**
* Get all the names of the given location and its parents, up
* to the root element. Array keys will be locationids, value the names.
- * @param int $locationId
* @return array|false locations, from furthest to nearest or false if locationId doesn't exist
*/
- public static function getNameChain($locationId)
+ public static function getNameChain(int $locationId)
{
if (self::$assocLocationCache === false) {
self::getLocationsAssoc();
}
- $locationId = (int)$locationId;
if (!isset(self::$assocLocationCache[$locationId]))
return false;
$ret = array();
@@ -84,10 +80,10 @@ class Location
return self::$assocLocationCache;
}
- private static function flattenTreeAssoc($tree, $parents = array(), $depth = 0)
+ private static function flattenTreeAssoc($tree, $parents = array(), $depth = 0): array
{
if ($depth > 20) {
- Util::traceError('Recursive location definition detected at ' . print_r($tree, true));
+ ErrorHandler::traceError('Recursive location definition detected at ' . print_r($tree, true));
}
$output = array();
foreach ($tree as $node) {
@@ -121,12 +117,12 @@ class Location
/**
* @param int|int[] $selected Which locationIDs to mark as selected
- * @param int $excludeId Which locationID to explude
+ * @param int $excludeId Which locationID to exclude
* @param bool $addNoParent Add entry for "no location" at the top
* @param bool $keepArrayKeys Keep location IDs as array index
* @return array Locations
*/
- public static function getLocations($selected = 0, $excludeId = 0, $addNoParent = false, $keepArrayKeys = false)
+ public static function getLocations($selected = 0, int $excludeId = 0, bool $addNoParent = false, bool $keepArrayKeys = false): array
{
if (self::$flatLocationCache === false) {
$rows = self::getTree();
@@ -170,15 +166,15 @@ class Location
* Get nested array of all the locations and children of given locationid(s).
*
* @param int[]|int $idList List of location ids
- * @param bool $locationTree used in recursive calls, don't pass
+ * @param ?array $locationTree used in recursive calls, don't pass
* @return array list of passed locations plus their children
*/
- public static function getRecursive($idList, $locationTree = false)
+ public static function getRecursive($idList, ?array $locationTree = null): array
{
if (!is_array($idList)) {
$idList = array($idList);
}
- if ($locationTree === false) {
+ if ($locationTree === null) {
$locationTree = self::getTree();
}
$ret = array();
@@ -198,7 +194,7 @@ class Location
* @param int[]|int $idList List of location ids
* @return array list of passed locations plus their children
*/
- public static function getRecursiveFlat($idList)
+ public static function getRecursiveFlat($idList): array
{
$ret = self::getRecursive($idList);
if (!empty($ret)) {
@@ -207,7 +203,7 @@ class Location
return $ret;
}
- public static function buildTree($elements, $parentId = 0)
+ public static function buildTree(array $elements, int $parentId = 0): array
{
$branch = array();
$sort = array();
@@ -227,10 +223,10 @@ class Location
return $branch;
}
- private static function flattenTree($tree, $depth = 0)
+ private static function flattenTree(array $tree, int $depth = 0): array
{
if ($depth > 20) {
- Util::traceError('Recursive location definition detected at ' . print_r($tree, true));
+ ErrorHandler::traceError('Recursive location definition detected at ' . print_r($tree, true));
}
$output = array();
foreach ($tree as $node) {
@@ -249,16 +245,16 @@ class Location
return $output;
}
- public static function isLeaf($locationid) {
+ public static function isLeaf(int $locationid): bool
+ {
$result = Database::queryFirst('SELECT COUNT(locationid) = 0 AS isleaf '
. 'FROM location '
. 'WHERE parentlocationid = :locationid', ['locationid' => $locationid]);
$result = $result['isleaf'];
- $result = (bool)$result;
- return $result;
+ return (bool)$result;
}
- public static function extractIds($tree)
+ public static function extractIds(array $tree): array
{
$ids = array();
foreach ($tree as $node) {
@@ -272,10 +268,11 @@ class Location
/**
* Get location id for given machine (by uuid)
+ *
* @param string $uuid machine uuid
- * @return bool|int locationid, false if no match
+ * @return false|int locationid, false if no match
*/
- public static function getFromMachineUuid($uuid)
+ public static function getFromMachineUuid(string $uuid)
{
// Only if we have the statistics module which supplies the machine table
if (Module::get('statistics') === false)
@@ -292,9 +289,9 @@ class Location
*
* @param string $ip IP address of client
* @param bool $honorRoomPlanner consider a fixed location assigned manually by roomplanner
- * @return bool|int locationid, or false if no match
+ * @return false|int locationid, or false if no match
*/
- public static function getFromIp($ip, $honorRoomPlanner = false)
+ public static function getFromIp(string $ip, bool $honorRoomPlanner = false)
{
if (Module::get('statistics') !== false) {
// Shortcut - try to use subnetlocationid in machine table
@@ -323,17 +320,17 @@ class Location
* client, so if it seems too fishy, the UUID will be ignored.
*
* @param string $ip IP address of client
- * @param string $uuid System-UUID of client
- * @return int|bool location id, or false if none matches
+ * @param ?string $uuid System-UUID of client
+ * @return int|false location id, or false if none matches
*/
- public static function getFromIpAndUuid($ip, $uuid)
+ public static function getFromIpAndUuid(string $ip, ?string $uuid)
{
$locationId = false;
$ipLoc = self::getFromIp($ip);
if ($ipLoc !== false) {
// Set locationId to ipLoc for now, it will be overwritten later if another case applies.
$locationId = $ipLoc;
- if ($uuid !== false) {
+ if ($uuid !== null) {
// Machine ip maps to a location, and we have a client supplied uuid (which might not be known if the client boots for the first time)
$uuidLoc = self::getFromMachineUuid($uuid);
if (self::isFixedLocationValid($uuidLoc, $ipLoc)) {
@@ -344,7 +341,7 @@ class Location
return $locationId;
}
- public static function isFixedLocationValid($uuidLoc, $ipLoc)
+ public static function isFixedLocationValid($uuidLoc, $ipLoc): bool
{
if ($uuidLoc === false)
return false;
@@ -367,12 +364,10 @@ class Location
/**
* Get all location IDs from the given location up to the root.
*
- * @param int $locationId
* @return int[] location ids, including $locationId
*/
- public static function getLocationRootChain($locationId)
+ public static function getLocationRootChain(int $locationId): array
{
- $locationId = (int)$locationId;
if (self::$assocLocationCache === false) {
self::getLocationsAssoc();
}
@@ -386,11 +381,11 @@ class Location
/**
* @return array list of subnets as numeric array
*/
- public static function getSubnets()
+ public static function getSubnets(): array
{
$res = Database::simpleQuery("SELECT startaddr, endaddr, locationid FROM subnet");
$subnets = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
settype($row['locationid'], 'int');
$subnets[] = $row;
}
@@ -398,9 +393,9 @@ class Location
}
/**
- * @return array|bool assoc array mapping from locationid to subnets
+ * @return array assoc array mapping from locationid to subnets
*/
- public static function getSubnetsByLocation($recursive = false)
+ public static function getSubnetsByLocation($recursive = false): array
{
$locs = self::getLocationsAssoc();
$subnets = self::getSubnets();
@@ -434,9 +429,9 @@ class Location
* random one will be returned.
*
* @param string $ip IP to look up
- * @return bool|int locationid ip matches, false = no match
+ * @return false|int locationid ip matches, false = no match
*/
- public static function mapIpToLocation($ip)
+ public static function mapIpToLocation(string $ip)
{
if (self::$subnetMapCache === false) {
self::$subnetMapCache = self::getSubnetsByLocation();
@@ -465,7 +460,10 @@ class Location
return (int)$best;
}
- public static function updateMapIpToLocation($uuid, $ip)
+ /**
+ * @return false|int newly determined location
+ */
+ public static function updateMapIpToLocation(string $uuid, string $ip)
{
$loc = self::mapIpToLocation($ip);
if ($loc === false) {
@@ -473,6 +471,7 @@ class Location
} else {
Database::exec("UPDATE machine SET subnetlocationid = :loc WHERE machineuuid = :uuid", compact('loc', 'uuid'));
}
+ return $loc;
}
}
diff --git a/modules-available/locations/inc/locationhooks.inc.php b/modules-available/locations/inc/locationhooks.inc.php
index 5ce3bbfe..f6ef02da 100644
--- a/modules-available/locations/inc/locationhooks.inc.php
+++ b/modules-available/locations/inc/locationhooks.inc.php
@@ -6,7 +6,7 @@ class LocationHooks
/**
* Resolve baseconfig id to locationid -- noop in this case
*/
- public static function baseconfigLocationResolver($id)
+ public static function baseconfigLocationResolver(int $id): int
{
return $id;
}
@@ -15,13 +15,13 @@ class LocationHooks
* Hook to get inheritance tree for all config vars
* @param int $id Locationid currently being edited
*/
- public static function baseconfigInheritance($id)
+ public static function baseconfigInheritance(int $id): array
{
$locs = Location::getLocationsAssoc();
if ($locs === false || !isset($locs[$id]))
return [];
BaseConfig::prepareWithOverrides([
- 'locationid' => $locs[$id]['parentlocationid']
+ 'locationid' => $locs[$id]['parentlocationid'] ?? 0
]);
return ConfigHolder::getRecursiveConfig(true);
}
diff --git a/modules-available/locations/inc/locationutil.inc.php b/modules-available/locations/inc/locationutil.inc.php
index 708cc8a2..91117445 100644
--- a/modules-available/locations/inc/locationutil.inc.php
+++ b/modules-available/locations/inc/locationutil.inc.php
@@ -48,7 +48,7 @@ class LocationUtil
if ($overlapOther) {
$overlapOther = array();
foreach ($other as $entry) {
- if (!isset($locs[$entry['lid1']]) || !isset($locs[$entry['lid2']]))
+ if (!isset($locs[$entry['lid1']]) && !isset($locs[$entry['lid2']]))
continue;
if (in_array($entry['lid1'], $locs[$entry['lid2']]['parents']) || in_array($entry['lid2'], $locs[$entry['lid1']]['parents']))
continue;
@@ -70,12 +70,9 @@ class LocationUtil
* grouped by the location the client was assigned to via roomplanner.
* Otherwise, just return an assoc array with the requested locationid, name
* and a list of all clients that are wrongfully assigned to that room.
- * @param int $locationId
- * @return array
*/
- public static function getMachinesWithLocationMismatch($locationId = 0, $checkPerms = false)
+ public static function getMachinesWithLocationMismatch(int $locationId = 0, bool $checkPerms = false): array
{
- $locationId = (int)$locationId;
if ($checkPerms) {
if ($locationId !== 0) {
// Query details for specific location -- use assert and fake array
@@ -124,7 +121,7 @@ class LocationUtil
$res = Database::simpleQuery($query, $params);
$return = [];
$locs = false;
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if (Location::isFixedLocationValid($row['fixedlocationid'], $row['subnetlocationid']))
continue;
$lid = (int)$row['fixedlocationid'];
@@ -162,19 +159,17 @@ class LocationUtil
}
if (empty($return))
return $return;
- if ($locationId === 0) {
+ if ($locationId === 0)
return array_values($return);
- } else {
- return $return[$locationId];
- }
+ return $return[$locationId];
}
- private static function overlap($net1, $net2)
+ private static function overlap(array $net1, array $net2): bool
{
return ($net1['startaddr'] <= $net2['endaddr'] && $net1['endaddr'] >= $net2['startaddr']);
}
- public static function rangeToLongVerbose($start, $end)
+ public static function rangeToLongVerbose(string $start, string $end): ?array
{
$result = self::rangeToLong($start, $end);
list($startLong, $endLong) = $result;
@@ -185,24 +180,19 @@ class LocationUtil
Message::addWarning('main.value-invalid', 'end addr', $start);
}
if ($startLong === false || $endLong === false)
- return false;
+ return null;
if ($startLong > $endLong) {
Message::addWarning('main.value-invalid', 'range', $start . ' - ' . $end);
- return false;
+ return null;
}
return $result;
}
- public static function rangeToLong($start, $end)
+ /** @return array{0: int, 1: int} */
+ public static function rangeToLong(string $start, string $end): array
{
$startLong = ip2long($start);
$endLong = ip2long($end);
- if ($startLong !== false) {
- $startLong = sprintf("%u", $startLong);
- }
- if ($endLong !== false) {
- $endLong = sprintf("%u", $endLong);
- }
return array($startLong, $endLong);
}
diff --git a/modules-available/locations/inc/openingtimes.inc.php b/modules-available/locations/inc/openingtimes.inc.php
new file mode 100644
index 00000000..74dae7c3
--- /dev/null
+++ b/modules-available/locations/inc/openingtimes.inc.php
@@ -0,0 +1,62 @@
+<?php
+
+class OpeningTimes
+{
+
+ /**
+ * Get opening times for given location.
+ * Format is the decoded JSON from DB column, i.e. currently a list of entries:
+ * <pre>{
+ * "days": ["Monday", "Tuesday", ...],
+ * "openingtime": "8:00",
+ * "closingtime": "20:00"
+ * }</pre>
+ */
+ public static function forLocation(int $locationId): ?array
+ {
+ static $openingTimesList = false;
+ if ($openingTimesList === false) {
+ $openingTimesList = Database::queryKeyValueList("SELECT locationid, openingtime FROM location
+ WHERE openingtime IS NOT NULL");
+ }
+ $chain = Location::getLocationRootChain($locationId);
+ foreach ($chain as $lid) {
+ if (isset($openingTimesList[$lid])) {
+ if (is_string($openingTimesList[$lid])) {
+ $openingTimesList[$lid] = json_decode($openingTimesList[$lid], true);
+ }
+ return $openingTimesList[$lid];
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Check whether given location is open according to openingtimes.
+ * @param int $locationId location
+ * @param int $openOffsetMin offset to apply to opening times when checking. this is subtracted from opening time
+ * @param int $closeOffsetMin offset to apply to closing times when checking. this is added to closing time
+ */
+ public static function isRoomOpen(int $locationId, int $openOffsetMin = 0, int $closeOffsetMin = 0): bool
+ {
+ $openingTimes = self::forLocation($locationId);
+ if ($openingTimes === null)
+ return true; // No opening times should mean room is always open
+ $now = time();
+ $today = date('l', $now);
+ foreach ($openingTimes as $row) {
+ foreach ($row['days'] as $day) {
+ if ($day !== $today)
+ continue; // Not today!
+ if (strtotime("today {$row['openingtime']} -$openOffsetMin minutes") > $now)
+ continue;
+ if (strtotime("today {$row['closingtime']} +$closeOffsetMin minutes") < $now)
+ continue;
+ // Bingo!
+ return true;
+ }
+ }
+ return false;
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/locations/install.inc.php b/modules-available/locations/install.inc.php
index c5fd9688..46a6544c 100644
--- a/modules-available/locations/install.inc.php
+++ b/modules-available/locations/install.inc.php
@@ -15,7 +15,7 @@ $res[] = tableCreate('location', '
`locationid` INT(11) NOT NULL AUTO_INCREMENT,
`parentlocationid` INT(11) NOT NULL,
`locationname` VARCHAR(100) NOT NULL,
- `openingtime` BLOB,
+ `openingtime` BLOB DEFAULT NULL,
PRIMARY KEY (`locationid`),
KEY `locationname` (`locationname`),
KEY `parentlocationid` (`parentlocationid`)
@@ -40,7 +40,7 @@ $res[] = tableAddConstraint('setting_location', 'locationid', 'location', 'locat
// 2020-07-14 Add openingtime column to location table, then migrate data and delete the column from locationinfo
if (!tableHasColumn('location', 'openingtime')) {
- if (Database::exec("ALTER TABLE location ADD openingtime BLOB") === false) {
+ if (Database::exec("ALTER TABLE location ADD openingtime BLOB DEFAULT NULL") === false) {
finalResponse(UPDATE_FAILED, 'Could not create openingtime column');
}
$res[] = UPDATE_DONE;
@@ -60,5 +60,8 @@ if (tableHasColumn('locationinfo_locationconfig', 'openingtime')) {
$res[] = UPDATE_DONE;
}
+// 2021-03-19: Fix this. No idea how this came to be, maybe during dev only? But better be safe...
+Database::exec("UPDATE location SET openingtime = NULL WHERE openingtime = ''");
+
// Create response for browser
responseFromArray($res);
diff --git a/modules-available/locations/lang/de/messages.json b/modules-available/locations/lang/de/messages.json
index ec1cac73..7167f5d3 100644
--- a/modules-available/locations/lang/de/messages.json
+++ b/modules-available/locations/lang/de/messages.json
@@ -1,5 +1,9 @@
{
"added-x-entries": "Eintr\u00e4ge hinzugef\u00fcgt: {{0}}",
+ "ignored-invalid-end": "Zeitspanne ignoriert: Ung\u00fcltiges Ende {{0}}",
+ "ignored-invalid-range": "Zeitspanne ignoriert: {{0}} - {{1}}",
+ "ignored-invalid-start": "Zeitspanne ignoriert: Ung\u00fcltiger Start {{0}}",
+ "ignored-line-no-days": "Eine Zeitspanne wurde ignoriert, da ihr keine Tage zugewiesen wurden",
"invalid-location-id": "Ung\u00fcltige Orts-id: {{0}}",
"location-deleted": "Location wurde gel\u00f6scht (Locations: {{0}}, IDs: {{1}})",
"location-updated": "Location {{0}} wurde aktualisiert",
diff --git a/modules-available/locations/lang/de/permissions.json b/modules-available/locations/lang/de/permissions.json
index 8a9336b3..e8e3cced 100644
--- a/modules-available/locations/lang/de/permissions.json
+++ b/modules-available/locations/lang/de/permissions.json
@@ -2,6 +2,7 @@
"location.add": "R\u00e4ume\/Orte hinzuf\u00fcgen.",
"location.delete": "R\u00e4ume\/Orte l\u00f6schen.",
"location.edit.name": "Orte umbenennen.",
+ "location.edit.openingtimes": "\u00d6ffnungszeiten bearbeiten.",
"location.edit.parent": "Den \u00fcbergeordneten Ort eines Ortes \u00e4ndern.",
"location.edit.subnets": "Die IP-Ranges eines Ortes \u00e4ndern.",
"location.view": "R\u00e4ume anschauen.",
diff --git a/modules-available/locations/lang/de/template-tags.json b/modules-available/locations/lang/de/template-tags.json
index 94f95ded..ecd3a647 100644
--- a/modules-available/locations/lang/de/template-tags.json
+++ b/modules-available/locations/lang/de/template-tags.json
@@ -3,18 +3,19 @@
"lang_areYouSureNoUndo": "Sind Sie sicher? Diese Aktion kann nicht r\u00fcckg\u00e4ngig gemacht werden.",
"lang_assignSubnetExplanation": "Rechner, die in einen der hier aufgef\u00fchrten Adressbereiche fallen, werden diesem Ort zugeschrieben und erhalten damit z.B. f\u00fcr diesen Raum angepasste Veranstaltungslisten.",
"lang_assignedSubnets": "Zugeordnete Subnetze bzw. IP-Bereiche",
- "lang_bootMenu": "Bootmen\u00fc",
+ "lang_automatedMachineActions": "Automatisierte Aktionen",
"lang_closingTime": "Schlie\u00dfungszeit",
"lang_day": "Tag",
"lang_deleteChildLocations": "Untergeordnete Orte ebenfalls l\u00f6schen",
"lang_deleteLocation": "Ort l\u00f6schen",
"lang_deleteSubnet": "Bereich l\u00f6schen",
"lang_deleteSubnetWarning": "Alle zum L\u00f6schen markierten Subnetze werden gel\u00f6scht. Diese Aktion kann nicht r\u00fcckg\u00e4ngig gemacht werden.",
- "lang_editConfigVariables": "Konfig.-Variablen",
+ "lang_editNews": "News\/Hilfe bearbeiten",
"lang_editRoomplan": "Raumplan bearbeiten",
"lang_endAddress": "Endadresse",
- "lang_expertMode": "Experten Modus",
+ "lang_expertMode": "Expertenmodus",
"lang_fixMachineAssign": "Zuweisungen anzeigen\/aufheben",
+ "lang_inheritOpeningTimes": "Vom \u00fcbergeordneten Ort \u00fcbernehmen",
"lang_ip": "IP-Adresse",
"lang_listOfSubnets": "Liste der Subnetze",
"lang_location": "Ort",
@@ -28,7 +29,6 @@
"lang_locationsMainHeading": "Verwaltung von R\u00e4umen\/Orten",
"lang_machine": "Rechner",
"lang_machineCount": "Rechner",
- "lang_machineLoad": "Besetzt",
"lang_matchingMachines": "Enthaltene Rechner",
"lang_mismatchHeading": "Rechner mit widerspr\u00fcchlicher Raumzuweisung",
"lang_mismatchIntroText": "Die hier aufgelisteten Rechner wurden mittels des Raumplaners im oben genannten Raum platziert. Ihrer IP-Adresse nach fallen diese jedoch in einen anderen Raum (durch die f\u00fcr diesen definierten IP-Ranges). Wenn Sie die entsprechenden Rechner hier markieren und auf \"Zur\u00fccksetzen\" klicken, werden die Rechner aus dem oben genannten Raumplan entfernt. Wenn Sie stattdessen auf \"Verschieben\" klicken, werden die Rechner mit ihrer aktuellen Position aus dem jetzigen Raum in den eigentlichen Raum (siehe letzte Spalte der Tabelle) verschoben.",
@@ -36,19 +36,23 @@
"lang_moveMachines": "In durch Subnet zugeordneten Raum verschieben",
"lang_moveable": "Verschiebbar",
"lang_name": "Name",
- "lang_numMachinesWithOverrides": "Anzahl Rechner, bei denen mindestens eine Konfigurationsvariable \u00fcberschrieben wird",
+ "lang_nextEvent": "N\u00e4chstes geplantes Ereignis",
"lang_offsetEarly": "Min. vorher",
"lang_offsetLate": "Min. danach",
"lang_openingTime": "\u00d6ffnungszeit",
- "lang_overridenVarsForLocation": "Anzahl Variablen, die an diesem Ort \u00fcberschrieben werden",
"lang_parentLocation": "\u00dcbergeordneter Ort",
"lang_referencingLectures": "Veranstaltungen",
+ "lang_remoteAccessConstraints": "Beschr\u00e4nkungen f\u00fcr den Fernzugriff",
+ "lang_remoteAccessHelp": "Wenn dieser Raum einer Fernzugriffsgruppe zugewiesen ist, kann der Zugriff hier eingeschr\u00e4nkt werden, sodass er z.B. nur au\u00dferhalb der oben angegebenen \u00d6ffnungszeiten tats\u00e4chlich f\u00fcr den Zugriff zur Verf\u00fcgung steht. Ist der Raum in keiner Fernzugriffsgruppe, hat diese Einstellung keine Auswirkungen.",
+ "lang_remoteAccessNever": "Niemals erlauben",
+ "lang_remoteAccessNoRestriction": "Keine Einschr\u00e4nkung",
+ "lang_remoteAccessOnlyWhenClosed": "Nur, wenn der Raum geschlossen ist",
"lang_resetMachines": "Raumzuweisung zur\u00fccksetzen",
"lang_saturday": "Samstag",
"lang_shortFriday": "Fr",
"lang_shortMonday": "Mo",
"lang_shortSaturday": "Sa",
- "lang_shortSunday": "Su",
+ "lang_shortSunday": "So",
"lang_shortThursday": "Do",
"lang_shortTuesday": "Di",
"lang_shortWednesday": "Mi",
@@ -57,7 +61,6 @@
"lang_startAddress": "Startadresse",
"lang_subnet": "IP-Bereich",
"lang_sunday": "Sonntag",
- "lang_sysConfig": "Lokalisierung",
"lang_thisListByLocation": "Orte",
"lang_thisListBySubnet": "Subnetze",
"lang_unassignedMachines": "Rechner, die in keinen definierten Ort fallen",
diff --git a/modules-available/locations/lang/en/messages.json b/modules-available/locations/lang/en/messages.json
index fd7c740a..16e0ef76 100644
--- a/modules-available/locations/lang/en/messages.json
+++ b/modules-available/locations/lang/en/messages.json
@@ -1,5 +1,9 @@
{
"added-x-entries": "Entries added: {{0}}",
+ "ignored-invalid-end": "Ignoring timespan: Invalid end {{0}}",
+ "ignored-invalid-range": "Ignoring timespan: {{0}} - {{1}}",
+ "ignored-invalid-start": "Ignoring timespan: Invalid start {{0}}",
+ "ignored-line-no-days": "Ignoring timespan without any days selected",
"invalid-location-id": "Invalid location id: {{0}}",
"location-deleted": "Location has been deleted (locations: {{0}}, IDs: {{1}})",
"location-updated": "Location {{0}} has been updated",
diff --git a/modules-available/locations/lang/en/permissions.json b/modules-available/locations/lang/en/permissions.json
index 1d01a9d1..c9f7a3ab 100644
--- a/modules-available/locations/lang/en/permissions.json
+++ b/modules-available/locations/lang/en/permissions.json
@@ -2,6 +2,7 @@
"location.add": "Add locations.",
"location.delete": "Delete locations.",
"location.edit.name": "Rename locations.",
+ "location.edit.openingtimes": "Edit opening times.",
"location.edit.parent": "Change the parent location of a location.",
"location.edit.subnets": "Edit the IP ranges of a location.",
"location.view": "View locations.",
diff --git a/modules-available/locations/lang/en/template-tags.json b/modules-available/locations/lang/en/template-tags.json
index 467af8e1..55eaec86 100644
--- a/modules-available/locations/lang/en/template-tags.json
+++ b/modules-available/locations/lang/en/template-tags.json
@@ -3,18 +3,19 @@
"lang_areYouSureNoUndo": "Are you sure? This cannot be undone!",
"lang_assignSubnetExplanation": "Client machines which fall into an IP range listed below will be assigned to this location and will see an according lecture list (e.g. they will see lectures that are exclusively assigned to this location).",
"lang_assignedSubnets": "Assigned subnets \/ IP ranges",
- "lang_bootMenu": "Boot menu",
+ "lang_automatedMachineActions": "Automated actions",
"lang_closingTime": "Closing time",
"lang_day": "Day",
"lang_deleteChildLocations": "Delete child locations as well",
"lang_deleteLocation": "Delete location",
"lang_deleteSubnet": "Delete range",
"lang_deleteSubnetWarning": "All subnets marked for deletion will be deleted. This cannot be undone!",
- "lang_editConfigVariables": "Config vars",
+ "lang_editNews": "Edit news\/help",
"lang_editRoomplan": "Edit roomplan",
"lang_endAddress": "End address",
"lang_expertMode": "Expert mode",
"lang_fixMachineAssign": "Fix or remove assignment",
+ "lang_inheritOpeningTimes": "Inherit from parent location",
"lang_ip": "IP address",
"lang_listOfSubnets": "List of subnets",
"lang_location": "Location",
@@ -28,7 +29,6 @@
"lang_locationsMainHeading": "Manage rooms and locations",
"lang_machine": "Machine",
"lang_machineCount": "Clients",
- "lang_machineLoad": "In use",
"lang_matchingMachines": "Matching clients",
"lang_mismatchHeading": "Machines with mismatching room plan assignment",
"lang_mismatchIntroText": "Machines listed here are assigned to the room above, but judging from their IP address, should actually be in another room (because of the IP range(s) assigned to that room). By selecting machines below and clicking \"reset\", they will be removed from their current room plan. If you choose \"move\", they will be transferred to the plan of the room they should actually belong to (see last column of table).",
@@ -36,13 +36,17 @@
"lang_moveMachines": "Move to room designated by IP address",
"lang_moveable": "Moveable",
"lang_name": "Name",
- "lang_numMachinesWithOverrides": "Number of clients where at least one variable is overridden",
+ "lang_nextEvent": "Next scheduled event",
"lang_offsetEarly": "min. before",
"lang_offsetLate": "min. after",
"lang_openingTime": "Opening Time",
- "lang_overridenVarsForLocation": "Number of variables that get overridden for this location",
"lang_parentLocation": "Parent location",
"lang_referencingLectures": "Assigned Lectures",
+ "lang_remoteAccessConstraints": "Remote access constraints",
+ "lang_remoteAccessHelp": "If this room is part of a remote access group, you can limit its accessibility here, like only making it available outside of the business hours configured above. If this room isn't assigned to any remote access group, this setting doesn't have any effect.",
+ "lang_remoteAccessNever": "Never allow access",
+ "lang_remoteAccessNoRestriction": "No restrictions",
+ "lang_remoteAccessOnlyWhenClosed": "Only when room is not open",
"lang_resetMachines": "Reset room assignment",
"lang_saturday": "Saturday",
"lang_shortFriday": "Fri",
@@ -57,7 +61,6 @@
"lang_startAddress": "Start address",
"lang_subnet": "IP range",
"lang_sunday": "Sunday",
- "lang_sysConfig": "Localization\/Integration",
"lang_thisListByLocation": "Locations",
"lang_thisListBySubnet": "Subnets",
"lang_unassignedMachines": "Machines not matching any location",
diff --git a/modules-available/locations/pages/cleanup.inc.php b/modules-available/locations/pages/cleanup.inc.php
index d10dbac0..423d6a6b 100644
--- a/modules-available/locations/pages/cleanup.inc.php
+++ b/modules-available/locations/pages/cleanup.inc.php
@@ -3,7 +3,7 @@
class SubPage
{
- public static function doPreprocess($action)
+ public static function doPreprocess($action): bool
{
if ($action === 'resetmachines') {
self::resetMachines();
@@ -16,17 +16,20 @@ class SubPage
return false;
}
- public static function doRender($action)
+ public static function doRender($action): bool
{
$list = self::loadForLocation();
if ($list === false)
return true;
+ $list['canmove'] = array_reduce($list['clients'], function (bool $carry, array $item): bool {
+ return $carry || ($item['canmove'] ?? false);
+ }, false);
Permission::addGlobalTags($list['perms'], NULL, ['subnets.edit', 'location.view']);
Render::addTemplate('mismatch-cleanup', $list);
return true;
}
- public static function doAjax($action)
+ public static function doAjax($action): bool
{
return false;
}
diff --git a/modules-available/locations/pages/details.inc.php b/modules-available/locations/pages/details.inc.php
index 2f444157..279eee44 100644
--- a/modules-available/locations/pages/details.inc.php
+++ b/modules-available/locations/pages/details.inc.php
@@ -3,24 +3,25 @@
class SubPage
{
- public static function doPreprocess($action)
+ public static function doPreprocess($action): bool
{
if ($action === 'updatelocation') {
self::updateLocation();
return true;
- } else if ($action === 'updateOpeningtimes') {
+ }
+ if ($action === 'updateOpeningtimes') {
self::updateOpeningTimes();
return true;
}
return false;
}
- public static function doRender($action)
+ public static function doRender($action): bool
{
return false;
}
- public static function doAjax($action)
+ public static function doAjax($action): bool
{
if ($action === 'showlocation') {
self::ajaxShowLocation();
@@ -35,25 +36,29 @@ class SubPage
private static function updateOpeningTimes()
{
+ $otInherited = Request::post('openingtimes-inherited', false, 'bool');
$openingTimes = Request::post('openingtimes', Request::REQUIRED, 'string');
$locationid = Request::post('locationid', Request::REQUIRED, 'int');
$wol = Request::post('wol', false, 'bool');
- $woloffset = Request::post('wol-offset', 0, 'int');
+ $wolOffset = Request::post('wol-offset', 0, 'int');
$sd = Request::post('sd', false, 'bool');
- $sdoffset = Request::post('sd-offset', 0, 'int');
+ $sdOffset = Request::post('sd-offset', 0, 'int');
+ $raMode = Request::post('ra-mode', 'ALWAYS', 'string');
- User::assertPermission('location.edit.*', $locationid); // TODO: Introduce permission
+ User::assertPermission('location.edit.openingtimes', $locationid);
// Construct opening-times for database
- if ($openingTimes !== '') {
+ if ($otInherited || $openingTimes === '') {
+ $openingTimes = null;
+ } else {
$openingTimes = json_decode($openingTimes, true);
if (!is_array($openingTimes)) {
- $openingTimes = '';
+ $openingTimes = null;
} else {
$mangled = array();
foreach (array_keys($openingTimes) as $key) {
$entry = $openingTimes[$key];
- if (!isset($entry['days']) || !is_array($entry['days']) || empty($entry['days'])) {
+ if (empty($entry['days']) || !is_array($entry['days'])) {
Message::addError('ignored-line-no-days');
continue;
}
@@ -89,34 +94,27 @@ class SubPage
array('locationid' => $locationid, 'openingtime' => $openingTimes));
if (Module::isAvailable('rebootcontrol')) {
- if ($wol) {
- $options = array();
- // Sanity checks
- if ($woloffset > 15) $woloffset = 15;
- else if ($woloffset < 0) $woloffset = 0;
- $options['wol-offset'] = $woloffset;
- Scheduler::updateSchedule($locationid, 'wol', $options, $openingTimes);
- } else {
- Scheduler::deleteSchedule($locationid, 'wol');
- }
- if ($sd) {
- $options = array();
- // Sanity checks
- if ($sdoffset > 15) $sdoffset = 15;
- else if ($sdoffset < 0) $sdoffset = 0;
- $options['sd-offset'] = $sdoffset;
- Scheduler::updateSchedule($locationid, 'sd', $options, $openingTimes);
- } else {
- Scheduler::deleteSchedule($locationid, 'sd');
+ // Set options
+ if (!Scheduler::isValidRaMode($raMode)) {
+ $raMode = Scheduler::RA_ALWAYS;
}
+ Scheduler::setLocationOptions($locationid, [
+ 'wol' => $wol,
+ 'sd' => $sd,
+ 'wol-offset' => $wolOffset,
+ 'sd-offset' => $sdOffset,
+ 'ra-mode' => $raMode,
+ ]);
}
}
private static function getTime($str)
{
$str = explode(':', $str);
- if (count($str) !== 2) return false;
- if ($str[0] < 0 || $str[0] > 23 || $str[1] < 0 || $str[1] > 59) return false;
+ if (count($str) !== 2)
+ return false;
+ if ($str[0] < 0 || $str[0] > 23 || $str[1] < 0 || $str[1] > 59)
+ return false;
return $str[0] * 60 + $str[1];
}
@@ -175,7 +173,7 @@ class SubPage
Util::redirect('?do=Locations');
}
- private static function updateLocationData($location)
+ private static function updateLocationData(array $location): bool
{
$locationId = (int)$location['locationid'];
$newParent = Request::post('parentlocationid', false, 'integer');
@@ -219,14 +217,12 @@ class SubPage
return $newParent != $location['parentlocationid'];
}
- private static function updateLocationSubnets()
+ private static function updateLocationSubnets(): bool
{
$locationId = Request::post('locationid', false, 'integer');
if (!User::hasPermission('location.edit.subnets', $locationId))
return false;
- $change = false;
-
// Deletion first
$dels = Request::post('deletesubnet', false);
$deleteCount = 0;
@@ -245,8 +241,9 @@ class SubPage
$starts = Request::post('startaddr', false);
$ends = Request::post('endaddr', false);
if (!is_array($starts) || !is_array($ends)) {
- return $change;
+ return false;
}
+ $change = false;
$editCount = 0;
$stmt = Database::prepare('UPDATE subnet SET startaddr = :start, endaddr = :end'
. ' WHERE subnetid = :id');
@@ -261,7 +258,7 @@ class SubPage
continue;
}
$range = LocationUtil::rangeToLongVerbose($start, $end);
- if ($range === false)
+ if ($range === null)
continue;
list($startLong, $endLong) = $range;
if ($stmt->execute(array('id' => $subnetid, 'start' => $startLong, 'end' => $endLong))) {
@@ -279,18 +276,18 @@ class SubPage
return $change;
}
- private static function addNewLocationSubnets($location)
+ private static function addNewLocationSubnets(array $location): bool
{
$locationId = (int)$location['locationid'];
if (!User::hasPermission('location.edit.subnets', $locationId))
return false;
- $change = false;
$starts = Request::post('newstartaddr', false);
$ends = Request::post('newendaddr', false);
if (!is_array($starts) || !is_array($ends)) {
- return $change;
+ return false;
}
+ $change = false;
$count = 0;
$stmt = Database::prepare('INSERT INTO subnet SET startaddr = :start, endaddr = :end, locationid = :location');
foreach ($starts as $key => $start) {
@@ -335,7 +332,7 @@ class SubPage
$res = Database::simpleQuery("SELECT subnetid, startaddr, endaddr FROM subnet WHERE locationid = :lid",
array('lid' => $locationId));
$rows = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$row['startaddr'] = long2ip($row['startaddr']);
$row['endaddr'] = long2ip($row['endaddr']);
$rows[] = $row;
@@ -345,6 +342,7 @@ class SubPage
'locationname' => $loc['locationname'],
'list' => $rows,
'roomplanner' => Module::get('roomplanner') !== false,
+ 'news' => Module::get('news') !== false && User::hasPermission('.news.*', $loc['locationid']),
'parents' => Location::getLocations($loc['parentlocationid'], $locationId, true)
);
@@ -373,7 +371,7 @@ class SubPage
if (Module::get('statistics') !== false) {
$mres = Database::simpleQuery("SELECT state FROM machine"
. " WHERE machine.locationid = :lid", array('lid' => $locationId));
- while ($row = $mres->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($mres as $row) {
$count++;
if ($row['state'] === 'IDLE') {
$online++;
@@ -397,48 +395,61 @@ class SubPage
$data['used_percent'] = $count === 0 ? 0 : round(($used / $count) * 100);
- Permission::addGlobalTags($data['perms'], $locationId, ['location.edit.name', 'location.edit.subnets', 'location.delete', '.roomplanner.edit'], 'save_button');
+ Permission::addGlobalTags($data['perms'], $locationId,
+ ['location.edit.name', 'location.edit.subnets', 'location.delete', 'location.edit.openingtimes', '.roomplanner.edit'],
+ 'save_button');
if (empty($allowedLocs)) {
$data['perms']['location']['edit']['parent']['disabled'] = 'disabled';
} else {
unset($data['perms']['save_button']);
}
+ if (Module::get('rebootcontrol') !== false) {
+ $res = Database::queryFirst("SELECT action, nextexecution FROM `reboot_scheduler`
+ WHERE locationid = :id", ['id' => $locationId]);
+ if ($res !== false && $res['nextexecution'] > 0) {
+ $data['next_action'] = $res['action'];
+ $data['next_time'] = Util::prettyTime($res['nextexecution']);
+ }
+ }
+
echo Render::parse('location-subnets', $data);
}
- private static function ajaxOpeningTimes($id) {
- User::assertPermission('location.edit', $id);
- $openTimes = Database::queryFirst("SELECT openingtime FROM `location` WHERE locationid = :id", array('id' => $id));
- if ($openTimes !== false) {
+ private static function ajaxOpeningTimes($id)
+ {
+ User::assertPermission('location.edit.openingtimes', $id);
+ $data = ['id' => $id];
+ $openTimes = Database::queryFirst("SELECT openingtime FROM `location`
+ WHERE locationid = :id", array('id' => $id));
+ if ($openTimes === false) {
+ Message::addError('invalid-location-id', $id);
+ return;
+ }
+ if ($openTimes['openingtime'] !== null) {
$openingTimes = json_decode($openTimes['openingtime'], true);
+ } else {
+ $openingTimes = OpeningTimes::forLocation($id);
+ $data['openingtimes_inherited'] = 'checked';
}
if (!isset($openingTimes) || !is_array($openingTimes)) {
$openingTimes = array();
}
- $data = array('id' => $id);
$data['expertMode'] = !self::isSimpleMode($openingTimes);
$data['schedule_data'] = json_encode($openingTimes);
$rebootcontrol = Module::isAvailable('rebootcontrol');
$data['rebootcontrol'] = $rebootcontrol;
if ($rebootcontrol) {
- $wol = Database::queryFirst("SELECT options FROM `reboot_scheduler` WHERE locationid = :id AND action = 'wol'", array('id' => $id));
- if ($wol !== false) {
- $data['wol'] = true;
- $data['wol-options'] = json_decode($wol['options']);
- }
- $sd = Database::queryFirst("SELECT options FROM `reboot_scheduler` WHERE locationid = :id AND action = 'sd'", array('id' => $id));
- if ($sd !== false) {
- $data['sd'] = true;
- $data['sd-options'] = json_decode($sd['options']);
- }
+ $data['scheduler-options'] = Scheduler::getLocationOptions($id);
+ $data['scheduler_' . $data['scheduler-options']['ra-mode'] . '_checked'] = 'checked';
}
echo Render::parse('ajax-opening-location', $data);
}
- private static function isSimpleMode(&$array) {
+ private static function isSimpleMode(&$array): bool
+ {
if (empty($array))
return true;
// Decompose by day
@@ -526,7 +537,7 @@ class SubPage
// $start must lie before range start, otherwise we'd have hit the case above
$e = $current[1];
unset($array[$day][$key]);
- continue;
+ //continue;
}
}
$array[$day][] = array($s, $e);
diff --git a/modules-available/locations/pages/locations.inc.php b/modules-available/locations/pages/locations.inc.php
index 8afb454a..78818328 100644
--- a/modules-available/locations/pages/locations.inc.php
+++ b/modules-available/locations/pages/locations.inc.php
@@ -3,7 +3,7 @@
class SubPage
{
- public static function doPreprocess($action)
+ public static function doPreprocess($action): bool
{
if ($action === 'addlocations') {
self::addLocations();
@@ -12,7 +12,7 @@ class SubPage
return false;
}
- public static function doRender($getAction)
+ public static function doRender($getAction): bool
{
if ($getAction === false) {
if (User::hasPermission('location.view')) {
@@ -32,7 +32,7 @@ class SubPage
return false;
}
- public static function doAjax($action)
+ public static function doAjax($action): bool
{
return false;
}
@@ -93,126 +93,43 @@ class SubPage
$unassignedIdle = $unassignedLoad = $unassignedOverrides = 0;
$allowedLocationIds = User::getAllowedLocations("location.view");
- foreach (array_keys($locationList) as $lid) {
- if (!User::hasPermission('.baseconfig.view', $lid)) {
- $locationList[$lid]['havebaseconfig'] = false;
- }
- if (!User::hasPermission('.sysconfig.config.view-list', $lid)) {
- $locationList[$lid]['havesysconfig'] = false;
- }
- if (!User::hasPermission('.statistics.view.list', $lid)) {
- $locationList[$lid]['havestatistics'] = false;
- }
- if (!User::hasPermission('.serversetup.ipxe.menu.assign', $lid)) {
- $locationList[$lid]['haveipxe'] = false;
- }
- if (!in_array($lid, $allowedLocationIds)) {
- $locationList[$lid]['show-only'] = true;
- }
- }
- // Client statistics
- if (Module::get('statistics') !== false) {
- $unassigned = 0;
- $extra = '';
- if (in_array(0, $allowedLocationIds)) {
- $extra = ' OR locationid IS NULL';
- }
- $res = Database::simpleQuery("SELECT m.locationid, Count(*) AS cnt,
- Sum(If(m.state = 'OCCUPIED', 1, 0)) AS used, Sum(If(m.state = 'IDLE', 1, 0)) AS idle
- FROM machine m WHERE (locationid IN (:allowedLocationIds) $extra) GROUP BY locationid", compact('allowedLocationIds'));
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- $locId = (int)$row['locationid'];
- if (isset($locationList[$locId])) {
- $locationList[$locId]['clientCount'] = $row['cnt'];
- $locationList[$locId]['clientLoad'] = round(100 * $row['used'] / $row['cnt']);
- $locationList[$locId]['clientIdle'] = round(100 * ($row['used'] + $row['idle']) / $row['cnt']);
- } else {
- $unassigned += $row['cnt'];
- $unassignedLoad += $row['used'];
- $unassignedIdle += $row['idle'];
- }
- }
- $res = Database::simpleQuery("SELECT m.locationid, Count(DISTINCT sm.machineuuid) AS cnt FROM setting_machine sm
- INNER JOIN machine m USING (machineuuid) GROUP BY m.locationid");
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- $locId = (int)$row['locationid'];
- if (isset($locationList[$locId])) {
- $locationList[$locId]['machineVarsOverrideCount'] = $row['cnt'];
- } else {
- $unassignedOverrides += $row['cnt'];
- }
- }
- unset($loc);
- foreach ($locationList as &$loc) {
- if (!in_array($loc['locationid'], $allowedLocationIds))
- continue;
- if (!isset($loc['clientCountSum'])) {
- $loc['clientCountSum'] = 0;
- }
- if (!isset($loc['clientCount'])) {
- $loc['clientCount'] = $loc['clientIdle'] = $loc['clientLoad'] = 0;
- }
- $loc['clientCountSum'] += $loc['clientCount'];
- foreach ($loc['parents'] as $pid) {
- if (!in_array($pid, $allowedLocationIds))
- continue;
- $locationList[(int)$pid]['hasChild'] = true;
- $locationList[(int)$pid]['clientCountSum'] += $loc['clientCount'];
- }
- }
- unset($loc);
-
- }
- // Show currently active sysconfig for each location
- $defaultConfig = false;
- if (Module::isAvailable('sysconfig')) {
- $confs = SysConfig::getAll();
- foreach ($confs as $conf) {
- if (strlen($conf['locs']) === 0)
- continue;
- $confLocs = explode(',', $conf['locs']);
- foreach ($confLocs as $locId) {
- settype($locId, 'int');
- if ($locId === 0) {
- $defaultConfig = $conf['title'];
+ $plugins = [];
+ foreach (Hook::load('locations-column') as $hook) {
+ $c = @include($hook->file);
+ if ($c instanceof AbstractLocationColumn) {
+ $plugins[sprintf('%04d.%s', $c->priority(), $hook->moduleId)] = $c;
+ } elseif (is_array($c)) {
+ foreach ($c as $i => $cc) {
+ if ($cc instanceof AbstractLocationColumn) {
+ $plugins[sprintf('%04d.%d.%s', $cc->priority(), $i, $hook->moduleId)] = $cc;
}
- if (!isset($locationList[$locId]))
- continue;
- $locationList[$locId] += array('configName' => $conf['title'], 'configClass' => 'slx-bold');
}
}
- self::propagateFields($locationList, $defaultConfig, 'configName', 'configClass');
}
- // Count overridden config vars
- if (Module::get('baseconfig') !== false) {
- $res = Database::simpleQuery("SELECT locationid, Count(*) AS cnt FROM `setting_location`
- WHERE locationid IN (:allowedLocationIds) GROUP BY locationid", compact('allowedLocationIds'));
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- $lid = (int)$row['locationid'];
- if (isset($locationList[$lid])) {
- $locationList[$lid]['overriddenVars'] = $row['cnt'];
- }
+ ksort($plugins);
+ foreach ($locationList as $lid => &$loc) {
+ $loc['plugins'] = [];
+ foreach ($plugins as $pk => $plugin) {
+ $loc['plugins'][$pk] = [
+ 'url' => $plugin->getEditUrl($lid),
+ 'html' => $plugin->getColumnHtml($lid),
+ ];
+ }
+ if (!in_array($lid, $allowedLocationIds)) {
+ $locationList[$lid]['show-only'] = true;
}
- // Confusing because the count might be inaccurate within a branch
- //$this->propagateFields($locationList, '', 'overriddenVars', 'overriddenClass');
}
- // Show ipxe menu
- if (Module::isAvailable('serversetup') && class_exists('IPxe')) {
- $res = Database::simpleQuery("SELECT ml.locationid, m.title, ml.defaultentryid FROM serversetup_menu m
- INNER JOIN serversetup_menu_location ml USING (menuid)
- WHERE locationid IN (:allowedLocationIds) GROUP BY locationid", compact('allowedLocationIds'));
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- $lid = (int)$row['locationid'];
- if (isset($locationList[$lid])) {
- if ($row['defaultentryid'] !== null) {
- $row['title'] .= '(*)';
- }
- $locationList[$lid]['customMenu'] = $row['title'];
- }
+ unset($loc);
+ foreach ($plugins as $pk => $plugin) {
+ if ($plugin->propagateColumn()) {
+ self::propagateFields($locationList, $plugin, $pk);
}
- self::propagateFields($locationList, '', 'customMenu', 'customMenuClass');
}
+ foreach ($locationList as &$loc) {
+ $loc['plugins'] = array_values($loc['plugins']);
+ }
+ unset($loc);
$addAllowedLocs = User::getAllowedLocations("location.add");
$addAllowedList = Location::getLocations(0, 0, true);
@@ -224,44 +141,36 @@ class SubPage
unset($loc);
// Output
- $data = array(
+ $data = [
+ 'colspan' => (2 + count($plugins)),
+ 'plugins' => array_values($plugins),
'list' => array_values($locationList),
- 'havestatistics' => Module::get('statistics') !== false,
- 'havebaseconfig' => Module::get('baseconfig') !== false,
- 'havesysconfig' => Module::get('sysconfig') !== false,
- 'haveipxe' => Module::isAvailable('serversetup') && class_exists('IPxe'),
'overlapSelf' => $overlapSelf,
'overlapOther' => $overlapOther,
'mismatchMachines' => $mismatchMachines,
- 'unassignedCount' => $unassigned,
- 'unassignedLoad' => ($unassigned ? (round(($unassignedLoad / $unassigned) * 100)) : ''),
- 'unassignedIdle' => ($unassigned ? (round((($unassignedLoad + $unassignedIdle) / $unassigned) * 100)) : ''),
- 'unassignedOverrides' => $unassignedOverrides,
- 'defaultConfig' => $defaultConfig,
'addAllowedList' => array_values($addAllowedList),
- );
- // TODO: Buttons for config vars and sysconfig are currently always shown, as their availability
- // depends on permissions in the according modules, not this one
+ ];
Permission::addGlobalTags($data['perms'], NULL, ['subnets.edit', 'location.add']);
Render::addTemplate('locations', $data);
Module::isAvailable('js_ip'); // For CIDR magic
}
- private static function propagateFields(&$locationList, $defaultValue, $name, $class)
+ private static function propagateFields(array &$locationList, AbstractLocationColumn $plugin, string $pluginKey)
{
$depth = array();
foreach ($locationList as &$loc) {
$d = $loc['depth'];
- if (!isset($loc[$name])) {
+ if (empty($loc['plugins'][$pluginKey]['html'])) {
// Has no explicit config assignment
- if ($d === 0) {
- $loc[$name] = $defaultValue;
- } else {
- $loc[$name] = $depth[$d - 1];
- }
- $loc[$class] = 'gray';
- }
- $depth[$d] = $loc[$name];
+ $loc['plugins'][$pluginKey]['html'] = $depth[$d - 1] ?? $plugin->propagateDefaultHtml();
+ $loc['plugins'][$pluginKey]['class'] = 'gray';
+ } elseif (empty($loc['plugins'][$pluginKey]['class'])) {
+ $loc['plugins'][$pluginKey]['class'] = 'slx-bold';
+ $loc['plugins'][$pluginKey]['html'] =
+ $plugin->propagationOverride($depth[$d - 1] ?? $plugin->propagateDefaultHtml(),
+ $loc['plugins'][$pluginKey]['html']);
+ }
+ $depth[$d] = $loc['plugins'][$pluginKey]['html'];
unset($depth[$d + 1]);
}
}
diff --git a/modules-available/locations/pages/subnets.inc.php b/modules-available/locations/pages/subnets.inc.php
index fb1e1e80..7628486b 100644
--- a/modules-available/locations/pages/subnets.inc.php
+++ b/modules-available/locations/pages/subnets.inc.php
@@ -3,7 +3,7 @@
class SubPage
{
- public static function doPreprocess($action)
+ public static function doPreprocess(string $action): bool
{
if ($action === 'updatesubnets') {
self::updateSubnets();
@@ -42,7 +42,7 @@ class SubPage
continue;
}
$range = LocationUtil::rangeToLongVerbose($start, $end);
- if ($range === false)
+ if ($range === null)
continue;
list($startLong, $endLong) = $range;
if ($stmt->execute(compact('startLong', 'endLong', 'loc', 'subnetid'))) {
@@ -59,7 +59,7 @@ class SubPage
Util::redirect('?do=Locations');
}
- public static function doRender($getAction)
+ public static function doRender($getAction): bool
{
if ($getAction === false) {
User::assertPermission('subnets.edit', NULL, '?do=locations');
@@ -67,7 +67,7 @@ class SubPage
FROM subnet
ORDER BY startaddr ASC, endaddr DESC");
$rows = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$row['startaddr'] = long2ip($row['startaddr']);
$row['endaddr'] = long2ip($row['endaddr']);
$row['locations'] = Location::getLocations($row['locationid']);
@@ -81,7 +81,7 @@ class SubPage
return false;
}
- public static function doAjax($action)
+ public static function doAjax($action): bool
{
return false;
}
diff --git a/modules-available/locations/permissions/permissions.json b/modules-available/locations/permissions/permissions.json
index 18b24a73..108bf8e0 100644
--- a/modules-available/locations/permissions/permissions.json
+++ b/modules-available/locations/permissions/permissions.json
@@ -14,6 +14,9 @@
"location.edit.parent": {
"location-aware": true
},
+ "location.edit.openingtimes": {
+ "location-aware": true
+ },
"location.view": {
"location-aware": true
},
diff --git a/modules-available/locations/style.css b/modules-available/locations/style.css
index 19950a38..0de0a801 100644
--- a/modules-available/locations/style.css
+++ b/modules-available/locations/style.css
@@ -1,4 +1,4 @@
-table.locations tbody td:nth-of-type(even) {
+table.locations > tbody > tr > td:nth-of-type(even) {
background-color: rgba(0, 0, 0, 0.025);
}
@@ -16,4 +16,12 @@ table.locations tbody td:nth-of-type(even) {
.load-col {
text-align: right;
text-shadow: 1px 1px #fff;
-} \ No newline at end of file
+ margin:0 -5px;
+ min-width: 80px;
+}
+
+.edit-btn {
+ background: inherit;
+ padding:0 2px;
+ text-align: right;
+}
diff --git a/modules-available/locations/templates/ajax-opening-location.html b/modules-available/locations/templates/ajax-opening-location.html
index a2bb357d..861bef65 100644
--- a/modules-available/locations/templates/ajax-opening-location.html
+++ b/modules-available/locations/templates/ajax-opening-location.html
@@ -1,5 +1,10 @@
<div>
- <h3>{{lang_openingTime}}</h3>
+ <h4>{{lang_openingTime}}</h4>
+ <div class="checkbox">
+ <input id="oi{{id}}" class="openingtimes-inherited"
+ type="checkbox" name="openingtimes-inherited" value="1" {{openingtimes_inherited}}>
+ <label for="oi{{id}}">{{lang_inheritOpeningTimes}}</label>
+ </div>
{{^expertMode}}
<div class="simple-mode">
@@ -124,18 +129,18 @@
</div>
{{#rebootcontrol}}
-<hr>
+<h4>{{lang_automatedMachineActions}}</h4>
<div class="row wol">
<div class="col-sm-4">
<div class="checkbox checkbox-inline">
- <input id="wol-check-{{id}}" name="wol" type="checkbox" {{#wol}}checked{{/wol}}>
+ <input id="wol-check-{{id}}" name="wol" type="checkbox" {{#scheduler-options.wol}}checked{{/scheduler-options.wol}}>
<label for="wol-check-{{id}}">{{lang_wakeonlan}}</label>
</div>
</div>
<div class="col-sm-8">
<div class="input-group">
<input disabled type="number" id="wol-offset-{{id}}" name="wol-offset" class="form-control"
- value="{{wol-options.wol-offset}}" placeholder="0" min="0" max="15">
+ value="{{scheduler-options.wol-offset}}" placeholder="0" min="0" max="60">
<span class="input-group-addon slx-ga2">
<label for="wol-offset-{{id}}">{{lang_offsetEarly}}</label>
</span>
@@ -145,23 +150,46 @@
<div class="row shutdown">
<div class="col-sm-4">
<div class="checkbox checkbox-inline">
- <input id="sd-check-{{id}}" name="sd" type="checkbox" {{#sd}}checked{{/sd}}>
+ <input id="sd-check-{{id}}" name="sd" type="checkbox" {{#scheduler-options.sd}}checked{{/scheduler-options.sd}}>
<label for="sd-check-{{id}}">{{lang_shutdown}}</label>
</div>
</div>
<div class="col-sm-8">
<div class="input-group">
<input disabled type="number" id="sd-offset-{{id}}" name="sd-offset" class="form-control"
- value="{{sd-options.sd-offset}}" placeholder="0" min="0" max="15">
+ value="{{scheduler-options.sd-offset}}" placeholder="0" min="0" max="60">
<span class="input-group-addon slx-ga2">
<label for="sd-offset-{{id}}">{{lang_offsetLate}}</label>
</span>
</div>
</div>
</div>
+<h4>{{lang_remoteAccessConstraints}}</h4>
+<div class="slx-smallspace">
+ <div class="radio">
+ <input id="ra-ALWAYS-check-{{id}}" name="ra-mode" value="ALWAYS" type="radio"
+ {{scheduler_ALWAYS_checked}}>
+ <label for="ra-ALWAYS-check-{{id}}">{{lang_remoteAccessNoRestriction}}</label>
+ </div>
+</div>
+<div class="slx-smallspace">
+ <div class="radio">
+ <input id="ra-SELECTIVE-check-{{id}}" name="ra-mode" value="SELECTIVE" type="radio"
+ {{scheduler_SELECTIVE_checked}}>
+ <label for="ra-SELECTIVE-check-{{id}}">{{lang_remoteAccessOnlyWhenClosed}}</label>
+ </div>
+</div>
+<div class="slx-smallspace">
+ <div class="radio">
+ <input id="ra-NEVER-check-{{id}}" name="ra-mode" value="NEVER" type="radio"
+ {{scheduler_NEVER_checked}}>
+ <label for="ra-NEVER-check-{{id}}">{{lang_remoteAccessNever}}</label>
+ </div>
+</div>
+<p><i>{{lang_remoteAccessHelp}}</i></p>
{{/rebootcontrol}}
-<script type="application/javascript">
+<script>
(function() {
var $loc = $('#openingTimesModal{{id}}');
@@ -204,6 +232,7 @@
$loc.find('.new-openingtime').click(function (e) {
e.preventDefault();
setTimepicker(newOpeningTime($loc, {}).find('.timepicker2'));
+ setInputEnabled();
});
$loc.find('.btn-show-expert').click(function (e) {
@@ -214,9 +243,21 @@
}
$loc.find('.simple-mode').remove();
$loc.find('.expert-mode').show();
+ setInputEnabled();
});
$loc.find('form').submit(validateOpeningTimes);
+ var setInputEnabled = function () {
+ $loc.find('.expert-mode input, .simple-mode input').prop('disabled', $inheritCb.is(':checked') ? 'disabled' : false);
+ };
+ var $inheritCb = $loc.find('.openingtimes-inherited');
+ setInputEnabled();
+ $inheritCb.change(setInputEnabled);
+ $loc.find('.new-openingtime').click(function (e) {
+ if ($inheritCb.is(':checked')) {
+ $inheritCb.click();
+ }
+ });
})();
</script>
diff --git a/modules-available/locations/templates/location-subnets.html b/modules-available/locations/templates/location-subnets.html
index e954bf10..976c3cc7 100644
--- a/modules-available/locations/templates/location-subnets.html
+++ b/modules-available/locations/templates/location-subnets.html
@@ -62,7 +62,7 @@
<div class="slx-bold">{{lang_locationInfo}}</div>
<div class="row">
- <div class="col-md-3">
+ <div class="col-md-5">
{{#haveDozmod}}
<div>
<span class="slx-ga2">{{lang_referencingLectures}}:</span> {{lectures}}
@@ -80,14 +80,23 @@
{{/statsLink}}
</div>
{{/haveStatistics}}
+ {{#next_action}}
+ <div>
+ {{lang_nextEvent}}: {{next_action}} – {{next_time}}
+ </div>
+ {{/next_action}}
</div>
- <div class="col-md-3 text-center">
- <button type="button" class="btn btn-default" data-toggle="modal" data-target="#openingTimesModal{{locationid}}" onclick="loadOpeningTimes('{{locationid}}')">
+ <div class="col-md-7 text-right">
+ <button {{perms.location.edit.openingtimes.disabled}} type="button" class="btn btn-default" data-toggle="modal" data-target="#openingTimesModal{{locationid}}" onclick="loadOpeningTimes('{{locationid}}')">
<span class="glyphicon glyphicon-time"></span>
{{lang_openingTime}}
</button>
- </div>
- <div class="col-md-3 text-center">
+ {{#news}}
+ <a class="btn btn-default" href="?do=news&amp;locationid={{locationid}}">
+ <span class="glyphicon glyphicon-pencil"></span>
+ {{lang_editNews}}
+ </a>
+ {{/news}}
{{#roomplanner}}
<a class="btn btn-default" href="?do=roomplanner&amp;locationid={{locationid}}" target="_blank"
onclick="window.open(this.href, '_blank', 'toolbar=0,scrollbars,resizable');return false">
@@ -96,8 +105,6 @@
{{#perms.roomplanner.edit.disabled}}{{lang_showRoomplan}}{{/perms.roomplanner.edit.disabled}}
</a>
{{/roomplanner}}
- </div>
- <div class="col-md-3 text-right">
<button {{perms.location.delete.disabled}} type="button" class="btn btn-danger" data-toggle="modal" data-target="#deleteLocationModal{{locationid}}"><span class="glyphicon glyphicon-trash"></span> {{lang_deleteLocation}}</button>
<button onclick="deleteSubnetWarning('{{locationid}}')" {{perms.save_button.disabled}} type="button" class="btn btn-primary"><span class="glyphicon glyphicon-floppy-disk"></span> {{lang_save}}</button>
</div>
@@ -149,7 +156,7 @@
<input type="hidden" name="openingtimes" value="">
<input type="hidden" name="locationid" value="{{locationid}}">
- <div class="modal-header">{{locationname}}</div>
+ <div class="modal-header"><h3>{{locationname}}</h3></div>
<div class="modal-body"></div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{lang_close}}</button>
diff --git a/modules-available/locations/templates/locations.html b/modules-available/locations/templates/locations.html
index 125c101a..efd48216 100644
--- a/modules-available/locations/templates/locations.html
+++ b/modules-available/locations/templates/locations.html
@@ -31,21 +31,11 @@
<table class="table table-condensed table-hover locations" style="margin-bottom:0">
<tr>
<th width="100%">{{lang_locationName}}</th>
- <th>
- {{#havestatistics}}{{lang_machineCount}}{{/havestatistics}}
- </th>
- <th>
- {{#havestatistics}}{{lang_machineLoad}}{{/havestatistics}}
- </th>
- <th class="text-nowrap">
- {{#havebaseconfig}}{{lang_editConfigVariables}}{{/havebaseconfig}}
- </th>
+ {{#plugins}}
<th class="text-nowrap">
- {{#havesysconfig}}{{lang_sysConfig}}{{/havesysconfig}}
- </th>
- <th class="text-nowrap">
- {{#haveipxe}}{{lang_bootMenu}}{{/haveipxe}}
+ {{header}}
</th>
+ {{/plugins}}
</tr>
{{#list}}
<tr>
@@ -61,85 +51,30 @@
</a>
{{/show-only}}
</td>
- <td class="text-nowrap" align="right">
- {{#havestatistics}}
- <a href="?do=Statistics&amp;show=list&amp;filters=location={{locationid}}">&nbsp;{{clientCount}}&nbsp;</a>
- <span style="display:inline-block;width:5ex">
- {{#hasChild}}
- (<a href="?do=Statistics&amp;show=list&amp;filters=location~{{locationid}}">&downarrow;{{clientCountSum}}</a>)
- {{/hasChild}}
- </span>
- {{/havestatistics}}
- </td>
- <td class="text-nowrap load-col" {{#clientCount}} style="background:linear-gradient(to right, #f97, #f97 {{clientLoad}}%, #6fa {{clientLoad}}%, #6fa {{clientIdle}}%, #eee {{clientIdle}}%)"{{/clientCount}}>
- {{#clientCount}}
- {{clientLoad}}&thinsp;%
- {{/clientCount}}
- </td>
- <td class="text-nowrap {{overriddenClass}}">
- {{#havebaseconfig}}
- <div class="pull-right" style="z-index:-1">
- <a class="btn btn-default btn-xs" href="?do=baseconfig&amp;module=locations&amp;locationid={{locationid}}"><span class="glyphicon glyphicon-edit"></span></a>
- </div>
- {{#overriddenVars}}
- <span class="badge" title="{{lang_overridenVarsForLocation}}">
- <span class="glyphicon glyphicon-home"></span> {{.}}
- </span>
- {{/overriddenVars}}
- {{#machineVarsOverrideCount}}
- <span class="badge" title="{{lang_numMachinesWithOverrides}}">
- <span class="glyphicon glyphicon-tasks"></span> {{.}}
- </span>
- {{/machineVarsOverrideCount}}
- &emsp;&emsp;
- {{/havebaseconfig}}
- </td>
- <td class="text-nowrap">
- {{#havesysconfig}}
- <div class="pull-right">
- <a class="btn btn-default btn-xs" href="?do=sysconfig&amp;locationid={{locationid}}"><span class="glyphicon glyphicon-edit"></span></a>
- </div>
- <span class="{{configClass}}">
- {{configName}}&emsp;&emsp;
- </span>
- {{/havesysconfig}}
- </td>
- <td class="text-nowrap">
- {{#haveipxe}}
- <div class="pull-right">
- <a class="btn btn-default btn-xs" href="?do=serversetup&amp;show=assignlocation&amp;locationid={{locationid}}"><span class="glyphicon glyphicon-edit"></span></a>
- </div>
- <span class="{{customMenuClass}}">
- {{customMenu}}&emsp;&emsp;
- </span>
- {{/haveipxe}}
+ {{#plugins}}
+ <td>
+ <table width="100%"><tr>
+ <td class="text-nowrap {{class}}">{{{html}}}</td>
+ {{#url}}
+ <td class="edit-btn">
+ <a class="btn btn-default btn-xs" href="{{.}}">
+ <span class="glyphicon glyphicon-edit"></span>
+ </a>
+ </td>
+ {{/url}}
+ </tr></table>
</td>
+ {{/plugins}}
</tr>
{{/list}}
- {{#unassignedCount}}
<tr>
<td>{{lang_unassignedMachines}}</td>
- <td class="text-nowrap" align="right">
- <a href="?do=Statistics&amp;show=list&amp;filters=location=0">
- &nbsp;{{unassignedCount}}&nbsp;
- </a>
- <span style="display:inline-block;width:5ex"></span>
- </td>
- <td class="text-nowrap load-col"{{#unassignedCount}} style="background:linear-gradient(to right, #f97, #f97 {{unassignedLoad}}%, #6fa {{unassignedLoad}}%, #6fa {{unassignedIdle}}%, #eee {{unassignedIdle}}%)"{{/unassignedCount}}>
- {{#unassignedCount}}
- {{unassignedLoad}}&thinsp;%
- {{/unassignedCount}}
- </td>
- <td>
- {{#unassignedOverrides}}
- <span class="badge" title="{{lang_numMachinesWithOverrides}}">
- <span class="glyphicon glyphicon-tasks"></span> {{.}}
- </span>
- {{/unassignedOverrides}}
+ {{#plugins}}
+ <td class="text-nowrap">
+ {{{propagateDefaultHtml}}}
</td>
- <td>{{defaultConfig}}</td>
+ {{/plugins}}
</tr>
- {{/unassignedCount}}
</table>
<form method="post" action="?do=Locations">
<input type="hidden" name="token" value="{{token}}">
@@ -214,7 +149,7 @@ function slxOpenLocation(e, lid) {
}
return;
}
- var td = $('<td>').attr('colspan', '6').css('padding', '0px 0px 12px');
+ var td = $('<td>').attr('colspan', '{{colspan}}').css('padding', '0px 0px 12px');
var tr = $('<tr>').attr('id', 'location-details-' + lid);
tr.append(td);
$(e).closest('tr').addClass('active slx-bold').after(tr);
diff --git a/modules-available/locations/templates/mismatch-cleanup.html b/modules-available/locations/templates/mismatch-cleanup.html
index a6f45d27..53a956df 100644
--- a/modules-available/locations/templates/mismatch-cleanup.html
+++ b/modules-available/locations/templates/mismatch-cleanup.html
@@ -32,7 +32,7 @@
<tr>
<td>
<div class="checkbox checkbox-inline">
- <input id="machine-{{machineuuid}}" type="checkbox" name="machines[]" value="{{machineuuid}}" {{disabled}}>
+ <input class="mcb {{#canmove}}cmov{{/canmove}}" id="machine-{{machineuuid}}" type="checkbox" name="machines[]" value="{{machineuuid}}" {{disabled}}>
<label for="machine-{{machineuuid}}">{{hostname}}{{^hostname}}{{clientip}}{{/hostname}}</label>
</div>
</td>
@@ -60,10 +60,28 @@
<span class="glyphicon glyphicon-repeat"></span>
{{lang_resetMachines}}
</button>
- <button type="submit" class="btn btn-success" name="action" value="movemachines">
+ {{#canmove}}
+ <button id="btn-move" type="submit" class="btn btn-success" name="action" value="movemachines">
<span class="glyphicon glyphicon-arrow-right"></span>
{{lang_moveMachines}}
</button>
+ {{/canmove}}
</div>
</form>
-<div class="clearfix"></div> \ No newline at end of file
+<div class="clearfix"></div>
+
+<script>
+ document.addEventListener('DOMContentLoaded', function () {
+ var $btn = $('#btn-move');
+ if ($btn.length === 0)
+ return;
+ var ccheck = function() {
+ var $e = $('input.mcb:checked');
+ var nope = $e.is(':not(.cmov)');
+ var yep = $e.is('.cmov');
+ $btn.prop('disabled', nope || !yep);
+ };
+ ccheck();
+ $('input.mcb').change(ccheck);
+ });
+</script> \ No newline at end of file
diff --git a/modules-available/main/hooks/cron.inc.php b/modules-available/main/hooks/cron.inc.php
index 89c91fcc..5b20b6d0 100644
--- a/modules-available/main/hooks/cron.inc.php
+++ b/modules-available/main/hooks/cron.inc.php
@@ -10,6 +10,10 @@ case 3:
case 4:
Database::exec("DELETE FROM callback WHERE (UNIX_TIMESTAMP() - 86400) > dateline");
break;
+default:
+ // Do nothing
}
Trigger::checkCallbacks();
+
+Mailer::flushQueue(); \ No newline at end of file
diff --git a/modules-available/main/hooks/translation.inc.php b/modules-available/main/hooks/translation.inc.php
index 7590dcb6..28247374 100644
--- a/modules-available/main/hooks/translation.inc.php
+++ b/modules-available/main/hooks/translation.inc.php
@@ -18,11 +18,8 @@ $HANDLER['subsections'] = array(
* Global tags.
* This just returns the union of global tags of all languages, as there is no
* way to define a definite set of required global tags.
- *
- * @param Module $module
- * @return array dem tags
*/
-$HANDLER['grep_global-tags'] = function($module) {
+$HANDLER['grep_global-tags'] = function(Module $module): array {
$want = array();
foreach (Dictionary::getLanguages() as $lang) {
$want += Dictionary::getArray($module->getIdentifier(), 'global-tags', $lang);
diff --git a/modules-available/main/install.inc.php b/modules-available/main/install.inc.php
index 92ad4db1..69c0da8f 100644
--- a/modules-available/main/install.inc.php
+++ b/modules-available/main/install.inc.php
@@ -21,17 +21,20 @@ $res[] = tableCreate('permission', "
$res[] = tableCreate('property', "
`name` varchar(50) NOT NULL,
`dateline` int(10) unsigned NOT NULL DEFAULT '0',
- `value` text NOT NULL,
+ `value` mediumblob NOT NULL,
PRIMARY KEY (`name`),
KEY `dateline` (`dateline`)
");
$res[] = tableCreate('property_list', "
`name` varchar(50) NOT NULL,
+ `subkey` int(10) unsigned NOT NULL AUTO_INCREMENT,
`dateline` int(10) unsigned NOT NULL DEFAULT '0',
- `value` text NOT NULL,
+ `value` mediumblob NOT NULL,
KEY (`name`),
- KEY `dateline` (`dateline`)
+ KEY `dateline` (`dateline`),
+ KEY (`subkey`),
+ UNIQUE KEY `compound` (`name`, `subkey`)
");
$res[] = tableCreate('user', "
@@ -48,13 +51,50 @@ $res[] = tableCreate('user', "
UNIQUE KEY `login` (`login`)
");
+$res[] = tableCreate('session', "
+ `sid` char(50) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ `userid` int(10) unsigned NOT NULL,
+ `dateline` int(10) unsigned NOT NULL DEFAULT '0',
+ `lastip` varchar(45) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ `fixedip` tinyint(1) unsigned NOT NULL DEFAULT '0',
+ `data` blob NOT NULL,
+ PRIMARY KEY (`sid`),
+ KEY `dateline` (`dateline`)
+");
+
+$res[] = tableCreate('mail_queue', "
+ `mailid` int(10) unsigned NOT NULL AUTO_INCREMENT,
+ `rcpt` varchar(200) NOT NULL,
+ `subject` varchar(500) NOT NULL,
+ `body` blob NOT NULL,
+ `dateline` int(10) unsigned NOT NULL,
+ `configid` int(10) unsigned NOT NULL,
+ `nexttry` int(10) unsigned NOT NULL DEFAULT '0',
+ PRIMARY KEY (`mailid`),
+ KEY (`configid`),
+ KEY (`nexttry`)
+");
+
+$res[] = tableCreate('mail_config', "
+ `configid` int(10) unsigned NOT NULL AUTO_INCREMENT,
+ `host` varchar(100) NOT NULL,
+ `port` smallint(5) UNSIGNED NOT NULL,
+ `ssl` ENUM('FORCE_NONE', 'NONE', 'IMPLICIT', 'EXPLICIT') NOT NULL,
+ `senderaddress` varchar(100) NOT NULL,
+ `replyto` varchar(100) NOT NULL,
+ `username` varchar(100) NOT NULL,
+ `password` varchar(100) NOT NULL,
+ PRIMARY KEY (`configid`)
+");
+
// Update path
// #######################
// ##### 2014-05-28
// Add dateline field to property table
if (!tableHasColumn('property', 'dateline')) {
- Database::exec("ALTER TABLE `property` ADD `dateline` INT( 10 ) UNSIGNED NOT NULL DEFAULT '0' AFTER `name` , ADD INDEX ( `dateline` )");
+ Database::exec("ALTER TABLE `property` ADD `dateline` INT( 10 ) UNSIGNED NOT NULL DEFAULT '0' AFTER `name`,
+ ADD INDEX ( `dateline` )");
}
// #######################
@@ -80,6 +120,29 @@ if (!tableHasColumn('user', 'serverid')) {
Database::exec("ALTER TABLE `user` ADD `serverid` int(10) unsigned NULL DEFAULT NULL");
}
+// #######################
+// ##### 2022-07-04
+// Add subkey to property_list, make value mediumblob instead of text
+if (!tableHasColumn('property_list', 'subkey')) {
+ $ret = Database::exec("ALTER TABLE property_list
+ ADD COLUMN `subkey` int(10) unsigned NOT NULL AUTO_INCREMENT AFTER `name`,
+ ADD KEY (`subkey`),
+ ADD UNIQUE KEY `compound` (`name`, `subkey`)");
+ if ($ret === false) {
+ finalResponse(UPDATE_FAILED, 'Cannot add subkey to property_list: ' . Database::lastError());
+ }
+ $res[] = UPDATE_DONE;
+}
+foreach (['property', 'property_list'] as $table) {
+ if (stripos(tableColumnType($table, 'value'), 'mediumblob') === false) {
+ $ret = Database::exec("ALTER TABLE `$table` MODIFY `value` mediumblob NOT NULL");
+ if ($ret === false) {
+ finalResponse(UPDATE_FAILED, "Cannot change value column of $table to mediumblob: " . Database::lastError());
+ }
+ $res[] = UPDATE_DONE;
+ }
+}
+
// Make sure that if any users exist, one of the has UID=1, otherwise if the permission module is
// used we'd lock out everyone
$someUser = Database::queryFirst('SELECT userid FROM user ORDER BY userid ASC LIMIT 1');
@@ -87,6 +150,9 @@ if ($someUser !== false && (int)$someUser['userid'] !== 1) {
Database::exec('UPDATE user SET userid = 1 WHERE userid = :oldid', ['oldid' => $someUser['userid']]);
}
+$res[] = tableAddConstraint('mail_queue', 'configid', 'mail_config', 'configid',
+ 'ON UPDATE CASCADE ON DELETE CASCADE');
+
// Create response for browser
if (in_array(UPDATE_DONE, $res)) {
diff --git a/modules-available/main/lang/de/template-tags.json b/modules-available/main/lang/de/template-tags.json
index 54373c56..1c9a97de 100644
--- a/modules-available/main/lang/de/template-tags.json
+++ b/modules-available/main/lang/de/template-tags.json
@@ -1,6 +1,5 @@
{
"lang_browserTime": "Browser",
- "lang_changePassword": "Passwort \u00e4ndern",
"lang_clockDriftWarn": "Die Uhrzeit des Satellitenservers weicht von der Uhrzeit des lokalen Systems\/Browsers ab. Bitte stellen Sie sicher, dass die Uhrzeit des Servers korrekt ist, da sonst zeitabh\u00e4ngige Einstellungen und Aufgaben evtl. nicht korrekt durchgef\u00fchrt werden.",
"lang_goTo": "Gehe zu",
"lang_intro": "Dies ist die bwLehrpool Konfigurationsoberfl\u00e4che.",
diff --git a/modules-available/main/lang/en/template-tags.json b/modules-available/main/lang/en/template-tags.json
index 6b7ded26..9f2fee42 100644
--- a/modules-available/main/lang/en/template-tags.json
+++ b/modules-available/main/lang/en/template-tags.json
@@ -1,10 +1,9 @@
{
"lang_browserTime": "Browser",
- "lang_changePassword": "Change password",
"lang_clockDriftWarn": "The local system's\/browser's time doesn't match the server's time. Please make sure the server's clock is running correctly, otherwise time sensitive settings or tasks might not work properly.",
"lang_goTo": "Go to",
"lang_intro": "This is the bwLehrpool configuration interface.",
- "lang_introGuest": "This is the administration interface of the local bwLehrpool installation. Please authenticate yourself to adjust settings.",
+ "lang_introGuest": "This is the administration interface of the local bwLehrpool installation. Please authenticate yourself to access settings.",
"lang_language": "Language",
"lang_loggedInPrefix": "Logged in as",
"lang_loggedInSuffix": " ",
@@ -14,8 +13,8 @@
"lang_noExistingAccount": "No account has been created yet. Sign up to become the administrator.",
"lang_register": "Register",
"lang_serverTime": "Server",
- "lang_toggleNavigation": "toggle navigation",
+ "lang_toggleNavigation": "Toggle navigation",
"lang_warning": "Warning",
- "lang_warningDebug": "Debug mode active!",
+ "lang_warningDebug": "Debug mode is enabled!",
"lang_welcome": "Welcome"
-}
+} \ No newline at end of file
diff --git a/modules-available/main/page.inc.php b/modules-available/main/page.inc.php
index baea8350..b0d7d125 100644
--- a/modules-available/main/page.inc.php
+++ b/modules-available/main/page.inc.php
@@ -33,12 +33,12 @@ class Page_Main extends Page
}
// Update warning state
- Property::setNeedsSetup($needSetup ? 1 : 0);
+ Property::setNeedsSetup($needSetup);
}
protected function doAjax()
{
- User::isLoggedIn();
+ User::load();
die('Status: DB running');
}
diff --git a/modules-available/main/templates/main-menu.html b/modules-available/main/templates/main-menu.html
index 35b7f57f..3f44e9b4 100644
--- a/modules-available/main/templates/main-menu.html
+++ b/modules-available/main/templates/main-menu.html
@@ -55,12 +55,11 @@
<ul class="nav navbar-nav navbar-right visible-xs visible-lg">
{{#user}}
- <li><span>{{lang_loggedInPrefix}} {{user}} {{lang_loggedInSuffix}}</span></li>
+ <li><span>{{lang_loggedInPrefix}} <a href="?do=session">{{user}}</a> {{lang_loggedInSuffix}}</span></li>
<li>
<form id="logoutForm" method="post" action="?do=session">
<input type="hidden" name="token" value="{{token}}">
<input type="hidden" name="action" value="logout">
- <a href="?do=session" class="btn btn-default btn-xs">{{lang_changePassword}}</a>
<button class="btn btn-default btn-xs" type="submit">{{lang_logout}}</button>
</form>
</li>
@@ -80,7 +79,6 @@
<ul class="dropdown-menu">
<!--<li><a href="#">Settings</a></li> -->
<!--<li role="separator" class="divider"></li> -->
- <li><a href="?do=session">{{lang_changePassword}}</a></li>
<li><a href="#" onclick="$('#logoutForm').submit();">{{lang_logout}}</a></li>
</ul>
{{/user}}
diff --git a/modules-available/minilinux/inc/linuxbootentryhook.inc.php b/modules-available/minilinux/inc/linuxbootentryhook.inc.php
index e3090054..1424b6b9 100644
--- a/modules-available/minilinux/inc/linuxbootentryhook.inc.php
+++ b/modules-available/minilinux/inc/linuxbootentryhook.inc.php
@@ -10,29 +10,31 @@
class LinuxBootEntryHook extends BootEntryHook
{
- public function name()
+ public function name(): string
{
- return Dictionary::translateFileModule('minilinux', 'module', 'module_name', true);
+ return Dictionary::translateFileModule('minilinux', 'module', 'module_name');
}
- public function extraFields()
+ public function extraFields(): array
{
/* For translate module:
* Dictionary::translate('ipxe-kcl-extra');
* Dictionary::translate('ipxe-debug');
* Dictionary::translate('ipxe-insecure-cpu');
+ * Dictionary::translate('ipxe-force-init-dhcp');
*/
return [
new HookExtraField('kcl-extra', 'string', ''),
new HookExtraField('debug', 'bool', false),
new HookExtraField('insecure-cpu', 'bool', false),
+ new HookExtraField('force-init-dhcp', 'bool', false),
];
}
/**
* @return HookEntryGroup[]
*/
- protected function groupsInternal()
+ protected function groupsInternal(): array
{
/*
* Dictionary::translate('default_boot_entry');
@@ -42,7 +44,7 @@ class LinuxBootEntryHook extends BootEntryHook
$array = [];
$array[] = new HookEntryGroup($this->name(), [
new HookEntry('default',
- Dictionary::translateFileModule('minilinux', 'module', 'default_boot_entry', true),
+ Dictionary::translateFileModule('minilinux', 'module', 'default_boot_entry'),
MiniLinux::updateCurrentBootSetting())
]);
$branches = Database::queryAll('SELECT sourceid, branchid, title FROM minilinux_branch ORDER BY title');
@@ -54,29 +56,28 @@ class LinuxBootEntryHook extends BootEntryHook
new HookEntry($branch['branchid'],
$branch['branchid'] . ' '
. Dictionary::translateFileModule('minilinux', 'module',
- 'latest_of_branch', true),
+ 'latest_of_branch'),
true),
];
foreach ($versions[$branch['branchid']] as $version) {
- $valid = $version['installed'] != 0;
+ $valid = $version['installed'] != MiniLinux::INSTALL_MISSING;
$title = $version['versionid'] . ' ' . $version['title'];
if (!$valid) {
$title .= ' ' . Dictionary::translateFileModule('minilinux', 'module',
- 'not_installed_hint', true);
+ 'not_installed_hint');
}
$group[] = new HookEntry($version['versionid'], $title, $valid);
}
- $array[] = new HookEntryGroup($branch['title'] ? $branch['title'] : $branch['branchid'], $group);
+ $array[] = new HookEntryGroup($branch['title'] ?: $branch['branchid'], $group);
}
}
return $array;
}
/**
- * @param $localData
- * @return BootEntry the actual boot entry instance for given entry, false if invalid id
+ * @return ?BootEntry the actual boot entry instance for given entry, false if invalid id
*/
- public function getBootEntryInternal($localData)
+ public function getBootEntryInternal(array $localData): ?BootEntry
{
$id = $localData['id'];
if ($id === 'default') { // Special case
@@ -88,14 +89,12 @@ class LinuxBootEntryHook extends BootEntryHook
['id' => $effectiveId]);
if ($res === false) {
// Maybe this is a branchid, which means latest from according branch (installed only)
- $res = Database::queryFirst('SELECT versionid, installed, data FROM minilinux_version WHERE branchid = :id
- ORDER BY installed DESC, dateline DESC LIMIT 1', // Order by installed instead of WHERE for better errormsg
- ['id' => $effectiveId]);
+ $res = Database::queryFirst('SELECT versionid, installed, data FROM minilinux_version
+ WHERE branchid = :id AND installed = :ok
+ ORDER BY dateline DESC LIMIT 1',
+ ['id' => $effectiveId, 'ok' => MiniLinux::INSTALL_OK]);
}
if ($res === false) {
- return BootEntry::newCustomBootEntry(['script' => 'prompt Invalid minilinux boot entry id: ' . $id]);
- }
- if ($res['installed'] == 0) {
return BootEntry::newCustomBootEntry(['script' => 'prompt Selected version not currently installed on server: ' . $effectiveId]);
}
$effectiveId = $res['versionid']; // In case we selected from a branchid, so above message doesn't show versionid
@@ -122,10 +121,10 @@ class LinuxBootEntryHook extends BootEntryHook
$arch = BootEntry::EFI;
}
}
- return BootEntry::newStandardBootEntry($bios, $efi, $arch);
+ return BootEntry::newStandardBootEntry($bios, $efi, $arch, 'ml-' . $id);
}
- private function generateExecData($effectiveId, $remoteData, $localData)
+ private function generateExecData($effectiveId, $remoteData, $localData): ExecData
{
$exec = new ExecData();
// Defaults
@@ -146,23 +145,34 @@ class LinuxBootEntryHook extends BootEntryHook
if (!empty($localData['debug'])) {
// Debug boot enabled
$exec->commandLine = IPxe::modifyCommandLine($exec->commandLine,
- isset($remoteData['debugCommandLineModifier'])
- ? $remoteData['debugCommandLineModifier']
- : '-vga -quiet -splash -loglevel loglevel=7'
+ $remoteData['debugCommandLineModifier'] ?? '-vga -quiet -splash -loglevel loglevel=7'
);
}
// disable all CPU sidechannel attack mitigations etc.
if (!empty($localData['insecure-cpu'])) {
$exec->commandLine = IPxe::modifyCommandLine($exec->commandLine,
- 'noibrs noibpb nopti nospectre_v2 nospectre_v1 l1tf=off nospec_store_bypass_disable no_stf_barrier mds=off mitigations=off');
+ 'noibrs noibpb nopti nospectre_v2 nospectre_v1 l1tf=off nospec_store_bypass_disable no_stf_barrier mds=off mitigations=off i915.mitigations=off');
+ }
+ // force that we
+ if (!empty($localData['force-init-dhcp'])) {
+ $exec->commandLine = IPxe::modifyCommandLine($exec->commandLine,
+ '-ipv4.router -ipv4.dns -ipv4.subnet');
+ }
+ // GVT, PCI Pass-thru etc.
+ if (Module::isAvailable('statistics')) {
+ $hwextra = HardwareInfo::getKclModifications();
+ if (!empty($hwextra)) {
+ $exec->commandLine = IPxe::modifyCommandLine($exec->commandLine, $hwextra);
+ }
}
+ // User-supplied modifications
if (!empty($localData['kcl-extra'])) {
$exec->commandLine = IPxe::modifyCommandLine($exec->commandLine, $localData['kcl-extra']);
}
$exec->commandLine = str_replace('%ID%', $effectiveId, $exec->commandLine);
$exec->executable = $root . $exec->executable;
foreach ($exec->initRd as &$rd) {
- if ($rd{0} !== '/') {
+ if ($rd[0] !== '/') {
$rd = $root . $rd;
}
}
@@ -170,12 +180,12 @@ class LinuxBootEntryHook extends BootEntryHook
return $exec;
}
- public function isValidId($id)
+ public function isValidId(string $id): bool
{
if ($id === 'default')
return true; // Meta-version that links to whatever the default is set to
$res = Database::queryFirst('SELECT installed FROM minilinux_version WHERE versionid = :id', ['id' => $id]);
- if ($res !== false && $res['installed'])
+ if ($res !== false && $res['installed'] != MiniLinux::INSTALL_MISSING)
return true;
$res = Database::queryFirst('SELECT branchid FROM minilinux_branch WHERE branchid = :id', ['id' => $id]);
return $res !== false;
diff --git a/modules-available/minilinux/inc/minilinux.inc.php b/modules-available/minilinux/inc/minilinux.inc.php
index d172d982..cbc797f2 100644
--- a/modules-available/minilinux/inc/minilinux.inc.php
+++ b/modules-available/minilinux/inc/minilinux.inc.php
@@ -11,21 +11,27 @@ class MiniLinux
const INVALID = 'invalid';
+ const INSTALL_MISSING = 0;
+
+ const INSTALL_OK = 1;
+
+ const INSTALL_BROKEN = 2;
+
/*
* Update of available versions by querying sources
*/
/**
- * Query all known sources for meta data
+ * Query all known sources for metadata
* @return int number of sources query was just initialized for
*/
- public static function updateList()
+ public static function updateList(): int
{
$stamp = time();
$last = Property::get(self::PROPERTY_KEY_FETCHTIME);
- if ($last !== false && $last + 10 > $stamp)
+ if ($last !== false && $last + 3 > $stamp)
return 0; // In progress...
- Property::set(self::PROPERTY_KEY_FETCHTIME, $stamp, 1);
+ Property::set(self::PROPERTY_KEY_FETCHTIME, $stamp, 10);
Database::exec('LOCK TABLES callback WRITE,
minilinux_source WRITE, minilinux_branch WRITE, minilinux_version WRITE');
Database::exec('UPDATE minilinux_source SET taskid = UUID()');
@@ -33,7 +39,8 @@ class MiniLinux
Database::exec("UPDATE minilinux_version
INNER JOIN minilinux_branch USING (branchid)
INNER JOIN minilinux_source USING (sourceid)
- SET orphan = orphan + 1 WHERE minilinux_source.lastupdate < $cutoff");
+ SET orphan = orphan + 1
+ WHERE minilinux_source.lastupdate < $cutoff AND orphan < 100");
$list = Database::queryAll('SELECT sourceid, url, taskid FROM minilinux_source');
foreach ($list as $source) {
Taskmanager::submit('DownloadText', array(
@@ -48,20 +55,22 @@ class MiniLinux
/**
* Called when downloading metadata from a specific update source is finished
- * @param mixed $task task structure
+ *
+ * @param array $task task structure
* @param string $sourceid see minilinux_source table
*/
- public static function listDownloadCallback($task, $sourceid)
+ public static function listDownloadCallback(array $task, string $sourceid): void
{
- if ($task['statusCode'] !== 'TASK_FINISHED')
+ if (!Taskmanager::isFinished($task))
return;
$taskId = $task['id'];
- $data = json_decode($task['data']['content'], true);
- if (!is_array($data)) {
- EventLog::warning('Cannot download Linux version meta data for ' . $sourceid);
+ $data = json_decode($task['data']['content'] ?? '', true);
+ if (!is_array($data) || empty($data['systems'])) {
+ EventLog::warning('Cannot download Linux version meta data for ' . $sourceid,
+ ($task['data']['error'] ?? '') . "\n\nContent:\n" . ($task['data']['content'] ?? ''));
$lastupdate = 'lastupdate';
} else {
- if (@is_array($data['systems'])) {
+ if (is_array($data['systems'])) {
self::addBranches($sourceid, $data['systems']);
}
$lastupdate = 'UNIX_TIMESTAMP()';
@@ -70,7 +79,8 @@ class MiniLinux
WHERE sourceid = :sourceid AND taskid = :taskid",
['sourceid' => $sourceid, 'taskid' => $taskId]);
// Clean up -- delete orphaned versions that are not installed
- Database::exec('DELETE FROM minilinux_version WHERE orphan > 4 AND installed = 0');
+ Database::exec('DELETE FROM minilinux_version WHERE orphan > 4 AND installed = :missing',
+ ['missing' => self::INSTALL_MISSING]);
// FKC makes sure we only delete orphaned ones
Database::exec('DELETE IGNORE FROM minilinux_branch WHERE 1', [], true);
}
@@ -81,18 +91,31 @@ class MiniLinux
if (!self::isValidIdPart($system['id']))
continue;
$branchid = $sourceid . '/' . $system['id'];
- $title = empty($system['title']) ? $branchid : $system['title'];
- $description = empty($system['description']) ? '' : $system['description'];
- Database::exec('INSERT INTO minilinux_branch (branchid, sourceid, title, description)
- VALUES (:branchid, :sourceid, :title, :description)
- ON DUPLICATE KEY UPDATE title = VALUES(title), description = VALUES(description)', [
- 'branchid' => $branchid,
- 'sourceid' => $sourceid,
- 'title' => $title,
- 'description' => $description,
- ]);
- if (@is_array($system['versions'])) {
+ $title = mb_substr(empty($system['title']) ? $branchid : $system['title'], 0, 150);
+ $description = $system['description'] ?? '';
+ $color = $system['color'] ?? '';
+ if (!empty($system['versions']) && is_array($system['versions'])) {
+ Database::exec('INSERT INTO minilinux_branch (branchid, sourceid, title, color, description)
+ VALUES (:branchid, :sourceid, :title, :color, :description)
+ ON DUPLICATE KEY UPDATE title = VALUES(title), color = VALUES(color), description = VALUES(description)', [
+ 'branchid' => $branchid,
+ 'sourceid' => $sourceid,
+ 'title' => $title,
+ 'color' => $color,
+ 'description' => $description,
+ ]);
self::addVersions($branchid, $system['versions']);
+ } else {
+ // Empty branch - only update metadata if branch exists locally
+ Database::exec('UPDATE minilinux_branch
+ SET title = :title, color = :color, description = :description
+ WHERE sourceid = :sourceid AND branchid = :branchid', [
+ 'branchid' => $branchid,
+ 'sourceid' => $sourceid,
+ 'title' => $title,
+ 'color' => $color,
+ 'description' => $description,
+ ]);
}
}
}
@@ -115,7 +138,8 @@ class MiniLinux
return;
}
$versionid = $branchid . '/' . $version['version'];
- $title = empty($version['title']) ? '' : $version['title'];
+ $title = $version['title'] ?? '';
+ $description = $version['description'] ?? '';
$dateline = empty($version['releasedate']) ? time() : (int)$version['releasedate'];
unset($version['version'], $version['title'], $version['releasedate']);
// Sanitize files array
@@ -148,18 +172,20 @@ class MiniLinux
$version['files'] = array_values($version['files']);
}
$data = json_encode($version);
- Database::exec('INSERT INTO minilinux_version (versionid, branchid, title, dateline, data, orphan)
- VALUES (:versionid, :branchid, :title, :dateline, :data, 0)
- ON DUPLICATE KEY UPDATE title = VALUES(title), data = VALUES(data), orphan = 0', [
+ Database::exec('INSERT INTO minilinux_version (versionid, branchid, title, description, dateline, data, orphan)
+ VALUES (:versionid, :branchid, :title, :description, :dateline, :data, 0)
+ ON DUPLICATE KEY UPDATE title = VALUES(title), description = VALUES(description),
+ dateline = VALUES(dateline), data = VALUES(data), orphan = 0', [
'versionid' => $versionid,
'branchid' => $branchid,
- 'title' => $title,
+ 'title' => mb_substr($title, 0, 150),
+ 'description' => $description,
'dateline' => $dateline,
'data' => $data,
]);
}
- private static function isValidIdPart($str)
+ private static function isValidIdPart(string $str): bool
{
return preg_match('/^[a-z0-9_\-]+$/', $str) > 0;
}
@@ -168,10 +194,10 @@ class MiniLinux
* Download of specific version
*/
- public static function validateDownloadTask($versionid, $taskid)
+ public static function validateDownloadTask(string $versionid, ?string $taskid): ?string
{
if ($taskid === null)
- return false;
+ return null;
$task = Taskmanager::status($taskid);
if (Taskmanager::isTask($task) && !Taskmanager::isFailed($task)
&& (is_dir(CONFIG_HTTP_DIR . '/' . $versionid) || !Taskmanager::isFinished($task)))
@@ -179,15 +205,13 @@ class MiniLinux
Database::exec('UPDATE minilinux_version SET taskid = NULL
WHERE versionid = :versionid AND taskid = :taskid',
['versionid' => $versionid, 'taskid' => $taskid]);
- return false;
+ return null;
}
/**
* Download the files for the given version id
- * @param $versionid
- * @return bool
*/
- public static function downloadVersion($versionid)
+ public static function downloadVersion(string $versionid): ?string
{
$ver = Database::queryFirst('SELECT s.url, s.pubkey, v.versionid, v.taskid, v.data FROM minilinux_version v
INNER JOIN minilinux_branch b USING (branchid)
@@ -195,17 +219,17 @@ class MiniLinux
WHERE versionid = :versionid',
['versionid' => $versionid]);
if ($ver === false)
- return false;
+ return null;
$taskid = self::validateDownloadTask($versionid, $ver['taskid']);
- if ($taskid !== false)
+ if ($taskid !== null)
return $taskid;
$data = json_decode($ver['data'], true);
if (!is_array($data)) {
EventLog::warning("Cannot download Linux '$versionid': Corrupted meta data.", $ver['data']);
- return false;
+ return null;
}
if (empty($data['files']))
- return false;
+ return null;
$list = [];
$legacyDir = preg_replace(',^[^/]*/,', '', $versionid);
foreach ($data['files'] as $file) {
@@ -224,6 +248,7 @@ class MiniLinux
Database::exec('LOCK TABLES minilinux_version WRITE');
$aff = Database::exec('UPDATE minilinux_version SET taskid = :taskid WHERE versionid = :versionid AND taskid IS NULL',
['taskid' => $uuid, 'versionid' => $versionid]);
+ $task = false;
if ($aff > 0) {
$task = Taskmanager::submit('DownloadFiles', [
'id' => $uuid,
@@ -234,10 +259,8 @@ class MiniLinux
if (Taskmanager::isFailed($task)) {
$task = false;
} else {
- $task = $task['id'];
+ $task = (string)$task['id'];
}
- } else {
- $task = false;
}
Database::exec('UNLOCK TABLES');
if ($task !== false) {
@@ -251,7 +274,7 @@ class MiniLinux
return $task;
}
- public static function fileToId($versionid, $fileName)
+ public static function fileToId(string $versionid, string $fileName): string
{
return 'x' . substr(md5($fileName . $versionid), 0, 8);
}
@@ -264,7 +287,7 @@ class MiniLinux
* Generate messages regarding setup und update availability.
* @return bool true if severe problems were found, false otherwise
*/
- public static function generateUpdateNotice()
+ public static function generateUpdateNotice(): bool
{
// Messages in here are with module name, as required by the
// main-warning hook.
@@ -307,7 +330,7 @@ class MiniLinux
* actually installed locally.
* @return bool true if installed locally, false otherwise
*/
- public static function updateCurrentBootSetting()
+ public static function updateCurrentBootSetting(): bool
{
$default = Property::get(self::PROPERTY_DEFAULT_BOOT);
if ($default === false)
@@ -320,7 +343,7 @@ class MiniLinux
} elseif ($slashes === 1) {
// Latest from branch
$ver = Database::queryFirst('SELECT versionid, installed FROM minilinux_version
- WHERE branchid = :branchid AND installed = 1 ORDER BY dateline DESC', ['branchid' => $default]);
+ WHERE branchid = :branchid AND installed = :ok ORDER BY dateline DESC', ['branchid' => $default, 'ok' => self::INSTALL_OK]);
} else {
// Unknown
return false;
@@ -331,35 +354,36 @@ class MiniLinux
return false;
}
Property::set(self::PROPERTY_DEFAULT_BOOT_EFFECTIVE, $ver['versionid']);
- return $ver['installed'] != 0;
+ return $ver['installed'] != self::INSTALL_MISSING;
}
public static function linuxDownloadCallback($task, $versionid)
{
- self::setInstalledState($versionid, $task['statusCode'] === 'TASK_FINISHED');
+ self::setInstalledState($versionid, $task['statusCode'] === 'TASK_FINISHED' ? self::INSTALL_OK : self::INSTALL_BROKEN);
}
- public static function setInstalledState($versionid, int $installed)
+ public static function setInstalledState($versionid, int $installed): void
{
- error_log("Setting $versionid to $installed");
Database::exec('UPDATE minilinux_version SET installed = :installed WHERE versionid = :versionid', [
'versionid' => $versionid,
'installed' => $installed,
]);
- if ($installed) {
- $res = Database::queryFirst('SELECT Count(*) AS cnt FROM minilinux_version WHERE installed <> 0');
+ if ($installed === self::INSTALL_OK) {
+ $res = Database::queryFirst('SELECT Count(*) AS cnt FROM minilinux_version WHERE installed = :ok',
+ ['ok' => self::INSTALL_OK]);
if ($res['cnt'] == 1) {
self::setDefaultVersion($versionid);
}
}
}
- public static function queryAllVersionsByBranch()
+ public static function queryAllVersionsByBranch(): array
{
$list = [];
- $res = Database::simpleQuery('SELECT branchid, versionid, title, dateline, orphan, taskid, installed
+ $res = Database::simpleQuery('SELECT branchid, versionid, title, Length(description) AS desclen,
+ dateline, orphan, taskid, installed
FROM minilinux_version ORDER BY branchid, dateline, versionid');
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$list[$row['branchid']][$row['versionid']] = $row;
}
return $list;
@@ -383,11 +407,12 @@ class MiniLinux
* Check whether an optionally required stage4 is available.
* Return true if there is no stage4, otherwise check filesystem,
* or try to request from local dnbd3-server.
+ *
* @param array $data decoded data column from minilinux_version
* @param string[] $errors in array of error messages if not available
* @return bool true if stage4 is available or none required
*/
- public static function checkStage4($data, &$errors = false)
+ public static function checkStage4(array $data, &$errors = false): bool
{
$errors = [];
$image = false;
@@ -405,12 +430,22 @@ class MiniLinux
}
if ($image === false)
return true; // No stage4
- $mask = $rid;
if ($rid === 0) {
- $mask = '*';
+ // Get latest local revision
+ foreach (glob(CONFIG_VMSTORE_DIR . '/' . $image . '.r*', GLOB_NOSORT) as $file) {
+ if (preg_match('/\.r(\d+)$/', $file, $out)) {
+ $cmp = (int)$out[1];
+ if ($cmp > $rid) {
+ $rid = $cmp;
+ }
+ }
+ }
+ }
+ if ($rid > 0 && file_exists(CONFIG_VMSTORE_DIR . '/' . $image . '.r' . $rid)
+ && !file_exists(CONFIG_VMSTORE_DIR . '/' . $image . '.r' . $rid . '.map')) {
+ // Accept if image exists locally and no map file (map file would mean incomplete)
+ return true;
}
- if (glob(CONFIG_VMSTORE_DIR . '/' . $image . '.r' . $mask, GLOB_NOSORT) !== [])
- return true; // Already exists locally
// Not found locally -- try to replicate
$sock = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
if ($sock === false) {
@@ -459,7 +494,7 @@ class MiniLinux
/**
* Determine by which menus/locations each MiniLinux version is being used.
*/
- public static function getBootMenuUsage()
+ public static function getBootMenuUsage(): array
{
if (!Module::isAvailable('serversetup') || !class_exists('BootEntryHook'))
return [];
@@ -472,34 +507,29 @@ class MiniLinux
WHERE module = 'minilinux'
GROUP BY be.data");
$return = [];
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ $usedMenuIds = [];
+ foreach ($res as $row) {
$data = json_decode($row['data'], true);
if (!isset($data['id']))
continue;
$id = self::resolveEntryId($data['id']);
- if ($id === false)
- continue;
$new = [
'entryids' => [$row['entryid']],
- 'menus' => explode(',', $row['menus']),
- 'locations' => explode(',', $row['locations']),
+ 'menus' => explode(',', $row['menus'] ?? ''),
+ 'locations' => explode(',', $row['locations'] ?? ''),
];
+ $usedMenuIds = array_merge($usedMenuIds, $new['menus']);
if (isset($return[$id])) {
$return[$id] = array_merge_recursive($return[$id], $new);
} else {
$return[$id] = $new;
}
}
- // Flatten and arrayfy the list of menu ids
- $ids = ArrayUtil::flattenByKey($return, 'menus');
- $ids = array_reduce($ids, function ($carry, $item) {
- return $carry + $item;
- }, []);
// Build id => title map for menus
$res = Database::simpleQuery("SELECT menuid, title FROM serversetup_menu m
- WHERE menuid IN (:menuid)", ['menuid' => $ids]);
+ WHERE menuid IN (:menuid)", ['menuid' => array_unique($usedMenuIds)]);
$menus = [];
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$menus[$row['menuid']] = $row['title'];
}
// Build output array
@@ -521,16 +551,16 @@ class MiniLinux
* Take a configured versionid from a bootentry (serversetup module) and translate
* it, in case it's "default" or just a branch name.
*/
- private static function resolveEntryId($id)
+ private static function resolveEntryId(string $id): string
{
if ($id === 'default') { // Special case
$id = Property::get(MiniLinux::PROPERTY_DEFAULT_BOOT_EFFECTIVE);
}
if (substr_count($id, '/') < 2) {
// Maybe this is a branchid, which means latest from according branch (installed only)
- $res = Database::queryFirst('SELECT versionid FROM minilinux_version WHERE branchid = :id AND installed = 1
+ $res = Database::queryFirst('SELECT versionid FROM minilinux_version WHERE branchid = :id AND installed = :ok
ORDER BY dateline DESC LIMIT 1',
- ['id' => $id]);
+ ['id' => $id, 'ok' => self::INSTALL_OK]);
if ($res !== false) {
$id = $res['versionid'];
}
@@ -538,4 +568,4 @@ class MiniLinux
return $id;
}
-} \ No newline at end of file
+}
diff --git a/modules-available/minilinux/install.inc.php b/modules-available/minilinux/install.inc.php
index e71e3c10..7ef82d74 100644
--- a/modules-available/minilinux/install.inc.php
+++ b/modules-available/minilinux/install.inc.php
@@ -2,7 +2,7 @@
$result[] = tableCreate('minilinux_source', "
`sourceid` varchar(8) CHARACTER SET ascii NOT NULL,
- `title` varchar(100) NOT NULL,
+ `title` varchar(150) NOT NULL,
`url` varchar(200) NOT NULL,
`lastupdate` int(10) UNSIGNED NOT NULL DEFAULT '0',
`taskid` char(36) CHARACTER SET ascii DEFAULT NULL,
@@ -13,7 +13,8 @@ $result[] = tableCreate('minilinux_source', "
$result[] = tableCreate('minilinux_branch', "
`sourceid` varchar(8) CHARACTER SET ascii DEFAULT NULL,
`branchid` varchar(40) CHARACTER SET ascii NOT NULL,
- `title` varchar(100) NOT NULL,
+ `title` varchar(150) NOT NULL,
+ `color` varchar(7) NOT NULL,
`description` blob NOT NULL,
PRIMARY KEY (`branchid`),
KEY (`title`)
@@ -21,7 +22,8 @@ $result[] = tableCreate('minilinux_branch', "
$result[] = tableCreate('minilinux_version', "
`branchid` varchar(40) CHARACTER SET ascii NOT NULL,
`versionid` varchar(72) CHARACTER SET ascii NOT NULL,
- `title` varchar(100) NOT NULL,
+ `title` varchar(150) NOT NULL,
+ `description` blob NOT NULL,
`dateline` int(10) UNSIGNED NOT NULL,
`data` blob NOT NULL,
`orphan` tinyint(3) UNSIGNED NOT NULL,
@@ -39,4 +41,28 @@ $result[] = tableAddConstraint('minilinux_version', 'branchid', 'minilinux_branc
$result[] = tableAddConstraint('minilinux_branch', 'sourceid', 'minilinux_source', 'sourceid',
'ON UPDATE CASCADE ON DELETE SET NULL');
+// 2022-10-17: Add color to branch, description to version
+if (!tableHasColumn('minilinux_branch', 'color')) {
+ if (Database::exec("ALTER TABLE `minilinux_branch` ADD COLUMN `color` varchar(7) NOT NULL DEFAULT '' AFTER `title`") !== false) {
+ $result[] = UPDATE_DONE;
+ } else {
+ finalResponse(UPDATE_FAILED, Database::lastError());
+ }
+}
+if (!tableHasColumn('minilinux_version', 'description')) {
+ // BLOB/TEXT cannot have non-NULL default on older MariaDB
+ if (Database::exec("ALTER TABLE `minilinux_version` ADD COLUMN `description` blob NULL DEFAULT NULL AFTER `title`") !== false) {
+ $result[] = UPDATE_DONE;
+ } else {
+ finalResponse(UPDATE_FAILED, Database::lastError());
+ }
+}
+
+// 2023-07-17: Make title columns larger
+foreach (['minilinux_source', 'minilinux_branch', 'minilinux_version'] as $table) {
+ if (stripos(tableColumnType($table, 'title'), 'varchar(150)') === false) {
+ Database::exec("ALTER TABLE `$table` MODIFY `title` varchar(150) NOT NULL");
+ }
+}
+
responseFromArray($result);
diff --git a/modules-available/minilinux/lang/de/messages.json b/modules-available/minilinux/lang/de/messages.json
index e957ee09..c32fa20d 100644
--- a/modules-available/minilinux/lang/de/messages.json
+++ b/modules-available/minilinux/lang/de/messages.json
@@ -1,10 +1,10 @@
{
- "default-is-invalid": "Gew\u00e4hltes Linux-Standardsystem ist ung\u00fcltig",
- "default-not-installed": "Gew\u00e4hltes Linux-Standardsystem {{0}} ist nicht (mehr) installiert",
- "default-update-available": "F\u00fcr das Gew\u00e4hlte Linux-Standardsystem {{0}} ist die Aktualisierung {{1}} verf\u00fcgbar",
+ "default-is-invalid": "Gew\u00e4hltes Netboot-Grundsystem ist ung\u00fcltig",
+ "default-not-installed": "Gew\u00e4hltes Netboot-Grundsystem {{0}} ist nicht (mehr) installiert",
+ "default-update-available": "F\u00fcr das Gew\u00e4hlte Netboot-Grundsystem {{0}} ist die Aktualisierung {{1}} verf\u00fcgbar",
"delete-error": "Fehler beim L\u00f6schen der Version {{0}}: {{1}}",
- "no-default-set": "Kein Linux-Standardsystem festgelegt",
+ "no-default-set": "Kein Netboot-Grundsystem als Standard festgelegt",
"no-such-version": "Ung\u00fcltige\/Unbekannte Version: {{0}}",
- "please-download-minilinux": "Wichtige Dateien der MiniLinux-Installation fehlen",
+ "please-download-minilinux": "Wichtige Dateien der Netboot-Grundsysteminstallation fehlen",
"version-deleted": "Version {{0}} wurde gel\u00f6scht"
-} \ No newline at end of file
+}
diff --git a/modules-available/minilinux/lang/de/module.json b/modules-available/minilinux/lang/de/module.json
index 9cf5c1a1..f47249d5 100644
--- a/modules-available/minilinux/lang/de/module.json
+++ b/modules-available/minilinux/lang/de/module.json
@@ -6,12 +6,13 @@
"file-ok": "OK",
"file-size-mismatch": "Dateigr\u00f6\u00dfe stimmt nicht",
"ipxe-debug": "Debug-Ausgaben statt Bootlogo",
- "ipxe-insecure-cpu": "Alle Mitigations for CPU-Sicherheitsl\u00fccken deaktivieren",
+ "ipxe-force-init-dhcp": "Erzwinge erneuten DHCP-Request nach Laden des initramfs",
+ "ipxe-insecure-cpu": "Alle Mitigations f\u00fcr CPU-Sicherheitsl\u00fccken deaktivieren",
"ipxe-kcl-extra": "Modifikation der Kernel-Command-Line",
"latest_of_branch": "(Neueste lokal vorhandene Version)",
"menu-sources": "Update-Quellen",
"menu-versions": "Verf\u00fcgbare Versionen",
- "module_name": "Netboot Grundsystem",
+ "module_name": "Netboot-Grundsystem",
"not_installed_hint": "(nicht installiert)",
- "page_title": "Linuxvarianten f\u00fcr Netboot verwalten"
-} \ No newline at end of file
+ "page_title": "Netboot-Grundsystemverwaltung"
+}
diff --git a/modules-available/minilinux/lang/de/permissions.json b/modules-available/minilinux/lang/de/permissions.json
index c7d7df54..4773611a 100644
--- a/modules-available/minilinux/lang/de/permissions.json
+++ b/modules-available/minilinux/lang/de/permissions.json
@@ -1,5 +1,5 @@
{
- "delete": "Ein heruntergeladenes Linux l\u00f6schen.",
- "update": "Aktualisieren von Komponenten des Minilinux.",
- "view": "Zeige Komponenten des Minilinux. Wird nicht ben\u00f6tigt, wenn Nutzer eine der anderen Rechte hat."
+ "delete": "Ein heruntergeladenes Netboot-Grundsystem l\u00f6schen.",
+ "update": "Aktualisieren von Komponenten des Netboot-Grundsystems.",
+ "view": "Zeige Komponenten des Netboot-Grundsystems. Wird nicht ben\u00f6tigt, wenn Nutzer eine der anderen Rechte hat."
} \ No newline at end of file
diff --git a/modules-available/minilinux/lang/de/template-tags.json b/modules-available/minilinux/lang/de/template-tags.json
index 3be2bea1..894b864b 100644
--- a/modules-available/minilinux/lang/de/template-tags.json
+++ b/modules-available/minilinux/lang/de/template-tags.json
@@ -13,7 +13,7 @@
"lang_maybeMissingStage4": "Stage 4 m\u00f6glicherweise nicht verf\u00fcgbar",
"lang_menuEntries": "Men\u00fceintr\u00e4ge",
"lang_menus": "Men\u00fcs",
- "lang_minilinuxHeading": "Netboot Linux verwalten",
+ "lang_minilinuxHeading": "Netboot-Grundsystem verwalten",
"lang_orphanedVersion": "Verwaist",
"lang_orphanedVersionToolTip": "Diese Version wird vom Update-Server nicht mehr angeboten",
"lang_releaseDate": "Ver\u00f6ffentlichungsdatum",
diff --git a/modules-available/minilinux/lang/en/messages.json b/modules-available/minilinux/lang/en/messages.json
index 7c0b2c5d..193b18fa 100644
--- a/modules-available/minilinux/lang/en/messages.json
+++ b/modules-available/minilinux/lang/en/messages.json
@@ -5,6 +5,6 @@
"delete-error": "Error deleting version {{0}}: {{1}}",
"no-default-set": "No default system selected",
"no-such-version": "No such version: {{0}}",
- "please-download-minilinux": "Important files from the mini Linux installation are missing.",
+ "please-download-minilinux": "Important files from the netboot Linux installation are missing.",
"version-deleted": "Deleted version {{0}}"
} \ No newline at end of file
diff --git a/modules-available/minilinux/lang/en/module.json b/modules-available/minilinux/lang/en/module.json
index b58c48e2..ff5c7a49 100644
--- a/modules-available/minilinux/lang/en/module.json
+++ b/modules-available/minilinux/lang/en/module.json
@@ -6,6 +6,7 @@
"file-ok": "OK",
"file-size-mismatch": "File size mismatch",
"ipxe-debug": "Print debug messages instead of showing splash screen",
+ "ipxe-force-init-dhcp": "Force another DHCP request after loading initramfs",
"ipxe-insecure-cpu": "Disable all mitigations for CPU security flaws",
"ipxe-kcl-extra": "Modifications to the kernel command line",
"latest_of_branch": "(Latest locally available version)",
diff --git a/modules-available/minilinux/lang/en/permissions.json b/modules-available/minilinux/lang/en/permissions.json
index 124dcdb8..9d97ad00 100644
--- a/modules-available/minilinux/lang/en/permissions.json
+++ b/modules-available/minilinux/lang/en/permissions.json
@@ -1,5 +1,5 @@
{
- "delete": "Delete a downloaded Linux version.",
- "update": "Update minilinux components.",
- "view": "Show list of minilinux components. Not needed if User has any of the other permissions."
+ "delete": "Delete a downloaded netboot Linux version.",
+ "update": "Update netboot Linux components.",
+ "view": "Show list of netboot Linux components. Not needed if user has any of the other permissions."
} \ No newline at end of file
diff --git a/modules-available/minilinux/page.inc.php b/modules-available/minilinux/page.inc.php
index 03ec121e..8004f1ab 100644
--- a/modules-available/minilinux/page.inc.php
+++ b/modules-available/minilinux/page.inc.php
@@ -25,49 +25,72 @@ class Page_MiniLinux extends Page
}
User::assertPermission('view');
- Dashboard::addSubmenu('?do=minilinux', Dictionary::translate('menu-versions', true));
- Dashboard::addSubmenu('?do=minilinux&show=sources', Dictionary::translate('menu-sources', true));
+ Dashboard::addSubmenu('?do=minilinux', Dictionary::translate('menu-versions'));
+ Dashboard::addSubmenu('?do=minilinux&show=sources', Dictionary::translate('menu-sources'));
}
protected function doRender()
{
- Render::addTemplate('page-minilinux', ['default' => Property::get(MiniLinux::PROPERTY_DEFAULT_BOOT)]);
- // Warning
- if (!MiniLinux::updateCurrentBootSetting()) {
- Message::addError('default-not-installed', Property::get(MiniLinux::PROPERTY_DEFAULT_BOOT));
- }
$show = Request::get('show', 'list', 'string');
if ($show === 'list') {
// List branches and versions
- $branches = Database::queryAll('SELECT sourceid, branchid, title, description FROM minilinux_branch ORDER BY title ASC');
+ $branches = Database::queryAll('SELECT sourceid, branchid, title, color, description FROM minilinux_branch ORDER BY title ASC');
$versions = MiniLinux::queryAllVersionsByBranch();
$usage = MiniLinux::getBootMenuUsage();
+ $sourceList = [];
// Group by branch for detailed listing, add usage info
foreach ($branches as &$branch) {
- $branch['bid'] = 'div-' . str_replace('/', '-', $branch['branchid']);
+ // Little hack: We abuse the title for ordering, so if the second char is a space, assume the first one
+ // is just for sort order and remove it.
+ if ($branch['title'][1] === ' ') {
+ $branch['title'] = substr($branch['title'], 2);
+ }
+ $bid = 'div-' . str_replace('/', '-', $branch['branchid']);
+ if (!isset($sourceList[$branch['sourceid']])) {
+ $sourceList[$branch['sourceid']] = ['sourceid' => $branch['sourceid'], 'list' => []];
+ }
+ $sourceList[$branch['sourceid']]['list'][] = [
+ 'title' => $branch['title'],
+ 'color' => $branch['color'],
+ 'bid' => $bid
+ ];
+ $branch['bid'] = $bid;
if (isset($versions[$branch['branchid']])) {
$branch['versionlist'] = $this->renderVersionList($versions[$branch['branchid']], $usage);
}
}
unset($branch);
- Render::addTemplate('branches', ['branches' => $branches]);
+ $sourceList = array_values($sourceList);
} elseif ($show === 'sources') {
// List sources
$res = Database::simpleQuery('SELECT sourceid, title, url, lastupdate, pubkey FROM minilinux_source ORDER BY title, sourceid');
- $data = ['list' => [], 'show_refresh' => true];
+ $sourceViewData = ['list' => [], 'show_refresh' => true];
$tooOld = strtotime('-7 days');
$showRefresh = strtotime('-5 minutes');
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$row['lastupdate_s'] = Util::prettyTime($row['lastupdate']);
if ($row['lastupdate'] != 0 && $row['lastupdate'] < $tooOld) {
$row['update_class'] = 'text-danger';
}
if ($row['lastupdate'] > $showRefresh) {
- $data['show_refresh'] = false;
+ $sourceViewData['show_refresh'] = false;
}
- $data['list'][] = $row;
+ $sourceViewData['list'][] = $row;
}
- Render::addTemplate('sources', $data);
+ }
+ // Output
+ Render::addTemplate('page-minilinux', [
+ 'default' => Property::get(MiniLinux::PROPERTY_DEFAULT_BOOT),
+ 'sources' => $sourceList ?? null,
+ ]);
+ // Warning
+ if (!MiniLinux::updateCurrentBootSetting()) {
+ Message::addError('default-not-installed', Property::get(MiniLinux::PROPERTY_DEFAULT_BOOT));
+ }
+ if (isset($branches)) {
+ Render::addTemplate('branches', ['branches' => $branches]);
+ } elseif (isset($sourceViewData)) {
+ Render::addTemplate('sources', $sourceViewData);
} else {
Message::addError('main.invalid-action', $show);
}
@@ -84,20 +107,20 @@ class Page_MiniLinux extends Page
}
}
- private function renderVersionList($versions, $usage)
+ private function renderVersionList(array $versions, array $usage): string
{
$def = Property::get(MiniLinux::PROPERTY_DEFAULT_BOOT);
//$eff = Property::get(MiniLinux::PROPERTY_DEFAULT_BOOT_EFFECTIVE);
foreach ($versions as &$version) {
$version['dateline_s'] = Util::prettyTime($version['dateline']);
- $version['orphan'] = ($version['orphan'] > 0 && !$version['installed']) || ($version['orphan'] > 1);
+ $version['orphan'] = ($version['orphan'] > 0 && $version['installed'] == MiniLinux::INSTALL_MISSING) || ($version['orphan'] > 1);
$version['downloading'] = $version['taskid'] && Taskmanager::isRunning(Taskmanager::status($version['taskid']));
- if ($version['installed'] && $version['versionid'] !== $def) {
+ if ($version['installed'] != MiniLinux::INSTALL_MISSING && $version['versionid'] !== $def) {
$version['showsetdefault'] = true;
}
if ($version['versionid'] === $def) {
$version['isdefault'] = true;
- if (!$version['installed']) {
+ if (!$version['installed'] != MiniLinux::INSTALL_OK) {
$version['default_class'] = 'bg-danger';
}
}
@@ -116,7 +139,8 @@ class Page_MiniLinux extends Page
if ($versionid === false) {
die('What!');
}
- $ver = Database::queryFirst('SELECT versionid, taskid, data, installed FROM minilinux_version WHERE versionid = :versionid',
+ $ver = Database::queryFirst('SELECT versionid, description, taskid, data, installed
+ FROM minilinux_version WHERE versionid = :versionid',
['versionid' => $versionid]);
if ($ver === false) {
die('No such version');
@@ -128,7 +152,7 @@ class Page_MiniLinux extends Page
}
$data['versionid'] = $versionid;
$data['dltask'] = MiniLinux::validateDownloadTask($versionid, $ver['taskid']);
- $data['verify_button'] = !$verify && $data['dltask'] === false;
+ $data['verify_button'] = !$verify && $data['dltask'] === null;
if (is_array($data['files'])) {
$valid = true;
$sort = [];
@@ -152,7 +176,7 @@ class Page_MiniLinux extends Page
if (isset($file['mtime'])) {
$file['mtime_s'] = Util::prettyTime($file['mtime']);
}
- if ($data['dltask']) {
+ if ($data['dltask'] !== null) {
$file['fileid'] = MiniLinux::fileToId($versionid, $file['name']);
}
}
@@ -160,16 +184,17 @@ class Page_MiniLinux extends Page
array_multisort($sort, SORT_ASC, $data['files']);
if (!$valid) {
$data['verify_button'] = false;
- if ($ver['installed']) {
- MiniLinux::setInstalledState($versionid, false);
+ if ($ver['installed'] != MiniLinux::INSTALL_MISSING) {
+ MiniLinux::setInstalledState($versionid, MiniLinux::INSTALL_BROKEN);
}
- } elseif (!$ver['installed'] && $verify) {
- MiniLinux::setInstalledState($versionid, true);
+ } elseif ($ver['installed'] != MiniLinux::INSTALL_OK && $verify) {
+ MiniLinux::setInstalledState($versionid, MiniLinux::INSTALL_OK);
}
}
- if ($data['dltask'] !== false || $ver['installed']) {
+ if ($data['dltask'] !== null || $ver['installed'] != MiniLinux::INSTALL_MISSING) {
MiniLinux::checkStage4($data, $data['s4_errors']);
}
+ $data['changelog'] = Util::markup($ver['description'] ?? '');
echo Render::parse('filelist', $data);
}
@@ -179,7 +204,7 @@ class Page_MiniLinux extends Page
const FILE_CHECKSUM_BAD = 3;
const FILE_NOT_READABLE = 4;
- private function getFileState($versionid, $file, $verify)
+ private function getFileState(string $versionid, array $file, bool $verify): int
{
$path = CONFIG_HTTP_DIR . '/' . $versionid . '/' . $file['name'];
if (!is_file($path))
@@ -206,15 +231,15 @@ class Page_MiniLinux extends Page
{
switch ($state) {
case self::FILE_CHECKSUM_BAD:
- return Dictionary::translate('file-checksum-bad', true);
+ return Dictionary::translate('file-checksum-bad');
case self::FILE_SIZE_MISMATCH:
- return Dictionary::translate('file-size-mismatch', true);
+ return Dictionary::translate('file-size-mismatch');
case self::FILE_MISSING:
- return Dictionary::translate('file-missing', true);
+ return Dictionary::translate('file-missing');
case self::FILE_NOT_READABLE:
- return Dictionary::translate('file-not-readable', true);
+ return Dictionary::translate('file-not-readable');
case self::FILE_OK:
- return Dictionary::translate('file-ok', true);
+ return Dictionary::translate('file-ok');
}
return '???';
}
@@ -227,7 +252,7 @@ class Page_MiniLinux extends Page
die('No version');
}
$task = MiniLinux::downloadVersion($version);
- if ($task === false) {
+ if ($task === null) {
Message::addError('no-such-version', $version);
Message::renderList();
} else {
@@ -249,7 +274,6 @@ class Page_MiniLinux extends Page
Message::addError('no-such-version');
return;
}
- MiniLinux::setInstalledState($version['versionid'], false);
$path = CONFIG_HTTP_DIR . '/' . $version['versionid'];
$task = Taskmanager::submit('DeleteDirectory', [
'path' => $path,
@@ -258,8 +282,10 @@ class Page_MiniLinux extends Page
if ($task !== false) {
$task = Taskmanager::waitComplete($task, 2500);
if (Taskmanager::isFailed($task)) {
+ MiniLinux::setInstalledState($version['versionid'], MiniLinux::INSTALL_BROKEN);
Message::addError('delete-error', $versionid, $task['data']['error']);
} else {
+ MiniLinux::setInstalledState($version['versionid'], MiniLinux::INSTALL_MISSING);
Message::addSuccess('version-deleted', $versionid);
}
}
diff --git a/modules-available/minilinux/templates/branches.html b/modules-available/minilinux/templates/branches.html
index 275f026c..372321e2 100644
--- a/modules-available/minilinux/templates/branches.html
+++ b/modules-available/minilinux/templates/branches.html
@@ -1,7 +1,10 @@
<h3>{{lang_branchesHeading}}</h3>
+<div class="clearfix"></div>
+
<div id="ibm-mainframe">
{{#branches}}
- <div class="panel panel-default">
+ <a id="a-{{bid}}"></a>
+ <div class="panel panel-default" {{#color}}style="background:linear-gradient(90deg, {{color}} 0%, {{color}} 4px, rgba(255,255,255,0) 4px)"{{/color}}>
<div class="panel-heading">
<div class="pull-right slx-pointer" data-toggle="collapse" data-target="#{{bid}}">
{{sourceid}} {{branchid}} <b class="caret"></b>
diff --git a/modules-available/minilinux/templates/filelist.html b/modules-available/minilinux/templates/filelist.html
index fdbef4ad..241d1264 100644
--- a/modules-available/minilinux/templates/filelist.html
+++ b/modules-available/minilinux/templates/filelist.html
@@ -49,7 +49,10 @@
<pre class="collapse" id="error-{{dltask}}"></pre>
{{/dltask}}
{{#changelog}}
-<h4>{{lang_changelog}}</h4>
-{{changelog}}
+ <div class="slx-space"></div>
+<div style="border:1px solid #bbb;padding:4px;border-radius: 3px">
+ <h4>{{lang_changelog}}</h4>
+ {{{changelog}}}
+</div>
{{/changelog}}
<div class="slx-space"></div> \ No newline at end of file
diff --git a/modules-available/minilinux/templates/page-minilinux.html b/modules-available/minilinux/templates/page-minilinux.html
index 161fa02f..c66de597 100644
--- a/modules-available/minilinux/templates/page-minilinux.html
+++ b/modules-available/minilinux/templates/page-minilinux.html
@@ -1,3 +1,18 @@
+{{#sources}}
+<div class="panel panel-default pull-right" style="margin:2px">
+ <table class="table table-condensed">
+ <tr style="background:#eee">
+ <th>{{sourceid}}</th>
+ </tr>
+ {{#list}}
+ <tr {{#color}}style="background:linear-gradient(90deg, {{color}} 0%, {{color}} 4px, rgba(255,255,255,0) 4px)"{{/color}}>
+ <td><a href="#a-{{bid}}">{{title}}</a></td>
+ </tr>
+ {{/list}}
+ </table>
+</div>
+{{/sources}}
+
<h1>{{lang_minilinuxHeading}}</h1>
{{lang_selectedDefaultIs}}: <b>{{default}}</b> \ No newline at end of file
diff --git a/modules-available/minilinux/templates/versionlist.html b/modules-available/minilinux/templates/versionlist.html
index 8ab6463c..e66960b2 100644
--- a/modules-available/minilinux/templates/versionlist.html
+++ b/modules-available/minilinux/templates/versionlist.html
@@ -16,7 +16,14 @@
<b class="caret"></b>
</a>
</td>
- <td class="text-nowrap">{{dateline_s}}</td>
+ <td class="text-nowrap">
+ {{#desclen}}
+ <div style="float:right;margin-right:-6px">
+ <span class="glyphicon glyphicon-list-alt"></span>
+ </div>
+ {{/desclen}}
+ {{dateline_s}}
+ </td>
<td>{{title}}</td>
<td class="text-nowrap">
{{#usage.entryids.0}}
@@ -26,18 +33,18 @@
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
- <li role="separator" class="dropdown-header">{{lang_menuEntries}}</li>
+ <li role="separator" class="dropdown-header slx-bold">{{lang_menuEntries}}</li>
{{#usage.entryids}}
<li><a href="?do=serversetup&amp;show=editbootentry&amp;id={{.}}">{{.}}</a></li>
{{/usage.entryids}}
{{#usage.menus.0}}
- <li role="separator" class="dropdown-header">{{lang_menus}}</li>
+ <li role="separator" class="dropdown-header slx-bold">{{lang_menus}}</li>
{{/usage.menus.0}}
{{#usage.menus}}
<li><a href="?do=serversetup&amp;show=editmenu&amp;id={{menuid}}">{{menuname}}</a></li>
{{/usage.menus}}
{{#usage.locations.0}}
- <li role="separator" class="dropdown-header">{{lang_locations}}</li>
+ <li role="separator" class="dropdown-header slx-bold">{{lang_locations}}</li>
{{/usage.locations.0}}
{{#usage.locations}}
<li class="disabled"><a href="#">{{locationname}}</a></li>
diff --git a/modules-available/news/api.inc.php b/modules-available/news/api.inc.php
index 851f31a8..3b56c70d 100644
--- a/modules-available/news/api.inc.php
+++ b/modules-available/news/api.inc.php
@@ -4,23 +4,58 @@ header('Content-Type: application/xml; charset=utf-8');
$type = Request::any('type', 'news', 'string');
+if (Module::isAvailable('locations')) {
+ $locationId = Request::any('location', 0, 'int');
+ if ($locationId === 0) {
+ $locationId = Location::getFromIp($_SERVER['REMOTE_ADDR']);
+ }
+ $locations = Location::getLocationRootChain($locationId);
+ $locations[] = 0;
+} else {
+ $locations = [0];
+}
+
// Fetch news from DB
-$row = Database::queryFirst('SELECT title, content, dateline FROM vmchooser_pages'
- . ' WHERE type = :type AND expires > UNIX_TIMESTAMP() ORDER BY dateline DESC LIMIT 1', compact('type'));
-if ($row !== false ) {
+$res = Database::simpleQuery('SELECT title, locationid, content, dateline FROM vmchooser_pages
+ WHERE type = :type AND (locationid IS NULL OR locationid IN (:lids))
+ AND expires > UNIX_TIMESTAMP() ORDER BY dateline DESC', [
+ 'type' => $type,
+ 'lids' => $locations,
+ ]);
- echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
- echo "<news>" . "\n";
- echo "\t" . '<headline>' . "\n";
- echo "\t\t" . htmlspecialchars($row['title']) . "\n";
- echo "\t" . '</headline>' . "\n";
- echo "\t" . "<info>" . "\n";
- echo "\t\t" . htmlspecialchars($row['content']) . "\n";
- echo "\t" . '</info>' . "\n";
- echo "\t" . "<date>" . "\n";
- echo "\t\t" . $row['dateline'] . "\n";
- echo "\t" . "</date>" . "\n";
- echo "</news>";
+// Get one for each location. As we sort by dateline and check expiry in the query, we only
+// need one per location and then pick the first one that is set, as the locations are ordered
+// by closest to furthest
+$locations = array_flip($locations);
+foreach ($res as $row) {
+ $lid = (int)$row['locationid'];
+ if (is_array($locations[$lid]))
+ continue; // Already have one
+ $locations[$lid] = $row;
+}
+// Pick first one
+foreach ($locations as $row) {
+ if (is_array($row))
+ break;
+}
+
+if (is_array($row)) {
+ $title = htmlspecialchars($row['title']);
+ $content = htmlspecialchars($row['content']);
+ echo <<<EOF
+<?xml version="1.0" encoding="UTF-8"?>
+<news>
+ <headline>
+ $title
+ </headline>
+ <info>
+ $content
+ </info>
+ <date>
+ {$row['dateline']}
+ </date>
+</news>
+EOF;
} else {
// no news in DB, output a 'null' news xml
diff --git a/modules-available/news/install.inc.php b/modules-available/news/install.inc.php
index 88a20749..a60f7748 100644
--- a/modules-available/news/install.inc.php
+++ b/modules-available/news/install.inc.php
@@ -1,6 +1,6 @@
<?php
-$res = array();
+$dbret = array();
@@ -10,8 +10,8 @@ if (tableExists('news')) {
if (!tableRename('news', 'vmchooser_pages')) {
finalResponse(UPDATE_FAILED, "Could not rename news to vmchooser_pages: " . Database::lastError());
}
- $res[] = UPDATE_DONE;
- if (false === Database::exec("ALTER TABLE `vmchooser_pages` ADD COLUMN type VARCHAR(10)")) {
+ $dbret[] = UPDATE_DONE;
+ if (false === Database::exec("ALTER TABLE `vmchooser_pages` ADD COLUMN type VARCHAR(10) CHARACTER SET ascii NOT NULL")) {
EventLog::warning("Could not add type column to vmchooser_pages: " . Database::lastError());
}
if (false === Database::exec("UPDATE `vmchooser_pages` SET `type` = 'news' WHERE 1")) {
@@ -20,33 +20,46 @@ if (tableExists('news')) {
}
-$res[] = tableCreate('vmchooser_pages', "
+$dbret[] = tableCreate('vmchooser_pages', "
`newsid` int(10) unsigned NOT NULL AUTO_INCREMENT,
`dateline` int(10) unsigned NOT NULL,
`expires` int(10) unsigned NOT NULL,
+ `locationid` int(11) NULL,
`title` varchar(200) DEFAULT NULL,
- `content` text,
- `type` varchar(10),
+ `content` text NOT NULL,
+ `type` varchar(10) CHARACTER SET ascii NOT NULL,
PRIMARY KEY (`newsid`),
- KEY `type` (`type`, `dateline`),
- KEY `all3` (`type`, `expires`, `dateline`)
+ KEY `type` (`type`, `dateline`)
");
if (tableGetIndex('vmchooser_pages', ['dateline']) !== false) {
Database::exec('ALTER TABLE vmchooser_pages DROP KEY `dateline`');
Database::exec('ALTER TABLE vmchooser_pages ADD KEY `type` (`type`, `dateline`)');
+ $dbret[] = UPDATE_DONE;
}
if (tableGetIndex('vmchooser_pages', ['type', 'expires', 'dateline']) === false) {
Database::exec('ALTER TABLE vmchooser_pages ADD KEY `all3` (`type`, `expires`, `dateline`)');
+ $dbret[] = UPDATE_DONE;
}
if (!tableHasColumn('vmchooser_pages', 'expires')) {
Database::exec('ALTER TABLE vmchooser_pages ADD COLUMN `expires` int(10) unsigned NOT NULL AFTER `dateline`');
Database::exec('UPDATE vmchooser_pages SET expires = dateline + 86400 * 3650 WHERE expires = 0'); // ~10 Years
+ $dbret[] = UPDATE_DONE;
}
+if (!tableHasColumn('vmchooser_pages', 'locationid')) {
+ Database::exec('ALTER TABLE vmchooser_pages ADD COLUMN `locationid` int(11) NULL AFTER `expires`');
+ $dbret[] = UPDATE_DONE;
+}
+
+Database::exec('ALTER TABLE vmchooser_pages MODIFY `type` varchar(10) CHARACTER SET ascii NOT NULL');
+
+$dbret[] = tableAddConstraint('vmchooser_pages', 'locationid', 'location', 'locationid',
+ 'ON UPDATE CASCADE ON DELETE CASCADE');
+
// Create response for browser
-if (in_array(UPDATE_DONE, $res)) {
+if (in_array(UPDATE_DONE, $dbret)) {
finalResponse(UPDATE_DONE, 'Tables created successfully');
}
diff --git a/modules-available/news/lang/de/template-tags.json b/modules-available/news/lang/de/template-tags.json
index 187d6cd3..dcd2f76f 100644
--- a/modules-available/news/lang/de/template-tags.json
+++ b/modules-available/news/lang/de/template-tags.json
@@ -2,12 +2,14 @@
"lang_confirmDelete": "Eintrag l\u00f6schen?",
"lang_content": "Inhalt",
"lang_created": "Erstellt",
+ "lang_editingForLocation": "Sie bearbeiten den Text f\u00fcr den oben angegebenen Ort (und ggf. alle Unterorte)",
"lang_expires": "Ablauf",
"lang_expiryDate": "Ablaufdatum",
"lang_infiniteDuration": "L\u00e4uft nicht ab",
"lang_introText": "Hier k\u00f6nnen Sie f\u00fcr verschiedene Bereiche des MiniLinux-Systems Neuigkeiten, Hilfetexte etc. definieren.",
"lang_lastUpdate": "Letzte Aktualisierung",
"lang_mainHeading": "Neuigkeiten und Hilfetexte",
+ "lang_overridenLocations": "Orte mit angepasstem Text",
"lang_previousEntries": "Vorherige Eintr\u00e4ge",
"lang_show": "Ansehen",
"lang_title": "Titel",
diff --git a/modules-available/news/lang/en/template-tags.json b/modules-available/news/lang/en/template-tags.json
index 9fbc4a46..0d62bd08 100644
--- a/modules-available/news/lang/en/template-tags.json
+++ b/modules-available/news/lang/en/template-tags.json
@@ -2,12 +2,14 @@
"lang_confirmDelete": "Delete entry?",
"lang_content": "Content",
"lang_created": "Created",
+ "lang_editingForLocation": "You're editing this text only for the location given above (and any sub-locations it might have)",
"lang_expires": "Expires",
"lang_expiryDate": "Expiry date",
"lang_infiniteDuration": "Never expires",
"lang_introText": "Here you can define news, help and other informational texts to be displayed in different places in the running MiniLinux system.",
"lang_lastUpdate": "Last update",
"lang_mainHeading": "News and help texts",
+ "lang_overridenLocations": "Locations with customized text",
"lang_previousEntries": "Previous entries",
"lang_show": "Show",
"lang_title": "Title",
diff --git a/modules-available/news/page.inc.php b/modules-available/news/page.inc.php
index 7a09d437..291f15fc 100644
--- a/modules-available/news/page.inc.php
+++ b/modules-available/news/page.inc.php
@@ -39,6 +39,10 @@ class Page_News extends Page
* @var int Unix epoch date when the news expires.
*/
private $newsExpires = false;
+ /**
+ * @var int location id
+ */
+ private $locationId = 0;
/**
@@ -63,30 +67,33 @@ class Page_News extends Page
User::assertPermission('access-page');
/* and also the news (or help) with the given id */
- $newsId = Request::get('newsid', false, 'int');
- $pageType = Request::get('type', false, 'string');
- if ($pageType === false && $newsId === false) {
- Util::redirect('?do=news&type=news');
+ $newsId = Request::get('newsid', null, 'int');
+ $pageType = Request::get('type', null, 'string');
+ $this->locationId = Request::get('locationid', 0, 'int');
+ if ($pageType === null && $newsId === null) {
+ Util::redirect('?do=news&type=news&locationid=' . $this->locationId);
}
- $this->pageType = $pageType === false ? 'news' : $pageType;
- $this->loadNews($newsId, $pageType);
+ $this->pageType = $pageType ?? 'news';
+ $this->loadNews($newsId);
foreach (self::TYPES as $type => $entry) {
- Dashboard::addSubmenu('?do=news&type=' . $type, Dictionary::translate('type_' . $type, true));
+ Dashboard::addSubmenu('?do=news&type=' . $type . '&locationid=' . $this->locationId,
+ Dictionary::translate('type_' . $type));
}
} else {
$action = Request::post('action', false, 'string');
$pageType = Request::post('type', false, 'string');
+ $this->locationId = Request::post('locationid', Request::REQUIRED_EMPTY, 'int');
if (!array_key_exists($pageType, self::TYPES)) {
Message::addError('invalid-type', $pageType);
- Util::redirect('?do=news');
+ Util::redirect('?do=news&locationid=' . $this->locationId);
}
if ($action === 'save') {
// save to DB
- User::assertPermission("$pageType.save");
+ User::assertPermission("$pageType.save", $this->locationId);
if (!$this->saveNews($pageType)) {
Message::addError('save-error');
} else {
@@ -95,14 +102,14 @@ class Page_News extends Page
} elseif ($action === 'delete') {
// delete it
- User::assertPermission("$pageType.delete");
- $this->delNews(Request::post('newsid', false, 'int'), $pageType);
+ User::assertPermission("$pageType.delete", $this->locationId);
+ $this->delNews(Request::post('newsid', Request::REQUIRED, 'int'), $pageType);
} else {
// unknown action, redirect user
Message::addError('invalid-action', $action);
}
- Util::redirect('?do=news&type=' . $pageType);
+ Util::redirect('?do=news&type=' . $pageType . '&locationid=' . $this->locationId);
}
/* load summernote module if available */
@@ -119,10 +126,11 @@ class Page_News extends Page
// fetch the list of the older news
$NOW = time();
$lines = array();
+ $str = $this->locationId === 0 ? 'IS NULL' : ' = ' . $this->locationId;
$res = Database::simpleQuery("SELECT newsid, dateline, expires, title, content FROM vmchooser_pages
- WHERE type = :type ORDER BY dateline DESC LIMIT 20", ['type' => $this->pageType]);
+ WHERE type = :type AND locationid $str ORDER BY dateline DESC LIMIT 20", ['type' => $this->pageType]);
$foundActive = false;
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$row['dateline_s'] = Util::prettyTime($row['dateline']);
$row['expires_s'] = $this->formatExpires($row['expires']);
if ($row['newsid'] == $this->newsId) {
@@ -141,7 +149,7 @@ class Page_News extends Page
$data = array(
'withTitle' => self::TYPES[$this->pageType]['headline'],
- 'newsTypeName' => Dictionary::translate('type_' . $this->pageType, true),
+ 'newsTypeName' => Dictionary::translate('type_' . $this->pageType),
'dateline_s' => Util::prettyTime($this->newsDateline),
'expires_s' => $this->formatExpires($this->newsExpires),
'currentContent' => $this->newsContent,
@@ -169,10 +177,19 @@ class Page_News extends Page
'disabled' => 'disabled',
];
}
+ $data['locationid'] = $this->locationId;
+ if ($this->locationId > 0) {
+ $data['location_name'] = Location::getName($this->locationId);
+ } else {
+ // Superadmin can see all overridden locations
+ $data['overridden'] = Database::queryAll("SELECT DISTINCT l.locationid, l.locationname FROM vmchooser_pages
+ INNER JOIN location l USING (locationid)
+ WHERE expires > UNIX_TIMESTAMP() ORDER BY locationname ASC");
+ }
Render::addTemplate('page-news', $data);
}
- private function formatExpires($ts)
+ private function formatExpires(int $ts): string
{
if ($ts - 86400 * 365 * 5 > time())
return '-';
@@ -182,15 +199,12 @@ class Page_News extends Page
/**
* Loads the news with the given ID into the form.
*
- * @param int $newsId ID of the news to be shown.
- * @param string $pageType type if news id is not given.
- *
- * @return bool true if loading that news worked
+ * @param ?int $newsId ID of the news to be shown, or latest if null
*/
- private function loadNews($newsId, $pageType)
+ private function loadNews(?int $newsId): void
{
// check to see if we need to request a specific newsid
- if ($newsId !== false) {
+ if ($newsId !== null) {
$row = Database::queryFirst('SELECT newsid, title, content, dateline, expires, type FROM vmchooser_pages
WHERE newsid = :newsid LIMIT 1', [
'newsid' => $newsId,
@@ -199,74 +213,74 @@ class Page_News extends Page
Message::addError('news-empty');
}
} else {
+ $str = $this->locationId === 0 ? 'IS NULL' : ' = ' . $this->locationId;
$row = Database::queryFirst("SELECT newsid, title, content, dateline, expires, type FROM vmchooser_pages
- WHERE type = :type AND expires > UNIX_TIMESTAMP() ORDER BY dateline DESC LIMIT 1", [
- 'type' => $pageType,
+ WHERE type = :type AND locationid $str AND expires > UNIX_TIMESTAMP() ORDER BY dateline DESC LIMIT 1", [
+ 'type' => $this->pageType,
]);
}
if ($row === false)
- return false;
+ return;
// fetch the news to be shown
- if ($row !== false) {
- $this->newsId = $row['newsid'];
- $this->newsTitle = $row['title'];
- $this->newsContent = $row['content'];
- $this->newsDateline = (int)$row['dateline'];
- $this->newsExpires = (int)$row['expires'];
- $this->pageType = $row['type'];
- }
- return true;
+ $this->newsId = $row['newsid'];
+ $this->newsTitle = $row['title'];
+ $this->newsContent = $row['content'];
+ $this->newsDateline = (int)$row['dateline'];
+ $this->newsExpires = (int)$row['expires'];
+ $this->pageType = $row['type'];
}
/**
* Save the given $newsTitle and $newsContent as POST'ed into the database.
*/
- private function saveNews($pageType)
+ private function saveNews(string $pageType): bool
{
// check if news content were set by the user
$newsTitle = Request::post('news-title', '', 'string');
- $newsContent = Request::post('news-content', false, 'string');
+ $newsContent = Request::post('news-content', Request::REQUIRED, 'string');
+ $test = trim(html_entity_decode(strip_tags($newsContent), ENT_QUOTES, 'UTF-8'));
+ if (empty($test)) {
+ Message::addError('main.empty-field');
+ return false;
+ }
$infinite = (Request::post('infinite', '', 'string') !== '');
if ($infinite) {
- $expires = strtotime('+10 years 0:00');
+ $expires = strtotime('+20 years 0:00');
} else {
$expires = strtotime(Request::post('enddate', 'today', 'string') . ' '
- . Request::post('endtime', '23:59', 'string'));
+ . Request::post('endtime', '23:59', 'string'));
}
- if (!empty($newsContent)) {
- // we got title and content, save it to DB
- // dup check first
- $row = Database::queryFirst('SELECT newsid FROM vmchooser_pages
- WHERE content = :content AND type = :type LIMIT 1', [
- 'content' => $newsContent,
- 'type' => $pageType,
- ]);
- if ($row !== false) {
- Database::exec('UPDATE vmchooser_pages SET dateline = :dateline, expires = :expires, title = :title
- WHERE newsid = :newsid LIMIT 1', [
- 'newsid' => $row['newsid'],
- 'dateline' => time(),
- 'expires' => $expires,
- 'title' => $newsTitle,
- ]);
- return true;
- }
- // new one
- Database::exec("INSERT INTO vmchooser_pages (dateline, expires, title, content, type)
- VALUES (:dateline, :expires, :title, :content, :type)", array(
+ $str = $this->locationId === 0 ? 'IS NULL' : ' = ' . $this->locationId;
+ // we got title and content, save it to DB
+ // dup check first
+ $row = Database::queryFirst("SELECT newsid FROM vmchooser_pages
+ WHERE content = :content AND type = :type AND locationid $str LIMIT 1", [
+ 'content' => $newsContent,
+ 'type' => $pageType,
+ ]);
+ if ($row !== false) {
+ Database::exec('UPDATE vmchooser_pages SET dateline = :dateline, expires = :expires, title = :title
+ WHERE newsid = :newsid LIMIT 1', [
+ 'newsid' => $row['newsid'],
'dateline' => time(),
'expires' => $expires,
'title' => $newsTitle,
- 'content' => $newsContent,
- 'type' => $pageType,
- ));
-
+ ]);
return true;
}
+ // new one
+ Database::exec("INSERT INTO vmchooser_pages (dateline, expires, locationid, title, content, type)
+ VALUES (:dateline, :expires, :locationid, :title, :content, :type)", array(
+ 'dateline' => time(),
+ 'expires' => $expires,
+ 'locationid' => $this->locationId === 0 ? null : $this->locationId,
+ 'title' => $newsTitle,
+ 'content' => $newsContent,
+ 'type' => $pageType,
+ ));
- Message::addError('main.empty-field');
- return false;
+ return true;
}
/**
@@ -275,18 +289,12 @@ class Page_News extends Page
* @param int $newsId ID of the entry to be deleted.
* @param string $pageType type of news to be deleted. Must match the ID, otherwise do nothing.
*/
- private function delNews($newsId, $pageType)
+ private function delNews(int $newsId, string $pageType): void
{
- // sanity check: is newsId even numeric?
- if (!is_numeric($newsId)) {
- Message::addError('main.value-invalid', 'newsid', $newsId);
- } else {
- // check passed - do delete
- Database::exec('DELETE FROM vmchooser_pages WHERE newsid = :newsid AND type = :type LIMIT 1', array(
- 'newsid' => $newsId,
- 'type' => $pageType,
- ));
- Message::addSuccess('news-del-success');
- }
+ Database::exec('DELETE FROM vmchooser_pages WHERE newsid = :newsid AND type = :type LIMIT 1', array(
+ 'newsid' => $newsId,
+ 'type' => $pageType,
+ ));
+ Message::addSuccess('news-del-success');
}
}
diff --git a/modules-available/news/permissions/permissions.json b/modules-available/news/permissions/permissions.json
index 4ff1a01b..83fad86c 100644
--- a/modules-available/news/permissions/permissions.json
+++ b/modules-available/news/permissions/permissions.json
@@ -3,21 +3,21 @@
"location-aware": false
},
"help.delete": {
- "location-aware": false
+ "location-aware": true
},
"help.save": {
- "location-aware": false
+ "location-aware": true
},
"news.delete": {
- "location-aware": false
+ "location-aware": true
},
"news.save": {
- "location-aware": false
+ "location-aware": true
},
"login-news.delete": {
- "location-aware": false
+ "location-aware": true
},
"login-news.save": {
- "location-aware": false
+ "location-aware": true
}
} \ No newline at end of file
diff --git a/modules-available/news/templates/page-news.html b/modules-available/news/templates/page-news.html
index 1c944cb8..5ace6e35 100644
--- a/modules-available/news/templates/page-news.html
+++ b/modules-available/news/templates/page-news.html
@@ -2,12 +2,30 @@
<p>{{lang_introText}}</p>
+{{#overridden.0}}
+ <div class="pull-right">
+ <h3>{{lang_overridenLocations}}</h3>
+ <ul>
+ {{#overridden}}
+ <li><a href="?do=news&amp;type={{type}}&amp;locationid={{locationid}}">{{locationname}}</a></li>
+ {{/overridden}}
+ </ul>
+ </div>
+{{/overridden.0}}
+
<h2>{{newsTypeName}}</h2>
+{{#locationid}}
+ <h3>{{location_name}}</h3>
+ <div class="alert alert-info">{{lang_editingForLocation}}</div>
+{{/locationid}}
+<div class="clearfix"></div>
+
<form action="?do=news" method="post">
<input type="hidden" name="token" value="{{token}}">
<input type="hidden" name="type" value="{{type}}">
<input type="hidden" name="action" value="save">
+ <input type="hidden" name="locationid" value="{{locationid}}">
{{#withTitle}}
<div class="form-group">
<label for="news-title-id">{{lang_title}}</label>
diff --git a/modules-available/passthrough/config.json b/modules-available/passthrough/config.json
new file mode 100644
index 00000000..34b16815
--- /dev/null
+++ b/modules-available/passthrough/config.json
@@ -0,0 +1,8 @@
+{
+ "category": "main.settings-client",
+ "collapse": true,
+ "dependencies": [
+ "statistics",
+ "locations"
+ ]
+} \ No newline at end of file
diff --git a/modules-available/passthrough/hooks/locations-column.inc.php b/modules-available/passthrough/hooks/locations-column.inc.php
new file mode 100644
index 00000000..2c09bd73
--- /dev/null
+++ b/modules-available/passthrough/hooks/locations-column.inc.php
@@ -0,0 +1,58 @@
+<?php
+
+if (!User::hasPermission('.passthrough.view')) {
+ return null;
+}
+
+class PassthroughLocationColumn extends AbstractLocationColumn
+{
+
+ private $lookup;
+
+ public function __construct(array $allowedLocationIds)
+ {
+ $this->lookup = Database::queryKeyValueList("SELECT gxl.locationid, GROUP_CONCAT(gxl.groupid SEPARATOR ', ') AS grps
+ FROM passthrough_group_x_location gxl
+ WHERE locationid IN (:allowedLocationIds) GROUP BY locationid", compact('allowedLocationIds'));
+ }
+
+ public function getColumnHtml(int $locationId): string
+ {
+ return htmlspecialchars($this->lookup[$locationId] ?? '');
+ }
+
+ public function getEditUrl(int $locationId): string
+ {
+ if (!User::hasPermission('.passthrough.edit.location', $locationId))
+ return '';
+ return '?do=passthrough&show=assignlocation&locationid=' . $locationId;
+ }
+
+ public function header(): string
+ {
+ return Dictionary::translateFileModule('passthrough', 'module', 'location-column-header');
+ }
+
+ public function priority(): int
+ {
+ return 4000;
+ }
+
+ public function propagateColumn(): bool
+ {
+ return true;
+ }
+
+ public function propagationOverride(string $parent, string $data): string
+ {
+ if (empty($parent))
+ return $data;
+ $merge = array_unique(array_merge(
+ explode(', ', $parent), explode(', ', $data)));
+ sort($merge);
+ return implode(', ', $merge);
+ }
+
+}
+
+return new PassthroughLocationColumn($allowedLocationIds); \ No newline at end of file
diff --git a/modules-available/passthrough/inc/passthrough.inc.php b/modules-available/passthrough/inc/passthrough.inc.php
new file mode 100644
index 00000000..524aea5e
--- /dev/null
+++ b/modules-available/passthrough/inc/passthrough.inc.php
@@ -0,0 +1,53 @@
+<?php
+
+class Passthrough
+{
+
+ public static function getGroupDropdown(array &$row): array
+ {
+ $out = [];
+ if ($row['class'] === '0300') {
+ foreach (['GPU', 'GVT'] as $id) {
+ $out[] = [
+ 'ptid' => $id,
+ 'ptname' => $id,
+ 'selected' => ($row['@PASSTHROUGH'] === $id ? 'selected' : ''),
+ ];
+ }
+ return $out;
+ }
+ static $list = false;
+ if ($list === false) {
+ $list = Database::queryKeyValueList("SELECT groupid, title FROM passthrough_group ORDER BY groupid");
+ self::ensurePrepopulated($list);
+ }
+ $row['custom_groups'] = true;
+ foreach ($list as $id => $title) {
+ if ($id === 'GPU' || $id === 'GVT')
+ continue;
+ $item = ['ptid' => $id, 'ptname' => $id . ' (' . $title . ')'];
+ if ($row['@PASSTHROUGH'] === $id) {
+ $item['selected'] = 'selected';
+ }
+ $out[] = $item;
+ }
+ return $out;
+ }
+
+ private static function ensurePrepopulated(&$list)
+ {
+ $want = [
+ 'GPU' => '[Special] GPU passthrough default group',
+ 'GVT' => '[Special] Intel GVT-g default group',
+ ];
+ foreach ($want as $id => $title) {
+ if (!isset($list[$id])) {
+ Database::exec("INSERT INTO passthrough_group (groupid, title) VALUES (:id, :title)
+ ON DUPLICATE KEY UPDATE title = VALUES(title)",
+ ['id' => $id, 'title' => $title]);
+ $list[$id] = $title;
+ }
+ }
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/passthrough/install.inc.php b/modules-available/passthrough/install.inc.php
new file mode 100644
index 00000000..01d3edbb
--- /dev/null
+++ b/modules-available/passthrough/install.inc.php
@@ -0,0 +1,23 @@
+<?php
+
+$result[] = tableCreate('passthrough_group', "
+ `groupid` varchar(32) CHARACTER SET ascii DEFAULT NULL,
+ `title` varchar(200) NOT NULL,
+ PRIMARY KEY (`groupid`)
+");
+
+$result[] = tableCreate('passthrough_group_x_location', "
+ `groupid` varchar(32) CHARACTER SET ascii DEFAULT NULL,
+ `locationid` INT(11) NOT NULL,
+ PRIMARY KEY (`groupid`, `locationid`)
+");
+
+$result[] = tableAddConstraint('passthrough_group_x_location', 'groupid',
+ 'passthrough_group', 'groupid',
+ 'ON DELETE CASCADE ON UPDATE CASCADE');
+
+$result[] = tableAddConstraint('passthrough_group_x_location', 'locationid',
+ 'location', 'locationid',
+ 'ON DELETE CASCADE ON UPDATE CASCADE');
+
+responseFromArray($result); \ No newline at end of file
diff --git a/modules-available/passthrough/lang/de/messages.json b/modules-available/passthrough/lang/de/messages.json
new file mode 100644
index 00000000..aa33f6e5
--- /dev/null
+++ b/modules-available/passthrough/lang/de/messages.json
@@ -0,0 +1,4 @@
+{
+ "list-updated": "Liste aktualisiert",
+ "location-updated": "Ort {{0}} gespeichert"
+} \ No newline at end of file
diff --git a/modules-available/passthrough/lang/de/module.json b/modules-available/passthrough/lang/de/module.json
new file mode 100644
index 00000000..53f186a0
--- /dev/null
+++ b/modules-available/passthrough/lang/de/module.json
@@ -0,0 +1,4 @@
+{
+ "location-column-header": "Passthrough",
+ "module_name": "PCI-Passthrough"
+} \ No newline at end of file
diff --git a/modules-available/passthrough/lang/de/permissions.json b/modules-available/passthrough/lang/de/permissions.json
new file mode 100644
index 00000000..fbde0c66
--- /dev/null
+++ b/modules-available/passthrough/lang/de/permissions.json
@@ -0,0 +1,5 @@
+{
+ "edit.group": "Kann Ger\u00e4te eine Passthrough-Gruppe zuweisen.",
+ "edit.location": "Kann Passthrough-Gruppe einem Raum\/Ort zuweisen.",
+ "view": "Kann diese Seite sehen."
+} \ No newline at end of file
diff --git a/modules-available/passthrough/lang/de/template-tags.json b/modules-available/passthrough/lang/de/template-tags.json
new file mode 100644
index 00000000..fa85edcd
--- /dev/null
+++ b/modules-available/passthrough/lang/de/template-tags.json
@@ -0,0 +1,15 @@
+{
+ "lang_add": "Hinzuf\u00fcgen",
+ "lang_addGroup": "Gruppe hinzuf\u00fcgen",
+ "lang_addPassthroughGroup": "Passthrough-Gruppe hinzuf\u00fcgen",
+ "lang_assignPassthrough": "Passthrough-Gruppe zuweisen",
+ "lang_deviceIdNumeric": "Ger\u00e4te-ID",
+ "lang_deviceName": "Ger\u00e4tename",
+ "lang_enabled": "Aktiv",
+ "lang_group": "Gruppe",
+ "lang_groupId": "Gruppen-ID",
+ "lang_groupTitle": "Titel",
+ "lang_noPassthroughGroup": "Keine Gruppe",
+ "lang_passthroughGroup": "Passthrough-Gruppe",
+ "lang_useCount": "#PCs"
+} \ No newline at end of file
diff --git a/modules-available/passthrough/lang/en/messages.json b/modules-available/passthrough/lang/en/messages.json
new file mode 100644
index 00000000..36e094d1
--- /dev/null
+++ b/modules-available/passthrough/lang/en/messages.json
@@ -0,0 +1,4 @@
+{
+ "list-updated": "List was updated",
+ "location-updated": "Location {{0}} was updated"
+} \ No newline at end of file
diff --git a/modules-available/passthrough/lang/en/module.json b/modules-available/passthrough/lang/en/module.json
new file mode 100644
index 00000000..53f186a0
--- /dev/null
+++ b/modules-available/passthrough/lang/en/module.json
@@ -0,0 +1,4 @@
+{
+ "location-column-header": "Passthrough",
+ "module_name": "PCI-Passthrough"
+} \ No newline at end of file
diff --git a/modules-available/passthrough/lang/en/permissions.json b/modules-available/passthrough/lang/en/permissions.json
new file mode 100644
index 00000000..0d1669d3
--- /dev/null
+++ b/modules-available/passthrough/lang/en/permissions.json
@@ -0,0 +1,5 @@
+{
+ "edit.group": "Assign devices to passthrough groups.",
+ "edit.location": "Assign passthrough groups to locations.",
+ "view": "Can access this page."
+} \ No newline at end of file
diff --git a/modules-available/passthrough/lang/en/template-tags.json b/modules-available/passthrough/lang/en/template-tags.json
new file mode 100644
index 00000000..47344da9
--- /dev/null
+++ b/modules-available/passthrough/lang/en/template-tags.json
@@ -0,0 +1,15 @@
+{
+ "lang_add": "Add",
+ "lang_addGroup": "Add group",
+ "lang_addPassthroughGroup": "Add passthrough group",
+ "lang_assignPassthrough": "Assign passthrough group",
+ "lang_deviceIdNumeric": "Device ID",
+ "lang_deviceName": "Device name",
+ "lang_enabled": "Enabled",
+ "lang_group": "Group",
+ "lang_groupId": "Group ID",
+ "lang_groupTitle": "Group title",
+ "lang_noPassthroughGroup": "No group",
+ "lang_passthroughGroup": "Passthrough group",
+ "lang_useCount": "#PCs"
+} \ No newline at end of file
diff --git a/modules-available/passthrough/page.inc.php b/modules-available/passthrough/page.inc.php
new file mode 100644
index 00000000..89ee7719
--- /dev/null
+++ b/modules-available/passthrough/page.inc.php
@@ -0,0 +1,195 @@
+<?php
+
+class Page_Passthrough extends Page
+{
+
+ protected function doPreprocess()
+ {
+ User::load();
+ User::assertPermission('view');
+ $action = Request::post('action');
+ if ($action === 'save-hwlist') {
+ $this->saveHwList();
+ } elseif ($action === 'save-location') {
+ $this->saveLocation();
+ }
+ if (Request::isPost()) {
+ Util::redirect('?do=passthrough');
+ }
+ }
+
+ private function saveHwList()
+ {
+ User::assertPermission('edit.group');
+ $newgroups = Request::post('newgroup', [], 'array');
+ foreach ($newgroups as $id => $title) {
+ $id = strtoupper(preg_replace('/[^a-z0-9_\-]/i', '', $id));
+ if (empty($id))
+ continue;
+ Database::exec("INSERT IGNORE INTO passthrough_group (groupid, title)
+ VALUES (:group, :title)",
+ ['group' => $id, 'title' => $title]);
+ }
+ $groups = Request::post('ptgroup', Request::REQUIRED, 'array');
+ $insert = [];
+ $delete = [];
+ foreach ($groups as $hwid => $group) {
+ if (empty($group)) {
+ $delete[] = $hwid;
+ } else {
+ $insert[] = [$hwid, '@PASSTHROUGH', $group];
+ }
+ }
+ if (!empty($insert)) {
+ Database::exec("INSERT INTO statistic_hw_prop (hwid, prop, `value`) VALUES :list
+ ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)",
+ ['list' => $insert]);
+ }
+ if (!empty($delete)) {
+ Database::exec("DELETE FROM statistic_hw_prop WHERE hwid IN (:list) AND prop = '@PASSTHROUGH'", ['list' => $delete]);
+ }
+ Message::addSuccess('list-updated');
+ Util::redirect('?do=passthrough&show=hwlist');
+ }
+
+ private function saveLocation()
+ {
+ $locationId = Request::post('locationid', Request::REQUIRED, 'int');
+ User::assertPermission('edit.location', $locationId);
+ $list = [];
+ $groups = [];
+ foreach (Request::post('enabled', [], 'array') as $groupId) {
+ $groupId = (string)$groupId;
+ $list[] = [$groupId, $locationId];
+ $groups[] = $groupId;
+ }
+ if (!empty($list)) {
+ Database::exec("INSERT IGNORE INTO passthrough_group_x_location (groupid, locationid)
+ VALUES :list", ['list' => $list]);
+ Database::exec("DELETE FROM passthrough_group_x_location
+ WHERE locationid = :lid AND groupid NOT IN (:groups)", ['lid' => $locationId, 'groups' => $groups]);
+ } else {
+ Database::exec("DELETE FROM passthrough_group_x_location
+ WHERE locationid = :lid", ['lid' => $locationId]);
+ }
+ Message::addSuccess('location-updated', Location::getName($locationId));
+ Util::redirect('?do=passthrough&show=assignlocation&locationid=' . $locationId);
+ }
+
+ /*
+ *
+ */
+
+ protected function doRender()
+ {
+ $show = Request::get('show');
+ if ($show === 'hwlist') {
+ $this->showHardwareList();
+ } elseif ($show === 'assignlocation') {
+ $this->showLocationMapping();
+ } else {
+ Util::redirect('?do=passthrough&show=hwlist');
+ }
+ }
+
+ /**
+ * Show all the hardware that is known. Start with video adapters.
+ * @return void
+ */
+ private function showHardwareList()
+ {
+ $q = new HardwareQuery(HardwareInfo::PCI_DEVICE, null, false);
+ $q->addGlobalColumn('vendor');
+ $q->addGlobalColumn('device');
+ $q->addGlobalColumn('rev');
+ $q->addGlobalColumn('class');
+ $q->addGlobalColumn('@PASSTHROUGH');
+ $rows = [];
+ foreach ($q->query('`shw`.`hwid`') as $row) {
+ $row['ptlist'] = Passthrough::getGroupDropdown($row);
+ $rows[] = $row;
+ }
+ // Sort Graphics Cards first, rest by class, vendor, device
+ usort($rows, function ($row1, $row2) {
+ $a = $row1['class'];
+ $b = $row2['class'];
+ if ($a === $b)
+ return hexdec($row1['vendor'].$row1['device']) - hexdec($row2['vendor'] . $row2['device']);
+ if ($a === '0300')
+ return -1;
+ if ($b === '0300')
+ return 1;
+ return hexdec($a) - hexdec($b);
+ });
+ $finalRows = [];
+ $missing = [];
+ $lastClass = '';
+ foreach ($rows as $row) {
+ if ($row['class'] !== $lastClass) {
+ // Add class row header
+ $lastClass = $row['class'];
+ $finalRows[$lastClass] = [
+ 'collapse' => $row['class'] !== '0300',
+ 'class' => $row['class'],
+ 'class_name' => PciId::getPciId(PciId::DEVCLASS, $row['class'], true) ?: 'Unknown',
+ 'devlist' => [],
+ ];
+ }
+ $row['vendor_name'] = PciId::getPciId(PciId::VENDOR, $row['vendor'] ?? '');
+ $row['device_name'] = PciId::getPciId(PciId::DEVICE, $row['vendor'] . ':' . $row['device']);
+ $finalRows[$lastClass]['devlist'][] = $row;
+ // Build up query
+ if ($row['vendor_name'] === false) {
+ $missing[$row['vendor']] = true;
+ }
+ if ($row['device_name'] === false) {
+ $missing[$row['vendor'] . ':' . $row['device']] = true;
+ }
+ }
+ Render::addTemplate('hardware-list', ['classlist' => array_values($finalRows)]);
+ if (!empty($missing)) {
+ Render::addTemplate('js-pciquery',
+ ['missing_ids' => json_encode(array_keys($missing))], 'statistics');
+ }
+ }
+
+ /**
+ * Show mapping between specific location and passthrough groups.
+ * @return void
+ */
+ private function showLocationMapping()
+ {
+ $locationId = Request::get('locationid', Request::REQUIRED, 'int');
+ $locationIds = Location::getLocationRootChain($locationId);
+ $res = Database::queryAll("SELECT g.groupid, g.title, GROUP_CONCAT(gxl.locationid) AS lids FROM passthrough_group g
+ LEFT JOIN passthrough_group_x_location gxl ON (g.groupid = gxl.groupid AND gxl.locationid IN (:lids))
+ GROUP BY groupid, title
+ ORDER BY lids ASC",
+ ['lids' => $locationIds]);
+ foreach ($res as &$item) {
+ if ($item['lids'] === null)
+ continue;
+ $item['checked'] = 'checked';
+ $list = explode(',', $item['lids']);
+ if (!in_array($locationId, $list)) {
+ $item['disabled'] = true;
+ $item['parent_location'] = Location::getName($list[0]);
+ }
+ }
+ Render::addTemplate('location-assign', [
+ 'list' => array_reverse($res),
+ 'locationid' => $locationId,
+ 'locationname' => Location::getName($locationId),
+ ]);
+ }
+
+ /*
+ *
+ */
+
+ protected function doAjax()
+ {
+ //
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/passthrough/permissions/permissions.json b/modules-available/passthrough/permissions/permissions.json
new file mode 100644
index 00000000..bf3095c4
--- /dev/null
+++ b/modules-available/passthrough/permissions/permissions.json
@@ -0,0 +1,5 @@
+{
+ "view": false,
+ "edit.group": false,
+ "edit.location": true
+} \ No newline at end of file
diff --git a/modules-available/passthrough/templates/hardware-list.html b/modules-available/passthrough/templates/hardware-list.html
new file mode 100644
index 00000000..4fdfb14f
--- /dev/null
+++ b/modules-available/passthrough/templates/hardware-list.html
@@ -0,0 +1,138 @@
+<form method="post" action="?do=passthrough">
+ <input type="hidden" name="token" value="{{token}}">
+ {{#classlist}}
+ <div class="panel panel-default">
+ <div class="panel-heading">
+ {{#collapse}}
+ <span class="slx-pointer" data-toggle="collapse" data-target="#div-class-{{class}}">
+ <b class="caret"></b>
+ {{/collapse}}
+ <span>{{class}}</span> – <strong>{{class_name}}</strong>
+ {{#collapse}}
+ </span>
+ {{/collapse}}
+ </div>
+ <div id="div-class-{{class}}" {{#collapse}}class="collapse"{{/collapse}}>
+ <table class="table">
+ <thead>
+ <tr>
+ <th class="text-nowrap slx-smallcol">{{lang_deviceIdNumeric}}</th>
+ <th>{{lang_deviceName}}</th>
+ <th class="text-nowrap slx-smallcol">{{lang_useCount}}</th>
+ <th class="text-nowrap">{{lang_passthroughGroup}}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{#devlist}}
+ <tr class="c-{{vendor}}-{{device}} tr">
+ <td class="text-nowrap">{{vendor}}:{{device}} [{{rev}}]</td>
+ <td>
+ <table class="slx-ellipsis">
+ <tr>
+ <td {{^device_name}}class="query-{{vendor}}-{{device}}" {{/device_name}}>
+ {{device_name}}
+ </td>
+ </tr>
+ </table>
+ <div class="small {{^vendor_name}}query-{{vendor}}{{/vendor_name}}">
+ <a href="?show=list&amp;do=statistics&amp;filter[pcidev]=1&amp;op[pcidev]=%3D&amp;arg[pcidev]={{vendor}},{{class}}">
+ {{vendor_name}}
+ </a>
+ </div>
+ </td>
+ <td class="text-right">
+ <a href="?show=list&amp;do=statistics&amp;filter[pcidev]=1&amp;op[pcidev]=%3D&amp;arg[pcidev]={{vendor}}:{{device}}">
+ {{connected_count}}
+ </a>
+ </td>
+ <td>
+ <select name="ptgroup[{{hwid}}]"
+ class="form-control {{#custom_groups}}ptgroup-select{{/custom_groups}}">
+ <option value="">{{lang_noPassthroughGroup}}</option>
+ {{#ptlist}}
+ <option value="{{ptid}}" {{selected}}>{{ptname}}</option>
+ {{/ptlist}}
+ </select>
+ </td>
+ </tr>
+ {{/devlist}}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ {{/classlist}}
+ <div id="new-groups"></div>
+ <div style="position:fixed;bottom:0;right:0;padding:8px;background:#fff;width:100%;border-top:1px solid #ddd">
+ <div class="buttonbar text-right">
+ <button type="button" data-target="#add-group-form" data-toggle="modal" class="btn btn-default">
+ <span class="glyphicon glyphicon-plus"></span>
+ {{lang_add}}
+ </button>
+ <button type="submit" name="action" value="save-hwlist" class="btn btn-success">
+ <span class="glyphicon glyphicon-floppy-disk"></span>
+ {{lang_save}}
+ </button>
+ </div>
+ </div>
+</form>
+
+<div class="modal fade" id="add-group-form" tabindex="-1" role="dialog">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <button type="button" class="close" data-dismiss="modal">&times;</button>
+ <b>{{lang_addPassthroughGroup}}</b>
+ </div>
+ <div class="modal-body">
+ <div class="form-group">
+ <label for="group-id">{{lang_groupId}}</label>
+ <input type="text" name="group-id" id="group-id" class="form-control">
+ </div>
+ <div class="form-group">
+ <label for="group-title">{{lang_groupTitle}}</label>
+ <input type="text" name="group-title" id="group-title" class="form-control">
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-default"
+ data-dismiss="modal">{{lang_cancel}}
+ </button>
+ <button id="add-group-button" type="button" class="btn btn-success" data-dismiss="modal">
+ <span class="glyphicon glyphicon-plus"></span>
+ {{lang_addGroup}}
+ </button>
+ </div>
+ </div>
+ </div>
+</div>
+
+<script>
+ document.addEventListener('DOMContentLoaded', function () {
+ $('#add-group-button').click(function () {
+ var gid = $('#group-id').val().replace(/[^a-zA-Z0-9_\-]/g, '').toUpperCase();
+ var title = $('#group-title').val().trim();
+ if (gid.length === 0)
+ return;
+ $('#new-groups').append($('<input type="hidden">')
+ .attr('name', 'newgroup[' + gid + ']')
+ .attr('value', title));
+ $('.ptgroup-select').each(function () {
+ $(this).append($('<option>').attr('value', gid).text(gid + ' (' + title + ')'));
+ });
+ });
+ hashChanged();
+ });
+ window.addEventListener('hashchange', function () {
+ hashChanged();
+ });
+
+ function hashChanged() {
+ var c = window.location.hash;
+ $('tr.tr').removeClass('bg-success');
+ if (c && c.length > 1) {
+ var d = $('.c-' + c.substr(1)).addClass('bg-success');
+ d.closest('.collapse').collapse('show');
+ if (d.length > 0) d[0].scrollIntoView();
+ }
+ }
+</script> \ No newline at end of file
diff --git a/modules-available/passthrough/templates/location-assign.html b/modules-available/passthrough/templates/location-assign.html
new file mode 100644
index 00000000..037ab79d
--- /dev/null
+++ b/modules-available/passthrough/templates/location-assign.html
@@ -0,0 +1,37 @@
+<h1>{{lang_assignPassthrough}}</h1>
+<h2>{{locationname}}</h2>
+
+<form method="post" action="?do=passthrough">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="locationid" value="{{locationid}}">
+ <div class="row">
+ <div class="col-sm-9">{{lang_group}}</div>
+ <div class="col-sm-3">{{lang_enabled}}</div>
+ </div>
+ {{#list}}
+ <div class="row">
+ <div class="col-sm-9">
+ {{groupid}}
+ <span class="small text-muted">{{title}}</span>
+ </div>
+ <div class="col-sm-3">
+ <div class="checkbox">
+ {{#disabled}}
+ <input id="check-{{groupid}}" type="checkbox" disabled checked>
+ {{/disabled}}
+ {{^disabled}}
+ <input id="check-{{groupid}}" type="checkbox" name="enabled[]" value="{{groupid}}" {{checked}}>
+ {{/disabled}}
+ <label for="check-{{groupid}}"></label>
+ <span class="text-muted">{{parent_location}}</span>
+ </div>
+ </div>
+ </div>
+ {{/list}}
+ <div class="buttonbar text-right">
+ <button class="btn btn-success" type="submit" name="action" value="save-location">
+ <span class="glyphicon glyphicon-floppy-disk"></span>
+ {{lang_save}}
+ </button>
+ </div>
+</form> \ No newline at end of file
diff --git a/modules-available/permissionmanager/hooks/translation-global.inc.php b/modules-available/permissionmanager/hooks/translation-global.inc.php
index 4810a719..cf2166bc 100644
--- a/modules-available/permissionmanager/hooks/translation-global.inc.php
+++ b/modules-available/permissionmanager/hooks/translation-global.inc.php
@@ -18,11 +18,8 @@ if (file_exists('modules/' . $moduleName . '/permissions/permissions.json')) {
/**
* Configuration categories.
- *
- * @param \Module $module
- * @return array
*/
- $HANDLER['grep_permissions'] = function ($module) {
+ $HANDLER['grep_permissions'] = function (Module $module): array {
$file = 'modules/' . $module->getIdentifier() . '/permissions/permissions.json';
if (!file_exists($file))
return [];
diff --git a/modules-available/permissionmanager/inc/getpermissiondata.inc.php b/modules-available/permissionmanager/inc/getpermissiondata.inc.php
index 4dfb09ec..a51619e0 100644
--- a/modules-available/permissionmanager/inc/getpermissiondata.inc.php
+++ b/modules-available/permissionmanager/inc/getpermissiondata.inc.php
@@ -11,7 +11,7 @@ class GetPermissionData
*
* @return array array of users (each with userid, username and roles (each with roleid and rolename))
*/
- public static function getUserData()
+ public static function getUserData(): array
{
$res = Database::simpleQuery("SELECT user.userid AS userid, user.login AS login, role.rolename AS rolename, role.roleid AS roleid
FROM user
@@ -19,7 +19,7 @@ class GetPermissionData
LEFT JOIN role ON role_x_user.roleid = role.roleid
");
$userdata = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$userdata[$row['userid'] . ' ' . $row['login']][] = array(
'roleid' => $row['roleid'],
'rolename' => $row['rolename']
@@ -42,12 +42,12 @@ class GetPermissionData
*
* @return array array of locations (each including the roles that have permissions for them)
*/
- public static function getLocationData()
+ public static function getLocationData(): array
{
$res = Database::simpleQuery("SELECT role.roleid AS roleid, rolename, GROUP_CONCAT(COALESCE(locationid, 0)) AS locationids FROM role
INNER JOIN role_x_location ON role.roleid = role_x_location.roleid GROUP BY roleid ORDER BY rolename ASC");
$locations = Location::getLocations(0, 0, false, true);
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$locationids = explode(",", $row['locationids']);
if (in_array("0", $locationids)) {
$locationids = array_map("intval", Location::extractIds(Location::getTree()));
@@ -70,7 +70,7 @@ class GetPermissionData
* @param int $flags Bitmask specifying additional data to fetch (WITH_* constants of this class)
* @return array array roles (each with roleid and rolename)
*/
- public static function getRoles($flags = 0)
+ public static function getRoles(int $flags = 0): array
{
$cols = $joins = '';
if ($flags & self::WITH_USER_COUNT) {
@@ -93,23 +93,23 @@ class GetPermissionData
* Get permissions and locations for a given role.
*
* @param string $roleid id of the role
- * @return array|false array containing an array of permissions and an array of locations, false if not found
+ * @return ?array array containing an array of permissions and an array of locations, null if not found
*/
- public static function getRoleData($roleid)
+ public static function getRoleData(string $roleid): ?array
{
$data = self::getRole($roleid);
$res = Database::simpleQuery("SELECT roleid, locationid FROM role_x_location WHERE roleid = :roleid",
array("roleid" => $roleid));
if ($res === false)
- return false;
+ return null;
$data["locations"] = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$data["locations"][] = $row['locationid'];
}
$res = Database::simpleQuery("SELECT roleid, permissionid FROM role_x_permission WHERE roleid = :roleid",
array("roleid" => $roleid));
$data["permissions"] = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$data["permissions"][] = $row['permissionid'];
}
return $data;
diff --git a/modules-available/permissionmanager/inc/permissiondbupdate.inc.php b/modules-available/permissionmanager/inc/permissiondbupdate.inc.php
index 0cd89b3a..49988420 100644
--- a/modules-available/permissionmanager/inc/permissiondbupdate.inc.php
+++ b/modules-available/permissionmanager/inc/permissiondbupdate.inc.php
@@ -9,14 +9,14 @@ class PermissionDbUpdate
* @param int[] $users userids
* @param int[] $roles roleids
*/
- public static function addRoleToUser($users, $roles)
+ public static function addRoleToUser(array $users, array $roles): int
{
if (empty($users) || empty($roles))
return 0;
- $arg = array();
+ $arg = [];
foreach ($users AS $userid) {
foreach ($roles AS $roleid) {
- $arg[] = compact('userid', 'roleid');
+ $arg[] = ['userid' => $userid, 'roleid' => $roleid];
}
}
return Database::exec("INSERT IGNORE INTO role_x_user (userid, roleid) VALUES :arg",
@@ -29,12 +29,12 @@ class PermissionDbUpdate
* @param int[] $users userids
* @param int[] $roles roleids
*/
- public static function removeRoleFromUser($users, $roles)
+ public static function removeRoleFromUser(array $users, array $roles): int
{
if (empty($users) || empty($roles))
return 0;
$query = "DELETE FROM role_x_user WHERE userid IN (:users) AND roleid IN (:roles)";
- return Database::exec($query, array("users" => $users, "roles" => $roles));
+ return Database::exec($query, ["users" => $users, "roles" => $roles]);
}
/**
@@ -44,7 +44,7 @@ class PermissionDbUpdate
* @param int[] $users list of user ids
* @param int[] $roles list of role ids
*/
- public static function setRolesForUser($users, $roles)
+ public static function setRolesForUser(array $users, array $roles): int
{
$count = Database::exec("DELETE FROM role_x_user WHERE userid in (:users) AND roleid NOT IN (:roles)",
compact('users', 'roles'));
@@ -56,7 +56,7 @@ class PermissionDbUpdate
*
* @param int $roleid roleid
*/
- public static function deleteRole($roleid)
+ public static function deleteRole(int $roleid): int
{
return Database::exec("DELETE FROM role WHERE roleid = :roleid", array("roleid" => $roleid));
}
@@ -69,7 +69,8 @@ class PermissionDbUpdate
* @param string[] $permissions array of permissions
* @param int|null $roleId roleid or null if the role does not exist yet
*/
- public static function saveRole($roleName, $roleDescription, $locations, $permissions, $roleId = null)
+ public static function saveRole(string $roleName, string $roleDescription, array $locations, array $permissions,
+ ?int $roleId = null): void
{
foreach ($permissions as &$permission) {
$permission = strtolower($permission);
@@ -92,14 +93,14 @@ class PermissionDbUpdate
if (!empty($locations)) {
$arg = array_map(function ($loc) use ($roleId) {
- return compact('roleId', 'loc');
+ return ['roleId' => $roleId, 'loc' => $loc];
}, $locations);
Database::exec("INSERT IGNORE INTO role_x_location (roleid, locationid) VALUES :arg", ['arg' => $arg]);
}
if (!empty($permissions)) {
$arg = array_map(function ($perm) use ($roleId) {
- return compact('roleId', 'perm');
+ return ['roleId' => $roleId, 'perm' => $perm];
}, $permissions);
Database::exec("INSERT IGNORE INTO role_x_permission (roleid, permissionid) VALUES :arg", ['arg' => $arg]);
}
diff --git a/modules-available/permissionmanager/inc/permissionutil.inc.php b/modules-available/permissionmanager/inc/permissionutil.inc.php
index 6aa97600..170fd699 100644
--- a/modules-available/permissionmanager/inc/permissionutil.inc.php
+++ b/modules-available/permissionmanager/inc/permissionutil.inc.php
@@ -14,7 +14,7 @@ class PermissionUtil
* @param string|false $wildcard if $permission is a wildcard string this returns the matching variant
* @param int|false $wclen if $permission is a wildcard string, this is the length of the matching variant
*/
- private static function makeComparisonVariants($permission, &$compare, &$wildcard, &$wclen)
+ private static function makeComparisonVariants($permission, ?array &$compare, &$wildcard, &$wclen): void
{
if (!is_array($permission)) {
$permission = explode('.', $permission);
@@ -46,12 +46,12 @@ class PermissionUtil
/**
* Check if the user has the given permission (for the given location).
*
- * @param string $userid userid to check
+ * @param int $userid userid to check
* @param string $permissionid permissionid to check
* @param int|null $locationid locationid to check or null if the location should be disregarded
* @return bool true if user has permission, false if not
*/
- public static function userHasPermission($userid, $permissionid, $locationid)
+ public static function userHasPermission(int $userid, string $permissionid, ?int $locationid): bool
{
$permissionid = strtolower($permissionid);
self::validatePermission($permissionid);
@@ -121,10 +121,12 @@ class PermissionUtil
// Compare to database result
if ($cacheAll) {
$allLocs = Location::getLocationsAssoc();
+ } else {
+ $allLocs = [];
}
self::makeComparisonVariants($parts, $compare, $wildcard, $wclen);
$retval = false;
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if (in_array($row['permissionid'], $compare, true)
|| ($wildcard !== false && strncmp($row['permissionid'], $wildcard, $wclen) === 0)) {
if (!$cacheAll || ($row['locationid'] == $locationid) || $row['locationid'] === null) {
@@ -132,15 +134,13 @@ class PermissionUtil
if (!$cacheAll)
break;
}
- if ($cacheAll) {
- $cache[$key][(int)$row['locationid']] = true;
- $list = ($row['locationid'] === null) ? array_keys($allLocs) : $allLocs[(int)$row['locationid']]['children'];
- foreach ($list as $lid) {
- $cache[$key][$lid] = true;
- }
- if ($row['locationid'] === null)
- break;
+ $cache[$key][(int)$row['locationid']] = true;
+ $list = ($row['locationid'] === null) ? array_keys($allLocs) : $allLocs[(int)$row['locationid']]['children'];
+ foreach ($list as $lid) {
+ $cache[$key][$lid] = true;
}
+ if ($row['locationid'] === null)
+ break;
}
}
if ($locationid === null) {
@@ -154,11 +154,11 @@ class PermissionUtil
/**
* Get all locations where the user has the given permission.
*
- * @param string $userid userid to check
+ * @param int $userid userid to check
* @param string $permissionid permissionid to check
* @return array array of locationids where the user has the given permission
*/
- public static function getAllowedLocations($userid, $permissionid)
+ public static function getAllowedLocations(int $userid, string $permissionid): array
{
$permissionid = strtolower($permissionid);
self::validatePermission($permissionid);
@@ -178,7 +178,7 @@ class PermissionUtil
// Gather locationid from relevant rows
self::makeComparisonVariants($parts, $compare, $wildcard, $wclen);
$allowedLocations = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if (in_array($row['permissionid'], $compare, true)
|| ($wildcard !== false && strncmp($row['permissionid'], $wildcard, $wclen) === 0)) {
$allowedLocations[(int)$row['locationid']] = true;
@@ -201,10 +201,10 @@ class PermissionUtil
* Extend an array of locations by adding all sublocations.
*
* @param array $tree tree of all locations (structured like Location::getTree())
- * @param array $allowedLocations the array of locationids to extend
+ * @param int[] $allowedLocations the array of locationids to extend
* @return array extended array of locationids
*/
- public static function getSublocations($tree, $allowedLocations)
+ public static function getSublocations(array $tree, array $allowedLocations): array
{
$result = $allowedLocations;
foreach ($tree as $location) {
@@ -226,7 +226,7 @@ class PermissionUtil
*
* @param string $permissionId permission to check
*/
- private static function validatePermission($permissionId)
+ private static function validatePermission(string $permissionId): void
{
if (!CONFIG_DEBUG || $permissionId === '*')
return;
@@ -255,7 +255,7 @@ class PermissionUtil
*
* @return array permission tree as a multidimensional array
*/
- public static function getPermissions()
+ public static function getPermissions(): array
{
$permissions = array();
foreach (glob("modules/*/permissions/permissions.json", GLOB_NOSORT) as $file) {
@@ -268,7 +268,8 @@ class PermissionUtil
continue;
foreach ($data as $perm => $permissionFlags) {
$description = Dictionary::translateFileModule($moduleId, "permissions", $perm);
- self::putInPermissionTree($moduleId . "." . $perm, $permissionFlags['location-aware'], $description, $permissions);
+ self::putInPermissionTree($moduleId . "." . $perm, $permissionFlags['location-aware'] ?? false,
+ $description, $permissions);
}
}
ksort($permissions);
@@ -277,6 +278,7 @@ class PermissionUtil
foreach ($permissions as $module => $v) {
$sortingOrder[Module::get($module)->getCategory()][] = $module;
}
+ $sortingOrder = array_values($sortingOrder);
$permissions = array_replace(array_flip(call_user_func_array('array_merge', $sortingOrder)), $permissions);
foreach ($permissions as $module => $v) {
if (is_int($v)) {
@@ -291,18 +293,17 @@ class PermissionUtil
/**
* Get all existing roles.
*
- * @param int|false $userid Which user to consider, false = none
- * @param bool $onlyMatching true = filter roles the user doesn't have
+ * @param ?int $userid Which user to consider, false = none (list all)
* @return array list of roles
*/
- public static function getRoles($userid = false, $onlyMatching = true)
+ public static function getRoles(?int $userid = null): array
{
if ($userid === false) {
return Database::queryAll('SELECT roleid, rolename FROM role ORDER BY rolename ASC');
}
$ret = Database::queryAll('SELECT r.roleid, r.rolename, u.userid AS hasRole FROM role r
LEFT JOIN role_x_user u ON (r.roleid = u.roleid AND u.userid = :userid)
- GROUP BY r.roleid
+ GROUP BY r.roleid, r.rolename
ORDER BY rolename ASC', ['userid' => $userid]);
foreach ($ret as &$role) {
settype($role['hasRole'], 'bool');
@@ -318,7 +319,7 @@ class PermissionUtil
* @param string $description the description of the permission
* @param array $tree the permission tree to modify
*/
- private static function putInPermissionTree($permission, $locationAware, $description, &$tree)
+ private static function putInPermissionTree(string $permission, bool $locationAware, string $description, array &$tree): void
{
$subPermissions = explode('.', $permission);
foreach ($subPermissions as $subPermission) {
diff --git a/modules-available/permissionmanager/install.inc.php b/modules-available/permissionmanager/install.inc.php
index 292a5f52..ae6c9b03 100644
--- a/modules-available/permissionmanager/install.inc.php
+++ b/modules-available/permissionmanager/install.inc.php
@@ -31,7 +31,7 @@ $res[] = tableCreate('role_x_location', "
$res[] = tableCreate('role_x_permission', "
roleid int(10) unsigned NOT NULL,
- permissionid varchar(200) NOT NULL,
+ permissionid varchar(100) NOT NULL,
PRIMARY KEY (roleid, permissionid)
");
@@ -109,14 +109,20 @@ if (!tableHasColumn('role', 'builtin')) {
$res[] = UPDATE_DONE;
}
+// 2022-07-06 permissionid too long for older mariadb versions
+if (stripos(tableColumnType('role_x_permission', 'permissionid'), 'varchar(200)') !== false) {
+ $alter = Database::exec("ALTER TABLE role_x_permission MODIFY permissionid varchar(100) NOT NULL");
+ if ($alter === false)
+ finalResponse(UPDATE_FAILED, 'Cannot shorten permissionid to 100: ' . Database::lastError());
+ $res[] = UPDATE_DONE;
+}
+
if (Database::exec("INSERT INTO `role` (roleid, rolename, builtin, roledescription) VALUES
(1,'Super-Admin', 1, 'Hat keinerlei Zugriffsbeschränkungen'),
(2,'Admin', 1, 'Alles bis auf Rechte-/Nutzerverwaltung'),
(3,'Prüfungsadmin', 1, 'Kann E-Prüfungen verwalten, Prüfungsmodus einschalten, etc.'),
(4,'Lesezugriff', 1, 'Kann auf die meisten Seiten zugreifen, jedoch keine Änderungen vornehmen')
ON DUPLICATE KEY UPDATE rolename = VALUES(rolename), builtin = 1, roledescription = VALUES(roledescription)") !== false) {
- // Old ruleset accidentally gave write permissions to the read-only role
- Database::exec("DELETE FROM role_x_permission WHERE roleid = 4 AND permissionid = 'news.*'");
// Assign roles to location (all)
Database::exec("DELETE FROM role_x_location WHERE roleid IN (1,2,3,4)");
Database::exec("INSERT INTO `role_x_location` VALUES (1,NULL),(2,NULL),(3,NULL),(4,NULL)");
@@ -124,24 +130,26 @@ if (Database::exec("INSERT INTO `role` (roleid, rolename, builtin, roledescripti
Database::exec("DELETE FROM role_x_permission WHERE roleid IN (1,2,3,4)");
// Assign permissions to roles
Database::exec("INSERT IGNORE INTO `role_x_permission` VALUES
+ -- Exams Admin
(3,'exams.exams.*'),
+ (3,'locations.location.view'),
(3,'rebootcontrol.action.*'),
(3,'statistics.hardware.projectors.view'),
+ (3,'statistics.hints'),
(3,'statistics.machine.note.*'),
(3,'statistics.machine.view-details'),
(3,'statistics.view.*'),
(3,'syslog.view'),
-
+ -- Super Admin
(1,'*'),
-
+ -- Read only
(4,'adduser.user.view-list'),
- (4,'backup.create'),
(4,'baseconfig.view'),
(4,'dnbd3.access-page'),
- (4,'dnbd3.refresh'),
(4,'dnbd3.view.details'),
(4,'dozmod.actionlog.view'),
(4,'dozmod.users.view'),
+ (4,'eventlog.filter.rules.view'),
(4,'eventlog.view'),
(4,'exams.exams.view'),
(4,'locationinfo.backend.check'),
@@ -149,13 +157,16 @@ if (Database::exec("INSERT INTO `role` (roleid, rolename, builtin, roledescripti
(4,'locations.location.view'),
(4,'minilinux.view'),
(4,'news.access-page'),
+ (4,'passthrough.view'),
(4,'permissionmanager.locations.view'),
(4,'permissionmanager.roles.view'),
(4,'permissionmanager.users.view'),
+ (4,'remoteaccess.view'),
(4,'runmode.list-all'),
(4,'serversetup.access-page'),
(4,'serversetup.download'),
(4,'statistics.hardware.projectors.view'),
+ (4,'statistics.hints'),
(4,'statistics.machine.note.view'),
(4,'statistics.machine.view-details'),
(4,'statistics.view.*'),
@@ -171,22 +182,24 @@ if (Database::exec("INSERT INTO `role` (roleid, rolename, builtin, roledescripti
(4,'webinterface.access-page'),
(4,'rebootcontrol.subnet.view'),
(4,'rebootcontrol.jumphost.view'),
-
+ -- Admin
(2,'adduser.user.view-list'),
(2,'backup.*'),
(2,'baseconfig.*'),
(2,'dnbd3.*'),
(2,'dozmod.*'),
- (2,'eventlog.view'),
+ (2,'eventlog.*'),
(2,'exams.exams.*'),
(2,'locationinfo.*'),
(2,'locations.*'),
(2,'minilinux.*'),
(2,'news.*'),
+ (4,'passthrough.*'),
(2,'permissionmanager.locations.view'),
(2,'permissionmanager.roles.view'),
(2,'permissionmanager.users.view'),
(2,'rebootcontrol.*'),
+ (2,'remoteaccess.*'),
(2,'roomplanner.edit'),
(2,'runmode.list-all'),
(2,'serversetup.*'),
@@ -195,7 +208,7 @@ if (Database::exec("INSERT INTO `role` (roleid, rolename, builtin, roledescripti
(2,'sysconfig.*'),
(2,'syslog.*'),
(2,'systemstatus.*'),
- (2,'vmstore.edit'),
+ (2,'vmstore.*'),
(2,'webinterface.*')");
Database::exec("OPTIMIZE TABLE role_x_permission");
// Assign the first user to the superadmin role (if one exists)
diff --git a/modules-available/permissionmanager/page.inc.php b/modules-available/permissionmanager/page.inc.php
index b431d9c9..7e9f17e4 100644
--- a/modules-available/permissionmanager/page.inc.php
+++ b/modules-available/permissionmanager/page.inc.php
@@ -18,13 +18,13 @@ class Page_PermissionManager extends Page
$action = Request::any('action', 'show', 'string');
if ($action === 'addRoleToUser') {
User::assertPermission('users.edit-roles');
- $users = Request::post('users', '');
- $roles = Request::post('roles', '');
+ $users = Request::post('users', [], 'array');
+ $roles = Request::post('roles', [], 'array');
PermissionDbUpdate::addRoleToUser($users, $roles);
} elseif ($action === 'removeRoleFromUser') {
User::assertPermission('users.edit-roles');
- $users = Request::post('users', '');
- $roles = Request::post('roles', '');
+ $users = Request::post('users', [], 'array');
+ $roles = Request::post('roles', [], 'array');
PermissionDbUpdate::removeRoleFromUser($users, $roles);
} elseif ($action === 'deleteRole') {
User::assertPermission('roles.edit');
@@ -115,7 +115,7 @@ class Page_PermissionManager extends Page
$roleid = Request::get("roleid", false, 'int');
if ($roleid !== false) {
$role = GetPermissionData::getRoleData($roleid);
- if ($role === false) {
+ if ($role === null) {
Message::addError('invalid-role-id', $roleid);
Util::redirect('?do=permissionmanager');
}
@@ -147,7 +147,8 @@ class Page_PermissionManager extends Page
* @param string $permString the prefix permission string with which all permissions in the permission tree should start
* @return string generated html code
*/
- private static function generatePermissionHTML($permissions, $selectedPermissions = array(), $selectAll = false, $permString = "", $tags = [])
+ private static function generatePermissionHTML(array $permissions, array $selectedPermissions = [],
+ bool $selectAll = false, string $permString = "", array $tags = []): string
{
$res = "";
$toplevel = $permString == "";
@@ -203,11 +204,12 @@ class Page_PermissionManager extends Page
*
* @param array $locations the location tree
* @param array $selectedLocations locations that should be preselected
- * @param array $selectAll true if all locations should be preselected, false if only those in $selectedLocations
- * @param array $toplevel true if the location tree are the children of the root location, false if not
+ * @param bool $selectAll true if all locations should be preselected, false if only those in $selectedLocations
+ * @param bool $toplevel true if the location tree are the children of the root location, false if not
* @return string generated html code
*/
- private static function generateLocationHTML($locations, $selectedLocations = array(), $selectAll = false, $toplevel = true, $tags = [])
+ private static function generateLocationHTML(array $locations, array $selectedLocations = [],
+ bool $selectAll = false, bool $toplevel = true, array $tags = []): string
{
$res = "";
if ($toplevel && in_array(0, $selectedLocations)) {
@@ -242,7 +244,7 @@ class Page_PermissionManager extends Page
* @param array $locations the locationid array
* @return array the locationid array without redundant locationids
*/
- private static function processLocations($locations)
+ private static function processLocations(array $locations): array
{
if (in_array(0, $locations))
return array(null);
@@ -267,7 +269,7 @@ class Page_PermissionManager extends Page
* @param array $permissions the permissionid array
* @return array the permissionid array without redundant permissionids
*/
- private static function processPermissions($permissions)
+ private static function processPermissions(array $permissions): array
{
if (in_array("*", $permissions))
return array("*");
@@ -287,7 +289,7 @@ class Page_PermissionManager extends Page
* @param array $permissions multidimensional array of permissionids
* @return array flat array of permissionids
*/
- private static function extractPermissions($permissions)
+ private static function extractPermissions(array $permissions): array
{
$result = array();
foreach ($permissions as $permission => $a) {
@@ -306,7 +308,7 @@ class Page_PermissionManager extends Page
return $result;
}
- private function denyActionIfBuiltin($roleID)
+ private function denyActionIfBuiltin(string $roleID): void
{
if ($roleID) {
$existing = GetPermissionData::getRole($roleID);
diff --git a/modules-available/rebootcontrol/api.inc.php b/modules-available/rebootcontrol/api.inc.php
index 05fa5699..b3e9e976 100644
--- a/modules-available/rebootcontrol/api.inc.php
+++ b/modules-available/rebootcontrol/api.inc.php
@@ -10,39 +10,3 @@ if (Request::any('action') === 'rebuild' && isLocalExecution()) {
}
exit(0);
}
-/*
- Needed POST-Parameters:
- 'token' -- for authentication
- 'action' -- which action should be performed (shutdown or reboot)
- 'clients' -- which are to reboot/shutdown (json encoded array!)
- 'timer' -- (optional) when to perform action in minutes (default value is 0)
-*/
-
-$ips = json_decode(Request::post('clients'));
-$minutes = Request::post('timer', 0, 'int');
-
-$clients = array();
-foreach ($ips as $client) {
- $clients[] = array("ip" => $client);
-}
-
-$apikey = Property::get("rebootcontrol_APIPOSTKEY", 'not-set');
-if (!empty($apikey) && Request::post('token') === $apikey) {
- if (Request::isPost()) {
- if (Request::post('action') == 'shutdown') {
- $shutdown = true;
- $task = Taskmanager::submit("RemoteReboot", array("clients" => $clients, "shutdown" => $shutdown, "minutes" => $minutes));
- echo $task["id"];
- } else if (Request::post('action') == 'reboot') {
- $shutdown = false;
- $task = Taskmanager::submit("RemoteReboot", array("clients" => $clients, "shutdown" => $shutdown, "minutes" => $minutes));
- echo $task["id"];
- } else {
- echo "Only action=shutdown and action=reboot available.";
- }
- } else {
- echo "Only POST Method available.";
- }
-} else {
- echo "Not authorized";
-} \ No newline at end of file
diff --git a/modules-available/rebootcontrol/hooks/client-update.inc.php b/modules-available/rebootcontrol/hooks/client-update.inc.php
index 006a5e11..e934988d 100644
--- a/modules-available/rebootcontrol/hooks/client-update.inc.php
+++ b/modules-available/rebootcontrol/hooks/client-update.inc.php
@@ -7,7 +7,7 @@ if ($type === '~poweron') {
&& $subnet[0] === $ip && $subnet[1] >= 8 && $subnet[1] < 32) {
$start = ip2long($ip);
if ($start !== false) {
- $maskHost = (int)(pow(2, 32 - $subnet[1]) - 1);
+ $maskHost = (int)(2 ** (32 - $subnet[1]) - 1);
$maskNet = ~$maskHost & 0xffffffff;
$end = $start | $maskHost;
$start &= $maskNet;
diff --git a/modules-available/rebootcontrol/hooks/config-tgz.inc.php b/modules-available/rebootcontrol/hooks/config-tgz.inc.php
index 90e32e8a..c9ce1255 100644
--- a/modules-available/rebootcontrol/hooks/config-tgz.inc.php
+++ b/modules-available/rebootcontrol/hooks/config-tgz.inc.php
@@ -8,8 +8,10 @@ if (!is_file($tmpfile) || !is_readable($tmpfile) || filemtime($tmpfile) + 86400
}
try {
$a = new PharData($tmpfile);
- $a["/etc/ssh/mgmt/authorized_keys"] = $pubkey;
- $a["/etc/ssh/mgmt/authorized_keys"]->chmod(0600);
+ $a->addFromString("/etc/ssh/mgmt/authorized_keys", $pubkey);
+ $fi = $a->offsetGet("/etc/ssh/mgmt/authorized_keys");
+ /** @var PharFileInfo $fi */
+ $fi->chmod(0600);
$file = $tmpfile;
} catch (Exception $e) {
EventLog::failure('Could not include ssh key for reboot-control in config.tgz', (string)$e);
diff --git a/modules-available/rebootcontrol/hooks/cron.inc.php b/modules-available/rebootcontrol/hooks/cron.inc.php
index 3651c779..289426c7 100644
--- a/modules-available/rebootcontrol/hooks/cron.inc.php
+++ b/modules-available/rebootcontrol/hooks/cron.inc.php
@@ -5,38 +5,18 @@
*/
if (in_array((int)date('G'), [6, 7, 9, 12, 15]) && in_array(date('i'), ['00', '01', '02', '03'])) {
$res = Database::simpleQuery('SELECT hostid, host, port, username, sshkey, script FROM reboot_jumphost');
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
RebootControl::wakeViaJumpHost($row, '255.255.255.255', [['macaddr' => '00:11:22:33:44:55']]);
}
}
// CRON for Scheduler
-$now = time();
-$res = Database::simpleQuery("SELECT * FROM reboot_scheduler WHERE nextexecution < :now", ['now' => $now]);
-while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
-
- // Calculate next_execution for the event.
- $location = Database::queryFirst("SELECT openingtime FROM `location` WHERE locationid = :lid", array('lid' => $row['locationid']));
- Scheduler::updateSchedule($row['locationid'], $row['action'], $row['options'], $location['openingtime']);
-
- if ($row['nextexecution'] + 1200 < $now) continue;
-
- $machinedb = Database::simpleQuery("SELECT machineuuid, clientip, macaddr, locationid FROM machine WHERE locationid = :locid", ['locid' => $row['locationid']]);
- $machines = [];
- while ($machine = $machinedb->fetch(PDO::FETCH_ASSOC)) {
- settype($machine['locationid'], 'int');
- $machines[] = $machine;
- }
- // Options not yet used.
- $options = json_decode($row['options']);
- if ($row['action'] === 'sd') RebootControl::execute($machines, RebootControl::SHUTDOWN, 0);
- else if ($row['action'] === 'wol') RebootControl::wakeMachines($machines);
-}
+Scheduler::cron();
/*
* Client reachability test -- can be disabled
*/
-if (mt_rand(1, 2) !== 1 || Property::get(RebootControl::KEY_AUTOSCAN_DISABLED))
+if (Property::get(RebootControl::KEY_AUTOSCAN_DISABLED))
return;
class Stuff
@@ -44,7 +24,7 @@ class Stuff
public static $subnets;
}
-function destSawPw($destTask, $destMachine, $passwd)
+function destSawPw(array $destTask, array $destMachine, string $passwd): bool
{
return strpos($destTask['data']['result'][$destMachine['machineuuid']]['stdout'], "passwd=$passwd") !== false;
}
@@ -160,7 +140,7 @@ function resultToTime($result)
$next = 86400 * 7; // a week
} else {
// Test finished, reachable
- $next = 86400 * 30; // a month
+ $next = 86400 * 14; // two weeks
}
return time() + round($next * mt_rand(90, 133) / 100);
}
@@ -170,12 +150,12 @@ function resultToTime($result)
*/
// First, cleanup: delete orphaned subnets that don't exist anymore, or don't have any clients using our server
-$cutoff = strtotime('-360 days');
+$cutoff = strtotime('-720 days');
Database::exec('DELETE FROM reboot_subnet WHERE fixed = 0 AND lastseen < :cutoff', ['cutoff' => $cutoff]);
// Get machines running, group by subnet
$cutoff = time() - 301; // Really only the ones that didn't miss the most recent update
-$res = Database::simpleQuery("SELECT s.subnetid, s.end AS bcast, m.machineuuid, m.clientip, m.macaddr
+$res = Database::simpleQuery("SELECT s.subnetid, s.end AS bcast, m.machineuuid, m.clientip, m.macaddr, m.locationid
FROM reboot_subnet s
INNER JOIN machine m ON (
(m.state = 'IDLE' OR m.state = 'OCCUPIED')
@@ -191,12 +171,13 @@ if ($res->rowCount() === 0)
return;
Stuff::$subnets = [];
-while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+foreach ($res as $row) {
if (!isset(Stuff::$subnets[$row['subnetid']])) {
Stuff::$subnets[$row['subnetid']] = [];
}
Stuff::$subnets[$row['subnetid']][] = $row;
}
+unset($res);
$task = Taskmanager::submit('DummyTask', []);
$task = Taskmanager::waitComplete($task, 4000);
@@ -214,7 +195,7 @@ $res = Database::simpleQuery("SELECT subnetid FROM reboot_subnet
WHERE subnetid IN (:active) AND nextdirectcheck < UNIX_TIMESTAMP() AND fixed = 0
ORDER BY nextdirectcheck ASC LIMIT 10", ['active' => array_keys(Stuff::$subnets)]);
cron_log('Direct checks: ' . $res->rowCount() . ' (' . implode(', ', array_keys(Stuff::$subnets)) . ')');
-while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+foreach ($res as $row) {
$dst = (int)$row['subnetid'];
cron_log('Direct check for subnetid ' . $dst);
$result = testServerToClient($dst);
@@ -259,7 +240,7 @@ if (count($combos) > 0) {
ORDER BY sxs.nextcheck ASC
LIMIT 10", ['combos' => $combos]);
cron_log('C2C checks: ' . $res->rowCount());
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$src = (int)$row['srcid'];
$dst = (int)$row['dstid'];
$result = testClientToClient($src, $dst);
diff --git a/modules-available/rebootcontrol/inc/rebootcontrol.inc.php b/modules-available/rebootcontrol/inc/rebootcontrol.inc.php
index a8018004..107c2a50 100644
--- a/modules-available/rebootcontrol/inc/rebootcontrol.inc.php
+++ b/modules-available/rebootcontrol/inc/rebootcontrol.inc.php
@@ -11,6 +11,8 @@ class RebootControl
const KEY_UDP_PORT = 'rebootcontrol.port';
+ const KEY_BROADCAST_ADDRESS = 'rebootcontrol.broadcast-addr';
+
const REBOOT = 'REBOOT';
const KEXEC_REBOOT = 'KEXEC_REBOOT';
const SHUTDOWN = 'SHUTDOWN';
@@ -23,7 +25,7 @@ class RebootControl
* @param bool $kexec whether to trigger kexec-reboot instead of full BIOS cycle
* @return false|array task struct for the reboot job
*/
- public static function reboot($uuids, $kexec = false)
+ public static function reboot(array $uuids, bool $kexec = false)
{
$list = RebootUtils::getMachinesByUuid($uuids);
if (empty($list))
@@ -35,10 +37,9 @@ class RebootControl
* @param array $list list of clients containing each keys 'machineuuid', 'clientip' and 'locationid'
* @param string $mode reboot mode: RebootControl::REBOOT ::KEXEC_REBOOT or ::SHUTDOWN
* @param int $minutes delay in minutes for action
- * @param int $locationId meta data only: locationId of clients
* @return array|false the task, or false if it could not be started
*/
- public static function execute($list, $mode, $minutes)
+ public static function execute(array $list, string $mode, int $minutes)
{
$task = Taskmanager::submit("RemoteReboot", array(
"clients" => $list,
@@ -48,12 +49,20 @@ class RebootControl
"port" => 9922, // Hard-coded, must match mgmt-sshd module
));
if (!Taskmanager::isFailed($task)) {
- self::addTask($task['id'], self::TASK_REBOOTCTL, $list, $task['id'], ['action' => $mode]);
+ self::addTask($task['id'], self::TASK_REBOOTCTL, $list, ['action' => $mode]);
+ foreach ($list as $client) {
+ $client['mode'] = $mode;
+ $client['minutes'] = $minutes;
+ EventLog::applyFilterRules('#action-power', $client);
+ }
}
return $task;
}
- private static function addTask($id, $type, $clients, $taskIds, $other = false)
+ /**
+ * Add wake task metadata to database, so we can display job details on the summary page.
+ */
+ private static function addTask(string $taskId, string $type, array $clients, array $other = null): void
{
$lids = ArrayUtil::flattenByKey($clients, 'locationid');
$lids = array_unique($lids);
@@ -65,15 +74,13 @@ class RebootControl
}
$newClients[] = $d;
}
- if (!is_array($taskIds)) {
- $taskIds = [$taskIds];
- }
$data = [
- 'id' => $id,
+ 'id' => $taskId,
'type' => $type,
'locations' => $lids,
'clients' => $newClients,
- 'tasks' => $taskIds,
+ 'tasks' => [$taskId], // This did hold multiple tasks in the past; keep it in case we need this again
+ 'timestamp' => time(),
];
if (is_array($other)) {
$data += $other;
@@ -83,19 +90,20 @@ class RebootControl
/**
* @param int[]|null $locations filter by these locations
+ * @param ?string $id only with this TaskID
* @return array|false list of active tasks for reboots/shutdowns.
*/
- public static function getActiveTasks($locations = null, $id = null)
+ public static function getActiveTasks(array $locations = null, string $id = null)
{
if (is_array($locations) && in_array(0, $locations)) {
$locations = null;
}
$list = Property::getList(RebootControl::KEY_TASKLIST);
$return = [];
- foreach ($list as $entry) {
+ foreach ($list as $subkey => $entry) {
$p = json_decode($entry, true);
if (!is_array($p) || !isset($p['id'])) {
- Property::removeFromList(RebootControl::KEY_TASKLIST, $entry);
+ Property::removeFromListByKey(RebootControl::KEY_TASKLIST, $subkey);
continue;
}
if (is_array($locations) && is_array($p['locations']) && array_diff($p['locations'], $locations) !== [])
@@ -118,7 +126,7 @@ class RebootControl
}
}
if (!$valid) {
- Property::removeFromList(RebootControl::KEY_TASKLIST, $entry);
+ Property::removeFromListByKey(RebootControl::KEY_TASKLIST, $subkey);
continue;
}
$return[] = $p;
@@ -138,18 +146,18 @@ class RebootControl
* @param string $command Command or script to execute on client
* @param int $timeout in seconds
* @param string|false $privkey SSH private key to use to connect
- * @return array|false
+ * @return array|false task struct, false on error
*/
- public static function runScript($clients, $command, $timeout = 5, $privkey = false)
+ public static function runScript(array $clients, string $command, int $timeout = 5, $privkey = false)
{
$task = self::runScriptInternal($clients, $command, $timeout, $privkey);
if (!Taskmanager::isFailed($task)) {
- self::addTask($task['id'], self::TASK_EXEC, $clients, $task['id']);
+ self::addTask($task['id'], self::TASK_EXEC, $clients);
}
return $task;
}
- private static function runScriptInternal(&$clients, $command, $timeout = 5, $privkey = false)
+ private static function runScriptInternal(array &$clients, string $command, int $timeout = 5, $privkey = false)
{
$valid = [];
$invalid = [];
@@ -167,7 +175,7 @@ class RebootControl
if (!empty($invalid)) {
$res = Database::simpleQuery('SELECT machineuuid, clientip, locationid FROM machine WHERE machineuuid IN (:uuids)',
['uuids' => array_keys($invalid)]);
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if (isset($invalid[$row['machineuuid']])) {
$valid[] = $row + $invalid[$row['machineuuid']];
} else {
@@ -207,13 +215,10 @@ class RebootControl
}
/**
- * @param array $sourceMachines list of source machines. array of [clientip, machineuuid] entries
- * @param string $bcast directed broadcast address to send to
- * @param string|string[] $macaddr destination mac address(es)
- * @param string $passwd optional WOL password, mac address or ipv4 notation
- * @return array|false task struct, false on error
+ * Wake clients given by MAC address(es) via jawol util.
+ * Multiple MAC addresses can be passed as a space separated list.
*/
- public static function wakeViaClient($sourceMachines, $macaddr, $bcast = false, $passwd = false)
+ private static function buildClientWakeCommand(string $macs, string $bcast = null, string $passwd = null): string
{
$command = 'jawol';
if (!empty($bcast)) {
@@ -224,22 +229,32 @@ class RebootControl
if (!empty($passwd)) {
$command .= " -p '$passwd'";
}
- if (is_array($macaddr)) {
- $macaddr = implode("' '", $macaddr);
- }
- $command .= " '$macaddr'";
- // Yes there is one zero missing from the usleep -- that's the whole point: we prefer 100ms sleeps
+ $command .= " $macs";
+ return $command;
+ }
+
+ /**
+ * @param array $sourceMachines list of source machines. array of [clientip, machineuuid] entries
+ * @param string $macaddr destination mac address(es)
+ * @param string $bcast directed broadcast address to send to
+ * @param string $passwd optional WOL password, mac address or ipv4 notation
+ * @return array|false task struct, false on error
+ */
+ public static function wakeViaClient(array $sourceMachines, string $macaddr, string $bcast = null, string $passwd = null)
+ {
+ $command = self::buildClientWakeCommand($macaddr, $bcast, $passwd);
+ // Yes there is one zero "missing" from the usleep -- that's the whole point: we prefer 100ms sleeps
return self::runScriptInternal($sourceMachines,
"for i in 1 1 0; do $command; usleep \${i}00000 2> /dev/null || sleep \$i; done");
}
/**
* @param string|string[] $macaddr destination mac address(es)
- * @param string $bcast directed broadcast address to send to
- * @param string $passwd optional WOL password; mac address or ipv4 notation
+ * @param ?string $bcast directed broadcast address to send to
+ * @param ?string $passwd optional WOL password; mac address or ipv4 notation
* @return array|false task struct, false on error
*/
- public static function wakeDirectly($macaddr, $bcast = false, $passwd = false)
+ public static function wakeDirectly($macaddr, string $bcast = null, string $passwd = null)
{
if (!is_array($macaddr)) {
$macaddr = [$macaddr];
@@ -248,15 +263,26 @@ class RebootControl
if ($port < 1 || $port > 65535) {
$port = 9;
}
- return Taskmanager::submit('WakeOnLan', [
- 'ip' => $bcast,
- 'password' => $passwd === false ? '' : $passwd,
- 'macs' => $macaddr,
- 'port' => $port,
- ]);
+ $arg = [];
+ foreach ($macaddr as $mac) {
+ $arg[] = [
+ 'ip' => $bcast,
+ 'mac' => $mac,
+ 'methods' => ['DIRECT'],
+ 'password' => $passwd,
+ ];
+ }
+ return Taskmanager::submit('WakeOnLan', ['clients' => $arg]);
}
- public static function wakeViaJumpHost($jumphost, $bcast, $clients)
+ /**
+ * Explicitly wake given clients via jumphost
+ * @param array $jumphost the according row from the database, representing the desired jumphost
+ * @param string $bcast (directed) broadcast address for WOL packet, %IP% in command template
+ * @param array $clients list of clients, must contain at least key 'macaddr' for every client
+ * @return array|false task struct on successful submission to TM, false on error
+ */
+ public static function wakeViaJumpHost(array $jumphost, string $bcast, array $clients)
{
$hostid = $jumphost['hostid'];
$macs = ArrayUtil::flattenByKey($clients, 'macaddr');
@@ -280,59 +306,42 @@ class RebootControl
}
/**
- * @param array $list list of clients containing each keys 'macaddr' and 'clientip'
- * @return string id of this job
+ * @param array $clientList list of clients containing each keys 'macaddr' and 'clientip', optionally 'locationid'
+ * @param ?array $failed list of failed clients from $clientList
+ * @return ?string taskid of this job
*/
- public static function wakeMachines($list, &$failed = [])
+ public static function wakeMachines(array $clientList, array &$failed = null): ?string
{
- /* TODO: Refactor mom's spaghetti
- * Now that I figured out what I want, do something like this:
- * 1) Group clients by subnet
- * 2) Only after step 1, start to collect possible ways to wake up clients for each subnet that's not empty
- * 3) Have some priority list for the methods, extend Taskmanager to have "negative dependency"
- * i.e. submit task B with task A as parent task, but only launch task B if task A failed.
- * If task A succeeded, mark task B as FINISHED immediately without actually running it.
- * (or introduce new statusCode for this?)
- */
$errors = '';
- $tasks = [];
- $bad = $unknown = [];
+ $sent = $unknown = $unreachable = $failed = [];
+ // For event filtering by rule
// Need all subnets...
+ /* subnetid => [
+ * subnetid => 1234,
+ * start => 1234, (ip2long)
+ * end => 5678, (ip2long)
+ * jumphosts => [id1, id2, ...],
+ */
$subnets = [];
$res = Database::simpleQuery('SELECT subnetid, start, end, isdirect FROM reboot_subnet');
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$row += [
- 'jumphosts' => [],
- 'direct' => [],
- 'indirect' => [],
+ 'djumphosts' => [],
+ 'ijumphosts' => [],
];
$subnets[$row['subnetid']] = $row;
}
// Get all jump hosts
- $jumphosts = [];
- $res = Database::simpleQuery('SELECT jh.hostid, host, port, username, sshkey, script, jh.reachable,
- Group_Concat(jxs.subnetid) AS subnets1, Group_Concat(sxs.dstid) AS subnets2
- FROM reboot_jumphost jh
- LEFT JOIN reboot_jumphost_x_subnet jxs ON (jh.hostid = jxs.hostid)
- LEFT JOIN reboot_subnet s ON (INET_ATON(jh.host) BETWEEN s.start AND s.end)
- LEFT JOIN reboot_subnet_x_subnet sxs ON (sxs.srcid = s.subnetid AND sxs.reachable <> 0)
- GROUP BY jh.hostid');
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- if ($row['subnets1'] === null && $row['subnets2'] === null)
- continue;
- $nets = explode(',', $row['subnets1'] . ',' . $row['subnets2']);
- foreach ($nets as $net) {
- if (empty($net) || !isset($subnets[$net]))
- continue;
- $subnets[$net]['jumphosts'][$row['hostid']] = $row['hostid'];
- }
- $row['jobs'] = [];
- $jumphosts[$row['hostid']] = $row;
+ self::addJumphostsToSubnets($subnets);
+ // Determine method for all clients
+ $taskClients = []; // array of arrays with keys [ip, mac, methods]
+ $taskSsh = []; // SSH configs for task, array of arrays with keys [username, sshkey, ip, port, command]
+ $overrideBroadcast = Property::get(self::KEY_BROADCAST_ADDRESS);
+ if (empty($overrideBroadcast)) {
+ $overrideBroadcast = false;
}
- // Group by subnet
- foreach ($list as $client) {
- $ip = sprintf('%u', ip2long($client['clientip']));
- //$client['numip'] = $ip;
+ foreach ($clientList as $dbClient) {
+ $ip = sprintf('%u', ip2long($dbClient['clientip'])); // 32Bit snprintf
unset($subnet);
$subnet = false;
foreach ($subnets as &$sn) {
@@ -342,125 +351,80 @@ class RebootControl
}
}
if ($subnet === false) {
- $unknown[] = $client;
+ $unknown[] = $dbClient;
continue;
}
- $ok = false;
- if (!$ok && $subnet['isdirect']) {
- // Directly reachable
- $subnet['direct'][] = $client;
- $ok = true;
- }
- if (!$ok && !empty($subnet['jumphosts'])) {
- foreach ($subnet['jumphosts'] as $hostid) {
- if ($jumphosts[$hostid]['reachable'] != 0) {
- $jumphosts[$hostid]['jobs'][$subnet['end']][] = $client;
- $ok = true;
- break;
- }
- }
+ $taskClient = [
+ 'ip' => long2ip($subnet['end']),
+ 'mac' => $dbClient['macaddr'],
+ 'methods' => [],
+ ];
+ // If we have an override broadcast address, unconditionally add this as the
+ // first method
+ if ($overrideBroadcast !== false) {
+ $taskClient['ip'] = $overrideBroadcast;
+ $taskClient['methods'][] = 'DIRECT';
}
- if (!$ok) {
- // find clients in same subnet, or reachable ones
- self::findMachinesForSubnet($subnet);
- if (empty($subnet['dclients']) && empty($subnet['iclients'])) {
- // Nothing found -- cannot wake this host
- $bad[] = $client;
- } else {
- // Found suitable indirect host
- $subnet['indirect'][] = $client;
- $ok = true;
- }
+ self::findMachinesForSubnet($subnet);
+ // Highest priority - clients in same subnet, no directed broadcast
+ // required, should be most reliable
+ self::addSshMethodUsingClient($subnet['dclients'], $taskClient['methods'], $taskSsh);
+ // Jumphost - usually in same subnet
+ self::addSshMethodUsingJumphost($subnet['djumphosts'], true, $taskClient['methods'], $taskSsh);
+ // Jumphosts in other subnets, determined to be able to reach destination subnet
+ self::addSshMethodUsingJumphost($subnet['ijumphosts'], true, $taskClient['methods'], $taskSsh);
+ // If directly reachable from server, prefer this now over the questionable approaches below,
+ // but only if we didn't already add this above because of override
+ if ($overrideBroadcast === false && $subnet['isdirect']) {
+ $taskClient['methods'][] = 'DIRECT';
}
- if ($ok && isset($client['machineuuid'])) {
+ // Use clients in other subnets, known to be able to reach the destination net
+ self::addSshMethodUsingClient($subnet['iclients'], $taskClient['methods'], $taskSsh);
+ // Add warning if nothing works
+ if (empty($taskClient['methods'])) {
+ $unreachable[] = $dbClient;
+ } else {
// TODO: Remember WOL was attempted
}
- }
- unset($subnet);
- // Batch process
- // First, via jump host
- foreach ($jumphosts as $jh) {
- foreach ($jh['jobs'] as $bcast => $clients) {
- $errors .= 'Via jumphost ' . $jh['host'] . ': ' . implode(', ', ArrayUtil::flattenByKey($clients, 'clientip')) . "\n";
- $task = self::wakeViaJumpHost($jh, $bcast, $clients);
- if (Taskmanager::isFailed($task)) {
- // TODO: Figure out $subnet from $bcast and queue as indirect
- // (rather, overhaul this whole spaghetti code)
- $errors .= ".... FAILED TO LAUNCH TASK ON JUMPHOST!\n";
- }
- }
- }
- // Server or client
- foreach ($subnets as $subnet) {
- if (!empty($subnet['direct'])) {
- // Can wake directly
- if (!self::wakeGroup('From server', $tasks, $errors, null, $subnet['direct'], $subnet['end'])) {
- if (!empty($subnet['dclients']) || !empty($subnet['iclients'])) {
- $errors .= "Re-queueing clients for indirect wakeup\n";
- $subnet['indirect'] = array_merge($subnet['indirect'], $subnet['direct']);
- }
- }
- }
- if (!empty($subnet['indirect'])) {
- // Can wake indirectly
- $ok = false;
- if (!empty($subnet['dclients'])) {
- $ok = true;
- if (!self::wakeGroup('in same subnet', $tasks, $errors, $subnet['dclients'], $subnet['indirect'])) {
- if (!empty($subnet['iclients'])) {
- $errors .= "Re-re-queueing clients for indirect wakeup\n";
- $ok = false;
- }
- }
- }
- if (!$ok && !empty($subnet['iclients'])) {
- $ok = self::wakeGroup('across subnets', $tasks, $errors, $subnet['iclients'], $subnet['indirect'], $subnet['end']);
- }
- if (!$ok) {
- $errors .= "I'm all out of ideas.\n";
- }
+ // "Questionable approaches":
+ // Last fallback is jumphosts that were not reachable when last checked, this is really a last resort
+ self::addSshMethodUsingJumphost($subnet['djumphosts'], false, $taskClient['methods'], $taskSsh);
+ self::addSshMethodUsingJumphost($subnet['ijumphosts'], false, $taskClient['methods'], $taskSsh);
+
+ if (!empty($taskClient['methods'])) {
+ $taskClients[] = $taskClient;
+ $sent[] = $dbClient;
}
}
- if (!empty($bad)) {
- $ips = ArrayUtil::flattenByKey($bad, 'clientip');
- $errors .= "**** WARNING ****\nNo way to send WOL packets to the following machines:\n" . implode("\n", $ips) . "\n";
- }
+ unset($subnet);
+
if (!empty($unknown)) {
$ips = ArrayUtil::flattenByKey($unknown, 'clientip');
$errors .= "**** WARNING ****\nThe following clients do not belong to a known subnet (bug?)\n" . implode("\n", $ips) . "\n";
+ foreach ($unknown as $val) {
+ $failed[$val['clientip']] = $val;
+ }
}
- $failed = array_merge($bad, $unknown);
- $id = Util::randomUuid();
- self::addTask($id, self::TASK_WOL, $list, $tasks, ['log' => $errors]);
- return $id;
- }
-
- private static function wakeGroup($type, &$tasks, &$errors, $via, $clients, $bcast = false)
- {
- $macs = ArrayUtil::flattenByKey($clients, 'macaddr');
- $ips = ArrayUtil::flattenByKey($clients, 'clientip');
- if ($via !== null) {
- $srcips = ArrayUtil::flattenByKey($via, 'clientip');
- $errors .= 'Via ' . implode(', ', $srcips) . ' ';
- }
- $errors .= $type . ': ' . implode(', ', $ips);
- if ($bcast !== false) {
- $errors .= ' (UDP to ' . long2ip($bcast) . ')';
- }
- $errors .= "\n";
- if ($via === null) {
- $task = self::wakeDirectly($macs, $bcast);
- } else {
- $task = self::wakeViaClient($via, $macs, $bcast);
- }
- if ($task !== false && isset($task['id'])) {
- $tasks[] = $task['id'];
+ if (!empty($unreachable)) {
+ $ips = ArrayUtil::flattenByKey($unreachable, 'clientip');
+ $errors .= "**** WARNING ****\nThe following clients are not reachable with any method\n" . implode("\n", $ips) . "\n";
+ foreach ($unreachable as $val) {
+ $failed[$val['clientip']] = $val;
+ }
}
- if (Taskmanager::isFailed($task)) {
- $errors .= ".... FAILED TO START ACCORDING TASK!\n";
- return false;
+ $task = Taskmanager::submit('WakeOnLan', [
+ 'clients' => $taskClients,
+ 'ssh' => $taskSsh,
+ ]);
+ if (isset($task['id'])) {
+ $id = $task['id'];
+ self::addTask($id, self::TASK_WOL, $clientList, ['log' => $errors]);
+ foreach ($sent as $dbClient) {
+ EventLog::applyFilterRules('#action-wol', $dbClient);
+ }
+ return $id;
}
- return true;
+ return null;
}
private static function findMachinesForSubnet(&$subnet)
@@ -469,15 +433,12 @@ class RebootControl
return;
$cutoff = time() - 320;
// Get clients from same subnet first
- $subnet['dclients'] = Database::queryAll("SELECT machineuuid, clientip FROM machine
+ $subnet['dclients'] = Database::queryColumnArray("SELECT clientip FROM machine
WHERE state IN ('IDLE', 'OCCUPIED') AND INET_ATON(clientip) BETWEEN :start AND :end AND lastseen > :cutoff
LIMIT 3",
['start' => $subnet['start'], 'end' => $subnet['end'], 'cutoff' => $cutoff]);
- $subnet['iclients'] = [];
- if (!empty($subnet['dclients']))
- return;
// If none, get clients from other subnets known to be able to reach this one
- $subnet['iclients'] = Database::queryAll("SELECT m.machineuuid, m.clientip FROM reboot_subnet_x_subnet sxs
+ $subnet['iclients'] = Database::queryColumnArray("SELECT m.clientip FROM reboot_subnet_x_subnet sxs
INNER JOIN reboot_subnet s ON (s.subnetid = sxs.srcid AND sxs.dstid = :subnetid AND sxs.reachable = 1)
INNER JOIN machine m ON (INET_ATON(m.clientip) BETWEEN s.start AND s.end AND state IN ('IDLE', 'OCCUPIED') AND m.lastseen > :cutoff)
LIMIT 20", ['subnetid' => $subnet['subnetid'], 'cutoff' => $cutoff]);
@@ -487,15 +448,99 @@ class RebootControl
public static function prepareExec()
{
- User::assertPermission('action.exec');
+ User::assertPermission('.rebootcontrol.action.exec');
$uuids = array_values(Request::post('uuid', Request::REQUIRED, 'array'));
- $machines = RebootUtils::getFilteredMachineList($uuids, 'action.exec');
+ $machines = RebootUtils::getFilteredMachineList($uuids, '.rebootcontrol.action.exec');
if ($machines === false)
return;
$id = mt_rand();
Session::set('exec-' . $id, $machines, 60);
- Session::save();
Util::redirect('?do=rebootcontrol&show=exec&what=prepare&id=' . $id);
}
+ /**
+ * Append a "wake via client" WOL method for the given client. Append at least one, but stop
+ * if there are at least two methods already.
+ *
+ * @param array $sshClients [in] list of online clients to use for waking
+ * @param array $c [out] The client's methods array
+ * @param array $taskSsh [out] add according task struct to this array, if not already exists
+ * @return void
+ */
+ private static function addSshMethodUsingClient(array $sshClients, array &$methods, array &$taskSsh)
+ {
+ foreach ($sshClients as $host) {
+ if (!isset($taskSsh[$host])) {
+ $taskSsh[$host] = [
+ 'username' => 'root',
+ 'sshkey' => SSHKey::getPrivateKey(),
+ 'ip' => $host,
+ 'port' => 9922,
+ 'command' => self::buildClientWakeCommand('%MACS%', '%IP%'),
+ ];
+ }
+ $methods[] = $host;
+ if (count($methods) >= 2)
+ break;
+ }
+ }
+
+ private static function addSshMethodUsingJumphost(array $jumpHosts, bool $reachable, array &$methods, array &$taskSsh)
+ {
+ // If it's the fallback to apparently unreachable jump-hosts, ignore if we already have two methods
+ if (!$reachable && count($methods) >= 2)
+ return;
+ // host, port, username, sshkey, script, jh.reachable
+ foreach ($jumpHosts as $jh) {
+ if ($reachable !== (bool)$jh['reachable'])
+ continue;
+ $key = substr(md5($jh['host'] . ':' . $jh['port'] . ':' . $jh['username']), 0, 10);
+ if (!isset($taskSsh[$key])) {
+ $taskSsh[$key] = [
+ 'username' => $jh['username'],
+ 'sshkey' => $jh['sshkey'],
+ 'ip' => $jh['host'],
+ 'port' => $jh['port'],
+ 'command' => $jh['script'],
+ ];
+ }
+ $methods[] = $key;
+ if (count($methods) >= 2)
+ break;
+ }
+ }
+
+ /**
+ * Load all jumphosts from DB, sort into passed $subnets. Also split up
+ * by directly assigned subnets, and indirectly dtermined, reachable subnets.
+ * @param array $subnets [in]
+ * @return void
+ */
+ private static function addJumphostsToSubnets(array &$subnets)
+ {
+ $res = Database::simpleQuery('SELECT host, port, username, sshkey, script, jh.reachable,
+ Group_Concat(jxs.subnetid) AS dsubnets, Group_Concat(sxs.dstid) AS isubnets
+ FROM reboot_jumphost jh
+ LEFT JOIN reboot_jumphost_x_subnet jxs ON (jh.hostid = jxs.hostid)
+ LEFT JOIN reboot_subnet s ON (INET_ATON(jh.host) BETWEEN s.start AND s.end)
+ LEFT JOIN reboot_subnet_x_subnet sxs ON (sxs.srcid = s.subnetid AND sxs.reachable <> 0)
+ GROUP BY jh.hostid');
+ foreach ($res as $row) {
+ $dnets = empty($row['dsubnets']) ? [] : explode(',', $row['dsubnets']);
+ $inets = empty($row['isubnets']) ? [] : explode(',', $row['isubnets']);
+ $inets = array_diff($inets, $dnets); // There might be duplicates if both joins match
+ foreach ($dnets as $net) {
+ if (empty($net) || !isset($subnets[$net]))
+ continue;
+ $subnets[$net]['djumphosts'][] =& $row;
+ }
+ foreach ($inets as $net) {
+ if (empty($net) || !isset($subnets[$net]))
+ continue;
+ $subnets[$net]['ijumphosts'][] =& $row;
+ }
+ unset($row);
+ }
+ }
+
}
diff --git a/modules-available/rebootcontrol/inc/rebootutils.inc.php b/modules-available/rebootcontrol/inc/rebootutils.inc.php
index 99235e8a..e05d90dc 100644
--- a/modules-available/rebootcontrol/inc/rebootutils.inc.php
+++ b/modules-available/rebootcontrol/inc/rebootutils.inc.php
@@ -8,19 +8,18 @@ class RebootUtils
* @param string[] $list list of system UUIDs
* @return array list of machines with machineuuid, hostname, clientip, state and locationid
*/
- public static function getMachinesByUuid($list, $assoc = false, $columns = ['machineuuid', 'hostname', 'clientip', 'state', 'locationid'])
+ public static function getMachinesByUuid(array $list, bool $assoc = false,
+ array $columns = ['machineuuid', 'hostname', 'clientip', 'state', 'locationid']): array
{
if (empty($list))
return array();
- if (is_array($columns)) {
- $columns = implode(',', $columns);
- }
+ $columns = implode(',', $columns);
$res = Database::simpleQuery("SELECT $columns FROM machine
WHERE machineuuid IN (:list)", compact('list'));
if (!$assoc)
- return $res->fetchAll(PDO::FETCH_ASSOC);
+ return $res->fetchAll();
$ret = [];
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$ret[$row['machineuuid']] = $row;
}
return $ret;
@@ -31,7 +30,7 @@ class RebootUtils
* Requires the array elements to have key "state" from machine table.
* @param array $clients list of clients
*/
- public static function sortRunningFirst(&$clients)
+ public static function sortRunningFirst(array &$clients): void
{
usort($clients, function($a, $b) {
$a = ($a['state'] === 'IDLE' || $a['state'] === 'OCCUPIED');
@@ -49,7 +48,7 @@ class RebootUtils
* @param string $permission name of location-aware permission to check
* @return array|false List of clients the user has access to.
*/
- public static function getFilteredMachineList($requestedClients, $permission)
+ public static function getFilteredMachineList(array $requestedClients, string $permission)
{
$actualClients = RebootUtils::getMachinesByUuid($requestedClients);
if (count($actualClients) !== count($requestedClients)) {
diff --git a/modules-available/rebootcontrol/inc/scheduler.inc.php b/modules-available/rebootcontrol/inc/scheduler.inc.php
index 27a22646..19a01beb 100644
--- a/modules-available/rebootcontrol/inc/scheduler.inc.php
+++ b/modules-available/rebootcontrol/inc/scheduler.inc.php
@@ -3,83 +3,328 @@
class Scheduler
{
- public static function updateSchedule($locationid, $action, $options, $openingTimes) {
- if ($openingTimes == '') {
- self::deleteSchedule($locationid, $action);
- return false;
- }
- $nextexec = self::calcNextexec($action, $options, $openingTimes);
- $json_options = json_encode($options);
- self::upsert($locationid, $action, $nextexec, $json_options);
- return true;
- }
+ const ACTION_SHUTDOWN = 'SHUTDOWN';
+ const ACTION_REBOOT = 'REBOOT';
+ const ACTION_WOL = 'WOL';
+ const RA_NEVER = 'NEVER';
+ const RA_SELECTIVE = 'SELECTIVE';
+ const RA_ALWAYS = 'ALWAYS';
+ const SCHEDULE_OPTIONS_DEFAULT = ['wol' => false, 'sd' => false, 'wol-offset' => 0, 'sd-offset' => 0, 'ra-mode' => self::RA_ALWAYS];
- public static function deleteSchedule($locationid, $action) {
+ /**
+ * @param int $locationid ID of location to delete WOL/shutdown settings for
+ */
+ public static function deleteSchedule(int $locationid)
+ {
Database::exec("DELETE FROM `reboot_scheduler`
- WHERE locationid = :lid AND action = :act", array(
- 'lid' => $locationid,
- 'act' => $action
- ));
+ WHERE locationid = :lid", ['lid' => $locationid]);
+ }
+
+ /**
+ * Calculate next time the given time description is reached.
+ * @param int $now unix timestamp representing now
+ * @param string $day Name of weekday
+ * @param string $time Time, fi. 13:45
+ * @return false|int unix timestamp in the future when we reach the given time
+ */
+ private static function calculateTimestamp(int $now, string $day, string $time)
+ {
+ $ts = strtotime("$day $time");
+ if ($ts < $now) {
+ $ts = strtotime("next $day $time");
+ }
+ if ($ts < $now) {
+ EventLog::warning("Invalid params to calculateTimestamp(): 'next $day $time'");
+ $ts = $now + 864000;
+ }
+ return $ts;
}
- private static function calcNextexec($action, $options, $openingTimes) {
- $openingTimes = json_decode($openingTimes, true);
- $now = time(); $times = [];
+ /**
+ * Take WOL/SD options and opening times schedule, return next event.
+ * @return array|false array with keys 'time' and 'action' false if no next event
+ */
+ private static function calculateNext(array $options, array $openingTimes)
+ {
+ // If ra-mode is selective, still execute even if wol and shutdown is disabled,
+ // because we still want to shutdown any sessions in the wrong runmode then
+ $selectiveRa = ($options['ra-mode'] === self::RA_SELECTIVE);
+ if ((!$options['wol'] && !$options['sd'] && !$selectiveRa) || empty($openingTimes))
+ return false;
+ $now = time();
+ $events = [];
+ $findWol = $options['wol'] || $options['ra-mode'] === self::RA_SELECTIVE;
+ $findSd = $options['sd'] || $options['ra-mode'] === self::RA_SELECTIVE;
foreach ($openingTimes as $row) {
- // Fetch hour and minutes of opening / closing time.
- $hourmin = explode(':', ($action == 'wol' ? $row['openingtime'] : $row['closingtime']));
- // Calculate time based on offset.
- $min = ($action == 'wol' ? $hourmin[0] * 60 + $hourmin[1] - $options['wol-offset'] : $hourmin[0] * 60 + $hourmin[1] + $options['sd-offset']);
- // Calculate opening / closing time of each day.
foreach ($row['days'] as $day) {
- $next = strtotime(date('Y-m-d H:i', strtotime($day . ' ' . $min . ' minutes')));
- if ($next < $now) {
- $times[] = strtotime(date('Y-m-d H:i', strtotime('next '.$day . ' ' . $min . ' minutes')));
- } else {
- $times[] = $next;
+ if ($findWol) {
+ $events[] = ['action' => self::ACTION_WOL,
+ 'time' => self::calculateTimestamp($now, $day, $row['openingtime'])];
+ }
+ if ($findSd) {
+ $events[] = ['action' => self::ACTION_SHUTDOWN,
+ 'time' => self::calculateTimestamp($now, $day, $row['closingtime'])];
}
}
}
- // Iterate over days, use timestamp with smallest difference to now.
- $res = 0; $smallestDiff = 0;
- foreach ($times as $time) {
- $diff = $time - $now;
- if ($res == 0 || $diff < $smallestDiff) {
- $smallestDiff = $diff;
- $res = $time;
+ $tmp = ArrayUtil::flattenByKey($events, 'time');
+ array_multisort($tmp, SORT_NUMERIC | SORT_ASC, $events);
+
+ // Only apply offsets now, so we can detect nonsensical overlap
+ $wolOffset = $options['wol-offset'] * 60;
+ $sdOffset = $options['sd-offset'] * 60;
+ $prev = PHP_INT_MAX;
+ for ($i = count($events) - 1; $i >= 0; --$i) {
+ $event =& $events[$i];
+ if ($event['action'] === self::ACTION_WOL) {
+ $event['time'] -= $wolOffset;
+ } elseif ($event['action'] === self::ACTION_SHUTDOWN) {
+ $event['time'] += $sdOffset;
+ } else {
+ error_log('BUG Unhandled event type ' . $event['action']);
+ }
+ if ($event['time'] >= $prev || $event['time'] < $now) {
+ // Overlap, delete this event
+ unset($events[$i]);
+ } else {
+ $prev = $event['time'];
+ }
+ }
+ unset($event);
+ // Reset array keys
+ $events = array_values($events);
+
+ // See which is the next suitable event to act upon
+ $lastEvent = count($events) - 1;
+ for ($i = 0; $i <= $lastEvent; $i++) {
+ $event =& $events[$i];
+ $diff = ($i === $lastEvent ? PHP_INT_MAX : $events[$i + 1]['time'] - $event['time']);
+ if ($diff < 300 && $event['action'] !== $events[$i + 1]['action']) {
+ // If difference to next event is < 5 min, ignore.
+ continue;
}
+ if ($diff < 900 && $event['action'] === self::ACTION_SHUTDOWN && $events[$i + 1]['action'] === self::ACTION_WOL) {
+ // If difference to next WOL is < 15 min and this is a shutdown, reboot instead.
+ $res['action'] = self::ACTION_REBOOT;
+ $res['time'] = $event['time'];
+ } else {
+ // Use first event.
+ $res = $event;
+ }
+ return $res;
}
- return $res;
+ unset($event);
+ return false;
}
+ /**
+ * Check if any actions have to be taken. To be called periodically by cron.
+ */
+ public static function cron()
+ {
+ $now = time();
+ $res = Database::simpleQuery("SELECT s.locationid, s.action, s.nextexecution, s.options
+ FROM reboot_scheduler s
+ WHERE s.nextexecution < :now AND s.nextexecution > 0", ['now' => $now]);
+ foreach ($res as $row) {
+ // Calculate next_execution for the event and update DB.
+ $options = json_decode($row['options'], true) + self::SCHEDULE_OPTIONS_DEFAULT;
+ // Determine proper opening times by waling up tree
+ $openingTimes = OpeningTimes::forLocation($row['locationid']);
+ if ($openingTimes !== null) {
+ self::updateScheduleSingle($row['locationid'], $options, $openingTimes);
+ }
+ // Weird clock drift? Server offline for a while? Do nothing.
+ if ($row['nextexecution'] + 900 < $now)
+ continue;
+ $selectiveRa = ($options['ra-mode'] === self::RA_SELECTIVE);
+ // Now, if selective remote access is active, we might modify the actual event:
+ if ($selectiveRa) {
+ // If this is WOL, and WOL is actually enabled, then reboot any running machines
+ // in remoteaccess mode, in addition to waking the others, so they exit remote access mode.
+ if ($row['action'] === Scheduler::ACTION_WOL && $options['wol']) {
+ self::executeCronForLocation($row['locationid'], Scheduler::ACTION_REBOOT, 'remoteaccess');
+ self::executeCronForLocation($row['locationid'], Scheduler::ACTION_WOL);
+ }
+ // If this is WOL, and WOL is disabled, shut down any running machines, this is so
+ // anybody walking into this room will not mess with a user's session by yanking the
+ // power cord etc.
+ if ($row['action'] === Scheduler::ACTION_WOL && !$options['wol']) {
+ self::executeCronForLocation($row['locationid'], Scheduler::ACTION_SHUTDOWN, 'remoteaccess');
+ }
+ // If this is SHUTDOWN, and SHUTDOWN is enabled, leave it at that.
+ if ($row['action'] === Scheduler::ACTION_SHUTDOWN && $options['sd']) {
+ self::executeCronForLocation($row['locationid'], Scheduler::ACTION_SHUTDOWN);
+ }
+ // If this is SHUTDOWN, and SHUTDOWN is disabled, do a reboot, so the machine ends up
+ // in the proper runmode.
+ if ($row['action'] === Scheduler::ACTION_SHUTDOWN && !$options['sd']) {
+ self::executeCronForLocation($row['locationid'], Scheduler::ACTION_REBOOT, '');
+ }
+ } else {
+ // Regular case, no selective remoteaccess – just do what the cron entry says
+ self::executeCronForLocation($row['locationid'], $row['action']);
+ }
+ }
+ }
+
+ /**
+ * Execute the given action for the given location.
+ * @param int $locationId location
+ * @param string $action action to perform, Scheduler::*
+ * @param string|null $onlyRunmode if not null, only process running clients in given runmode
+ * @return void
+ */
+ private static function executeCronForLocation(int $locationId, string $action, string $onlyRunmode = null)
+ {
+ if ($onlyRunmode === null) {
+ $machines = Database::queryAll("SELECT machineuuid, clientip, macaddr, locationid FROM machine
+ WHERE locationid = :locid", ['locid' => $locationId]);
+ } else {
+ $machines = Database::queryAll("SELECT machineuuid, clientip, macaddr, locationid FROM machine
+ WHERE locationid = :locid AND currentrunmode = :runmode AND state <> 'OFFLINE'",
+ ['locid' => $locationId, 'runmode' => $onlyRunmode]);
+ }
+ if (empty($machines))
+ return;
+ if ($action === Scheduler::ACTION_SHUTDOWN) {
+ RebootControl::execute($machines, RebootControl::SHUTDOWN, 0);
+ } elseif ($action === Scheduler::ACTION_WOL) {
+ RebootControl::wakeMachines($machines);
+ } elseif ($action === Scheduler::ACTION_REBOOT) {
+ RebootControl::execute($machines, RebootControl::REBOOT, 0);
+ } else {
+ EventLog::warning("Invalid action '$action' in schedule for location " . $locationId);
+ }
+ }
+ /**
+ * Get current settings for given location.
+ */
+ public static function getLocationOptions(int $id): array
+ {
+ static $optionList = false;
+ if ($optionList === false) {
+ $optionList = Database::queryKeyValueList("SELECT locationid, `options` FROM `reboot_scheduler`");
+ }
+ if (isset($optionList[$id])) {
+ return (json_decode($optionList[$id], true) ?? []) + self::SCHEDULE_OPTIONS_DEFAULT;
+ }
+ return self::SCHEDULE_OPTIONS_DEFAULT;
+ }
- private static function upsert($locationid, $action, $nextexec, $options) {
- $schedule = Database::queryFirst("SELECT locationid, action
- FROM `reboot_scheduler`
- WHERE locationid = :lid AND action = :act", array(
- 'lid' => $locationid,
- 'act' => $action
- ));
- if ($schedule === false) {
- Database::exec("INSERT INTO `reboot_scheduler` (locationid, action, nextexecution, options)
- VALUES (:lid, :act, :next, :opt)", array(
- 'lid' => $locationid,
- 'act' => $action,
- 'next' => $nextexec,
- 'opt' => $options
- ));
+ /**
+ * Write new WOL/Shutdown options for given location.
+ * @param array $options 'wol' 'sd' 'wol-offset' 'sd-offset' 'ra-mode'
+ */
+ public static function setLocationOptions(int $locationId, array $options)
+ {
+ $options += self::SCHEDULE_OPTIONS_DEFAULT;
+ $openingTimes = OpeningTimes::forLocation($locationId);
+ if (!$options['wol'] && !$options['sd'] && $options['ra-mode'] === self::RA_ALWAYS) {
+ self::deleteSchedule($locationId);
} else {
- Database::exec("UPDATE `reboot_scheduler`
- SET nextexecution = :next, options = :opt
- WHERE locationid = :lid AND action = :act", array(
- 'next' => $nextexec,
- 'opt' => $options,
- 'lid' => $locationid,
- 'act' => $action
- ));
- }
- return true;
+ // Sanitize
+ Util::clamp($options['wol-offset'], 0, 60);
+ Util::clamp($options['sd-offset'], 0, 60);
+ $json_options = json_encode($options);
+ // Write settings, reset schedule
+ Database::exec("INSERT INTO `reboot_scheduler` (locationid, action, nextexecution, options)
+ VALUES (:lid, :act, :next, :opt)
+ ON DUPLICATE KEY UPDATE
+ action = VALUES(action), nextexecution = VALUES(nextexecution), options = VALUES(options)", [
+ 'lid' => $locationId,
+ 'act' => 'WOL',
+ 'next' => 0,
+ 'opt' => $json_options,
+ ]);
+ // Write new timestamps for this location
+ if ($openingTimes !== null) {
+ self::updateScheduleSingle($locationId, $options, $openingTimes);
+ }
+ }
+ // In either case, refresh data for children as well
+ if ($openingTimes !== null) {
+ self::updateScheduleRecursive($locationId, $openingTimes);
+ }
+ }
+
+ /**
+ * Write next WOL/shutdown action to DB, using given options and opening times.
+ * @param int $locationid Location to store settings for
+ * @param array $options Options for calculation (WOL/Shutdown enabled, offsets)
+ * @param array $openingTimes Opening times to use
+ */
+ private static function updateScheduleSingle(int $locationid, array $options, array $openingTimes)
+ {
+ if (!$options['wol'] && !$options['sd'] && $options['ra-mode'] === self::RA_ALWAYS) {
+ self::deleteSchedule($locationid);
+ return;
+ }
+ $nextexec = self::calculateNext($options, $openingTimes);
+ if ($nextexec === false) {
+ // Empty opening times, or all intervals seem to be < 5 minutes, disable.
+ $nextexec = [
+ 'action' => 'WOL',
+ 'time' => 0,
+ ];
+ }
+ Database::exec("UPDATE reboot_scheduler
+ SET action = :act, nextexecution = :next
+ WHERE locationid = :lid", [
+ 'lid' => $locationid,
+ 'act' => $nextexec['action'],
+ 'next' => $nextexec['time'],
+ ]);
+ }
+
+ /**
+ * Recurse into all child locations of the given location-id and re-calculate the next
+ * WOL or shutdown event, based on the given opening times. Recursion stops at locations
+ * that come with their own opening times.
+ * @param int $parentId parent location to start recursion from. Not actually processed.
+ * @param array $openingTimes Opening times to use for calculations
+ */
+ private static function updateScheduleRecursive(int $parentId, array $openingTimes)
+ {
+ $list = Location::getLocationsAssoc();
+ if (!isset($list[$parentId]))
+ return;
+ $childIdList = $list[$parentId]['directchildren'];
+ if (empty($childIdList))
+ return;
+ $res = Database::simpleQuery("SELECT l.locationid, l.openingtime, rs.options
+ FROM location l
+ LEFT JOIN reboot_scheduler rs USING (locationid)
+ WHERE l.locationid IN (:list)", ['list' => $childIdList]);
+ $locationData = [];
+ foreach ($res as $row) {
+ $locationData[$row['locationid']] = $row;
+ }
+ // Handle all child locations
+ foreach ($childIdList as $locationId) {
+ if (!isset($locationData[$locationId]) || $locationData[$locationId]['openingtime'] !== null) {
+ continue; // Ignore entire sub-tree where new opening times are assigned
+ }
+ // This location doesn't have a new openingtimes schedule
+ // If any options are set for this location, update its schedule
+ if ($locationData[$locationId]['options'] !== null) {
+ $options = json_decode($locationData[$locationId]['options'], true);
+ if (!is_array($options)) {
+ trigger_error("Invalid options for lid:$locationId", E_USER_WARNING);
+ } else {
+ $options += self::SCHEDULE_OPTIONS_DEFAULT;
+ self::updateScheduleSingle($locationId, $options, $openingTimes);
+ }
+ }
+ // Either way, further walk down the tree
+ self::updateScheduleRecursive($locationId, $openingTimes);
+ }
+ }
+
+ public static function isValidRaMode(string $raMode): bool
+ {
+ return $raMode === self::RA_ALWAYS || $raMode === self::RA_NEVER || $raMode === self::RA_SELECTIVE;
}
} \ No newline at end of file
diff --git a/modules-available/rebootcontrol/inc/sshkey.inc.php b/modules-available/rebootcontrol/inc/sshkey.inc.php
index cce9b3dc..e0954415 100644
--- a/modules-available/rebootcontrol/inc/sshkey.inc.php
+++ b/modules-available/rebootcontrol/inc/sshkey.inc.php
@@ -3,13 +3,17 @@
class SSHKey
{
- public static function getPrivateKey(&$regen = false) {
+ public static function getPrivateKey(?bool &$regen = false): ?string
+ {
$privKey = Property::get("rebootcontrol-private-key");
if (!$privKey) {
- $rsaKey = openssl_pkey_new(array(
+ $rsaKey = openssl_pkey_new([
'private_key_bits' => 2048,
- 'private_key_type' => OPENSSL_KEYTYPE_RSA));
- openssl_pkey_export( openssl_pkey_get_private($rsaKey), $privKey);
+ 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
+ if (!openssl_pkey_export( openssl_pkey_get_private($rsaKey), $privKey)) {
+ $regen = false;
+ return null;
+ }
Property::set("rebootcontrol-private-key", $privKey);
if (Module::isAvailable('sysconfig')) {
ConfigTgz::rebuildAllConfigs();
@@ -19,21 +23,30 @@ class SSHKey
return $privKey;
}
- public static function getPublicKey() {
+ public static function getPublicKey(): ?string
+ {
$pkImport = openssl_pkey_get_private(self::getPrivateKey());
+ if ($pkImport === false)
+ return null;
return self::sshEncodePublicKey($pkImport);
}
- private static function sshEncodePublicKey($privKey) {
+ private static function sshEncodePublicKey($privKey): ?string
+ {
$keyInfo = openssl_pkey_get_details($privKey);
+ if ($keyInfo === false)
+ return null;
$buffer = pack("N", 7) . "ssh-rsa" .
self::sshEncodeBuffer($keyInfo['rsa']['e']) .
self::sshEncodeBuffer($keyInfo['rsa']['n']);
return "ssh-rsa " . base64_encode($buffer);
}
- private static function sshEncodeBuffer($buffer) {
+ private static function sshEncodeBuffer(string $buffer): string
+ {
$len = strlen($buffer);
+ // Prefix with extra null byte if the MSB is set, to ensure
+ // nobody will ever interpret this as a negative number
if (ord($buffer[0]) & 0x80) {
$len++;
$buffer = "\x00" . $buffer;
diff --git a/modules-available/rebootcontrol/install.inc.php b/modules-available/rebootcontrol/install.inc.php
index 7d4382d0..d45a2443 100644
--- a/modules-available/rebootcontrol/install.inc.php
+++ b/modules-available/rebootcontrol/install.inc.php
@@ -39,10 +39,10 @@ $output[] = tableCreate('reboot_subnet_x_subnet', "
$output[] = tableCreate('reboot_scheduler', "
`locationid` INT(11) NOT NULL,
- `action` ENUM('wol', 'sd'),
+ `action` ENUM('WOL', 'SHUTDOWN', 'REBOOT'),
`nextexecution` INT(10) UNSIGNED NOT NULL DEFAULT 0,
`options` BLOB,
- PRIMARY KEY (`locationid`, `action`)");
+ PRIMARY KEY (`locationid`)");
$output[] = tableAddConstraint('reboot_jumphost_x_subnet', 'hostid', 'reboot_jumphost', 'hostid',
'ON UPDATE CASCADE ON DELETE CASCADE');
@@ -55,4 +55,23 @@ $output[] = tableAddConstraint('reboot_subnet_x_subnet', 'dstid', 'reboot_subnet
$output[] = tableAddConstraint('reboot_scheduler', 'locationid', 'location', 'locationid',
'ON UPDATE CASCADE ON DELETE CASCADE');
+if (tableColumnKeyType('reboot_scheduler', 'action') === 'PRI') {
+ Database::exec("DELETE FROM reboot_scheduler WHERE action <> 'wol'");
+ $res = Database::exec("ALTER TABLE `reboot_scheduler` DROP PRIMARY KEY, ADD PRIMARY KEY (`locationid`)");
+ $output[] = $res !== false ? UPDATE_DONE : UPDATE_FAILED;
+}
+if (strpos(tableColumnType('reboot_scheduler', 'action'), 'REBOOT') === false) {
+ // Fiddle with column to rename ENUM values
+ $res = Database::exec("ALTER TABLE `reboot_scheduler` MODIFY COLUMN `action` ENUM('sd', 'rb', 'WOL', 'SHUTDOWN', 'REBOOT')");
+ handleUpdateResult($res);
+ $res = Database::exec("UPDATE reboot_scheduler SET action =
+ CASE WHEN action = 'sd' THEN 'SHUTDOWN' WHEN action = 'rb' THEN 'REBOOT' ELSE 'WOL' END");
+ handleUpdateResult($res);
+ $res = Database::exec("ALTER TABLE `reboot_scheduler` MODIFY COLUMN `action` ENUM('WOL', 'SHUTDOWN', 'REBOOT')");
+ handleUpdateResult($res);
+ $output[] = UPDATE_DONE;
+}
+
+
+
responseFromArray($output); \ No newline at end of file
diff --git a/modules-available/rebootcontrol/lang/de/permissions.json b/modules-available/rebootcontrol/lang/de/permissions.json
index fb32225d..589db5b9 100644
--- a/modules-available/rebootcontrol/lang/de/permissions.json
+++ b/modules-available/rebootcontrol/lang/de/permissions.json
@@ -2,6 +2,7 @@
"action.exec": "Befehle als root auf laufenden Clients ausf\u00fchren.",
"action.reboot": "Client neustarten.",
"action.shutdown": "Client herunterfahren.",
+ "action.view": "Laufende WOL\/Reboot\/Exec-Tasks sehen.",
"action.wol": "Client per WOL starten.",
"jumphost.assign-subnet": "Einem Sprung-Host ein Subnetz zuweisen.",
"jumphost.edit": "Einen Sprung-Host bearbeiten.",
@@ -11,4 +12,4 @@
"subnet.flag": "Eigenschaften eines Subnetzes bearbeiten.",
"subnet.view": "Liste der Subnetze sehen.",
"woldiscover": "Automatische Ermittlung von subnetz\u00fcbergreifender WOL-F\u00e4higkeit."
-}
+} \ No newline at end of file
diff --git a/modules-available/rebootcontrol/lang/de/template-tags.json b/modules-available/rebootcontrol/lang/de/template-tags.json
index 6b01dc1e..b54adbcd 100644
--- a/modules-available/rebootcontrol/lang/de/template-tags.json
+++ b/modules-available/rebootcontrol/lang/de/template-tags.json
@@ -13,6 +13,9 @@
"lang_clientCount": "# Clients",
"lang_confirmDeleteSubnet": "Dieses Subnetz wirklich l\u00f6schen?",
"lang_connecting": "Verbinde...",
+ "lang_directedBroadcastAddress": "Ziel-Adresse",
+ "lang_directedBroadcastDescription": "Diese Adresse wird als Ziel-Adresse zum Wecken s\u00e4mtlicher Clients benutzt. Dies ist bei Verwendung von WOL-Proxies sinnvoll. Wenn das Feld leer ist, wird die Directed Broadcast Adresse des Zielnetzes verwendet.",
+ "lang_directedBroadcastOverrideHeading": "Directed Broadcast Adresse \u00fcberschreiben",
"lang_editJumpHost": "Sprung-Host bearbeiten",
"lang_editSubnet": "Subnetz bearbeiten",
"lang_enterCommand": "Auszuf\u00fchrende Befehle",
@@ -20,7 +23,7 @@
"lang_execRemoteCommand": "Befehl auf Rechner(n) ausf\u00fchren",
"lang_executingRemotely": "F\u00fchre auf gew\u00e4hlten Clients aus...",
"lang_exitCode": "Exit Code",
- "lang_fixSubnetDesc": "Wenn aktiviert, wird die Erreichbarkeit f\u00fcr dieses Subnetz nicht mehr automatisch ermittelt. Sie k\u00f6nnen in diesem Fall selbst festlegen, ob das Subnetz WOL-Pakete vom Satelliten-Server empfangen kann. Au\u00dferdem wird das Subnetz bei Setzen dieser Option nicht mehr automatisch aus der Datenbank gel\u00f6scht, wenn 6 Monate lang kein Client in diesem Subnetz gesehen wurde.",
+ "lang_fixSubnetDesc": "Wenn aktiviert, wird die Erreichbarkeit f\u00fcr dieses Subnetz nicht mehr automatisch ermittelt. Sie k\u00f6nnen in diesem Fall selbst festlegen, ob das Subnetz WOL-Pakete vom Satellitenserver empfangen kann. Au\u00dferdem wird das Subnetz bei Setzen dieser Option nicht mehr automatisch aus der Datenbank gel\u00f6scht, wenn 6 Monate lang kein Client in diesem Subnetz gesehen wurde.",
"lang_fixSubnetSettings": "Subnetz-Einstellungen manuell festlegen",
"lang_genNew": "Neues Schl\u00fcsselpaar generieren",
"lang_help": "Hilfe",
@@ -31,7 +34,7 @@
"lang_hostReachable": "Host erreichbar",
"lang_ip": "IP",
"lang_isDirect": "Direkt erreichbar",
- "lang_isDirectHelp": "Dieses Subnetz kann WOL-Pakete direkt vom Satelliten-Server empfangen. Keine Sprung-Hosts oder laufende Clients im Zielnetz notwendig.",
+ "lang_isDirectHelp": "Dieses Subnetz kann WOL-Pakete direkt vom Satellitenserver empfangen. Keine Sprung-Hosts oder laufende Clients im Zielnetz notwendig.",
"lang_isFixed": "Manuell konfiguriert",
"lang_isFixedHelp": "Die direkte Erreichbarkeit vom Satelliten aus wird nicht periodisch automatisch ermittelt, sondern manuell \u00fcber die Weboberfl\u00e4che festgelegt.",
"lang_jumpHost": "Sprung-Host",
@@ -50,7 +53,7 @@
"lang_pubKey": "SSH Public Key:",
"lang_reachable": "Erreichbar",
"lang_reachableFrom": "Erreichbar von",
- "lang_reachableFromServer": "Erreichbar vom Satelliten-Server",
+ "lang_reachableFromServer": "Erreichbar vom Satellitenserver",
"lang_reachableFromServerDesc": "Wenn dieser Haken gesetzt ist wird angenommen, dass WOL-Pakete, die vom Server aus gesendet werden, dieses Subnetz erreichen k\u00f6nnen. Dazu muss der Router des Ziel-Netzes sog. \"Directed Broadcasts\" unterst\u00fctzen bzw. nicht filtern.",
"lang_rebootAt": "Neustart um:",
"lang_rebooting": "Neustart...",
@@ -64,16 +67,17 @@
"lang_stdout": "Standard-Output Ausgabe",
"lang_subnet": "Subnetz",
"lang_subnets": "Subnetze",
- "lang_subnetsDescription": "Dies sind dem Satelliten-Server bekannte Subnetze. Damit WOL \u00fcber Subnetz-Grenzen hinaus funktioniert, muss bekannt sein, in welche Netze \"Directed Broadcasts\" gesendet werden k\u00f6nnen, bzw. f\u00fcr welche Netze ein \"Sprung-Host\" existiert. Diese Liste wird sich automatisch f\u00fcllen, wenn Clients gestartet werden. Au\u00dferdem wird automatisch ermittelt, welche Netze mittels \"Directed Broadcasts\" erreichbar sind, sofern diese Funktion nicht oben unter \"Einstellungen\" deaktiviert wird.",
+ "lang_subnetsDescription": "Dies sind dem Satellitenserver bekannte Subnetze. Damit WOL \u00fcber Subnetz-Grenzen hinaus funktioniert, muss bekannt sein, in welche Netze \"Directed Broadcasts\" gesendet werden k\u00f6nnen, bzw. f\u00fcr welche Netze ein \"Sprung-Host\" existiert. Diese Liste wird sich automatisch f\u00fcllen, wenn Clients gestartet werden. Au\u00dferdem wird automatisch ermittelt, welche Netze mittels \"Directed Broadcasts\" erreichbar sind, sofern diese Funktion nicht oben unter \"Einstellungen\" deaktiviert wird.",
"lang_task": "Task",
"lang_taskListIntro": "Hier sehen Sie eine Liste k\u00fcrzlich gestarteter Aufgaben, wie z.B. WOL-Aktionen, das Neustarten oder Herunterfahren von Clients, etc.",
"lang_wakeScriptHelp": "Dieses Script wird auf dem Sprung-Host ausgef\u00fchrt, um den\/die gew\u00fcnschten Maschinen aufzuwecken. Es wird unter der Standard-Shell des oben angegebenen Benutzers ausgef\u00fchrt. Das Script kann zwei spezielle Platzhalter enthalten, die vor dem Ausf\u00fchren des Scripts vom Satellitenserver ersetzt werden: %MACS% ist eine durch Leerzeichen getrennte Liste von MAC-Adressen, die aufzuwecken sind. Das Tool \"wakeonlan\" unterst\u00fctzt direkt mehrere MAC-Adressen, sodass der Platzhalter %MACS% direkt als Kommandozeilenargument verwendet werden kann. Das Tool \"etherwake\" hingegen kann pro Aufruf immer nur einen Host aufwecken, weshalb eine for-Schleife notwendig ist. Au\u00dferdem wird der Platzhalter %IP% ersetzt, welcher je nach Ziel entweder \"255.255.255.255\" ist, oder bei einem netz\u00fcbergreifenden WOL-Paket die \"directed broadcast address\" des Zielnetzes. Netz\u00fcbergreifende WOL-Pakete werden vom \"etherwake\" nicht unterst\u00fctzt.",
"lang_wakeupScript": "Aufweck-Script",
+ "lang_when": "Wann",
"lang_wol": "WakeOnLAN",
"lang_wolAutoDiscoverCheck": "WOL-Erreichbarkeit von Subnetzen automatisch ermitteln",
"lang_wolDestPort": "UDP-Ziel-Port f\u00fcr Pakete, die vom Satelliten gesendet werden",
"lang_wolDiscoverClientToClient": "Auch Erreichbarkeit zwischen Client-Subnetzen pr\u00fcfen",
- "lang_wolDiscoverDescription": "Ist die erste Option aktiv, ermittelt der Satelliten-Server automatisch, welche Client-Subnetze direkt per \"Directed Broadcast\" erreichbar sind, unter Verwendung des oben angegebenen Ports. Ist die zweite Option aktiviert, wird zus\u00e4tzlich noch ermittelt, welche Client-Subnetze sich untereinander UDP-WOL-Pakete schicken k\u00f6nnen. Dies ist i.d.R. nicht notwendig, au\u00dfer in Setups mit ungew\u00f6hnlichen Firewall-Regelungen.",
+ "lang_wolDiscoverDescription": "Ist die erste Option aktiv, ermittelt der Satellitenserver automatisch, welche Client-Subnetze direkt per \"Directed Broadcast\" erreichbar sind, unter Verwendung des oben angegebenen Ports. Ist die zweite Option aktiviert, wird zus\u00e4tzlich noch ermittelt, welche Client-Subnetze sich untereinander UDP-WOL-Pakete schicken k\u00f6nnen. Dies ist i.d.R. nicht notwendig, au\u00dfer in Setups mit ungew\u00f6hnlichen Firewall-Regelungen.",
"lang_wolDiscoverHeading": "Automatische WOL-Ermittlung",
"lang_wolMachineSupportText": "Sind die technischen Voraussetzungen erf\u00fcllt, dass ein WOL-Paket den gew\u00fcnschten Rechner erreichen kann, ist es weiterhin erforderlich, dass der Rechner mittels BIOS und evtl. vorhandenem Betriebssystem so konfiguriert wird, dass er auch auf WOL-Pakete reagiert. Zum einen muss die Funktion im BIOS aktiviert sein. Hier ist auch darauf zu achten, ob es eine zus\u00e4tzliche Einstellung gibt, die die normale Bootreihenfolge \u00fcberschreibt, und dass diese wie gew\u00fcnscht konfiguriert wird. Ist WOL im BIOS aktiviert, kann das Betriebssystem die Funktionalit\u00e4t noch per Software ein- und ausschalten. Unter Windows erfolgt dies im Ger\u00e4temanager in den Eigenschaften der Netzwerkkarte. Dies ist relevant, wenn parallel zu bwLehrpool noch ein Windows von der lokaler Platte betrieben wird. Unter Linux kann die WOL-Funktion mit dem ethtool beeinflusst werden. bwLehrpool aktiviert WOL automatisch bei jedem Boot.",
"lang_wolReachability": "Erreichbarkeit",
diff --git a/modules-available/rebootcontrol/lang/en/permissions.json b/modules-available/rebootcontrol/lang/en/permissions.json
index f5144d18..b925c2b2 100644
--- a/modules-available/rebootcontrol/lang/en/permissions.json
+++ b/modules-available/rebootcontrol/lang/en/permissions.json
@@ -2,6 +2,7 @@
"action.exec": "Execute commands on clients (as root).",
"action.reboot": "Reboot Client.",
"action.shutdown": "Shutdown Client.",
+ "action.view": "See running WOL\/reboot\/exec jobs.",
"action.wol": "Send WOL packet to client.",
"jumphost.assign-subnet": "Assign subnet to jump host.",
"jumphost.edit": "Edit jump host.",
diff --git a/modules-available/rebootcontrol/lang/en/template-tags.json b/modules-available/rebootcontrol/lang/en/template-tags.json
index a50ba7fe..5740b208 100644
--- a/modules-available/rebootcontrol/lang/en/template-tags.json
+++ b/modules-available/rebootcontrol/lang/en/template-tags.json
@@ -13,6 +13,9 @@
"lang_clientCount": "# clients",
"lang_confirmDeleteSubnet": "Delete this subnet?",
"lang_connecting": "Connecting...",
+ "lang_directedBroadcastAddress": "Destination address",
+ "lang_directedBroadcastDescription": "This address will be used as the destination address for wake on LAN. This is useful if you want to use a WOL-proxy that does the actual waking. If you leave this field empty, the broadcast address of the destination network will be used.",
+ "lang_directedBroadcastOverrideHeading": "Override directed broadcast address",
"lang_editJumpHost": "Edit jump host",
"lang_editSubnet": "Edit subnet",
"lang_enterCommand": "Command(s) to execute",
@@ -69,11 +72,12 @@
"lang_taskListIntro": "This is a list of running or recently finished tasks (WOL, Shutdown, Reboot, ...).",
"lang_wakeScriptHelp": "This script will be executed on the jump host to wake up the selected machines. If will be executed using the default shell of the host. There are two special placeholders which will be replaced before executing the script on the host: %MACS% will be a space separated list of mac addresses to wake up. %IP% is either the directed broadcast address of the destination subnet, or simply 255.255.255.255, if the destination subnet is the same as the jump host's address.",
"lang_wakeupScript": "Wake script",
+ "lang_when": "When",
"lang_wol": "WakeOnLAN",
"lang_wolAutoDiscoverCheck": "Automatically determine WOL-reachability of subnets",
"lang_wolDestPort": "UDP destination port for packets sent from satellite",
"lang_wolDiscoverClientToClient": "Check reachability between different client subnets too",
- "lang_wolDiscoverDescription": "If the first option is active, the Satellite Server automatically determines which client subnets are directly reachable via \"Directed Broadcasts\", using the port specified above. If the second option is enabled, the satellite server also determines which client subnets can send UDP WOL packets to each other. This is usually not necessary, except in setups with unusual firewall rules. ",
+ "lang_wolDiscoverDescription": "If the first option is active, the satellite server automatically determines which client subnets are directly reachable via \"Directed Broadcasts\", using the port specified above. If the second option is enabled, the satellite server also determines which client subnets can send UDP WOL packets to each other. This is usually not necessary, except in setups with unusual firewall rules. ",
"lang_wolDiscoverHeading": "Automatic WOL detection",
"lang_wolMachineSupportText": "If the technical requirements for reaching a destination host with WOL packets are met, it is still required to enable wake on LAN on the client computer. This usually happens in the BIOS, where sometimes it is also possible to set a different boot order for when the machine was powered on via WOL. After enabling WOL in the BIOS, it is still possible to disable WOL in the driver after booting up an Operating System. In Windows, this can be configured in the device manager, which would be relevant if bwLehrpool is being used in a dual-boot environment. On Linux you can change this setting with the ethtool utility. bwLehrpool (re-)enables WOL on every boot.",
"lang_wolReachability": "Reachability",
diff --git a/modules-available/rebootcontrol/page.inc.php b/modules-available/rebootcontrol/page.inc.php
index 571e92e0..80eff842 100644
--- a/modules-available/rebootcontrol/page.inc.php
+++ b/modules-available/rebootcontrol/page.inc.php
@@ -21,10 +21,10 @@ class Page_RebootControl extends Page
}
if (User::hasPermission('jumphost.*')) {
- Dashboard::addSubmenu('?do=rebootcontrol&show=jumphost', Dictionary::translate('jumphosts', true));
+ Dashboard::addSubmenu('?do=rebootcontrol&show=jumphost', Dictionary::translate('jumphosts'));
}
if (User::hasPermission('subnet.*')) {
- Dashboard::addSubmenu('?do=rebootcontrol&show=subnet', Dictionary::translate('subnets', true));
+ Dashboard::addSubmenu('?do=rebootcontrol&show=subnet', Dictionary::translate('subnets'));
}
$section = Request::any('show', false, 'string');
@@ -48,9 +48,11 @@ class Page_RebootControl extends Page
$enabled = Request::post('enabled', false);
$c2c = Request::post('enabled-c2c', false);
$port = Request::post('port', 9, 'int');
+ $dbcast = Request::post('dbcast', '', 'string');
Property::set(RebootControl::KEY_AUTOSCAN_DISABLED, !$enabled);
Property::set(RebootControl::KEY_SCAN_CLIENT_TO_CLIENT, $c2c);
Property::set(RebootControl::KEY_UDP_PORT, $port);
+ Property::set(RebootControl::KEY_BROADCAST_ADDRESS, $dbcast);
if ($enabled) {
Message::addInfo('woldiscover-enabled');
} else {
@@ -63,7 +65,13 @@ class Page_RebootControl extends Page
if (Request::isPost()) {
Util::redirect('?do=rebootcontrol' . ($section ? '&show=' . $section : ''));
} elseif ($section === false) {
- Util::redirect('?do=rebootcontrol&show=task');
+ if (User::hasPermission('action.*')) {
+ Util::redirect('?do=rebootcontrol&show=task');
+ } elseif (User::hasPermission('jumphost.*')) {
+ Util::redirect('?do=rebootcontrol&show=jumphost');
+ } else {
+ Util::redirect('?do=rebootcontrol&show=subnet');
+ }
}
}
@@ -93,7 +101,6 @@ class Page_RebootControl extends Page
if (Taskmanager::isTask($task)) {
Util::redirect("?do=rebootcontrol&show=task&what=task&taskid=" . $task["id"]);
}
- return;
}
/**
@@ -112,13 +119,13 @@ class Page_RebootControl extends Page
'wol_auto_checked' => Property::get(RebootControl::KEY_AUTOSCAN_DISABLED) ? '' : 'checked',
'wol_c2c_checked' => Property::get(RebootControl::KEY_SCAN_CLIENT_TO_CLIENT) ? 'checked' : '',
'port' => $port,
+ 'dbcast' => Property::get(RebootControl::KEY_BROADCAST_ADDRESS),
];
Permission::addGlobalTags($data['perms'], null, ['newkeypair', 'woldiscover']);
Render::addTemplate('header', $data);
if ($this->haveSubpage) {
SubPage::doRender();
- return;
}
}
diff --git a/modules-available/rebootcontrol/pages/exec.inc.php b/modules-available/rebootcontrol/pages/exec.inc.php
index e5fe3cd8..6b5ea407 100644
--- a/modules-available/rebootcontrol/pages/exec.inc.php
+++ b/modules-available/rebootcontrol/pages/exec.inc.php
@@ -46,7 +46,6 @@ class SubPage
return;
}
Session::set('exec-' . $id, false);
- Session::save();
Render::addTemplate('exec-enter-command', ['clients' => $machines, 'id' => $id]);
}
diff --git a/modules-available/rebootcontrol/pages/jumphost.inc.php b/modules-available/rebootcontrol/pages/jumphost.inc.php
index bf0a67e2..d9aae234 100644
--- a/modules-available/rebootcontrol/pages/jumphost.inc.php
+++ b/modules-available/rebootcontrol/pages/jumphost.inc.php
@@ -32,7 +32,6 @@ class SubPage
if ($id !== false) {
User::assertPermission('jumphost.edit');
self::deleteJumphost($id);
- return;
}
}
@@ -135,7 +134,7 @@ class SubPage
LEFT JOIN reboot_jumphost_x_subnet jxs USING (hostid)
GROUP BY hostid
ORDER BY hostid');
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$hosts[] = $row;
}
$data = [
@@ -188,7 +187,7 @@ class SubPage
ORDER BY start ASC',
['id' => $id]);
$list = [];
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$row['cidr'] = IpUtil::rangeToCidr($row['start'], $row['end']);
if ($row['hostid'] !== null) {
$row['checked'] = 'checked';
diff --git a/modules-available/rebootcontrol/pages/subnet.inc.php b/modules-available/rebootcontrol/pages/subnet.inc.php
index cbd5d8f2..a6d8d837 100644
--- a/modules-available/rebootcontrol/pages/subnet.inc.php
+++ b/modules-available/rebootcontrol/pages/subnet.inc.php
@@ -24,7 +24,7 @@ class SubPage
User::assertPermission('subnet.edit');
$cidr = Request::post('cidr', Request::REQUIRED, 'string');
$range = IpUtil::parseCidr($cidr);
- if ($range === false) {
+ if ($range === null) {
Message::addError('invalid-cidr', $cidr);
return;
}
@@ -106,6 +106,7 @@ class SubPage
{
User::assertPermission('subnet.*');
$nets = [];
+ $c2c = Property::get(RebootControl::KEY_SCAN_CLIENT_TO_CLIENT);
$res = Database::simpleQuery('SELECT subnetid, start, end, fixed, isdirect,
nextdirectcheck, lastseen, seencount, Count(hxs.hostid) AS jumphostcount, Count(sxs.srcid) AS sourcecount
FROM reboot_subnet s
@@ -114,12 +115,15 @@ class SubPage
GROUP BY subnetid, start, end
ORDER BY start ASC, end DESC');
$deadline = strtotime('-60 days');
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$row['cidr'] = IpUtil::rangeToCidr($row['start'], $row['end']);
$row['lastseen_s'] = Util::prettyTime($row['lastseen']);
if ($row['lastseen'] && $row['lastseen'] < $deadline) {
$row['lastseen_class'] = 'text-danger';
}
+ if (!$c2c) {
+ $row['sourcecount'] = '-';
+ }
$nets[] = $row;
}
$data = ['subnets' => $nets];
@@ -145,20 +149,24 @@ class SubPage
ORDER BY h.host ASC', ['id' => $id]);
// Mark those assigned to the current subnet
$jh = [];
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$row['checked'] = $row['subnetid'] === null ? '' : 'checked';
$jh[] = $row;
}
$subnet['jumpHosts'] = $jh;
- // Get list of all subnets that can broadcast into this one
- $res = Database::simpleQuery('SELECT s.start, s.end FROM reboot_subnet s
+ $c2c = Property::get(RebootControl::KEY_SCAN_CLIENT_TO_CLIENT);
+ if ($c2c) {
+ // Get list of all subnets that can broadcast into this one
+ $res = Database::simpleQuery('SELECT s.start, s.end FROM reboot_subnet s
INNER JOIN reboot_subnet_x_subnet sxs ON (s.subnetid = sxs.srcid AND sxs.dstid = :id AND sxs.reachable = 1)
ORDER BY s.start ASC', ['id' => $id]);
- $sn = [];
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- $sn[] = ['cidr' => IpUtil::rangeToCidr($row['start'], $row['end'])];
+ $sn = [];
+ foreach ($res as $row) {
+ $sn[] = ['cidr' => IpUtil::rangeToCidr($row['start'], $row['end'])];
+ }
+ $subnet['sourceNets'] = $sn;
+ $subnet['showC2C'] = true;
}
- $subnet['sourceNets'] = $sn;
Permission::addGlobalTags($subnet['perms'], null, ['subnet.flag', 'jumphost.view', 'jumphost.assign-subnet']);
Render::addTemplate('subnet-edit', $subnet);
}
diff --git a/modules-available/rebootcontrol/pages/task.inc.php b/modules-available/rebootcontrol/pages/task.inc.php
index 691fd9e2..7db2a90b 100644
--- a/modules-available/rebootcontrol/pages/task.inc.php
+++ b/modules-available/rebootcontrol/pages/task.inc.php
@@ -26,6 +26,8 @@ class SubPage
private static function showTask()
{
+ // No permission check here - user had to guess the UUID, not very likely,
+ // but this way we can still link to some implicitly triggered job
$taskid = Request::get("taskid", Request::REQUIRED, 'string');
$type = Request::get('type', false, 'string');
if ($type === 'checkhost') {
@@ -93,8 +95,9 @@ class SubPage
} elseif ($type === RebootControl::TASK_WOL) {
// Nothing (yet)
} else {
- Util::traceError('oopsie');
+ ErrorHandler::traceError('oopsie');
}
+ $job['timestamp_s'] = Util::prettyTime($job['timestamp']);
Render::addTemplate('status-' . $template, $job);
}
@@ -103,6 +106,9 @@ class SubPage
Render::addTemplate('task-header');
// Append list of active reboot/shutdown tasks
$allowedLocs = User::getAllowedLocations("action.*");
+ if (empty($allowedLocs)) {
+ User::assertPermission('action.*');
+ }
$active = RebootControl::getActiveTasks($allowedLocs);
if (empty($active)) {
Message::addInfo('no-current-tasks');
@@ -112,8 +118,10 @@ class SubPage
if (isset($entry['clients'])) {
$entry['clients'] = count($entry['clients']);
}
+ $entry['timestamp_s'] = Util::prettyTime($entry['timestamp']);
}
unset($entry);
+ ArrayUtil::sortByColumn($active, 'timestamp', SORT_ASC, SORT_NUMERIC);
Render::addTemplate('task-list', ['list' => $active]);
}
}
@@ -121,7 +129,7 @@ class SubPage
private static function expandLocationIds(&$lids)
{
foreach ($lids as &$locid) {
- if ($locid === 0) {
+ if ($locid === null || $locid === 0) {
$name = '-';
} else {
$name = Location::getName($locid);
@@ -137,14 +145,3 @@ class SubPage
}
}
-
-
-// Remove when we require >= 7.3.0
-if (!function_exists('array_key_first')) {
- function array_key_first(array $arr) {
- foreach($arr as $key => $unused) {
- return $key;
- }
- return NULL;
- }
-}
diff --git a/modules-available/rebootcontrol/permissions/permissions.json b/modules-available/rebootcontrol/permissions/permissions.json
index 5416a482..a508f8b6 100644
--- a/modules-available/rebootcontrol/permissions/permissions.json
+++ b/modules-available/rebootcontrol/permissions/permissions.json
@@ -34,5 +34,8 @@
},
"action.exec": {
"location-aware": true
+ },
+ "action.view": {
+ "location-aware": true
}
} \ No newline at end of file
diff --git a/modules-available/rebootcontrol/templates/header.html b/modules-available/rebootcontrol/templates/header.html
index d5e79a14..47d97714 100644
--- a/modules-available/rebootcontrol/templates/header.html
+++ b/modules-available/rebootcontrol/templates/header.html
@@ -42,28 +42,37 @@
</div>
<div class="modal-body">
<label>{{lang_wolDiscoverHeading}}</label>
- <div class="checkbox">
- <input {{perms.woldiscover.disabled}} id="wol-auto-discover"
- type="checkbox" name="enabled" {{wol_auto_checked}}>
- <label for="wol-auto-discover">{{lang_wolAutoDiscoverCheck}}</label>
- </div>
- <div class="checkbox">
- <input {{perms.woldiscover.disabled}} id="wol-c2c"
- type="checkbox" name="enabled-c2c" {{wol_c2c_checked}}>
- <label for="wol-c2c">{{lang_wolDiscoverClientToClient}}</label>
- </div>
- <div class="slx-space"></div>
- <p>{{lang_wolDiscoverDescription}}</p>
- <button {{perms.woldiscover.disabled}} class="btn btn-primary pull-right"
- onclick="generateNewKeypair()" type="submit">
- <span class="glyphicon glyphicon-floppy-disk"></span>
- {{lang_save}}
- </button>
- <div class="clearfix"></div>
+ <div class="checkbox">
+ <input {{perms.woldiscover.disabled}} id="wol-auto-discover"
+ type="checkbox" name="enabled" {{wol_auto_checked}}>
+ <label for="wol-auto-discover">{{lang_wolAutoDiscoverCheck}}</label>
+ </div>
+ <div class="checkbox">
+ <input {{perms.woldiscover.disabled}} id="wol-c2c"
+ type="checkbox" name="enabled-c2c" {{wol_c2c_checked}}>
+ <label for="wol-c2c">{{lang_wolDiscoverClientToClient}}</label>
+ </div>
+ <div class="slx-space"></div>
+ <p>{{lang_wolDiscoverDescription}}</p>
+ </div>
+ <div class="modal-body">
+ <label for="bcast-input">{{lang_directedBroadcastOverrideHeading}}</label>
+ <div class="input-group">
+ <span class="input-group-addon">{{lang_directedBroadcastAddress}}</span>
+ <input {{perms.woldiscover.disabled}} type="text" pattern="[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+"
+ minlength="7" maxlength="15" class="form-control" name="dbcast" value="{{dbcast}}" id="bcast-input">
+ </div>
+ <p>{{lang_directedBroadcastDescription}}</p>
+ </div>
+ <div class="modal-body">
+ <button {{perms.woldiscover.disabled}} class="btn btn-primary pull-right"
+ onclick="generateNewKeypair()" type="submit">
+ <span class="glyphicon glyphicon-floppy-disk"></span>
+ {{lang_save}}
+ </button>
+ <div class="clearfix"></div>
</div>
</form>
- <div class="modal-body">
- </div>
</div>
</div>
</div>
@@ -98,4 +107,4 @@ document.addEventListener('DOMContentLoaded', function() {
});
-</script> \ No newline at end of file
+</script>
diff --git a/modules-available/rebootcontrol/templates/status-checkconnection.html b/modules-available/rebootcontrol/templates/status-checkconnection.html
index e31d95ea..da1177e7 100644
--- a/modules-available/rebootcontrol/templates/status-checkconnection.html
+++ b/modules-available/rebootcontrol/templates/status-checkconnection.html
@@ -1,4 +1,4 @@
-<h3>{{lang_checkingJumpHost}}: {{host}}</h3>
+<h3>{{lang_checkingJumpHost}}: {{host}} – {{timestamp_s}}</h3>
<div class="clearfix"></div>
<div class="collapse alert alert-success" id="result-ok">
diff --git a/modules-available/rebootcontrol/templates/status-exec.html b/modules-available/rebootcontrol/templates/status-exec.html
index 403b7fca..a3efef5f 100644
--- a/modules-available/rebootcontrol/templates/status-exec.html
+++ b/modules-available/rebootcontrol/templates/status-exec.html
@@ -1,3 +1,5 @@
+<h3>{{timestamp_s}}</h3>
+
<div data-tm-id="{{id}}" data-tm-log="error" data-tm-callback="updateStatus">{{lang_executingRemotely}}</div>
<div class="slx-space"></div>
@@ -14,7 +16,11 @@
{{#clients}}
<div class="list-group-item" id="client-{{machineuuid}}">
<div class="row">
- <div class="col-md-6 col-sm-8 col-xs-12 slx-bold">{{hostname}}{{^hostname}}{{clientip}}{{/hostname}}</div>
+ <div class="col-md-6 col-sm-8 col-xs-12 slx-bold">
+ <a href="?do=statistics&amp;uuid={{machineuuid}}">
+ {{hostname}}{{^hostname}}{{clientip}}{{/hostname}}
+ </a>
+ </div>
<div class="col-md-4 col-sm-2 col-xs-6 state"></div>
<div class="col-md-2 col-sm-2 col-xs-6 text-right exitCode"></div>
</div>
@@ -57,6 +63,13 @@ function updateStatusClient(id, status) {
$p.find('.exitCode').text(status.exitCode).addClass((status.exitCode === 0 ? 'text-success' : 'text-danger'));
}
ignoreHosts[id] = true;
+ var txt = status.stdout.trim();
+ if (txt.startsWith('<') && txt.endsWith('</svg>')) {
+ var $i = $('<img class="img-responsive">');
+ $i[0].src = 'data:image/svg+xml,' + encodeURIComponent(txt);
+ $p.find('.stdout').hide();
+ $p.append($i);
+ }
}
}
diff --git a/modules-available/rebootcontrol/templates/status-reboot.html b/modules-available/rebootcontrol/templates/status-reboot.html
index 7b46cab4..34971845 100644
--- a/modules-available/rebootcontrol/templates/status-reboot.html
+++ b/modules-available/rebootcontrol/templates/status-reboot.html
@@ -1,4 +1,5 @@
-<h3>{{action}}</h3>
+<h3>{{action}} – {{timestamp_s}}</h3>
+
{{#locations}}
<div class="loc">{{name}}</div>
{{/locations}}
@@ -18,7 +19,7 @@
<tbody>
{{#clients}}
<tr>
- <td>{{hostname}}{{^hostname}}{{machineuuid}}{{/hostname}}</td>
+ <td><a href="?do=statistics&amp;uuid={{machineuuid}}">{{hostname}}{{^hostname}}{{machineuuid}}{{/hostname}}</a></td>
<td>{{clientip}}</td>
<td>
<span id="status-{{machineuuid}}" class="machineuuid" data-uuid="{{machineuuid}}"></span>
diff --git a/modules-available/rebootcontrol/templates/status-wol.html b/modules-available/rebootcontrol/templates/status-wol.html
index 5a53a6f8..70517f84 100644
--- a/modules-available/rebootcontrol/templates/status-wol.html
+++ b/modules-available/rebootcontrol/templates/status-wol.html
@@ -1,10 +1,12 @@
+<h3>{{timestamp_s}}</h3>
+
{{#locations}}
<div class="loc">{{name}}</div>
{{/locations}}
<div class="clearfix slx-space"></div>
{{#tasks}}
-<div data-tm-id="{{.}}" data-tm-callback="wolCallback" data-tm-log="messages" data-tm-log-fail-only="true">{{lang_aWolJob}}</div>
+<div data-tm-id="{{.}}" data-tm-callback="wolCallback" data-tm-log="messages">{{lang_aWolJob}}</div>
{{/tasks}}
{{^tasks}}
<div class="alert alert-warning">
@@ -29,17 +31,23 @@
<tbody>
{{#clients}}
<tr>
- <td>{{hostname}}{{^hostname}}{{machineuuid}}{{^machineuuid}}{{clientip}}{{/machineuuid}}{{/hostname}}</td>
- <td>{{clientip}}</td>
+ <td>
{{#machineuuid}}
+ <a href="?do=statistics&amp;uuid={{machineuuid}}">
+ {{hostname}}{{^hostname}}{{machineuuid}}{{/hostname}}
+ </a>
+ {{/machineuuid}}
+ {{^machineuuid}}
+ {{hostname}}{{^hostname}}{{clientip}}{{/hostname}}
+ {{/machineuuid}}
+ </td>
+ <td>{{clientip}}</td>
<td>
+ {{#machineuuid}}
<span id="status-{{machineuuid}}" class="machineuuid" data-uuid="{{machineuuid}}"></span>
<span id="spinner-{{machineuuid}}" class="glyphicon glyphicon-refresh slx-rotation"></span>
- </td>
- {{/machineuuid}}
- {{^machineuuid}}
- <td></td>
{{/machineuuid}}
+ </td>
</tr>
{{/clients}}
</tbody>
@@ -48,7 +56,7 @@
<a class="text-muted" href="#debug-out" data-toggle="collapse">Debug</a>
<pre id="debug-out" class="collapse"></pre>
-<script><!--
+<script>
function wolCallback(task) {
if (task.statusCode === 'TASK_WAITING' || task.statusCode === 'TASK_PROCESSING') {
stillActive = 25;
@@ -71,4 +79,4 @@ function wolCallback(task) {
$do.text(txt);
}
}
-//--></script> \ No newline at end of file
+</script>
diff --git a/modules-available/rebootcontrol/templates/subnet-edit.html b/modules-available/rebootcontrol/templates/subnet-edit.html
index 5a6adf3c..570865c7 100644
--- a/modules-available/rebootcontrol/templates/subnet-edit.html
+++ b/modules-available/rebootcontrol/templates/subnet-edit.html
@@ -39,12 +39,14 @@
</div>
{{/jumpHosts}}
</div>
+ {{#showC2C}}
<div class="list-group-item">
<label>{{lang_reachableFrom}}</label>
{{#sourceNets}}
<div>{{cidr}}</div>
{{/sourceNets}}
</div>
+ {{/showC2C}}
</div>
<div class="panel-footer text-right">
<button type="submit" class="btn btn-danger" name="action" value="delete"
diff --git a/modules-available/rebootcontrol/templates/subnet-list.html b/modules-available/rebootcontrol/templates/subnet-list.html
index 8ecf66b4..2bc9208f 100644
--- a/modules-available/rebootcontrol/templates/subnet-list.html
+++ b/modules-available/rebootcontrol/templates/subnet-list.html
@@ -30,7 +30,7 @@
<td class="text-center">{{#fixed}}<span class="glyphicon glyphicon-lock"></span>{{/fixed}}</td>
<td class="text-center">{{#isdirect}}<span class="glyphicon glyphicon-ok"></span>{{/isdirect}}</td>
<td class="text-right"><span class="badge">{{jumphostcount}}</span> / <span class="badge">{{sourcecount}}</span></td>
- <td class="{{lastseen_class}}">{{lastseen_s}}</td>
+ <td class="{{lastseen_class}} text-nowrap">{{lastseen_s}}</td>
</tr>
{{/subnets}}
</tbody>
diff --git a/modules-available/rebootcontrol/templates/task-list.html b/modules-available/rebootcontrol/templates/task-list.html
index 5ab75675..dcb04450 100644
--- a/modules-available/rebootcontrol/templates/task-list.html
+++ b/modules-available/rebootcontrol/templates/task-list.html
@@ -2,6 +2,7 @@
<table class="table">
<thead>
<tr>
+ <th>{{lang_when}}</th>
<th>{{lang_task}}</th>
<th>{{lang_location}}</th>
<th>{{lang_clientCount}}</th>
@@ -12,6 +13,9 @@
{{#list}}
<tr>
<td class="text-nowrap">
+ {{timestamp_s}}
+ </td>
+ <td class="text-nowrap">
<a href="?do=rebootcontrol&amp;show=task&amp;what=task&amp;taskid={{id}}">{{type}}</a>
<div class="small">{{action}}</div>
</td>
diff --git a/modules-available/remoteaccess/api.inc.php b/modules-available/remoteaccess/api.inc.php
index 2e1e4bf9..c558d126 100644
--- a/modules-available/remoteaccess/api.inc.php
+++ b/modules-available/remoteaccess/api.inc.php
@@ -5,27 +5,47 @@ if (substr($ip, 0, 7) === '::ffff:') $ip = substr($ip, 7);
$password = Request::post('password', false, 'string');
if ($password !== false) {
- $c = Database::queryFirst("SELECT machineuuid FROM machine WHERE clientip = :ip", ['ip' => $ip]);
+ $c = Database::queryFirst("SELECT machineuuid FROM machine
+ WHERE clientip = :ip
+ ORDER BY lastseen DESC
+ LIMIT 1", ['ip' => $ip]);
if ($c !== false) {
- Database::exec("INSERT INTO remoteaccess_machine (machineuuid, password)
- VALUES (:uuid, :passwd)
- ON DUPLICATE KEY UPDATE password = VALUES(password)", ['uuid' => $c['machineuuid'], 'passwd' => $password]);
+ $vncport = Request::post('vncport', 5900, 'int');
+ Database::exec("INSERT INTO remoteaccess_machine (machineuuid, password, vncport)
+ VALUES (:uuid, :passwd, :vncport)
+ ON DUPLICATE KEY UPDATE
+ password = VALUES(password), vncport = VALUES(vncport)",
+ ['uuid' => $c['machineuuid'], 'passwd' => $password, 'vncport' => $vncport]);
}
exit;
}
$range = IpUtil::parseCidr(Property::get(RemoteAccess::PROP_ALLOWED_VNC_NET));
-if ($range === false) {
+if ($range === null) {
die('No allowed IP defined');
}
$iplong = ip2long($ip);
-if (PHP_INT_SIZE === 4) {
- $iplong = sprintf('%u', $iplong);
-}
if ($iplong < $range['start'] || $iplong > $range['end']) {
die('Access denied');
}
+$headers = getallheaders();
+$version = false;
+if (!empty($headers['Bwlp-Plugin-Build-Revision'])) {
+ $version = substr($headers['Bwlp-Plugin-Build-Revision'], 0, 6);
+ if (!empty($headers['Bwlp-Plugin-Build-Timestamp'])) {
+ $ts = $headers['Bwlp-Plugin-Build-Timestamp'];
+ if (is_numeric($ts)) {
+ if ($ts > 9999999999) {
+ $ts = round($ts / 1000);
+ }
+ $ts = date('d.m.Y H:i', $ts);
+ }
+ $version .= ' (' . $ts . ')';
+ }
+}
+Property::set(RemoteAccess::PROP_PLUGIN_VERSION, $version, 2880);
+
Header('Content-Type: application/json');
$remoteLocations = RemoteAccess::getEnabledLocations();
@@ -35,7 +55,7 @@ if (empty($remoteLocations)) {
} else {
// TODO fail-counter for WOL, so we can ignore machines that apparently can't be woken up
// -> Reset counter in our ~poweron hook, but only if the time roughly matches a WOL attempt (within ~5 minutes)
- $rows = Database::queryAll("SELECT m.clientip, m.locationid, m.state, ram.password, ram.woltime FROM machine m
+ $rows = Database::queryAll("SELECT m.clientip, m.locationid, m.state, ram.password, ram.vncport, ram.woltime FROM machine m
LEFT JOIN remoteaccess_machine ram ON (ram.machineuuid = m.machineuuid AND (ram.password IS NOT NULL OR m.state <> 'IDLE'))
LEFT JOIN runmode r ON (r.machineuuid = m.machineuuid)
WHERE m.locationid IN (:locs)
@@ -48,6 +68,7 @@ if (empty($remoteLocations)) {
$row['wol_in_progress'] = true;
}
settype($row['locationid'], 'int');
+ settype($row['vncport'], 'int');
unset($row['woltime']);
}
}
diff --git a/modules-available/remoteaccess/baseconfig/getconfig.inc.php b/modules-available/remoteaccess/baseconfig/getconfig.inc.php
index 3c849b45..182daef1 100644
--- a/modules-available/remoteaccess/baseconfig/getconfig.inc.php
+++ b/modules-available/remoteaccess/baseconfig/getconfig.inc.php
@@ -1,31 +1,50 @@
<?php
-(function ($machineUuid) {
+/** @var ?string $uuid */
+/** @var ?string $ip */
+
+if ($uuid !== null) {
// Leave clients in any runmode alone
$res = Database::queryFirst('SELECT machineuuid FROM runmode WHERE machineuuid = :uuid',
- array('uuid' => $machineUuid), true);
+ ['uuid' => $uuid], true);
if (is_array($res))
return;
// Locations from closest to furthest (order)
$locationId = ConfigHolder::get('SLX_LOCATIONS');
- if ($locationId !== false) {
- $locationId = (int)$locationId;
- $ret = Database::queryFirst("SELECT l.locationid FROM remoteaccess_x_location l
- INNER JOIN remoteaccess_group g USING (groupid)
- WHERE locationid = :lid AND g.active = 1",
- ['lid' => $locationId], true); // TODO Remove true after next point release (2020-05-12)
- if ($ret !== false) {
- // TODO Properly merge
- if (Property::get(RemoteAccess::PROP_TRY_VIRT_HANDOVER)) {
- ConfigHolder::add("SLX_REMOTE_VNC", 'vmware virtualbox');
- } else {
- ConfigHolder::add("SLX_REMOTE_VNC", 'x11vnc');
- }
- ConfigHolder::add("SLX_REMOTE_HOST_ACCESS", Property::get(RemoteAccess::PROP_ALLOWED_VNC_NET));
- ConfigHolder::add('SLX_RUNMODE_MODULE', 'remoteaccess');
- // No saver
- ConfigHolder::add('SLX_SCREEN_SAVER_TIMEOUT', '0', 1000);
- }
+ if ($locationId === null)
+ return;
+ $locationId = (int)$locationId;
+ $ret = Database::queryFirst("SELECT l.locationid FROM remoteaccess_x_location l
+ INNER JOIN remoteaccess_group g USING (groupid)
+ WHERE locationid = :lid AND g.active = 1",
+ ['lid' => $locationId], true); // TODO Remove true after next point release (2020-05-12)
+ if ($ret === false)
+ return;
+ // Special case – location admin can limit accessibility of this machine to never, or only when room is closed
+ $opts = Scheduler::getLocationOptions($locationId);
+ if ($opts['ra-mode'] === Scheduler::RA_NEVER)
+ return; // Completely disallowed
+ if ($opts['ra-mode'] === Scheduler::RA_SELECTIVE) {
+ // Only when room is closed
+ if (OpeningTimes::isRoomOpen($locationId, $opts['wol-offset'], $opts['sd-offset']))
+ return; // Open, do not interfere with ongoing lectures etc., do nothing
+ }
+ // TODO Properly merge
+ if (Property::get(RemoteAccess::PROP_TRY_VIRT_HANDOVER)) {
+ ConfigHolder::add("SLX_REMOTE_VNC", 'vmware virtualbox');
+ } else {
+ ConfigHolder::add("SLX_REMOTE_VNC", 'x11vnc');
+ }
+ ConfigHolder::add("SLX_REMOTE_HOST_ACCESS", Property::get(RemoteAccess::PROP_ALLOWED_VNC_NET));
+ ConfigHolder::add('SLX_REMOTE_VNC_PORT', Property::get(RemoteAccess::PROP_VNC_PORT, 5900));
+ ConfigHolder::add('SLX_RUNMODE_MODULE', 'remoteaccess');
+ // No saver
+ $saverTimeout = ConfigHolder::get('SLX_SCREEN_SAVER_TIMEOUT');
+ if (!is_numeric($saverTimeout) || $saverTimeout < 1800) {
+ ConfigHolder::add('SLX_SCREEN_SAVER_TIMEOUT', '1800', 1000);
}
-})($uuid); \ No newline at end of file
+ ConfigHolder::add('SLX_SCREEN_SAVER_GRACE_TIME', '86400', 1000);
+ // Autologin will never work as the machine is immediately in use and will never get assigned
+ ConfigHolder::add('SLX_AUTOLOGIN', 'OFF', 10000);
+}
diff --git a/modules-available/remoteaccess/inc/remoteaccess.inc.php b/modules-available/remoteaccess/inc/remoteaccess.inc.php
index 37d33d45..95ca3821 100644
--- a/modules-available/remoteaccess/inc/remoteaccess.inc.php
+++ b/modules-available/remoteaccess/inc/remoteaccess.inc.php
@@ -7,14 +7,32 @@ class RemoteAccess
const PROP_TRY_VIRT_HANDOVER = 'remoteaccess.virthandover';
- public static function getEnabledLocations($group = 0)
+ const PROP_VNC_PORT = 'remoteaccess.vncport';
+
+ const PROP_PLUGIN_VERSION = 'remoteaccess.plugin-version';
+
+ /**
+ * Get a list of locationIds where remote access is enabled. If $filterOverridden is true,
+ * the list will not contain any locations where remote access is disabled via location override.
+ * @param int $group Group to get locations for, or '0' for all locations
+ * @param bool $filterOverridden iff true, remove any locations where remote access is currently disabled
+ */
+ public static function getEnabledLocations(int $group = 0, bool $filterOverridden = true): array
{
if ($group === 0) {
- return Database::queryColumnArray("SELECT DISTINCT rxl.locationid FROM remoteaccess_x_location rxl
+ $list = Database::queryColumnArray("SELECT DISTINCT rxl.locationid FROM remoteaccess_x_location rxl
INNER JOIN remoteaccess_group g ON (g.groupid = rxl.groupid AND g.active = 1)");
- }
- return Database::queryColumnArray("SELECT DISTINCT locationid FROM remoteaccess_x_location
+ } else {
+ $list = Database::queryColumnArray("SELECT DISTINCT locationid FROM remoteaccess_x_location
WHERE groupid = :gid", ['gid' => $group]);
+ }
+ if (!$filterOverridden || !Module::isAvailable('rebootcontrol'))
+ return $list;
+ return array_filter($list, function (int $lid) {
+ $mode = Scheduler::getLocationOptions($lid)['ra-mode'];
+ return ($mode !== Scheduler::RA_NEVER
+ && ($mode !== Scheduler::RA_SELECTIVE || !OpeningTimes::isRoomOpen($lid, 5, 5)));
+ });
}
public static function ensureMachinesRunning()
@@ -24,41 +42,58 @@ class RemoteAccess
return;
}
- $res = Database::simpleQuery("SELECT rg.groupid, rg.groupname, rg.wolcount, GROUP_CONCAT(rxl.locationid) AS locs FROM remoteaccess_group rg
+ $res = Database::simpleQuery("SELECT rg.groupid, rg.groupname, rg.wolcount,
+ GROUP_CONCAT(rxl.locationid) AS locs
+ FROM remoteaccess_group rg
INNER JOIN remoteaccess_x_location rxl USING (groupid)
WHERE rg.active = 1
GROUP BY groupid");
// Consider machines we tried to wake in the past 90 seconds as online
$wolDeadline = time() - 90;
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- if ($row['wolcount'] <= 0)
- continue;
+ foreach ($res as $row) {
+ $wantNum = $row['wolcount'];
// This can't really be anything but a CSV list, but better be safe
$locs = preg_replace('/[^0-9,]/', '', $row['locs']);
- if (empty($locs))
- continue;
- $active = Database::queryFirst("SELECT Count(*) AS cnt FROM machine m
+ if (!empty($locs)) {
+ // Filter out locations for which remote-access is disabled
+ $locArray = explode(',', $locs);
+ $locArray = array_filter($locArray, function (int $lid) {
+ $mode = Scheduler::getLocationOptions($lid)['ra-mode'];
+ return ($mode !== Scheduler::RA_NEVER
+ && ($mode !== Scheduler::RA_SELECTIVE || !OpeningTimes::isRoomOpen($lid, 5, 5)));
+ });
+ $locs = implode(',', $locArray);
+ }
+ if ($wantNum > 0 && !empty($locs)) {
+ $active = Database::queryFirst("SELECT Count(*) AS cnt FROM machine m
INNER JOIN remoteaccess_machine rm USING (machineuuid)
WHERE m.locationid IN ($locs) AND (m.state = 'IDLE' OR rm.woltime > $wolDeadline)");
- $active = (isset($active['cnt']) ? $active['cnt'] : 0);
- $wantNum = $row['wolcount'] - $active;
- if ($wantNum <= 0)
- continue;
- self::tryWakeMachines($locs, $wantNum);
+ $active = ($active['cnt'] ?? 0);
+ $wantNum -= $active;
+ }
+ if ($wantNum > 0) {
+ $numFailed = self::tryWakeMachines($locs, $wantNum);
+ } else {
+ $numFailed = 0;
+ }
+ Database::exec("UPDATE remoteaccess_group SET unwoken = :num WHERE groupid = :groupid",
+ ['num' => $numFailed, 'groupid' => $row['groupid']]);
}
}
- private static function tryWakeMachines($locs, $num)
+ private static function tryWakeMachines(string $locs, int $num): int
{
- $res = Database::simpleQuery("SELECT m.machineuuid, m.macaddr, m.clientip FROM machine m
+ if (empty($locs))
+ return $num;
+ $res = Database::simpleQuery("SELECT m.machineuuid, m.macaddr, m.clientip, m.locationid FROM machine m
LEFT JOIN remoteaccess_machine rm USING (machineuuid)
WHERE m.locationid IN ($locs) AND m.state IN ('OFFLINE', 'STANDBY')
ORDER BY rm.woltime ASC");
$NOW = time();
while ($num > 0) {
$list = [];
- for ($i = 0; $i < $num && $row = $res->fetch(PDO::FETCH_ASSOC); ++$i) {
+ for ($i = 0; $i < $num && ($row = $res->fetch()); ++$i) {
$list[] = $row;
Database::exec("INSERT INTO remoteaccess_machine (machineuuid, password, woltime)
VALUES (:uuid, NULL, :now)
@@ -68,7 +103,7 @@ class RemoteAccess
if (empty($list))
break; // No more clients in this location
RebootControl::wakeMachines($list, $fails);
- $num -= count($list) - count($fails);
+ $num -= (count($list) - count($fails));
if (!empty($fails)) {
$failIds = ArrayUtil::flattenByKey($fails, 'machineuuid');
// Reduce time so they won't be marked as wol_in_progress
@@ -76,9 +111,7 @@ class RemoteAccess
['faketime' => $NOW - 95, 'fails' => $failIds]);
}
}
- if ($num > 0) {
- error_log("Could not wake $num clients in ($locs)...");
- }
+ return $num;
}
}
diff --git a/modules-available/remoteaccess/install.inc.php b/modules-available/remoteaccess/install.inc.php
index 11656218..2a6fec36 100644
--- a/modules-available/remoteaccess/install.inc.php
+++ b/modules-available/remoteaccess/install.inc.php
@@ -8,6 +8,7 @@ $dbret[] = tableCreate('remoteaccess_group', "
`wolcount` smallint(11) NOT NULL,
`passwd` varchar(100) NOT NULL,
`active` tinyint(1) UNSIGNED NOT NULL DEFAULT '1',
+ `unwoken` int(10) UNSIGNED NOT NULL DEFAULT '0',
PRIMARY KEY (`groupid`)
");
@@ -21,6 +22,7 @@ $dbret[] = tableCreate('remoteaccess_machine', "
`machineuuid` char(36) CHARACTER SET ascii NOT NULL,
`password` char(8) CHARACTER SET ascii NULL DEFAULT NULL,
`woltime` int(10) UNSIGNED NOT NULL DEFAULT '0',
+ `vncport` smallint(5) UNSIGNED NOT NULL DEFAULT '5900',
PRIMARY KEY (`machineuuid`)
");
@@ -57,4 +59,22 @@ if (tableExists('remoteaccess_location')
Database::exec("DROP TABLE remoteaccess_location");
}
+// 2021-03-05: Add vncport column to machine table
+if (!tableHasColumn('remoteaccess_machine', 'vncport')) {
+ $ret = Database::exec("ALTER TABLE remoteaccess_machine ADD COLUMN `vncport` smallint(5) UNSIGNED NOT NULL DEFAULT '5900'");
+ if ($ret === false) {
+ finalResponse(UPDATE_FAILED, Database::lastError());
+ }
+ $dbret[] = UPDATE_DONE;
+}
+
+// 2022-06-01 Unwoken machines: Keeps track of how many machines could not be WOLed
+if (!tableHasColumn('remoteaccess_group', 'unwoken')) {
+ $ret = Database::exec("ALTER TABLE remoteaccess_group ADD COLUMN `unwoken` int(10) UNSIGNED NOT NULL DEFAULT '0'");
+ if ($ret === false) {
+ finalResponse(UPDATE_FAILED, Database::lastError());
+ }
+ $dbret[] = UPDATE_DONE;
+}
+
responseFromArray($dbret);
diff --git a/modules-available/remoteaccess/lang/de/template-tags.json b/modules-available/remoteaccess/lang/de/template-tags.json
index a5d9ef07..1a502a6b 100644
--- a/modules-available/remoteaccess/lang/de/template-tags.json
+++ b/modules-available/remoteaccess/lang/de/template-tags.json
@@ -3,15 +3,25 @@
"lang_allowAccessText": "IP-Adresse oder Netz in CIDR Notation, welches auf den VNC-Port des Clients zugreifen darf. (I.d.R. nur der Guacamole-Server)",
"lang_allowedAccessToVncPort": "Erlaubte Quelle f\u00fcr VNC-Zugriff",
"lang_assignLocations": "R\u00e4ume zuweisen",
+ "lang_clientVncPort": "VNC Port (Client)",
"lang_general": "Allgemein",
"lang_group": "Gruppe",
"lang_groupListText": "Liste verf\u00fcgbarer Gruppen (\"virtuelle R\u00e4ume\")",
"lang_groups": "Gruppen",
"lang_keepAvailableWol": "WoL#",
+ "lang_location": "Ort",
"lang_locationSelectionText": "Ausgew\u00e4hlte Orte werden in den Remote-Modus geschaltet (beim n\u00e4chsten Boot des Clients) und sind damit im Pool f\u00fcr den Fernzugriff.",
+ "lang_locations": "Konfigurierte Orte",
+ "lang_locationsInUse": "Liste der Orte, die in mindestens einer Gruppe verwendet werden",
"lang_numLocs": "R\u00e4ume",
+ "lang_pluginVersion": "Plugin-Version",
+ "lang_pluginVersionOldOrUnknown": "Unbekannt oder zu alt",
"lang_reallyDelete": "Wirklich l\u00f6schen?",
"lang_remoteAccessSettings": "Einstellungen f\u00fcr den Fernzugriff",
+ "lang_roomRemoteAccessDisabled": "Zugriff f\u00fcr diesen Raum generell deaktiviert",
+ "lang_roomRemoteAccessWhenClosed": "Zugriff f\u00fcr diesen Raum deaktiviert, solange er laut \u00d6ffnungszeiten offen ist",
"lang_tryVirtualizerHandover": "Versuche, VNC-Server des Virtualisierers zu verwenden",
- "lang_tryVirtualizerText": "Wenn aktiviert wird versucht, nach dem Start einer VM die Verbindung auf den VNC-Server des Virtualisierers umzubuchen. Zumindest f\u00fcr VMware haben wir hier allerdings eher eine Verschlechterung der Performance beobachten k\u00f6nnen; au\u00dferdem bricht die Verbindung beim Handover manchmal ab -> Nur experimentell!"
+ "lang_tryVirtualizerText": "Wenn aktiviert wird versucht, nach dem Start einer VM die Verbindung auf den VNC-Server des Virtualisierers umzubuchen. Zumindest f\u00fcr VMware haben wir hier allerdings eher eine Verschlechterung der Performance beobachten k\u00f6nnen; au\u00dferdem bricht die Verbindung beim Handover manchmal ab -> Nur experimentell!",
+ "lang_vncPortText": "Port, auf dem die Clients auf VNC-Verbindungen warten. Bei Verwendung eines Ports ungleich 5900 bitte sicherstellen, dass das aktuelle Guacamole-Plugin verwendet wird.",
+ "lang_wolFailCount": "Anzahl erfolgloser WoL-Versuche"
} \ No newline at end of file
diff --git a/modules-available/remoteaccess/lang/en/template-tags.json b/modules-available/remoteaccess/lang/en/template-tags.json
index 85577438..037550e4 100644
--- a/modules-available/remoteaccess/lang/en/template-tags.json
+++ b/modules-available/remoteaccess/lang/en/template-tags.json
@@ -3,15 +3,25 @@
"lang_allowAccessText": "IP address (or net in CIDR notation) which is allowed to access the VNC port of the clients (usually only the guacamole proxy-server)",
"lang_allowedAccessToVncPort": "Allowed source for VNC-access",
"lang_assignLocations": "Assing locations",
+ "lang_clientVncPort": "VNC port (client)",
"lang_general": "General",
"lang_group": "Group",
"lang_groupListText": "Available groups (\"virtual locations\")",
"lang_groups": "Groups",
"lang_keepAvailableWol": "WoL#",
+ "lang_location": "Location",
"lang_locationSelectionText": "Clients in the selected locations will start the remoteaccess-mode after the next reboot.",
+ "lang_locations": "Configured locations",
+ "lang_locationsInUse": "List of locations that are active in at least one group",
"lang_numLocs": "Locations",
+ "lang_pluginVersion": "Plugin version",
+ "lang_pluginVersionOldOrUnknown": "Unknown or too old",
"lang_reallyDelete": "Delete?",
"lang_remoteAccessSettings": "Settings for remoteaccess",
- "lang_tryVirtualizerHandover": "Try to use VNC-server of the virtual hypervisor",
- "lang_tryVirtualizerText": "If activated the system tries to change the remote VNC-connection to the internal VNC-server of the hypervisor after VM start.\r\nAt least in the case of VMware it seems to reduce performance and sometimes the connection during handover is lost.\r\n-> Only experimental!"
+ "lang_roomRemoteAccessDisabled": "Remote access for this room is disabled via override",
+ "lang_roomRemoteAccessWhenClosed": "Remote access for this room is disabled while the room is open (according to its schedule)",
+ "lang_tryVirtualizerHandover": "Try to use VNC-server of the hypervisor",
+ "lang_tryVirtualizerText": "If activated the system tries to change the remote VNC-connection to the internal VNC-server of the hypervisor after VM start.\r\nAt least in the case of VMware it seems to reduce performance and sometimes the connection during handover is lost.\r\n-> Just experimental!",
+ "lang_vncPortText": "Port on which clients will wait for VNC connections. Please make sure you're running the latest version of the Guacamole plugin when changing this to something other than 5900.",
+ "lang_wolFailCount": "Number of unsuccesful WoL attempts"
} \ No newline at end of file
diff --git a/modules-available/remoteaccess/page.inc.php b/modules-available/remoteaccess/page.inc.php
index 68781ffa..ba248b4d 100644
--- a/modules-available/remoteaccess/page.inc.php
+++ b/modules-available/remoteaccess/page.inc.php
@@ -29,10 +29,10 @@ class Page_RemoteAccess extends Page
Database::exec("UPDATE remoteaccess_group SET groupname = :name, wolcount = :wol,
passwd = :passwd, active = :active WHERE groupid = :id", [
'id' => $id,
- 'name' => isset($group['groupname']) ? $group['groupname'] : $id,
- 'wol' => isset($group['wolcount']) ? $group['wolcount'] : 0,
- 'passwd' => isset($group['passwd']) ? $group['passwd'] : 0,
- 'active' => isset($group['active']) && $group['active'] ? 1 : 0,
+ 'name' => $group['groupname'] ?? $id,
+ 'wol' => $group['wolcount'] ?? 0,
+ 'passwd' => $group['passwd'] ?? 0,
+ 'active' => (int)($group['active'] ?? 0),
]);
}
Message::addSuccess('settings-saved');
@@ -40,6 +40,7 @@ class Page_RemoteAccess extends Page
User::assertPermission('set-proxy-ip');
Property::set(RemoteAccess::PROP_ALLOWED_VNC_NET, Request::post('allowed-source', '', 'string'));
Property::set(RemoteAccess::PROP_TRY_VIRT_HANDOVER, Request::post('virt-handover', false, 'int'));
+ Property::set(RemoteAccess::PROP_VNC_PORT, Request::post('vncport', 5900, 'int'));
Message::addSuccess('settings-saved');
} elseif ($action === 'delete-group') {
User::assertPermission('group.edit');
@@ -91,28 +92,62 @@ class Page_RemoteAccess extends Page
if ($groupid === false) {
// Edit list of groups and their settings
$groups = Database::queryAll("SELECT g.groupid, g.groupname, g.wolcount, g.passwd,
- Count(l.locationid) AS locs, If(g.active, 'checked', '') AS checked
- FROM remoteaccess_group g LEFT JOIN remoteaccess_x_location l USING (groupid)
+ Count(l.locationid) AS locs, If(g.active, 'checked', '') AS checked, unwoken
+ FROM remoteaccess_group g
+ LEFT JOIN remoteaccess_x_location l USING (groupid)
GROUP BY g.groupid, g.groupname
ORDER BY g.groupname ASC");
$data = [
'allowed-source' => Property::get(RemoteAccess::PROP_ALLOWED_VNC_NET),
'virt-handover_checked' => Property::get(RemoteAccess::PROP_TRY_VIRT_HANDOVER) ? 'checked' : '',
+ 'vncport' => Property::get(RemoteAccess::PROP_VNC_PORT, 5900),
'groups' => $groups,
];
+ $data['plugin_version'] = Property::get(RemoteAccess::PROP_PLUGIN_VERSION);
Permission::addGlobalTags($data['perms'], null, ['group.locations', 'group.add', 'group.edit', 'set-proxy-ip']);
+ // List of locations used in at least one group
+ $res = Database::simpleQuery("SELECT l.locationid, l.locationname, g.groupid, g.groupname, g.active
+ FROM location l
+ INNER JOIN remoteaccess_x_location rxl USING (locationid)
+ INNER JOIN remoteaccess_group g USING (groupid)
+ ORDER BY locationname, locationid");
+ $data['locations'] = [];
+ $last = null;
+ foreach ($res as $row) {
+ if ($last === null || $last['locationid'] !== $row['locationid']) {
+ unset($last);
+ $last = [
+ 'locationid' => $row['locationid'],
+ 'locationname' => $row['locationname'],
+ 'lclass' => 'slx-strike',
+ 'groups' => [],
+ ];
+ $data['locations'][] =& $last;
+ }
+ $last['groups'][] = [
+ 'groupid' => $row['groupid'],
+ 'groupname' => $row['groupname'],
+ 'gclass' => $row['active'] ? '' : 'slx-strike',
+ ];
+ if ($row['active']) {
+ $last['lclass'] = '';
+ }
+ }
+ unset($last);
+ $this->addSchedulerTags($data['locations']);
Render::addTemplate('edit-settings', $data);
} else {
// Edit locations for group
$group = $this->groupNameOrFail($groupid);
$locationList = Location::getLocationsAssoc();
- $enabled = RemoteAccess::getEnabledLocations($groupid);
+ $enabled = RemoteAccess::getEnabledLocations($groupid, false);
$allowed = User::getAllowedLocations('group.locations');
foreach ($enabled as $lid) {
if (isset($locationList[$lid])) {
$locationList[$lid]['checked'] = 'checked';
}
}
+ $this->addSchedulerTags($locationList);
foreach ($locationList as $lid => &$loc) {
if (!in_array($lid, $allowed)) {
$loc['disabled'] = 'disabled';
@@ -133,7 +168,7 @@ class Page_RemoteAccess extends Page
* @param int $groupid group to check
* @return bool if we have permission for all the locations assigned to group
*/
- private function checkGroupLocations($groupid)
+ private function checkGroupLocations(int $groupid): bool
{
$allowed = User::getAllowedLocations('group.locations');
if (in_array(0, $allowed))
@@ -144,4 +179,18 @@ class Page_RemoteAccess extends Page
return empty($diff);
}
+ private function addSchedulerTags(array &$locationList)
+ {
+ if (!Module::isAvailable('rebootcontrol'))
+ return;
+ foreach ($locationList as $lid => &$loc) {
+ $options = Scheduler::getLocationOptions($loc['locationid'] ?? $lid);
+ if ($options['ra-mode'] === Scheduler::RA_SELECTIVE) {
+ $loc['ra_selective'] = true;
+ } elseif ($options['ra-mode'] === Scheduler::RA_NEVER) {
+ $loc['ra_never'] = true;
+ }
+ }
+ }
+
}
diff --git a/modules-available/remoteaccess/templates/edit-group.html b/modules-available/remoteaccess/templates/edit-group.html
index 0f09f071..93fa66be 100644
--- a/modules-available/remoteaccess/templates/edit-group.html
+++ b/modules-available/remoteaccess/templates/edit-group.html
@@ -30,9 +30,17 @@
<label></label>
</div>
</td>
- <td class="text-nowrap">
+ <td class="slx-smallcol text-nowrap">
<div style="display:inline-block;width:{{depth}}em"></div>
<label for="loc-check-{{locationid}}" class="{{disabled}}">{{locationname}}</label>
+ </td>
+ <td>
+ {{#ra_never}}
+ <span class="glyphicon glyphicon-remove text-danger" title="{{lang_roomRemoteAccessDisabled}}"></span>
+ {{/ra_never}}
+ {{#ra_selective}}
+ <span class="glyphicon glyphicon-time text-danger" title="{{lang_roomRemoteAccessWhenClosed}}"></span>
+ {{/ra_selective}}
</td>
</tr>
{{/locations}}
diff --git a/modules-available/remoteaccess/templates/edit-settings.html b/modules-available/remoteaccess/templates/edit-settings.html
index 3c890b91..4c4c011a 100644
--- a/modules-available/remoteaccess/templates/edit-settings.html
+++ b/modules-available/remoteaccess/templates/edit-settings.html
@@ -4,13 +4,29 @@
<form method="post" action="?do=remoteaccess">
<input type="hidden" name="token" value="{{token}}">
- <div class="form-group">
- <label>
- {{lang_allowedAccessToVncPort}}
- <input type="text" class="form-control" name="allowed-source" value="{{allowed-source}}"
- required {{perms.set-proxy-ip.disabled}}>
- </label>
- <p>{{lang_allowAccessText}}</p>
+ <div class="row">
+ <div class="form-group col-md-6">
+ <label>
+ {{lang_allowedAccessToVncPort}}
+ <input type="text" class="form-control" name="allowed-source" value="{{allowed-source}}"
+ required {{perms.set-proxy-ip.disabled}}>
+ </label>
+ <p>{{lang_allowAccessText}}</p>
+ </div>
+ <div class="form-group col-md-6">
+ <label>
+ {{lang_clientVncPort}}
+ <input type="number" class="form-control" name="vncport" value="{{vncport}}" min="1025" max="65535"
+ required {{perms.set-proxy-ip.disabled}}>
+ </label>
+ <p>{{lang_vncPortText}}</p>
+ <div class="text-right">
+ {{lang_pluginVersion}}: {{plugin_version}}
+ {{^plugin_version}}
+ {{lang_pluginVersionOldOrUnknown}}
+ {{/plugin_version}}
+ </div>
+ </div>
</div>
<div class="form-group">
<div class="checkbox">
@@ -64,9 +80,14 @@
<span class="glyphicon glyphicon-edit"></span>
</a>
</td>
- <td>
+ <td class="input-group">
<input type="number" class="form-control" name="group[{{groupid}}][wolcount]" value="{{wolcount}}"
{{perms.group.edit.disabled}}>
+ {{#unwoken}}
+ <span class="input-group-addon" title="{{lang_wolFailCount}}">
+ <span class="glyphicon glyphicon-remove"></span>{{unwoken}}
+ </span>
+ {{/unwoken}}
</td>
<td>
<input type="text" class="form-control" name="group[{{groupid}}][passwd]" value="{{passwd}}"
@@ -87,4 +108,35 @@
</button>
</div>
<div class="clearfix"></div>
-</form> \ No newline at end of file
+</form>
+
+<h3>{{lang_locations}}</h3>
+
+<p>{{lang_locationsInUse}}</p>
+
+<table class="table">
+ <thead>
+ <tr>
+ <th>{{lang_location}}</th>
+ <th>{{lang_groups}}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{#locations}}
+ <tr>
+ <td class="{{lclass}}">
+ {{locationname}}
+ {{#ra_never}}
+ <span class="glyphicon glyphicon-remove text-danger" title="{{lang_roomRemoteAccessDisabled}}"></span>
+ {{/ra_never}}
+ {{#ra_selective}}
+ <span class="glyphicon glyphicon-time text-danger" title="{{lang_roomRemoteAccessWhenClosed}}"></span>
+ {{/ra_selective}}
+ </td>
+ <td>{{#groups}}
+ [<a class="{{gclass}}" href="?do=remoteaccess&amp;groupid={{groupid}}">{{groupname}}</a>]
+ {{/groups}}</td>
+ </tr>
+ {{/locations}}
+ </tbody>
+</table> \ No newline at end of file
diff --git a/modules-available/roomplanner/api.inc.php b/modules-available/roomplanner/api.inc.php
index 8ddb945c..1af25ca8 100644
--- a/modules-available/roomplanner/api.inc.php
+++ b/modules-available/roomplanner/api.inc.php
@@ -5,14 +5,14 @@ if (Request::any('show') === 'svg') {
$ret = PvsGenerator::generateSvg(Request::any('locationid', false, 'int'),
Request::any('machineuuid', false, 'string'),
Request::any('rotate', 0, 'int'),
- Request::any('scale', 1, 'float'));
+ Request::any('scale', 1, 'float'),
+ Request::any('url', false, 'bool'));
if ($ret === false) {
if (Request::any('fallback', 0, 'int') === 0) {
Header('HTTP/1.1 404 Not Found');
exit;
}
$ret = <<<EOF
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 64 64" height="64" width="64">
<g>
<path d="M 0,0 64,64 Z" style="stroke:#ff0000;stroke-width:5" />
@@ -22,7 +22,7 @@ if (Request::any('show') === 'svg') {
EOF;
}
Header('Content-Type: image/svg+xml');
- die($ret);
+ die('<?xml version="1.0" encoding="UTF-8" standalone="no"?>' . $ret);
}
// PVS.ini
diff --git a/modules-available/roomplanner/inc/composedroom.inc.php b/modules-available/roomplanner/inc/composedroom.inc.php
index cdd50984..3ee892db 100644
--- a/modules-available/roomplanner/inc/composedroom.inc.php
+++ b/modules-available/roomplanner/inc/composedroom.inc.php
@@ -98,7 +98,7 @@ class ComposedRoom extends Room
return $this->orientation;
}
- public function subLocationIds()
+ public function subLocationIds(): array
{
return $this->list;
}
@@ -108,7 +108,7 @@ class ComposedRoom extends Room
return $this->controlRoom;
}
- public function machineCount()
+ public function machineCount(): int
{
$sum = 0;
foreach ($this->list as $lid) {
@@ -117,10 +117,9 @@ class ComposedRoom extends Room
return $sum;
}
- public function getSize(&$width, &$height)
+ public function getSize(?int &$width, ?int &$height)
{
$horz = ($this->orientation == 'horizontal');
- $width = $height = 0;
foreach ($this->list as $locId) {
self::$rooms[$locId]->getSize($w, $h);
$width = $horz ? $width + $w : max($width, $w);
@@ -128,7 +127,7 @@ class ComposedRoom extends Room
}
}
- public function getIniClientSection(&$i, $offX = 0, $offY = 0)
+ public function getIniClientSection(int &$i, int $offX = 0, int $offY = 0)
{
if (!$this->enabled)
return false;
@@ -154,10 +153,10 @@ class ComposedRoom extends Room
return $out;
}
- public function getShiftedArray($offX = 0, $offY = 0)
+ public function getShiftedArray(int $offX = 0, int $offY = 0): ?array
{
if (!$this->enabled)
- return false;
+ return null;
if ($this->orientation == 'horizontal') {
$x = 1;
$y = 0;
@@ -168,7 +167,7 @@ class ComposedRoom extends Room
$ret = [];
foreach ($this->list as $locId) {
$new = self::$rooms[$locId]->getShiftedArray($offX, $offY);
- if ($new !== false) {
+ if ($new !== null) {
$ret = array_merge($ret, $new);
self::$rooms[$locId]->getSize($w, $h);
$offX += $w * $x;
@@ -176,7 +175,7 @@ class ComposedRoom extends Room
}
}
if (empty($ret))
- return false;
+ return null;
return $ret;
}
@@ -194,12 +193,12 @@ class ComposedRoom extends Room
return false;
}
- public function isLeaf()
+ public function isLeaf(): bool
{
return false;
}
- public function shouldSkip()
+ public function shouldSkip(): bool
{
return !$this->enabled;
}
diff --git a/modules-available/roomplanner/inc/pvsgenerator.inc.php b/modules-available/roomplanner/inc/pvsgenerator.inc.php
index 0275054b..f3c5c838 100644
--- a/modules-available/roomplanner/inc/pvsgenerator.inc.php
+++ b/modules-available/roomplanner/inc/pvsgenerator.inc.php
@@ -3,7 +3,7 @@
class PvsGenerator
{
- public static function generate()
+ public static function generate(): string
{
/* collect names and build room blocks - filter empty rooms while at it */
$roomNames = array();
@@ -36,7 +36,7 @@ class PvsGenerator
* @param Room $room room/location data as fetched from db
* @return string|false .ini section for room, or false if room is empty
*/
- private static function generateRoomBlock($room)
+ private static function generateRoomBlock(Room $room)
{
$room->getSize($sizeX, $sizeY);
if ($sizeX === 0 || $sizeY === 0)
@@ -78,7 +78,7 @@ class PvsGenerator
* @param float $scale scaling factor for output
* @return string SVG
*/
- public static function generateSvg($locationId = false, $highlightUuid = false, $rotate = 0, $scale = 1)
+ public static function generateSvg($locationId = false, $highlightUuid = false, int $rotate = 0, $scale = 1, $links = false, array $present = null)
{
if ($locationId === false) {
$locationId = Database::queryFirst('SELECT fixedlocationid FROM machine
@@ -91,13 +91,13 @@ class PvsGenerator
}
// Load room
$room = Room::get($locationId);
- if ($room === false)
+ if ($room === null)
return false;
$room->getSize($sizeX, $sizeY);
if ($sizeX === 0 || $sizeY === 0)
return false; // Empty
- $machines = $room->getShiftedArray();
+ $machines = $room->getShiftedArray() ?? [];
$ORIENTATION = ['north' => 2, 'east' => 3, 'south' => 0, 'west' => 1];
if (is_string($highlightUuid)) {
$highlightUuid = strtoupper($highlightUuid);
@@ -105,7 +105,7 @@ class PvsGenerator
// Figure out autorotate
$auto = ($rotate < 0);
if ($auto && $highlightUuid !== false) {
- foreach ($machines as &$machine) {
+ foreach ($machines as $machine) {
if ($machine['machineuuid'] === $highlightUuid) {
$rotate = 4 - $ORIENTATION[$machine['rotation']]; // Reverse rotation
break;
@@ -117,6 +117,8 @@ class PvsGenerator
foreach ($machines as &$machine) {
if ($machine['machineuuid'] === $highlightUuid) {
$machine['class'] = 'hl';
+ } elseif (!empty($present) && !in_array($machine['machineuuid'], $present)) {
+ $machine['class'] = 'muted';
}
$machine['rotation'] = $ORIENTATION[$machine['rotation']] * 90;
}
@@ -140,6 +142,7 @@ class PvsGenerator
'centerY' => $centerY,
'rotate' => $rotate * 90,
'machines' => $machines,
+ 'links' => $links,
'line' => ['x' => $sizeX, 'y' => $sizeY],
], 'roomplanner'); // FIXME: Needs module param if called from api.inc.php
}
@@ -173,14 +176,12 @@ class PvsGenerator
/**
* Get display name for manager of given locationId.
* Hook for "runmode" module to resolve mode name.
- * @param $locationId
- * @return bool|string
*/
- public static function getManagerName($locationId)
+ public static function getManagerName(int $locationId): ?string
{
$names = Location::getNameChain($locationId);
if ($names === false)
- return false;
+ return null;
return implode(' / ', $names);
}
diff --git a/modules-available/roomplanner/inc/room.inc.php b/modules-available/roomplanner/inc/room.inc.php
index 855bdbcf..880cb6d0 100644
--- a/modules-available/roomplanner/inc/room.inc.php
+++ b/modules-available/roomplanner/inc/room.inc.php
@@ -28,11 +28,11 @@ abstract class Room
'SELECT lr.locationid, lr.managerip, lr.tutoruuid, lr.roomplan, m.clientip as tutorip
FROM location_roomplan lr
LEFT JOIN machine m ON (lr.tutoruuid = m.machineuuid)');
- while ($row = $ret->fetch(PDO::FETCH_ASSOC)) {
- $row = self::loadSingleRoom($row);
- if ($row === false)
+ foreach ($ret as $row) {
+ $room = self::loadSingleRoom($row);
+ if ($room === null)
continue;
- self::$rooms[$row->locationId] = $row;
+ self::$rooms[$room->locationId] = $room;
}
foreach (self::$rooms as $room) {
$room->sanitize();
@@ -42,14 +42,14 @@ abstract class Room
/**
* Instantiate ComposedRoom or MachineGroup depending on contents of $row
* @param array $row DB row from location_roomplan.
- * @return Room|false Room instance, false on error
+ * @return ?Room Room instance, null on error
*/
- private static function loadSingleRoom($row)
+ private static function loadSingleRoom(array $row): ?Room
{
$locations = Location::getLocationsAssoc();
settype($row['locationid'], 'int');
if (!isset($locations[$row['locationid']]))
- return false;
+ return null;
if ($locations[$row['locationid']]['isleaf'])
return new SimpleRoom($row);
return new ComposedRoom($row, false);
@@ -59,7 +59,7 @@ abstract class Room
* Get array of all rooms with room plan
* @return Room[]
*/
- public static function getAll()
+ public static function getAll(): array
{
self::init();
return self::$rooms;
@@ -68,9 +68,9 @@ abstract class Room
/**
* Get room instance for given location
* @param int $locationId room to get
- * @return Room|false requested room, false if not configured or not found
+ * @return ?Room requested room, false if not configured or not found
*/
- public static function get($locationId)
+ public static function get(int $locationId): ?Room
{
if (self::$rooms === null) {
$room = Database::queryFirst(
@@ -79,8 +79,10 @@ abstract class Room
LEFT JOIN machine m ON (lr.tutoruuid = m.machineuuid)
WHERE lr.locationid = :lid', ['lid' => $locationId]);
if ($room === false)
- return false;
+ return null;
$room = self::loadSingleRoom($room);
+ if ($room === null)
+ return null;
// If it's a leaf room we probably don't need any other rooms, return it
if ($room->isLeaf())
return $room;
@@ -89,7 +91,7 @@ abstract class Room
}
if (isset(self::$rooms[$locationId]))
return self::$rooms[$locationId];
- return false;
+ return null;
}
public function __construct($row)
@@ -102,35 +104,34 @@ abstract class Room
/**
* @return int number of machines in this room
*/
- abstract public function machineCount();
+ abstract public function machineCount(): int;
/**
* Size of this room, returned by reference.
- * @param int $width OUT width of room
- * @param int $height OUT height of room
+ *
+ * @param int|null $width OUT width of room
+ * @param int|null $height OUT height of room
*/
- abstract public function getSize(&$width, &$height);
+ abstract public function getSize(?int &$width, ?int &$height);
/**
* Get clients in this room in .ini format for PVS.
* Adjusted so the top/left client is at (0|0), which
* is further adjustable with $offX and $offY.
+ *
* @param int $i offset for indexing clients
* @param int $offX positional X offset for clients
* @param int $offY positional Y offset for clients
* @return string|false
*/
- abstract public function getIniClientSection(&$i, $offX = 0, $offY = 0);
+ abstract public function getIniClientSection(int &$i, int $offX = 0, int $offY = 0);
/**
* Get clients in this room as array.
* Adjusted so the top/left client is at (0|0), which
*is further adjustable with $offX and $offY.
- * @param int $offX
- * @param int $offY
- * @return array
*/
- abstract public function getShiftedArray($offX = 0, $offY = 0);
+ abstract public function getShiftedArray(int $offX = 0, int $offY = 0): ?array;
/**
* @return string|false IP address of manager.
@@ -145,12 +146,12 @@ abstract class Room
/**
* @return bool true if this is a simple/leaf room, false for composed rooms.
*/
- abstract public function isLeaf();
+ abstract public function isLeaf(): bool;
/**
* @return bool should this room be skipped from output? true for empty SimpleRoom or disabled ComposedRoom.
*/
- abstract public function shouldSkip();
+ abstract public function shouldSkip(): bool;
/**
* Sanitize this room's data.
@@ -160,7 +161,7 @@ abstract class Room
/**
* @return string get room's name.
*/
- public function locationName()
+ public function locationName(): string
{
return $this->locationName;
}
@@ -168,7 +169,7 @@ abstract class Room
/**
* @return int get room's id.
*/
- public function locationId()
+ public function locationId(): int
{
return $this->locationId;
}
diff --git a/modules-available/roomplanner/inc/simpleroom.inc.php b/modules-available/roomplanner/inc/simpleroom.inc.php
index 78db6c4a..b4d3e744 100644
--- a/modules-available/roomplanner/inc/simpleroom.inc.php
+++ b/modules-available/roomplanner/inc/simpleroom.inc.php
@@ -11,6 +11,7 @@ class SimpleRoom extends Room
private $tutorIp = false;
+ /** @var ?string */
private $managerIp = false;
public function __construct($row)
@@ -21,7 +22,7 @@ class SimpleRoom extends Room
'SELECT machineuuid, clientip, position FROM machine WHERE fixedlocationid = :locationid',
['locationid' => $locationId]);
- while ($clientRow = $ret->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($ret as $clientRow) {
$position = json_decode($clientRow['position'], true);
if ($position === false || !isset($position['gridRow']) || !isset($position['gridCol']))
@@ -55,27 +56,29 @@ class SimpleRoom extends Room
}
}
- public function machineCount()
+ public function machineCount(): int
{
return count($this->machines);
}
- public function getSize(&$width, &$height)
+ public function getSize(?int &$width, ?int &$height)
{
if (empty($this->machines)) {
$width = $height = 0;
return;
}
+ $minX = $minY = $maxX = $maxY = 0;
$this->boundingBox($minX, $minY, $maxX, $maxY);
// client's size that cannot be configured as of today
$width = max($maxX - $minX + self::CLIENT_SIZE, 1);
$height = max($maxY - $minY + self::CLIENT_SIZE, 1);
}
- public function getIniClientSection(&$i, $offX = 0, $offY = 0)
+ public function getIniClientSection(int &$i, int $offX = 0, int $offY = 0): string
{
/* output individual client positions, shift coordinates to requested position */
$out = '';
+ $minX = $minY = $maxX = $maxY = 0;
$this->boundingBox($minX, $minY, $maxX, $maxY);
foreach ($this->machines as $pos) {
$i++;
@@ -86,10 +89,11 @@ class SimpleRoom extends Room
return $out;
}
- public function getShiftedArray($offX = 0, $offY = 0)
+ public function getShiftedArray(int $offX = 0, int $offY = 0): ?array
{
/* output individual client positions, shift coordinates to requested position */
$ret = [];
+ $minX = $minY = $maxX = $maxY = 0;
$this->boundingBox($minX, $minY, $maxX, $maxY);
foreach ($this->machines as $pos) {
$pos['gridCol'] += $offX - $minX;
@@ -100,7 +104,7 @@ class SimpleRoom extends Room
return $ret;
}
- private function boundingBox(&$minX, &$minY, &$maxX, &$maxY)
+ private function boundingBox(int &$minX, int &$minY, int &$maxX, int &$maxY): void
{
if ($this->bb !== false) {
$minX = $this->bb[0];
@@ -130,12 +134,12 @@ class SimpleRoom extends Room
return $this->tutorIp;
}
- public function isLeaf()
+ public function isLeaf(): bool
{
return true;
}
- public function shouldSkip()
+ public function shouldSkip(): bool
{
return empty($this->machines);
}
diff --git a/modules-available/roomplanner/install.inc.php b/modules-available/roomplanner/install.inc.php
index 13365fe1..05fd7589 100644
--- a/modules-available/roomplanner/install.inc.php
+++ b/modules-available/roomplanner/install.inc.php
@@ -47,7 +47,7 @@ if (tableHasColumn('location_roomplan', 'dedicatedmgr')) {
if ($ret === false) {
$res[] = UPDATE_FAILED;
} else {
- while ($row = $ret->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($ret as $row) {
$dedi = $row['dedicatedmgr'] != 0;
$data = json_encode(array('dedicatedmgr' => $dedi));
Database::exec("INSERT IGNORE INTO runmode (machineuuid, module, modeid, modedata, isclient)
diff --git a/modules-available/roomplanner/page.inc.php b/modules-available/roomplanner/page.inc.php
index 8c3beace..94bc3f78 100644
--- a/modules-available/roomplanner/page.inc.php
+++ b/modules-available/roomplanner/page.inc.php
@@ -4,14 +4,14 @@ class Page_Roomplanner extends Page
{
/**
- * @var int locationid of location we're editing
+ * @var ?int locationid of location we're editing, or null if unknown/not set
*/
- private $locationid = false;
+ private $locationid = null;
/**
* @var array location data from location table
*/
- private $location = false;
+ private $location = null;
/**
* @var string action to perform
@@ -25,8 +25,8 @@ class Page_Roomplanner extends Page
private function loadRequestedLocation()
{
- $this->locationid = Request::get('locationid', false, 'integer');
- if ($this->locationid !== false) {
+ $this->locationid = Request::get('locationid', null, 'integer');
+ if ($this->locationid !== null) {
$locs = Location::getLocationsAssoc();
if (isset($locs[$this->locationid])) {
$this->location = $locs[$this->locationid];
@@ -46,11 +46,11 @@ class Page_Roomplanner extends Page
$this->action = Request::any('action', 'show', 'string');
$this->loadRequestedLocation();
- if ($this->locationid === false) {
+ if ($this->locationid === null) {
Message::addError('need-locationid');
Util::redirect('?do=locations');
}
- if ($this->location === false) {
+ if ($this->location === null) {
Message::addError('locations.invalid-location-id', $this->locationid);
Util::redirect('?do=locations');
}
@@ -82,6 +82,9 @@ class Page_Roomplanner extends Page
$config = Database::queryFirst('SELECT roomplan, managerip, tutoruuid
FROM location_roomplan
WHERE locationid = :locationid', ['locationid' => $this->locationid]);
+ if ($config === false) {
+ $config = ['managerip' => '', 'tutoruuid' => ''];
+ }
$runmode = RunMode::getForMode(Page::getModule(), $this->locationid, true);
if (empty($runmode)) {
$config['dedicatedmgr'] = false;
@@ -92,12 +95,6 @@ class Page_Roomplanner extends Page
$data = json_decode($runmode['modedata'], true);
$config['dedicatedmgr'] = (isset($data['dedicatedmgr']) && $data['dedicatedmgr']);
}
- if ($config !== false) {
- $managerIp = $config['managerip'];
- $dediMgr = $config['dedicatedmgr'] ? 'checked' : '';
- } else {
- $dediMgr = $managerIp = '';
- }
$furniture = $this->getFurniture($config);
$subnetMachines = $this->getPotentialMachines();
$machinesOnPlan = $this->getMachinesOnPlan($config['tutoruuid']);
@@ -105,8 +102,8 @@ class Page_Roomplanner extends Page
$canEdit = User::hasPermission('edit', $this->locationid);
$params = [
'location' => $this->location,
- 'managerip' => $managerIp,
- 'dediMgrChecked' => $dediMgr,
+ 'managerip' => $config['managerip'],
+ 'dediMgrChecked' => $config['dedicatedmgr'] ? 'checked' : '',
'subnetMachines' => json_encode($subnetMachines),
'locationid' => $this->locationid,
'roomConfiguration' => json_encode($roomConfig),
@@ -195,7 +192,7 @@ class Page_Roomplanner extends Page
$returnObject = ['machines' => []];
- while ($row = $result->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($result as $row) {
if (!Location::isFixedLocationValid($roomLocationId, $row['subnetlocationid']))
continue;
if (empty($row['hostname'])) {
@@ -209,10 +206,10 @@ class Page_Roomplanner extends Page
} elseif ($this->action === 'save') {
// Save roomplan - give feedback if it failed so the window can stay open
$this->loadRequestedLocation();
- if ($this->locationid === false) {
+ if ($this->locationid === null) {
die('Missing locationid in save data');
}
- if ($this->location === false) {
+ if ($this->location === null) {
die('Location with id ' . $this->locationid . ' does not exist.');
}
$this->handleSaveRequest(true);
@@ -229,11 +226,9 @@ class Page_Roomplanner extends Page
if ($leaf !== $this->isLeaf) {
if ($isAjax) {
die('Leaf mode mismatch. Did you restructure locations while editing this room?');
- } else {
- Message::addError('leaf-mode-mismatch');
- Util::redirect("?do=roomplanner&locationid={$this->locationid}&action=show");
}
- return;
+ Message::addError('leaf-mode-mismatch');
+ Util::redirect("?do=roomplanner&locationid={$this->locationid}&action=show");
}
if ($this->isLeaf) {
$this->saveLeafRoom($isAjax);
@@ -250,10 +245,9 @@ class Page_Roomplanner extends Page
if (!is_array($config) || !isset($config['furniture']) || !isset($config['computers'])) {
if ($isAjax) {
die('JSON data incomplete');
- } else {
- Message::addError('json-data-invalid');
- Util::redirect("?do=roomplanner&locationid={$this->locationid}&action=show");
}
+ Message::addError('json-data-invalid');
+ Util::redirect("?do=roomplanner&locationid={$this->locationid}&action=show");
}
$tutorUuid = Request::post('tutoruuid', '', 'string');
if (empty($tutorUuid)) {
@@ -263,10 +257,9 @@ class Page_Roomplanner extends Page
if ($ret === false) {
if ($isAjax) {
die('Invalid tutor UUID');
- } else {
- Message::addError('invalid-tutor-uuid');
- Util::redirect("?do=roomplanner&locationid={$this->locationid}&action=show");
}
+ Message::addError('invalid-tutor-uuid');
+ Util::redirect("?do=roomplanner&locationid={$this->locationid}&action=show");
}
}
$this->saveRoomConfig($config['furniture'], $tutorUuid);
@@ -282,10 +275,9 @@ class Page_Roomplanner extends Page
if ($res === false) {
if ($isAjax) {
die('Error writing config to DB');
- } else {
- Message::addError('db-error');
- Util::redirect("?do=roomplanner&locationid={$this->locationid}&action=show");
}
+ Message::addError('db-error');
+ Util::redirect("?do=roomplanner&locationid={$this->locationid}&action=show");
}
}
@@ -302,7 +294,7 @@ class Page_Roomplanner extends Page
* @param array $computers Deserialized json from browser with all the computers
* @param array $oldComputers Deserialized old roomplan from database, used to find removed computers
*/
- protected function saveComputerConfig($computers, $oldComputers)
+ protected function saveComputerConfig(array $computers, array $oldComputers)
{
$oldUuids = [];
@@ -323,12 +315,12 @@ class Page_Roomplanner extends Page
if (!isset($computer['gridRow'])) {
$computer['gridRow'] = 0;
} else {
- $this->sanitizeNumber($computer['gridRow'], 0, 32 * 4);
+ Util::clamp($computer['gridRow'], 0, 32 * 4);
}
if (!isset($computer['gridCol'])) {
$computer['gridCol'] = 0;
} else {
- $this->sanitizeNumber($computer['gridCol'], 0, 32 * 4);
+ Util::clamp($computer['gridCol'], 0, 32 * 4);
}
$position = json_encode(['gridRow' => $computer['gridRow'],
@@ -347,7 +339,7 @@ class Page_Roomplanner extends Page
}
}
- protected function saveRoomConfig($furniture, $tutorUuid)
+ protected function saveRoomConfig(?array $furniture, ?string $tutorUuid)
{
$obj = json_encode(['furniture' => $furniture]);
$managerIp = Request::post('managerip', '', 'string');
@@ -358,13 +350,11 @@ class Page_Roomplanner extends Page
'locationid' => $this->locationid,
'roomplan' => $obj,
'managerip' => $managerIp,
- 'tutoruuid' => $tutorUuid
+ 'tutoruuid' => $tutorUuid,
]);
// See if the client is known, set run-mode
- if (empty($managerIp)) {
- RunMode::deleteMode(Page::getModule(), $this->locationid);
- } else {
- RunMode::deleteMode(Page::getModule(), $this->locationid);
+ RunMode::deleteMode(Page::getModule(), (string)$this->locationid);
+ if (!empty($managerIp)) {
$pc = Statistics::getMachinesByIp($managerIp, Machine::NO_DATA, 'lastseen DESC');
if (!empty($pc)) {
$dedicated = (Request::post('dedimgr') === 'on');
@@ -376,24 +366,27 @@ class Page_Roomplanner extends Page
}
}
- protected function getFurniture($config)
+ protected function getFurniture(array $config): array
{
- if ($config === false)
- return array();
+ if (empty($config['roomplan']))
+ return [];
$config = json_decode($config['roomplan'], true);
if (!is_array($config))
- return array();
+ return [];
return $config;
}
- protected function getMachinesOnPlan($tutorUuid)
+ /**
+ * @return array{computers: array}
+ */
+ protected function getMachinesOnPlan(?string $tutorUuid): array
{
$result = Database::simpleQuery('SELECT machineuuid, macaddr, clientip, hostname, position
FROM machine
WHERE fixedlocationid = :locationid',
['locationid' => $this->locationid]);
$machines = [];
- while ($row = $result->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($result as $row) {
$machine = [];
$pos = json_decode($row['position'], true);
if ($pos === false || !isset($pos['gridRow']) || !isset($pos['gridCol'])) {
@@ -420,7 +413,7 @@ class Page_Roomplanner extends Page
return ['computers' => $machines];
}
- protected function getPotentialMachines()
+ protected function getPotentialMachines(): array
{
$result = Database::simpleQuery('SELECT m.machineuuid, m.macaddr, m.clientip, m.hostname, l.locationname AS otherroom, m.fixedlocationid
FROM machine m
@@ -429,7 +422,7 @@ class Page_Roomplanner extends Page
$machines = [];
- while ($row = $result->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($result as $row) {
if (empty($row['hostname'])) {
$row['hostname'] = $row['clientip'];
}
diff --git a/modules-available/roomplanner/templates/svg-plan.html b/modules-available/roomplanner/templates/svg-plan.html
index a2ecd5a7..c75e01f8 100644
--- a/modules-available/roomplanner/templates/svg-plan.html
+++ b/modules-available/roomplanner/templates/svg-plan.html
@@ -1,4 +1,3 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.1"
@@ -18,9 +17,17 @@
stroke-width: .2;
fill: url(#screen);
}
- rect.hl {
+ g.hl rect.scrn {
fill: url(#screenhl);
}
+ g.muted rect.scrn {
+ fill: url(#muted);
+ stroke: #777;
+ }
+ g.muted rect.kb {
+ fill: #eee;
+ stroke: #777;
+ }
]]>
</style>
<defs>
@@ -32,13 +39,20 @@
<stop offset="0%" stop-color="#afa" />
<stop offset="100%" stop-color="#074" />
</radialGradient>
+ <radialGradient id="muted" cx=".4" cy=".3" r="1">
+ <stop offset="0%" stop-color="#fff" />
+ <stop offset="100%" stop-color="#999" />
+ </radialGradient>
</defs>
<g transform="scale({{scale}}) rotate({{rotate}} {{centerX}} {{centerY}})">
<line x1="0" y1="{{line.y}}"
x2="{{line.x}}" y2="{{line.y}}"
style="stroke:#555;stroke-width:.2;opacity:.5" />
{{#machines}}
- <g transform="translate({{gridCol}} {{gridRow}})">
+ {{#links}}
+ <a xlink:href="./?do=statistics&amp;uuid={{machineuuid}}">
+ {{/links}}
+ <g transform="translate({{gridCol}} {{gridRow}})" class="{{class}}">
<rect transform="rotate({{rotation}} 1.9 1.9)"
x=".1"
y="2.6"
@@ -52,9 +66,11 @@
ry=".4"
height="2.2"
width="3.4"
- class="scrn {{class}}"
- />
+ class="scrn" />
</g>
+ {{#links}}
+ </a>
+ {{/links}}
{{/machines}}
</g>
</svg>
diff --git a/modules-available/runmode/baseconfig/getconfig.inc.php b/modules-available/runmode/baseconfig/getconfig.inc.php
index 9c36cc75..a5de1053 100644
--- a/modules-available/runmode/baseconfig/getconfig.inc.php
+++ b/modules-available/runmode/baseconfig/getconfig.inc.php
@@ -1,20 +1,23 @@
<?php
-(function($machineUuid) {
+/** @var ?string $uuid */
+/** @var ?string $ip */
+
+if ($uuid !== null) {
$res = Database::queryFirst('SELECT module, modeid, modedata FROM runmode WHERE machineuuid = :uuid',
- array('uuid' => $machineUuid));
+ array('uuid' => $uuid));
if ($res === false)
return;
$config = RunMode::getModuleConfig($res['module']);
- if ($config === false)
+ if ($config === null)
return;
if (!Module::isAvailable($res['module']))
return; // Not really possible because getModuleConfig would have failed but we should make sure
if ($config->configHook !== false) {
- call_user_func($config->configHook, $machineUuid, $res['modeid'], $res['modedata']);
+ call_user_func($config->configHook, $uuid, $res['modeid'], $res['modedata']);
}
if ($config->systemdDefaultTarget !== false) {
ConfigHolder::add('SLX_SYSTEMD_TARGET', $config->systemdDefaultTarget, 10000);
}
ConfigHolder::add('SLX_RUNMODE_MODULE', $res['module']);
-})($uuid);
+}
diff --git a/modules-available/runmode/inc/runmode.inc.php b/modules-available/runmode/inc/runmode.inc.php
index 4d077f02..2d676cae 100644
--- a/modules-available/runmode/inc/runmode.inc.php
+++ b/modules-available/runmode/inc/runmode.inc.php
@@ -12,36 +12,36 @@ class RunMode
* Get runmode config for a specific module
*
* @param string $module name of module
- * @return \RunModeModuleConfig|false config, false if moudles doesn't support run modes
+ * @return ?RunModeModuleConfig config, null if module doesn't support run modes
*/
- public static function getModuleConfig($module)
+ public static function getModuleConfig(string $module): ?RunModeModuleConfig
{
if (isset(self::$moduleConfigs[$module]))
return self::$moduleConfigs[$module];
if (Module::get($module) === false)
- return false;
+ return null;
$file = 'modules/' . $module . '/hooks/runmode/config.json';
if (!file_exists($file))
- return false;
+ return null;
return (self::$moduleConfigs[$module] = new RunModeModuleConfig($file));
}
/**
- * @param string $machineuuid
* @param string|\Module $moduleId
* @param string|null $modeId an ID specific to the module to further specify the run mode, NULL to delete the run mode entry
* @param string|null $modeData optional, additional data for the run mode
* @param bool|null $isClient whether to count the machine as a client (in statistics etc.) NULL for looking at module's general runmode config
* @return bool whether it was set/deleted
*/
- public static function setRunMode($machineuuid, $moduleId, $modeId, $modeData = null, $isClient = null)
+ public static function setRunMode(string $machineuuid, $moduleId, ?string $modeId,
+ ?string $modeData = null, ?bool $isClient = null): bool
{
if (is_object($moduleId)) {
$moduleId = $moduleId->getIdentifier();
}
// - Check if machine exists
$machine = Statistics::getMachine($machineuuid, Machine::NO_DATA);
- if ($machine === false)
+ if ($machine === null)
return false;
// - Delete entry if mode is null
if ($modeId === null) {
@@ -72,25 +72,21 @@ class RunMode
* Change the isClient flag for existing client.
* @param string $machineUuid existing machine with some runmode
* @param string $moduleId module that assigned the current runmode of that client
- * @param bool $isClient
+ * @param bool $isClient should this machine be considered a normal client?
*/
- public static function updateClientFlag($machineUuid, $moduleId, $isClient)
+ public static function updateClientFlag(string $machineUuid, string $moduleId, bool $isClient): void
{
Database::exec('UPDATE runmode SET isclient = :isclient WHERE machineuuid = :uuid AND module = :module',
['uuid' => $machineUuid, 'module' => $moduleId, 'isclient' => ($isClient ? 1 : 0)]);
}
/**
- * @param string $machineuuid
* @param int $returnData bitfield of data to return
* @return false|array {'machineuuid', 'isclient', 'module', 'modeid', 'modedata',
* ('hostname', 'clientip', 'macaddr', 'locationid', 'lastseen'), ('moduleName', 'modeName')}
*/
- public static function getRunMode($machineuuid, $returnData = self::DATA_MACHINE_DATA)
+ public static function getRunMode(string $machineuuid, int $returnData = self::DATA_MACHINE_DATA)
{
- if ($returnData === true) {
- $returnData = self::DATA_MACHINE_DATA | self::DATA_DETAILED;
- }
if ($returnData & self::DATA_MACHINE_DATA) {
if ($returnData & self::DATA_DETAILED) {
$sel = ', m.hostname, m.clientip, m.macaddr, m.locationid, m.lastseen';
@@ -127,11 +123,11 @@ class RunMode
}
/**
- * @param string|\Module $module
- * @param bool true = wrap in array where key is modeid
- * @return array key=machineuuid, value={'machineuuid', 'modeid', 'modedata'}
+ * @param string|Module $module
+ * @param bool $groupByModeId true = wrap in array where key is modeid
+ * @return array - format depending on $groupByModeId
*/
- public static function getForModule($module, $groupByModeId = false)
+ public static function getForModule($module, bool $groupByModeId = false): array
{
if (is_object($module)) {
$module = $module->getIdentifier();
@@ -139,7 +135,7 @@ class RunMode
$res = Database::simpleQuery('SELECT machineuuid, modeid, modedata FROM runmode WHERE module = :module',
compact('module'));
$ret = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if ($groupByModeId) {
if (!isset($ret[$row['modeid']])) {
$ret[$row['modeid']] = array();
@@ -153,14 +149,14 @@ class RunMode
}
/**
- * @param string|\Module $module
- * @param string $modeId
+ * @param string|Module $module Module the mode belongs to
+ * @param string $modeId module-specific runmode identifier
* @param bool $detailed whether to return meta data about machine, not just machineuuid
* @param bool $assoc use machineuuid as array key
* @return array <key=machineuuid>, value={'machineuuid', 'modedata',
* <'hostname', 'clientip', 'macaddr', 'locationid', 'lastseen'>}
*/
- public static function getForMode($module, $modeId, $detailed = false, $assoc = false)
+ public static function getForMode($module, string $modeId, bool $detailed = false, bool $assoc = false): array
{
if (is_object($module)) {
$module = $module->getIdentifier();
@@ -177,7 +173,7 @@ class RunMode
WHERE module = :module AND modeid = :modeId",
compact('module', 'modeId'));
$ret = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if ($detailed && empty($row['hostname'])) {
$row['hostname'] = $row['clientip'];
}
@@ -192,11 +188,12 @@ class RunMode
/**
* Return assoc array of all configured clients.
+ *
* @param bool $withData also return data field?
- * @param bool $isClient true = return clients only, false = return non-clients only, null = return both
+ * @param bool|null $isClient true = return clients only, false = return non-clients only, null = return both
* @return array all the entries from the table
*/
- public static function getAllClients($withData = false, $isClient = null)
+ public static function getAllClients(bool $withData = false, bool $isClient = null): array
{
$xtra = '';
if ($withData) {
@@ -204,7 +201,7 @@ class RunMode
}
$res = Database::simpleQuery("SELECT machineuuid, module, modeid, isclient $xtra FROM runmode");
$ret = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if (!is_null($isClient) && ($row['isclient'] != 0) !== $isClient)
continue;
$ret[$row['machineuuid']] = $row;
@@ -218,11 +215,11 @@ class RunMode
* that method is passed through. getModeName by contract should return false if
* the module doesn't think the given modeId exists.
*
- * @param string|\Module $module
- * @param string $modeId
- * @return string|bool mode name if known, modeId as fallback, or false if mode is not known by module
+ * @param string|Module $module module the runmode belongs to
+ * @param string $modeId module-specific runmode identifier
+ * @return string|false mode name if known, modeId as fallback, or false if mode is not known by module
*/
- public static function getModeName($module, $modeId)
+ public static function getModeName($module, string $modeId)
{
if (is_object($module)) {
$module = $module->getIdentifier();
@@ -236,10 +233,10 @@ class RunMode
/**
* Delete given runmode.
*
- * @param string|\Module $module Module runmode belongs to
+ * @param string|Module $module Module runmode belongs to
* @param string $modeId run mode id
*/
- public static function deleteMode($module, $modeId)
+ public static function deleteMode($module, string $modeId): void
{
if (is_object($module)) {
$module = $module->getIdentifier();
@@ -297,7 +294,7 @@ class RunModeModuleConfig
*/
public $permission = false;
- public function __construct($file)
+ public function __construct(string $file)
{
$data = json_decode(file_get_contents($file), true);
if (!is_array($data))
@@ -313,19 +310,16 @@ class RunModeModuleConfig
$this->loadType($data, 'permission', 'string');
}
- private function loadType($data, $key, $type)
+ private function loadType(array $data, string $key, string $type): void
{
if (!isset($data[$key]))
- return false;
- if (is_string($type) && gettype($data[$key]) !== $type)
- return false;
- if (is_array($type) && !in_array(gettype($data[$key]), $type))
- return false;
+ return;
+ if (gettype($data[$key]) !== $type)
+ return;
$this->{$key} = $data[$key];
- return true;
}
- public function userHasPermission($locationId)
+ public function userHasPermission(?int $locationId): bool
{
return $this->permission === false || User::hasPermission($this->permission, $locationId);
}
diff --git a/modules-available/runmode/page.inc.php b/modules-available/runmode/page.inc.php
index 0b6dfa02..5654456a 100644
--- a/modules-available/runmode/page.inc.php
+++ b/modules-available/runmode/page.inc.php
@@ -30,7 +30,7 @@ class Page_RunMode extends Page
$module = Request::post('module', false, 'string');
$modeId = Request::post('modeid', false, 'string');
$modConfig = RunMode::getModuleConfig($module);
- if ($modConfig === false) {
+ if ($modConfig === null) {
Message::addError('runmode.module-hasnt-runmode', $module);
return;
}
@@ -70,7 +70,7 @@ class Page_RunMode extends Page
if ($oldMachineMode !== false) {
$machineLocation = $oldMachineMode['locationid'];
$oldModule = RunMode::getModuleConfig($oldMachineMode['module']);
- if ($oldModule !== false) {
+ if ($oldModule !== null) {
if ($oldMachineMode['module'] !== $module || $oldMachineMode['modeid'] !== $modeId) {
if (!$oldModule->allowGenericEditor || $oldModule->deleteUrlSnippet !== false) {
Message::addError('runmode.machine-still-assigned', $machine, $oldMachineMode['module']);
@@ -87,7 +87,7 @@ class Page_RunMode extends Page
} else {
// Not existing, no old mode - query machine to get location, so we can do a perm-check for new loc
$m = Statistics::getMachine($machine, Machine::NO_DATA);
- if ($m !== false) {
+ if ($m !== null) {
$machineLocation = $m->locationid;
}
}
@@ -134,7 +134,7 @@ class Page_RunMode extends Page
return;
}
$modConfig = RunMode::getModuleConfig($mode['module']);
- if ($modConfig === false) {
+ if ($modConfig === null) {
Message::addError('module-hasnt-runmode', $mode['moduleName']);
return;
}
@@ -172,7 +172,7 @@ class Page_RunMode extends Page
}
$module->activate(1, false);
$config = RunMode::getModuleConfig($moduleId);
- if ($config === false) {
+ if ($config === null) {
Message::addError('module-hasnt-runmode', $moduleId);
Util::redirect('?do=runmode');
}
@@ -187,7 +187,6 @@ class Page_RunMode extends Page
if (!$config->userHasPermission(null) && !User::hasPermission('list-all')) {
Message::addError('main.no-permission');
Util::redirect('?do=runmode');
- return;
}
// Show list of machines with assigned mode for this module
$this->renderClientList($moduleId);
@@ -198,19 +197,24 @@ class Page_RunMode extends Page
{
if ($onlyModule === false) {
$where = '';
+ $args = [];
} else {
$where = ' AND r.module = :moduleId ';
+ $args = ['moduleId' => $onlyModule];
}
$res = Database::simpleQuery("SELECT m.machineuuid, m.hostname, m.clientip, r.module, r.modeid, r.isclient"
. " FROM runmode r"
. " INNER JOIN machine m ON (m.machineuuid = r.machineuuid $where )"
- . " ORDER BY m.hostname ASC, m.clientip ASC", array('moduleId' => $onlyModule));
+ . " ORDER BY m.hostname ASC, m.clientip ASC", $args);
$modules = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if (!isset($modules[$row['module']])) {
if (!Module::isAvailable($row['module']))
continue;
- $modules[$row['module']] = array('config' => RunMode::getModuleConfig($row['module']), 'list' => array());
+ $config = RunMode::getModuleConfig($row['module']);
+ if ($config === null)
+ continue;
+ $modules[$row['module']] = array('config' => $config, 'list' => array());
}
if (empty($row['hostname'])) {
$row['hostname'] = $row['clientip'];
@@ -220,16 +224,20 @@ class Page_RunMode extends Page
}
$modules[$row['module']]['list'][] = $row;
}
+ $anythingOk = false;
foreach ($modules as $moduleId => $rows) {
$disabled = '';
if ($onlyModule === false) {
// Permissions - not required if rendering specific module, since it's been already done
+ // in $this->renderModule()
+ /** @var array{config: RunModeModuleConfig} $rows */
if (!$rows['config']->userHasPermission(null)) {
if (!User::hasPermission('list-all'))
continue;
$disabled = 'disabled';
} // </Permissions>
}
+ $anythingOk = true;
$module = Module::get($moduleId);
if ($module === false)
continue;
@@ -238,19 +246,17 @@ class Page_RunMode extends Page
'list' => $rows['list'],
'modulename' => $module->getDisplayName(),
'module' => $moduleId,
- 'canedit' => $config !== false && $config->allowGenericEditor && $config->deleteUrlSnippet === false,
+ 'canedit' => $config !== null && $config->allowGenericEditor && $config->deleteUrlSnippet === false,
'deleteUrl' => $config->deleteUrlSnippet,
'disabled' => $disabled,
));
}
+ if (!empty($modules) && !$anythingOk) {
+ User::assertPermission('list-all');
+ }
}
- /**
- * @param \Module $module
- * @param string $modeId
- * @param \RunModeModuleConfig $config
- */
- private function renderModuleMode($module, $modeId, $config)
+ private function renderModuleMode(Module $module, string $modeId, RunModeModuleConfig $config)
{
$moduleId = $module->getIdentifier();
$modeName = RunMode::getModeName($moduleId, $modeId);
@@ -274,7 +280,6 @@ class Page_RunMode extends Page
} else {
Message::addError('main.no-permission');
Util::redirect('?do=runmode');
- return;
}
$machines = RunMode::getForMode($module, $modeId, true);
if ($config->permission !== false) {
@@ -308,7 +313,7 @@ class Page_RunMode extends Page
$config = RunMode::getModuleConfig(Request::any('module', '', 'string'));
$returnObject = ['machines' => []];
- if ($config !== false) {
+ if ($config !== null) {
$params = ['query' => "%$query%"];
if ($config->permission === false) {
// Global
@@ -332,7 +337,7 @@ class Page_RunMode extends Page
LIMIT 100", $params);
$returnObject = [
- 'machines' => $result->fetchAll(PDO::FETCH_ASSOC)
+ 'machines' => $result->fetchAll()
];
}
}
diff --git a/modules-available/serversetup-bwlp-ipxe/api.inc.php b/modules-available/serversetup-bwlp-ipxe/api.inc.php
index 4ca9fdec..dc78f481 100644
--- a/modules-available/serversetup-bwlp-ipxe/api.inc.php
+++ b/modules-available/serversetup-bwlp-ipxe/api.inc.php
@@ -1,9 +1,20 @@
<?php
(function() {
- $type = Request::any('type');
+ $type = Request::any('type', null, 'string');
+ if ($type === null && isset($_SERVER['HTTP_USER_AGENT'])) {
+ if (preg_match('/^iPXE/', $_SERVER['HTTP_USER_AGENT'])) {
+ $type = 'ipxe';
+ } elseif (preg_match('/^GRUB/', $_SERVER['HTTP_USER_AGENT'])) {
+ $type = 'grub';
+ } elseif (preg_match('/wget|curl/i', $_SERVER['HTTP_USER_AGENT'])) {
+ $type = 'bash';
+ }
+ }
if ($type === 'bash') {
$builder = new ScriptBuilderBash();
+ } elseif ($type === 'grub') {
+ $builder = new ScriptBuilderGrub();
} else {
$builder = new ScriptBuilderIpxe();
}
diff --git a/modules-available/serversetup-bwlp-ipxe/hooks/ipxe-update.inc.php b/modules-available/serversetup-bwlp-ipxe/hooks/ipxe-update.inc.php
index c58a64ae..76f8cfa2 100644
--- a/modules-available/serversetup-bwlp-ipxe/hooks/ipxe-update.inc.php
+++ b/modules-available/serversetup-bwlp-ipxe/hooks/ipxe-update.inc.php
@@ -1,12 +1,22 @@
<?php
+$get = Module::get('serversetup');
+if ($get === false)
+ return;
+
+$get->activate(1, false);
+
$data = [
- 'ipaddress' => Property::getServerIp()
+ 'ipaddress' => Property::getServerIp(),
+ 'parentTask' => $taskId,
];
if ($data['ipaddress'] === 'invalid')
- return false;
+ return null;
$task = Taskmanager::submit('CompileIPxeNew', $data);
-if (Taskmanager::isFailed($task))
- return false;
-Property::set('ipxe-task-id', $task['id'], 15);
+if (Taskmanager::isFailed($task)) {
+ error_log(print_r($task, true));
+ return null;
+}
+TaskmanagerCallback::addCallback($task, 'ipxeCompileDone');
+Property::set(IPxeBuilder::PROP_IPXE_COMPILE_TASKID, $task['id'], 15);
return $task['id']; \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-ipxe/hooks/locations-column.inc.php b/modules-available/serversetup-bwlp-ipxe/hooks/locations-column.inc.php
new file mode 100644
index 00000000..d3290b62
--- /dev/null
+++ b/modules-available/serversetup-bwlp-ipxe/hooks/locations-column.inc.php
@@ -0,0 +1,57 @@
+<?php
+
+if (!User::hasPermission('.serversetup.ipxe.menu.assign')
+ || !Module::isAvailable('serversetup')
+ || !class_exists('IPxe')) {
+ return null;
+}
+
+class IpxeLocationColumn extends AbstractLocationColumn
+{
+
+ private $lookup = [];
+
+ public function __construct(array $allowedLocationIds)
+ {
+ $res = Database::simpleQuery("SELECT ml.locationid, m.title, ml.defaultentryid FROM serversetup_menu m
+ INNER JOIN serversetup_menu_location ml USING (menuid)
+ WHERE locationid IN (:allowedLocationIds) GROUP BY locationid", compact('allowedLocationIds'));
+ foreach ($res as $row) {
+ $lid = (int)$row['locationid'];
+ if ($row['defaultentryid'] !== null) {
+ $row['title'] .= '(*)';
+ }
+ $this->lookup[$lid] = $row['title'];
+ }
+ }
+
+ public function getColumnHtml(int $locationId): string
+ {
+ return htmlspecialchars($this->lookup[$locationId] ?? '');
+ }
+
+ public function getEditUrl(int $locationId): string
+ {
+ if (!User::hasPermission('.serversetup.ipxe.menu.assign', $locationId))
+ return '';
+ return '?do=serversetup&show=assignlocation&locationid=' . $locationId;
+ }
+
+ public function header(): string
+ {
+ return Dictionary::translateFileModule('serversetup', 'module', 'location-column-header');
+ }
+
+ public function priority(): int
+ {
+ return 3000;
+ }
+
+ public function propagateColumn(): bool
+ {
+ return true;
+ }
+
+}
+
+return new IpxeLocationColumn($allowedLocationIds); \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-ipxe/inc/bootentry.inc.php b/modules-available/serversetup-bwlp-ipxe/inc/bootentry.inc.php
index 614f5ee4..5812c0cd 100644
--- a/modules-available/serversetup-bwlp-ipxe/inc/bootentry.inc.php
+++ b/modules-available/serversetup-bwlp-ipxe/inc/bootentry.inc.php
@@ -12,17 +12,28 @@ abstract class BootEntry
/** Supports both via distinct entry */
const BOTH = 'PCBIOS-EFI';
- public abstract function supportsMode($mode);
-
/**
- * @param ScriptBuilderBase $builder
- * @return string
+ * @var string Internal ID - set to your liking, e.g. the MiniLinux version identifier
*/
- public abstract function toScript($builder);
+ protected $internalId;
+
+ public function __construct(string $internalId)
+ {
+ $this->internalId = $internalId;
+ }
+
+ public abstract function supportsMode(string $mode): bool;
- public abstract function toArray();
+ public abstract function toScript(ScriptBuilderBase $builder): string;
- public abstract function addFormFields(&$array);
+ public abstract function toArray(): array;
+
+ public abstract function addFormFields(array &$array): void;
+
+ public function internalId(): string
+ {
+ return $this->internalId;
+ }
/*
*
@@ -32,15 +43,15 @@ abstract class BootEntry
* Return a BootEntry instance from the serialized data.
*
* @param string $module module this entry belongs to, or special values .script/.exec
- * @param string $jsonString serialized entry data
- * @return BootEntry|null instance representing boot entry, null on error
+ * @param string $data serialized entry data
+ * @return ?BootEntry instance representing boot entry, null on error
*/
- public static function fromJson($module, $data)
+ public static function fromJson(string $module, string $data): ?BootEntry
{
- if ($module{0} !== '.') {
+ if ($module[0] !== '.') {
// Hook from other module
$hook = Hook::loadSingle($module, 'ipxe-bootentry');
- if ($hook === false) {
+ if ($hook === null) {
error_log('Module ' . $module . ' doesnt have an ipxe-bootentry hook');
return null;
}
@@ -49,9 +60,9 @@ abstract class BootEntry
return null;
return $ret->getBootEntry($data);
}
- if (is_string($data)) {
- $data = json_decode($data, true);
- }
+ $data = json_decode($data, true);
+ if (!is_array($data))
+ return null;
if ($module === '.script') {
return new CustomBootEntry($data);
}
@@ -64,14 +75,14 @@ abstract class BootEntry
return null;
}
- public static function forMenu($menuId)
+ public static function forMenu(int $menuId): MenuBootEntry
{
return new MenuBootEntry($menuId);
}
- public static function newStandardBootEntry($initData, $efi = false, $arch = false)
+ public static function newStandardBootEntry($initData, $efi = false, $arch = false, string $internalId = ''): ?StandardBootEntry
{
- $ret = new StandardBootEntry($initData, $efi, $arch);
+ $ret = new StandardBootEntry($initData, $efi, $arch, $internalId);
$list = [];
if ($ret->arch() !== self::EFI) {
$list[] = self::BIOS;
@@ -89,7 +100,7 @@ abstract class BootEntry
return $ret;
}
- public static function newCustomBootEntry($initData)
+ public static function newCustomBootEntry($initData): ?CustomBootEntry
{
if (!is_array($initData) || empty($initData))
return null;
@@ -99,15 +110,14 @@ abstract class BootEntry
/**
* Return a BootEntry instance from database with the given id.
*
- * @param string $id
- * @return BootEntry|null|false false == unknown id, null = unknown entry type, BootEntry instance on success
+ * @return ?BootEntry null = unknown entry type, BootEntry instance on success
*/
- public static function fromDatabaseId($id)
+ public static function fromDatabaseId(string $id): ?BootEntry
{
$row = Database::queryFirst("SELECT module, data FROM serversetup_bootentry
WHERE entryid = :id LIMIT 1", ['id' => $id]);
if ($row === false)
- return false;
+ return null;
return self::fromJson($row['module'], $row['data']);
}
@@ -117,11 +127,11 @@ abstract class BootEntry
*
* @return BootEntry[] all existing BootEntries
*/
- public static function getAll()
+ public static function getAll(): array
{
- $res = Database::simpleQuery("SELECT entryid, data FROM serversetup_bootentry");
+ $res = Database::simpleQuery("SELECT entryid, module, data FROM serversetup_bootentry");
$ret = [];
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$tmp = self::fromJson($row['module'], $row['data']);
if ($tmp === null)
continue;
@@ -143,14 +153,15 @@ class StandardBootEntry extends BootEntry
*/
protected $efi;
/**
- * @var string BootEntry Constants above
+ * @var ?string BootEntry Constants above
*/
protected $arch;
const KEYS = ['executable', 'initRd', 'commandLine', 'replace', 'imageFree', 'autoUnload', 'resetConsole', 'dhcpOptions'];
- public function __construct($data, $efi = false, $arch = false)
+ public function __construct($data, $efi = false, ?string $arch = null, string $internalId = '')
{
+ parent::__construct($internalId);
$this->pcbios = new ExecData();
$this->efi = new ExecData();
if ($data instanceof PxeSection) {
@@ -223,10 +234,7 @@ class StandardBootEntry extends BootEntry
}
}
- /**
- * @param PxeSection $data
- */
- private function fromPxeMenu($data)
+ private function fromPxeMenu(PxeSection $data): void
{
$bios = $this->pcbios;
$bios->executable = $data->kernel;
@@ -250,12 +258,12 @@ class StandardBootEntry extends BootEntry
$bios->commandLine = trim(preg_replace('/\s+/', ' ', $bios->commandLine));
}
- public function arch()
+ public function arch(): ?string
{
return $this->arch;
}
- public function supportsMode($mode)
+ public function supportsMode(string $mode): bool
{
if ($mode === $this->arch || $this->arch === BootEntry::AGNOSTIC)
return true;
@@ -266,7 +274,7 @@ class StandardBootEntry extends BootEntry
return false;
}
- public function toScript($builder)
+ public function toScript(ScriptBuilderBase $builder): string
{
if ($this->arch === BootEntry::AGNOSTIC) // Same as below, could construct fall-through but this is more clear
return $builder->execDataToScript($this->pcbios, null, null);
@@ -275,7 +283,7 @@ class StandardBootEntry extends BootEntry
$this->supportsMode(BootEntry::EFI) ? $this->efi : null);
}
- public function addFormFields(&$array)
+ public function addFormFields(array &$array): void
{
$array[$this->arch . '_selected'] = 'selected';
$array['entries'][] = $this->pcbios->toFormFields(BootEntry::BIOS);
@@ -283,7 +291,10 @@ class StandardBootEntry extends BootEntry
$array['exec_checked'] = 'checked';
}
- public function toArray()
+ /**
+ * @return array{PCBIOS: array, EFI: array, arch: string}
+ */
+ public function toArray(): array
{
return [
BootEntry::BIOS => $this->pcbios->toArray(),
@@ -298,7 +309,7 @@ class CustomBootEntry extends BootEntry
/**
* @var string iPXE
*/
- protected $ipxe;
+ protected $ipxe = '';
protected $bash;
@@ -306,6 +317,7 @@ class CustomBootEntry extends BootEntry
public function __construct($data)
{
+ parent::__construct('custom');
if (is_array($data)) {
$this->ipxe = $data['script'] ?? ''; // LEGACY
foreach (['bash', 'grub'] as $key) {
@@ -314,21 +326,24 @@ class CustomBootEntry extends BootEntry
}
}
- public function supportsMode($mode)
+ public function supportsMode(string $mode): bool
{
return true;
}
- public function toScript($builder)
+ public function toScript(ScriptBuilderBase $builder): string
{
+ // TODO: A (very) simple translator for oneliners like "poweroff || goto fail" maybe?
if ($builder instanceof ScriptBuilderIpxe)
return $this->ipxe;
if ($builder instanceof ScriptBuilderBash)
return $this->bash;
+ if ($builder instanceof ScriptBuilderGrub)
+ return $this->grub;
return '';
}
- public function addFormFields(&$array)
+ public function addFormFields(array &$array): void
{
$array['entry'] = [
'script' => $this->ipxe,
@@ -336,7 +351,10 @@ class CustomBootEntry extends BootEntry
$array['script_checked'] = 'checked';
}
- public function toArray()
+ /**
+ * @return array{script: string}
+ */
+ public function toArray(): array
{
return ['script' => $this->ipxe];
}
@@ -344,30 +362,32 @@ class CustomBootEntry extends BootEntry
class MenuBootEntry extends BootEntry
{
+ /** @var int */
protected $menuId;
- public function __construct($menuId)
+ public function __construct(int $menuId)
{
+ parent::__construct('menu-' . $menuId);
$this->menuId = $menuId;
}
- public function supportsMode($mode)
+ public function supportsMode(string $mode): bool
{
return true;
}
- public function toScript($builder)
+ public function toScript(ScriptBuilderBase $builder): string
{
- $menu = IPxeMenu::get($this->menuId);
+ $menu = IPxeMenu::get($this->menuId, true);
return $builder->menuToScript($menu);
}
- public function toArray()
+ public function toArray(): array
{
return [];
}
- public function addFormFields(&$array)
+ public function addFormFields(array &$array): void
{
}
}
@@ -380,23 +400,24 @@ class SpecialBootEntry extends BootEntry
public function __construct($type)
{
$this->type = $type['type'] ?? $type;
+ parent::__construct('special-' . $this->type);
}
- public function supportsMode($mode)
+ public function supportsMode(string $mode): bool
{
return true;
}
- public function toScript($builder)
+ public function toScript(ScriptBuilderBase $builder): string
{
return $builder->getSpecial($this->type);
}
- public function toArray()
+ public function toArray(): array
{
return [];
}
- public function addFormFields(&$array) { }
+ public function addFormFields(array &$array): void { }
-} \ No newline at end of file
+}
diff --git a/modules-available/serversetup-bwlp-ipxe/inc/bootentryhook.inc.php b/modules-available/serversetup-bwlp-ipxe/inc/bootentryhook.inc.php
index 73611b0a..ab55c888 100644
--- a/modules-available/serversetup-bwlp-ipxe/inc/bootentryhook.inc.php
+++ b/modules-available/serversetup-bwlp-ipxe/inc/bootentryhook.inc.php
@@ -6,41 +6,37 @@ abstract class BootEntryHook
/**
* @var string -- set by ipxe, not module implementing hook
*/
- public $moduleId;
+ public $moduleId = '';
/**
* @var string -- set by ipxe, not module implementing hook
*/
- public $checked;
+ public $checked = '';
- private $selectedId;
+ private $selectedId = '';
private $data = [];
/**
* @return string
*/
- public abstract function name();
+ public abstract function name(): string;
/**
* @return HookExtraField[]
*/
- public abstract function extraFields();
+ public abstract function extraFields(): array;
- /**
- * @param string $id
- * @return bool
- */
- public abstract function isValidId($id);
+ public abstract function isValidId(string $id): bool;
/**
* @return HookEntryGroup[]
*/
- protected abstract function groupsInternal();
+ protected abstract function groupsInternal(): array;
/**
* @return HookEntryGroup[]
*/
- public final function groups()
+ public final function groups(): array
{
$groups = $this->groupsInternal();
foreach ($groups as $group) {
@@ -54,16 +50,13 @@ abstract class BootEntryHook
}
/**
- * @param $id
* @return BootEntry|null the actual boot entry instance for given entry, null if invalid id
*/
- public abstract function getBootEntryInternal($localData);
+ public abstract function getBootEntryInternal(array $localData): ?BootEntry;
- public final function getBootEntry($data)
+ public final function getBootEntry(string $jsonString): ?BootEntry
{
- if (!is_array($data)) {
- $data = json_decode($data, true);
- }
+ $data = json_decode($jsonString, true);
return $this->getBootEntryInternal($data);
}
@@ -71,7 +64,7 @@ abstract class BootEntryHook
* @param string $mixed either the plain ID if the entry to be marked as selected, or the JSON string representing
* the entire entry, which must have a key called 'id' that will be used as the ID then.
*/
- public function setSelected($mixed)
+ public function setSelected(string $mixed): void
{
$json = @json_decode($mixed, true);
if (is_array($json)) {
@@ -86,16 +79,19 @@ abstract class BootEntryHook
/**
* @return string ID of entry that was marked as selected by setSelected()
*/
- public function getSelected()
+ public function getSelected(): string
{
return $this->selectedId;
}
- public function renderExtraFields()
+ /**
+ * @return HookExtraField[]
+ */
+ public function renderExtraFields(): array
{
$list = $this->extraFields();
- foreach ($list as &$entry) {
- $entry->currentValue = isset($this->data[$entry->name]) ? $this->data[$entry->name] : $entry->default;
+ foreach ($list as $entry) {
+ $entry->currentValue = $this->data[$entry->name] ?? $entry->default;
$entry->hook = $this;
}
return $list;
@@ -144,14 +140,7 @@ class HookEntry
*/
public $selected;
- /**
- * HookEntry constructor.
- *
- * @param string $id
- * @param string $name
- * @param bool $valid
- */
- public function __construct($id, $name, $valid)
+ public function __construct(string $id, string $name, bool $valid)
{
$this->id = $id;
$this->name = $name;
@@ -182,7 +171,7 @@ class HookExtraField
*/
public $hook;
- public function __construct($name, $type, $default)
+ public function __construct(string $name, string $type, $default)
{
$this->name = $name;
$this->type = $type;
@@ -203,10 +192,10 @@ class HookExtraField
return $val;
}
- public function html()
+ public function html(): string
{
$fieldId = 'extra-' . $this->hook->moduleId . '-' . $this->name;
- $fieldText = htmlspecialchars(Dictionary::translateFileModule($this->hook->moduleId, 'module', 'ipxe-' . $this->name, true));
+ $fieldText = htmlspecialchars(Dictionary::translateFileModule($this->hook->moduleId, 'module', 'ipxe-' . $this->name));
if (is_array($this->type)) {
$out = '<label for="' . $fieldId . '">' . $fieldText . '</label><select class="form-control" name="' . $fieldId . '" id="' . $fieldId . '">';
foreach ($this->type as $entry) {
diff --git a/modules-available/serversetup-bwlp-ipxe/inc/execdata.inc.php b/modules-available/serversetup-bwlp-ipxe/inc/execdata.inc.php
index 1f6fa265..e4f7a1d7 100644
--- a/modules-available/serversetup-bwlp-ipxe/inc/execdata.inc.php
+++ b/modules-available/serversetup-bwlp-ipxe/inc/execdata.inc.php
@@ -108,7 +108,10 @@ class ExecData
$this->dhcpOptions = array_values($this->dhcpOptions);
}
- public function toArray()
+ /**
+ * @return array{executable: string, initRd: string[], commandLine: string, imageFree: bool, replace: bool, autoUnload: bool, resetConsole: bool, dhcpOptions: array}
+ */
+ public function toArray(): array
{
$this->sanitize();
return [
@@ -123,7 +126,7 @@ class ExecData
];
}
- public function toFormFields($arch)
+ public function toFormFields(string $arch): array
{
$this->sanitize();
$opts = [];
diff --git a/modules-available/serversetup-bwlp-ipxe/inc/ipxe.inc.php b/modules-available/serversetup-bwlp-ipxe/inc/ipxe.inc.php
index 29885588..5e0531ab 100644
--- a/modules-available/serversetup-bwlp-ipxe/inc/ipxe.inc.php
+++ b/modules-available/serversetup-bwlp-ipxe/inc/ipxe.inc.php
@@ -12,13 +12,13 @@ class IPxe
* Import all IP-Range based pxe menus from the given directory.
*
* @param string $configPath The pxelinux.cfg path where to look for menu files in hexadecimal IP format.
- * @return Number of menus imported
+ * @return int Number of menus imported
*/
- public static function importSubnetPxeMenus($configPath)
+ public static function importSubnetPxeMenus(string $configPath): int
{
$res = Database::simpleQuery('SELECT menuid, entryid FROM serversetup_menuentry ORDER BY sortval ASC');
$menus = [];
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if (!isset($menus[$row['menuid']])) {
$menus[(int)$row['menuid']] = [];
}
@@ -40,7 +40,7 @@ class IPxe
WHERE startaddr >= :start AND endaddr <= :end", compact('start', 'end'));
$locations = [];
// Iterate over result, eliminate those that are dominated by others
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
foreach ($locations as &$loc) {
if ($row['startaddr'] <= $loc['startaddr'] && $row['endaddr'] >= $loc['endaddr']) {
$loc = false;
@@ -52,6 +52,10 @@ class IPxe
$locations[] = $row;
}
$menu = PxeLinux::parsePxeLinux($content, true);
+ if ($menu === null) {
+ error_log("Skipping empty pxelinux menu file $file");
+ continue;
+ }
// Insert all entries first, so we can get the list of entry IDs
$entries = [];
self::importPxeMenuEntries($menu, $entries);
@@ -83,10 +87,10 @@ class IPxe
} else {
error_log('Imported menu ' . $menu->title . ' is NEW, using for ' . count($locations) . ' locations.');
// Insert new menu
- $menuId = self::insertMenu($menu, 'Auto Imported', false, 0, [], []);
- if ($menuId === false)
+ $menuId = self::insertMenu($menu, 'Auto Imported', null, 0, [], []);
+ if ($menuId === null)
continue;
- $menus[(int)$menuId] = $entries;
+ $menus[$menuId] = $entries;
$importCount++;
}
foreach ($locations as $loc) {
@@ -103,20 +107,20 @@ class IPxe
return $importCount;
}
- public static function importLegacyMenu($force = false)
+ public static function importLegacyMenu(bool $force = false): bool
{
// See if anything is there
if (!$force && false !== Database::queryFirst("SELECT menuentryid FROM serversetup_menuentry LIMIT 1"))
return false; // Already exists
// Now create the default entry
self::createDefaultEntries();
- $prepend = ['bwlp-default' => false, 'localboot' => false];
+ $prepend = ['bwlp-default' => null, 'localboot' => null];
$defaultLabel = 'bwlp-default';
$menuTitle = 'bwLehrpool Bootauswahl';
$pxeConfig = '';
$timeoutSecs = 60;
- // Try to import any customization
- $oldMenu = Property::getBootMenu();
+ // Try to import any customization of the legacy PXELinux menu (despite the property name hinting at iPXE)
+ $oldMenu = json_decode(Property::get('ipxe-menu'), true);
if (is_array($oldMenu)) {
//
if (isset($oldMenu['timeout'])) {
@@ -136,47 +140,45 @@ class IPxe
}
}
$append = [
- '',
- 'bwlp-default-dbg' => false,
- '',
- 'poweroff' => false,
+ new PxeSection(null),
+ 'bwlp-default-dbg' => null,
+ new PxeSection(null),
+ 'poweroff' => null,
];
- return self::insertMenu(PxeLinux::parsePxeLinux($pxeConfig, false), $menuTitle, $defaultLabel, $timeoutSecs, $prepend, $append);
+ self::insertMenu(PxeLinux::parsePxeLinux($pxeConfig, false), $menuTitle, $defaultLabel, $timeoutSecs, $prepend, $append);
+ return !empty($pxeConfig);
}
/**
- * @param PxeMenu $pxeMenu
- * @param string $menuTitle
- * @param string|false $defaultLabel Fallback for the default label, if PxeMenu doesn't set one
+ * @param ?string $defaultLabel Fallback for the default label, if PxeMenu doesn't set one
* @param int $defaultTimeoutSeconds Default timeout, if PxeMenu doesn't set one
- * @param array $prepend
- * @param array $append
- * @return int|false
+ * @param (?PxeSection)[] $prepend
+ * @param (?PxeSection)[] $append
+ * @return ?int ID of newly created menu, or null on error, e.g. if the menu is empty
*/
- public static function insertMenu($pxeMenu, $menuTitle, $defaultLabel, $defaultTimeoutSeconds, $prepend, $append)
+ public static function insertMenu(?PxeMenu $pxeMenu, string $menuTitle, ?string $defaultLabel, int $defaultTimeoutSeconds,
+ array $prepend, array $append): ?int
{
$timeoutMs = [];
$menuEntries = $prepend;
- settype($menuEntries, 'array');
- if (!empty($pxeMenu)) {
- $pxe =& $pxeMenu;
- if (!empty($pxe->title)) {
- $menuTitle = $pxe->title;
+ if ($pxeMenu !== null) {
+ if (!empty($pxeMenu->title)) {
+ $menuTitle = $pxeMenu->title;
}
- if ($pxe->timeoutLabel !== null && $pxe->hasLabel($pxe->timeoutLabel)) {
- $defaultLabel = $pxe->timeoutLabel;
- } elseif ($pxe->hasLabel($pxe->default)) {
- $defaultLabel = $pxe->default;
+ if ($pxeMenu->timeoutLabel !== null && $pxeMenu->hasLabel($pxeMenu->timeoutLabel)) {
+ $defaultLabel = $pxeMenu->timeoutLabel;
+ } elseif ($pxeMenu->hasLabel($pxeMenu->default)) {
+ $defaultLabel = $pxeMenu->default;
}
- $timeoutMs[] = $pxe->timeoutMs;
- $timeoutMs[] = $pxe->totalTimeoutMs;
- self::importPxeMenuEntries($pxe, $menuEntries);
+ $timeoutMs[] = $pxeMenu->timeoutMs;
+ $timeoutMs[] = $pxeMenu->totalTimeoutMs;
+ self::importPxeMenuEntries($pxeMenu, $menuEntries);
}
- if (is_array($append)) {
+ if (!empty($append)) {
$menuEntries += $append;
}
if (empty($menuEntries))
- return false;
+ return null;
// Make menu
$timeoutMs = array_filter($timeoutMs, function($x) { return is_int($x) && $x > 0; });
if (empty($timeoutMs)) {
@@ -195,25 +197,29 @@ class IPxe
// Figure out entryid for default label
// Fiddly diddly way of getting the mangled entryid for the wanted pxe menu label
$defaultEntryId = false;
+ $fallbackDefault = false;
foreach ($menuEntries as $entryId => $section) {
- if ($section instanceof PxeSection) {
- if ($section->isDefault) {
- $defaultEntryId = $entryId;
- break;
- }
- if ($section->label === $defaultLabel) {
- $defaultEntryId = $entryId;
- }
+ if ($section === null)
+ continue;
+ if ($section->isDefault) {
+ $defaultEntryId = $entryId;
+ break;
+ }
+ if ($section->label === $defaultLabel) {
+ $defaultEntryId = $entryId;
+ }
+ if ($fallbackDefault === false && !empty($entryId)) {
+ $fallbackDefault = $entryId;
}
}
if ($defaultEntryId === false) {
- $defaultEntryId = array_keys($menuEntries)[0];
+ $defaultEntryId = $fallbackDefault;
}
// Link boot entries to menu
$defaultMenuEntryId = null;
$order = 1000;
foreach ($menuEntries as $entryId => $entry) {
- if (is_string($entry)) {
+ if ($entry !== null && $entry->isTextOnly()) {
// Gap entry
Database::exec("INSERT INTO serversetup_menuentry
(menuid, entryid, hotkey, title, hidden, sortval, plainpass, md5pass)
@@ -221,7 +227,7 @@ class IPxe
'menuid' => $menuId,
'entryid' => null,
'hotkey' => '',
- 'title' => self::sanitizeIpxeString($entry),
+ 'title' => self::sanitizeIpxeString($entry->title),
'hidden' => 0,
'sortval' => $order += 100,
]);
@@ -232,7 +238,7 @@ class IPxe
continue;
$data['pass'] = '';
$data['hidden'] = 0;
- if ($entry instanceof PxeSection) {
+ if ($entry !== null) {
$data['hidden'] = (int)$entry->isHidden;
// Prefer explicit data from this imported menu over the defaults
$title = self::sanitizeIpxeString($entry->title);
@@ -243,7 +249,7 @@ class IPxe
$data['hotkey'] = $entry->hotkey;
}
if (!empty($entry->passwd)) {
- // Most likely it's a hash so we cannot recover; ask people to reset
+ // Most likely it's a hash, so we cannot recover; ask people to reset
$data['pass'] ='please_reset';
}
}
@@ -268,36 +274,28 @@ class IPxe
/**
* Import only the bootentries from the given PXELinux menu
- * @param PxeMenu $pxe
- * @param array $menuEntries Where to append the generated menu items to
+ *
+ * @param PxeSection[] $menuEntries Where to append the generated menu items to
*/
- public static function importPxeMenuEntries($pxe, &$menuEntries)
+ public static function importPxeMenuEntries(PxeMenu $pxe, array &$menuEntries): void
{
if (self::$allEntries === false) {
self::$allEntries = BootEntry::getAll();
}
foreach ($pxe->sections as $section) {
- if ($section->localBoot !== false || preg_match('/chain\.c32$/i', $section->kernel)) {
+ if ($section->isLocalboot()) {
$menuEntries['localboot'] = $section;
continue;
}
- if ($section->label === null) {
- if (!$section->isHidden && !empty($section->title)) {
- $menuEntries[] = $section->title;
- }
- continue;
- }
- if (empty($section->kernel)) {
- if (!$section->isHidden && !empty($section->title)) {
- $menuEntries[] = $section->title;
- }
+ if ($section->isTextOnly()) {
+ $menuEntries[] = $section;
continue;
}
$label = self::cleanLabelFixLocal($section);
$entry = self::pxe2BootEntry($section);
if ($entry === null)
continue; // Error? Ignore
- if ($label !== false || ($label = array_search($entry, self::$allEntries))) {
+ if ($label !== false || ($label = array_search($entry, self::$allEntries)) !== false) {
// Exact Duplicate, Do Nothing
error_log('Ignoring duplicate boot entry ' . $section->label . ' (' . $section->kernel . ')');
} else {
@@ -321,7 +319,7 @@ class IPxe
Database::exec('INSERT IGNORE INTO serversetup_bootentry (entryid, module, hotkey, title, builtin, data)
VALUES (:label, :module, :hotkey, :title, 0, :data)', [
'label' => $label,
- 'module' => ($entry instanceof \StandardBootEntry) ? '.exec' : '.script',
+ 'module' => ($entry instanceof StandardBootEntry) ? '.exec' : '.script',
'hotkey' => $hotkey,
'title' => $title,
'data' => json_encode($data),
@@ -408,10 +406,9 @@ class IPxe
* Also it patches the entry if it's referencing the local bwlp install
* but with different options.
*
- * @param PxeSection $section
* @return string|false existing label if match, false otherwise
*/
- private static function cleanLabelFixLocal($section)
+ private static function cleanLabelFixLocal(PxeSection $section)
{
$myip = Property::getServerIp();
// Detect our "old" entry types
@@ -434,10 +431,9 @@ class IPxe
}
/**
- * @param PxeSection $section
* @return BootEntry|null The according boot entry, null if it's unparsable
*/
- private static function pxe2BootEntry($section)
+ private static function pxe2BootEntry(PxeSection $section): ?BootEntry
{
if (preg_match('/(pxechain\.com|pxechn\.c32)$/i', $section->kernel)) {
// Chaining -- create script
@@ -466,7 +462,7 @@ class IPxe
$script .= "set netX/{$opt}:{$type} {$args[$i]} || goto %fail%\n";
}
}
- } elseif ($arg{0} === '-') {
+ } elseif ($arg[0] === '-') {
continue;
} elseif ($file === false) {
$file = self::parseFile($arg);
@@ -496,11 +492,8 @@ class IPxe
/**
* Parse PXELINUX file notion. Basically, turn
* server::file into tftp://server/file.
- *
- * @param string $file
- * @return string
*/
- private static function parseFile($file)
+ private static function parseFile(string $file): string
{
if (preg_match(',^([^:/]+)::(.*)$,', $file, $out)) {
return 'tftp://' . $out[1] . '/' . $out[2];
@@ -508,12 +501,12 @@ class IPxe
return $file;
}
- public static function sanitizeIpxeString($string)
+ public static function sanitizeIpxeString(string $string): string
{
return str_replace(['&', '|', ';', '$', "\r", "\n"], ['+', '/', ':', 'S', ' ', ' '], $string);
}
- public static function makeMd5Pass($plainpass, $salt)
+ public static function makeMd5Pass(string $plainpass, string $salt): string
{
if (empty($plainpass))
return '';
@@ -528,15 +521,16 @@ class IPxe
* remove any occurrence of either "option" or "option=something". If the argument starts with a
* '+', it will be added to the command line after removing the '+'. If the argument starts with any
* other character, it will also be added to the command line.
+ *
* @param string $cmdLine command line to modify
- * @param string $modifier modification string of space separated arguments
+ * @param string $modifier modification string of space separated arguments
* @return string the modified command line
*/
- public static function modifyCommandLine($cmdLine, $modifier)
+ public static function modifyCommandLine(string $cmdLine, string $modifier): string
{
$items = preg_split('/\s+/', $modifier, -1, PREG_SPLIT_NO_EMPTY);
foreach ($items as $item) {
- if ($item{0} === '-') {
+ if ($item[0] === '-') {
$item = preg_quote(substr($item, 1), '/');
$cmdLine = preg_replace('/(^|\s)' . $item . '(=\S*)?($|\s)/', ' ', $cmdLine);
} else {
diff --git a/modules-available/serversetup-bwlp-ipxe/inc/ipxebuilder.inc.php b/modules-available/serversetup-bwlp-ipxe/inc/ipxebuilder.inc.php
new file mode 100644
index 00000000..a2b25f55
--- /dev/null
+++ b/modules-available/serversetup-bwlp-ipxe/inc/ipxebuilder.inc.php
@@ -0,0 +1,80 @@
+<?php
+
+class IPxeBuilder
+{
+
+ const PROP_IPXE_BUILDSTRING = 'ipxe.compile-time';
+ const PROP_IPXE_HASH = 'ipxe.commit-hash';
+ const PROP_IPXE_COMPILE_TASKID = 'ipxe-task-id';
+ const VERSION_LIST_TASK = 'ipxe-version-list-id';
+ const PROP_VERSION_SELECT_TASKID = 'ipxe-version-select-id';
+
+ /**
+ * Checkout given commit/ref of ipxe repo. Returns the according task-id, or null on error
+ *
+ * @param string $version version ref (commit, tag, ...)
+ * @param ?string $parent parent task id, if any
+ */
+ public static function setIpxeVersion(string $version, ?string $parent = null): ?string
+ {
+ $task = Taskmanager::submit('IpxeVersion', [
+ 'action' => 'CHECKOUT',
+ 'ref' => $version,
+ 'parentTask' => $parent,
+ ]);
+ if (!Taskmanager::isTask($task))
+ return null;
+ TaskmanagerCallback::addCallback($task, 'ipxeVersionSet');
+ Property::set(IPxeBuilder::PROP_VERSION_SELECT_TASKID, $task['id'], 2);
+ return $task['id'];
+ }
+
+ public static function getVersionTaskResult(): ?array
+ {
+ $task = Taskmanager::status(IPxeBuilder::VERSION_LIST_TASK);
+ if (!Taskmanager::isTask($task) || Taskmanager::isFailed($task)) {
+ $task = Taskmanager::submit('IpxeVersion',
+ ['id' => IPxeBuilder::VERSION_LIST_TASK, 'action' => 'LIST']);
+ }
+ $task = Taskmanager::waitComplete($task);
+ if (Taskmanager::isFinished($task) && !Taskmanager::isFailed($task)) {
+ return $task['data'];
+ }
+ return null;
+ }
+
+ /**
+ * Callback when compile Taskmanager job finished
+ */
+ public static function compileCompleteCallback(array $task): void
+ {
+ if (!Taskmanager::isFinished($task) || Taskmanager::isFailed($task))
+ return;
+ $version = 'Unknown';
+ if (isset($task['data']['hash'])) {
+ $hash = $task['data']['hash'];
+ Property::set(IPxeBuilder::PROP_IPXE_HASH, $hash);
+ $version = $hash;
+ $list = IPxeBuilder::getVersionTaskResult();
+ if (isset($list['versions'])) {
+ foreach ($list['versions'] as $v) {
+ if ($v['hash'] === $version) {
+ // Do NOT change (see below)
+ $version = date('Y-m-d H:i', $v['date']) . ' (' . substr($version, 0, 7) . ')';
+ break;
+ }
+ }
+ }
+ }
+ // Do NOT change the format of this string -- we depend on it in ScriptBuilderIpxe::output()
+ $buildString = date('d.m.Y H:i') . ', Version: ' . $version;
+ Property::set(IPxeBuilder::PROP_IPXE_BUILDSTRING, $buildString);
+ }
+
+ public static function setIPxeVersionCallback(array $task): void
+ {
+ if (!Taskmanager::isFinished($task) || Taskmanager::isFailed($task) || empty($task['data']['ref']))
+ return;
+ Property::set(IPxeBuilder::PROP_IPXE_HASH, $task['data']['ref']);
+ }
+} \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-ipxe/inc/ipxemenu.inc.php b/modules-available/serversetup-bwlp-ipxe/inc/ipxemenu.inc.php
index b1e13e87..3ffecba1 100644
--- a/modules-available/serversetup-bwlp-ipxe/inc/ipxemenu.inc.php
+++ b/modules-available/serversetup-bwlp-ipxe/inc/ipxemenu.inc.php
@@ -3,19 +3,28 @@
class IPxeMenu
{
+ /**
+ * @var int ID of this menu, from DB
+ */
protected $menuid;
+ /**
+ * @var int 0 = disabled, otherwise, launch default option after timeout
+ */
public $timeoutMs;
+ /**
+ * @var string title to display above menu
+ */
public $title;
+ /**
+ * @var int menu entry id from DB
+ */
public $defaultEntryId;
/**
* @var MenuEntry[]
*/
public $items = [];
- /**
- * @param int $menuId
- */
- public static function get($menuId, $emptyFallback = false)
+ public static function get(int $menuId, bool $emptyFallback = false): ?IPxeMenu
{
$menu = Database::queryFirst("SELECT menuid, timeoutms, title, defaultentryid FROM serversetup_menu
WHERE menuid = :menuid LIMIT 1", ['menuid' => $menuId]);
@@ -31,33 +40,34 @@ class IPxeMenu
*
* @param array $menu array for according menu row
*/
- public function __construct($menu)
+ public function __construct(array $menu)
{
$this->menuid = (int)$menu['menuid'];
$this->timeoutMs = (int)$menu['timeoutms'];
- $this->title = $menu['title'];
- $this->defaultEntryId = $menu['defaultentryid'];
+ $this->title = (string)$menu['title'];
+ $defaultEntryId = $menu['defaultentryid'];
$res = Database::simpleQuery("SELECT e.menuentryid, e.entryid, e.refmenuid, e.hotkey, e.title,
e.hidden, e.sortval, e.md5pass, b.module, b.data AS bootentry, b.title AS betitle
FROM serversetup_menuentry e
LEFT JOIN serversetup_bootentry b USING (entryid)
WHERE e.menuid = :menuid
ORDER BY e.sortval ASC, e.title ASC", ['menuid' => $menu['menuid']]);
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$this->items[] = new MenuEntry($row);
}
// Make sure we have a default entry if the menu isn't empty
- if ($this->defaultEntryId === null && !empty($this->items)) {
- $this->defaultEntryId = $this->items[0]->menuEntryId();
+ if ($defaultEntryId === null && !empty($this->items)) {
+ $defaultEntryId = $this->items[0]->menuEntryId();
}
+ $this->defaultEntryId = (int)$defaultEntryId;
}
- public function title()
+ public function title(): string
{
return $this->title;
}
- public function timeoutMs()
+ public function timeoutMs(): int
{
return $this->timeoutMs;
}
@@ -65,36 +75,74 @@ class IPxeMenu
/**
* @return int Number of items in this menu
*/
- public function itemCount()
+ public function itemCount(): int
{
return count($this->items);
}
/**
- * @return string|null Return script label of default entry, null if not set
+ * @return MenuEntry|null Return preselected menu entry
*/
- public function getDefaultEntryId()
+ public function defaultEntry(): ?MenuEntry
{
- return $this->defaultEntryId;
+ foreach ($this->items as $item) {
+ if ($item->menuEntryId() === $this->defaultEntryId)
+ return $item;
+ }
+ return null;
+ }
+
+ private function maybeOverrideDefault(string $uuid)
+ {
+ $e = $this->defaultEntry();
+ // Shortcut - is already bwlp and timeout is reasonable (1-15s), do nothing
+ $defIsMl = $e !== null && substr($e->internalId(), 0, 3) === 'ml-';
+ $timeoutOk = $this->timeoutMs > 0 && $this->timeoutMs <= 15000;
+ if ($timeoutOk && $defIsMl)
+ return;
+ // No runmode module anyways
+ if (!Module::isAvailable('runmode'))
+ return;
+ $rm = RunMode::getRunMode($uuid);
+ // No runmode for this client, cannot be PVSmgr
+ if ($rm === false)
+ return;
+ // Is not pvsmgr
+ if ($rm['module'] !== 'roomplanner')
+ return;
+ // See if it's a dedicated station, if so make sure it boots into bwLehrpool
+ $data = json_decode($rm['modedata'], true);
+ if ($data['dedicatedmgr'] ?? false) {
+ if (!$defIsMl) {
+ $this->overrideDefaultToMinilinux();
+ }
+ if (!$timeoutOk) {
+ $this->timeoutMs = 5000;
+ }
+ }
}
/**
- * @return MenuEntry|null Return preselected menu entry
+ * Patch the menu to make sure bwLehrpool/"MiniLinux" is the default
+ * boot option, and set timeout to something reasonable. This is used
+ * for dedicated PVS managers, as they might not have a keyboard
+ * connected.
*/
- public function defaultEntry()
+ private function overrideDefaultToMinilinux()
{
foreach ($this->items as $item) {
- if ($item->menuEntryId() == $this->defaultEntryId)
- return $item;
+ if (substr($item->internalId(), 0, 3) === 'ml-') {
+ $this->defaultEntryId = $item->menuEntryId();
+ return;
+ }
}
- return null;
}
/*
*
*/
- public static function forLocation($locationId) : IPxeMenu
+ public static function forLocation(int $locationId): IPxeMenu
{
$chain = null;
if (Module::isAvailable('locations')) {
@@ -109,7 +157,7 @@ class IPxeMenu
if ($res->rowCount() > 0) {
// Make the location id key, preserving order (closest location is first)
$chain = array_flip($chain);
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
// Overwrite the value (numeric ascending values, useless) with menu array of according location
$chain[(int)$row['locationid']] = $row;
}
@@ -132,13 +180,19 @@ class IPxeMenu
return new IPxeMenu($menu);
}
- public static function forClient($ip, $uuid) : IPxeMenu
+ public static function forClient(string $ip, ?string $uuid): IPxeMenu
{
$locationId = 0;
if (Module::isAvailable('locations')) {
$locationId = Location::getFromIpAndUuid($ip, $uuid);
}
- return self::forLocation($locationId);
+ $menu = self::forLocation($locationId);
+ if ($uuid !== null) {
+ // Super specialcase hackery: If this is a dedicated PVS, force the default to
+ // be bwlp/"minilinux"
+ $menu->maybeOverrideDefault($uuid);
+ }
+ return $menu;
}
}
@@ -146,11 +200,14 @@ class IPxeMenu
class EmptyIPxeMenu extends IPxeMenu
{
- /** @noinspection PhpMissingParentConstructorInspection */
public function __construct()
{
- $this->title = 'No menu defined';
- $this->menuid = -1;
+ parent::__construct([
+ 'menuid' => -1,
+ 'timeoutms' => 120,
+ 'defaultentryid' => null,
+ 'title' => 'No menu defined',
+ ]);
$this->items[] = new MenuEntry([
'title' => 'Please create a menu in Server-Setup first'
]);
diff --git a/modules-available/serversetup-bwlp-ipxe/inc/localboot.inc.php b/modules-available/serversetup-bwlp-ipxe/inc/localboot.inc.php
index 4203f931..4d1a56c7 100644
--- a/modules-available/serversetup-bwlp-ipxe/inc/localboot.inc.php
+++ b/modules-available/serversetup-bwlp-ipxe/inc/localboot.inc.php
@@ -7,38 +7,49 @@ class Localboot
const BOOT_METHODS = [
'PCBIOS' => [
- 'EXIT' => 'exit 1',
+ 'EXIT' => 'set slx_exit 1 ||
+exit 1',
'COMBOOT' => 'set netX/209:string localboot.cfg ||
set netX/210:string http://${serverip}/tftp/sl-bios/ ||
chain -ar /tftp/sl-bios/lpxelinux.0',
'SANBOOT' => 'sanboot --no-describe',
],
'EFI' => [
- 'EXIT' => 'exit 1',
- 'COMBOOT' => 'set netX/209:string localboot.cfg ||
-set netX/210:string http://${serverip}/tftp/sl-efi64/ ||
-chain -ar /tftp/sl-efi64/syslinux.efi',
+ 'EXIT' => 'set slx_exit 1 ||
+exit 1',
+ 'SANBOOT' => 'imgfree ||
+console ||
+set filename \EFI\Boot\bootx64.efi ||
+set i:int32 0 ||
+:blubber
+sanboot --no-describe --drive ${i} --filename ${filename} ||
+inc i
+iseq ${i} 10 || goto blubber',
+ 'GRUB' => 'chain /tftp/grub-boot.img',
],
];
- public static function getDefault()
+ /**
+ * @return array{PCBIOS: string, EFI: string}
+ */
+ public static function getDefault(): array
{
- $ret = explode(',', Property::get(self::PROPERTY_KEY, 'SANBOOT,EXIT'));
+ $ret = explode(',', Property::get(self::PROPERTY_KEY, 'SANBOOT,GRUB'));
if (empty($ret)) {
- $ret = ['SANBOOT', 'EXIT'];
+ $ret = ['SANBOOT', 'GRUB'];
} elseif (count($ret) < 2) {
- $ret[] = 'EXIT';
+ $ret[] = 'SANBOOT';
}
- if (null === self::BOOT_METHODS['PCBIOS'][$ret[0]]) {
+ if (!isset(self::BOOT_METHODS['PCBIOS'][$ret[0]])) {
$ret[0] = 'SANBOOT';
}
- if (null === self::BOOT_METHODS['EFI'][$ret[1]]) {
- $ret[1] = 'EXIT';
+ if (!isset(self::BOOT_METHODS['EFI'][$ret[1]])) {
+ $ret[1] = 'GRUB';
}
return ['PCBIOS' => $ret[0], 'EFI' => $ret[1]];
}
- public static function setDefault($pcbios, $efi)
+ public static function setDefault(string $pcbios, string $efi)
{
Property::set(self::PROPERTY_KEY, "$pcbios,$efi");
}
diff --git a/modules-available/serversetup-bwlp-ipxe/inc/menuentry.inc.php b/modules-available/serversetup-bwlp-ipxe/inc/menuentry.inc.php
index eb4a98de..da94a16b 100644
--- a/modules-available/serversetup-bwlp-ipxe/inc/menuentry.inc.php
+++ b/modules-available/serversetup-bwlp-ipxe/inc/menuentry.inc.php
@@ -27,7 +27,7 @@ class MenuEntry
*/
public $sortval;
/**
- * @var BootEntry
+ * @var ?BootEntry
*/
public $bootEntry = null;
@@ -35,11 +35,7 @@ class MenuEntry
public $md5pass = null;
- /**
- * @param int $menuEntryId
- * @return MenuEntry|null
- */
- public static function get($menuEntryId)
+ public static function get(int $menuEntryId): ?MenuEntry
{
$row = Database::queryFirst("SELECT e.menuentryid, e.entryid, e.refmenuid, e.hotkey, e.title,
e.hidden, e.sortval, e.plainpass, e.md5pass, b.module, b.data AS bootentry, b.title AS betitle
@@ -56,48 +52,53 @@ class MenuEntry
*
* @param array $row row from database
*/
- public function __construct($row)
+ public function __construct(array $row)
{
- if (is_array($row)) {
- if (empty($row['title']) && !empty($row['betitle'])) {
- $row['title'] = $row['betitle'];
- }
- foreach ($row as $key => $value) {
- if (property_exists($this, $key)) {
- $this->{$key} = $value;
- }
- }
- $this->hotkey = self::getKeyCode($row['hotkey'] ?? '');
- if (!empty($row['bootentry'])) {
- $this->bootEntry = BootEntry::fromJson($row['module'], $row['bootentry']);
- } elseif (isset($row['refmenuid'])) {
- $this->bootEntry = BootEntry::forMenu($row['refmenuid']);
+ if (empty($row['title']) && !empty($row['betitle'])) {
+ $row['title'] = $row['betitle'];
+ }
+ foreach ($row as $key => $value) {
+ if (property_exists($this, $key)) {
+ $this->{$key} = $value;
}
- $this->gap = (array_key_exists('entryid', $row) && $row['entryid'] === null && $row['refmenuid'] === null);
}
+ $this->hotkey = self::getKeyCode($row['hotkey'] ?? '');
+ if (!empty($row['bootentry'])) {
+ $this->bootEntry = BootEntry::fromJson($row['module'], $row['bootentry']);
+ } elseif (isset($row['refmenuid'])) {
+ $this->bootEntry = BootEntry::forMenu($row['refmenuid']);
+ }
+ $this->gap = (array_key_exists('entryid', $row) && $row['entryid'] === null && $row['refmenuid'] === null);
settype($this->hidden, 'bool');
settype($this->gap, 'bool');
settype($this->sortval, 'int');
settype($this->menuentryid, 'int');
}
- public function getBootEntryScript($builder)
+ public function getBootEntryScript(ScriptBuilderBase $builder): string
{
if ($this->bootEntry === null)
return '';
return $this->bootEntry->toScript($builder);
}
- public function menuEntryId()
+ public function menuEntryId(): int
{
return $this->menuentryid;
}
- public function title()
+ public function title(): string
{
return $this->title;
}
+ public function internalId(): string
+ {
+ if ($this->bootEntry === null)
+ return '';
+ return $this->bootEntry->internalId();
+ }
+
/*
*
*/
@@ -137,7 +138,7 @@ class MenuEntry
*
* @return string[] list of known key names
*/
- public static function getKeyList()
+ public static function getKeyList(): array
{
return array_keys(self::getKeyArray());
}
@@ -146,10 +147,9 @@ class MenuEntry
* Get the key code ipxe expects for the given named
* key. Returns false if the key name is unknown.
*
- * @param string $keyName
* @return false|string Key code as hex string, or false if not found
*/
- public static function getKeyCode($keyName)
+ public static function getKeyCode(string $keyName)
{
$data = self::getKeyArray();
if (isset($data[$keyName]))
@@ -161,7 +161,7 @@ class MenuEntry
* @param string $keyName desired key name
* @return string $keyName if it's known, empty string otherwise
*/
- public static function filterKeyName($keyName)
+ public static function filterKeyName(string $keyName): string
{
$data = self::getKeyArray();
if (isset($data[$keyName]))
diff --git a/modules-available/serversetup-bwlp-ipxe/inc/pxelinux.inc.php b/modules-available/serversetup-bwlp-ipxe/inc/pxelinux.inc.php
index 3f406767..24b099dc 100644
--- a/modules-available/serversetup-bwlp-ipxe/inc/pxelinux.inc.php
+++ b/modules-available/serversetup-bwlp-ipxe/inc/pxelinux.inc.php
@@ -7,11 +7,14 @@ class PxeLinux
/**
* Takes a (partial) pxelinux menu and parses it into
* a PxeMenu object.
+ *
* @param string $input The pxelinux menu to parse
- * @return PxeMenu the parsed menu
+ * @return ?PxeMenu the parsed menu, or null if input is not a PXELinux menu
*/
- public static function parsePxeLinux($input, $isCp437)
+ public static function parsePxeLinux(string $input, bool $isCp437): ?PxeMenu
{
+ if (empty($input))
+ return null;
if ($isCp437) {
$input = iconv('IBM437', 'UTF8//TRANSLIT//IGNORE', $input);
}
@@ -76,12 +79,14 @@ class PxeLinux
}
$section->helpText = $text;
} elseif (self::handleKeyword($key, $val, $sectionPropMap, $section)) {
- continue;
+ //continue;
}
}
if ($section !== null) {
$menu->sections[] = $section;
}
+ if (empty($menu->sections))
+ return null; // Probably not a PXE menu but random text?
foreach ($menu->sections as $section) {
$section->mangle();
}
@@ -93,13 +98,14 @@ class PxeLinux
* to the given object. The map to look up the keyword has to be passed
* as well as the object to set the value in. Map and object should
* obviously match.
+ *
* @param string $key keyword of parsed line
* @param string $val raw value of currently parsed line (empty if not present)
* @param array $map Map in which $key is looked up as key
- * @param PxeMenu|PxeSection The object to set the parsed and sanitized value in
+ * @param PxeMenu|PxeSection $object The object to set the parsed and sanitized value in
* @return bool true if the value was found in the map (and set in the object), false otherwise
*/
- private static function handleKeyword($key, $val, $map, $object)
+ private static function handleKeyword(string $key, string $val, array $map, $object): bool
{
if (!isset($map[$key]))
return false;
@@ -122,161 +128,3 @@ class PxeLinux
}
-/**
- * Class representing a parsed pxelinux menu. Members
- * will be set to their annotated type if present or
- * be null otherwise, except for present-only boolean
- * options, which will default to false.
- */
-class PxeMenu
-{
-
- /**
- * @var string menu title, shown at the top of the menu
- */
- public $title;
- /**
- * @var int initial timeout after which $timeoutLabel would be executed
- */
- public $timeoutMs;
- /**
- * @var int if the user canceled the timeout by pressing a key, this timeout would still eventually
- * trigger and launch the $timeoutLabel section
- */
- public $totalTimeoutMs;
- /**
- * @var string label of section which will execute if the timeout expires
- */
- public $timeoutLabel;
- /**
- * @var bool hide menu and just show background after triggering an entry
- */
- public $menuClear = false;
- /**
- * @var bool boot the associated entry directly if its corresponding hotkey is pressed instead of just highlighting
- */
- public $immediateHotkeys = false;
- /**
- * @var PxeSection[] list of sections the menu contains
- */
- public $sections = [];
- /**
- * @var string The DEFAULT entry of the menu. Usually refers either to a
- * LABEL, or a loadable module (like vesamenu.c32)
- */
- public $default;
-
- /**
- * Check if any of the sections has the given label.
- */
- public function hasLabel($label)
- {
- foreach ($this->sections as $section) {
- if ($section->label === $label)
- return true;
- }
- return false;
- }
-
-}
-
-/**
- * Class representing a parsed pxelinux menu entry. Members
- * will be set to their annotated type if present or
- * be null otherwise, except for present-only boolean
- * options, which will default to false.
- */
-class PxeSection
-{
-
- /**
- * @var string label used internally in PXEMENU definition to address this entry
- */
- public $label;
- /**
- * @var string MENU LABEL of PXEMENU - title of entry displayed to the user
- */
- public $title;
- /**
- * @var int Number of spaces to prefix the title with
- */
- public $indent;
- /**
- * @var string help text to display when the entry is highlighted
- */
- public $helpText;
- /**
- * @var string Kernel to load
- */
- public $kernel;
- /**
- * @var string|string[] initrd to load for the kernel.
- * If mangle() has been called this will be an array,
- * otherwise it's a comma separated list.
- */
- public $initrd;
- /**
- * @var string command line options to pass to the kernel
- */
- public $append;
- /**
- * @var int IPAPPEND from PXEMENU. Bitmask of valid options 1 and 2.
- */
- public $ipAppend;
- /**
- * @var string Password protecting the entry. This is most likely in encrypted form.
- */
- public $passwd;
- /**
- * @var bool whether this section is marked as default (booted after timeout)
- */
- public $isDefault = false;
- /**
- * @var bool Menu entry is not visible (can only be triggered by timeout)
- */
- public $isHidden = false;
- /**
- * @var bool Disable this entry, making it unselectable
- */
- public $isDisabled = false;
- /**
- * @var int|false Value of the LOCALBOOT field, false if not set
- */
- public $localBoot = false;
- /**
- * @var string hotkey to trigger item. Only valid after calling mangle()
- */
- public $hotkey;
-
- public function __construct($label) { $this->label = $label; }
-
- public function mangle()
- {
- if (($i = strpos($this->title, '^')) !== false) {
- $this->hotkey = strtoupper($this->title{$i+1});
- $this->title = substr($this->title, 0, $i) . substr($this->title, $i + 1);
- }
- if (strpos($this->append, 'initrd=') !== false) {
- $parts = preg_split('/\s+/', $this->append);
- $this->append = '';
- for ($i = 0; $i < count($parts); ++$i) {
- if (preg_match('/^initrd=(.*)$/', $parts[$i], $out)) {
- if (!empty($this->initrd)) {
- $this->initrd .= ',';
- }
- $this->initrd .= $out[1];
- } else {
- $this->append .= ' ' . $parts[$i];
- }
- }
- $this->append = trim($this->append);
- }
- if (is_string($this->initrd)) {
- $this->initrd = explode(',', $this->initrd);
- } elseif (!is_array($this->initrd)) {
- $this->initrd = [];
- }
- }
-
-}
-
diff --git a/modules-available/serversetup-bwlp-ipxe/inc/pxemenu.inc.php b/modules-available/serversetup-bwlp-ipxe/inc/pxemenu.inc.php
new file mode 100644
index 00000000..7be57ef1
--- /dev/null
+++ b/modules-available/serversetup-bwlp-ipxe/inc/pxemenu.inc.php
@@ -0,0 +1,59 @@
+<?php
+
+/**
+ * Class representing a parsed pxelinux menu. Members
+ * will be set to their annotated type if present or
+ * be null otherwise, except for present-only boolean
+ * options, which will default to false.
+ */
+class PxeMenu
+{
+
+ /**
+ * @var string menu title, shown at the top of the menu
+ */
+ public $title;
+ /**
+ * @var int initial timeout after which $timeoutLabel would be executed
+ */
+ public $timeoutMs;
+ /**
+ * @var int if the user canceled the timeout by pressing a key, this timeout would still eventually
+ * trigger and launch the $timeoutLabel section
+ */
+ public $totalTimeoutMs;
+ /**
+ * @var string label of section which will execute if the timeout expires
+ */
+ public $timeoutLabel;
+ /**
+ * @var bool hide menu and just show background after triggering an entry
+ */
+ public $menuClear = false;
+ /**
+ * @var bool boot the associated entry directly if its corresponding hotkey is pressed instead of just highlighting
+ */
+ public $immediateHotkeys = false;
+ /**
+ * @var PxeSection[] list of sections the menu contains
+ */
+ public $sections = [];
+ /**
+ * @var string The DEFAULT entry of the menu. Usually refers either to a
+ * LABEL, or a loadable module (like vesamenu.c32)
+ */
+ public $default;
+
+ /**
+ * Check if any of the sections has the given label.
+ */
+ public function hasLabel(string $label): bool
+ {
+ foreach ($this->sections as $section) {
+ if ($section->label === $label)
+ return true;
+ }
+ return false;
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-ipxe/inc/pxesection.inc.php b/modules-available/serversetup-bwlp-ipxe/inc/pxesection.inc.php
new file mode 100644
index 00000000..2d9cd6ab
--- /dev/null
+++ b/modules-available/serversetup-bwlp-ipxe/inc/pxesection.inc.php
@@ -0,0 +1,117 @@
+<?php
+
+/**
+ * Class representing a parsed pxelinux menu entry. Members
+ * will be set to their annotated type if present or
+ * be null otherwise, except for present-only boolean
+ * options, which will default to false.
+ */
+class PxeSection
+{
+
+ /**
+ * @var ?string label used internally in PXEMENU definition to address this entry
+ */
+ public $label;
+ /**
+ * @var string MENU LABEL of PXEMENU - title of entry displayed to the user
+ */
+ public $title;
+ /**
+ * @var int Number of spaces to prefix the title with
+ */
+ public $indent;
+ /**
+ * @var string help text to display when the entry is highlighted
+ */
+ public $helpText;
+ /**
+ * @var string Kernel to load
+ */
+ public $kernel;
+ /**
+ * @var string|string[] initrd to load for the kernel.
+ * If mangle() has been called this will be an array,
+ * otherwise it's a comma separated list.
+ */
+ public $initrd;
+ /**
+ * @var string command line options to pass to the kernel
+ */
+ public $append;
+ /**
+ * @var int IPAPPEND from PXEMENU. Bitmask of valid options 1 and 2.
+ */
+ public $ipAppend;
+ /**
+ * @var string Password protecting the entry. This is most likely in encrypted form.
+ */
+ public $passwd;
+ /**
+ * @var bool whether this section is marked as default (booted after timeout)
+ */
+ public $isDefault = false;
+ /**
+ * @var bool Menu entry is not visible (can only be triggered by timeout)
+ */
+ public $isHidden = false;
+ /**
+ * @var bool Disable this entry, making it unselectable
+ */
+ public $isDisabled = false;
+ /**
+ * @var int|false Value of the LOCALBOOT field, false if not set
+ */
+ public $localBoot = false;
+ /**
+ * @var string hotkey to trigger item. Only valid after calling mangle()
+ */
+ public $hotkey;
+
+ public function __construct(?string $label) { $this->label = $label; }
+
+ public function mangle()
+ {
+ if (($i = strpos($this->title, '^')) !== false) {
+ $this->hotkey = strtoupper($this->title[$i + 1]);
+ $this->title = substr($this->title, 0, $i) . substr($this->title, $i + 1);
+ }
+ if (strpos($this->append, 'initrd=') !== false) {
+ $parts = preg_split('/\s+/', $this->append);
+ $this->append = '';
+ for ($i = 0; $i < count($parts); ++$i) {
+ if (preg_match('/^initrd=(.*)$/', $parts[$i], $out)) {
+ if (!empty($this->initrd)) {
+ $this->initrd .= ',';
+ }
+ $this->initrd .= $out[1];
+ } else {
+ $this->append .= ' ' . $parts[$i];
+ }
+ }
+ $this->append = trim($this->append);
+ }
+ if (is_string($this->initrd)) {
+ $this->initrd = explode(',', $this->initrd);
+ } elseif (!is_array($this->initrd)) {
+ $this->initrd = [];
+ }
+ }
+
+ /**
+ * Does this appear to be an entry that triggers localboot?
+ */
+ public function isLocalboot(): bool
+ {
+ return $this->localBoot !== false || preg_match('/chain\.c32$/i', $this->kernel);
+ }
+
+ /**
+ * Is this (most likely) a separating entry only that cannot be selected?
+ */
+ public function isTextOnly(): bool
+ {
+ return ($this->label === null || empty($this->kernel)) && !$this->isHidden && !empty($this->title);
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderbase.inc.php b/modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderbase.inc.php
index 84cfd7db..9cd07388 100644
--- a/modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderbase.inc.php
+++ b/modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderbase.inc.php
@@ -9,8 +9,10 @@ abstract class ScriptBuilderBase
protected $platform = '';
+ /** @var string */
protected $clientIp;
+ /** @var ?string */
protected $uuid;
/**
@@ -18,57 +20,57 @@ abstract class ScriptBuilderBase
*/
protected $hasExtension = false;
- public function hasExtensions()
+ public function hasExtensions(): bool
{
return $this->hasExtension;
}
- public function platform()
+ public function platform(): string
{
return $this->platform;
}
- public function uuid()
+ public function uuid(): ?string
{
return $this->uuid;
}
- public function clientIp()
+ public function clientIp(): string
{
return $this->clientIp;
}
- public function getLabel()
+ public function getLabel(): string
{
return 'b' . mt_rand(100, 999) . 'x' . (++$this->lblId);
}
- public function __construct($platform = null, $serverIp = null, $slxExtensions = null)
+ public function __construct(?string $platform = null, ?string $serverIp = null, ?bool $slxExtensions = null)
{
- $this->clientIp = $_SERVER['REMOTE_ADDR'];
+ $this->clientIp = (string)$_SERVER['REMOTE_ADDR'];
if (substr($this->clientIp, 0, 7) === '::ffff:') {
$this->clientIp = substr($this->clientIp, 7);
}
$this->serverIp = $serverIp ?? $_SERVER['SERVER_ADDR'] ?? Property::getServerIp();
- $this->platform = $platform ?? Request::any('platform', false, 'string');
- if ($this->platform !== false) {
+ $this->platform = $platform ?? Request::any('platform', null, 'string');
+ if ($this->platform !== null) {
$this->platform = strtoupper($this->platform);
}
if ($this->platform !== 'EFI' && $this->platform !== 'PCBIOS') {
$this->platform = '';
}
$this->hasExtension = $slxExtensions ?? (bool)Request::any('slx-extensions', false, 'int');
- $this->uuid = Request::any('uuid', false, 'string');
- if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $this->uuid)) {
- $this->uuid = false;
+ $uuid = Request::any('uuid', null, 'string');
+ if ($uuid !== null
+ && preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $uuid)) {
+ $this->uuid = (string)$uuid;
}
}
/**
* Output given string (script) to client, in a suitable encoding, headers, etc.
- * @param string $string
*/
- public abstract function output($string);
+ public abstract function output(string $string): void;
public abstract function bootstrapLive();
@@ -79,31 +81,22 @@ abstract class ScriptBuilderBase
* @param bool $honorPassword Whether we should generate a password dialog if protected, or skip
* @return string generated script/code/...
*/
- public abstract function getMenuEntry($menuEntry, $honorPassword = true);
+ public abstract function getMenuEntry(?MenuEntry $entry, bool $honorPassword = true): string;
/**
* @param BootEntry|null|false $bootEntry
- * @return string
*/
- public abstract function getBootEntry($bootEntry);
+ public abstract function getBootEntry(?BootEntry $entry): string;
- public abstract function getSpecial($special);
+ public abstract function getSpecial(string $special);
- /**
- * @param IPxeMenu|null $menu
- * @return string
- */
- public abstract function menuToScript($menu);
+ public abstract function menuToScript(IPxeMenu $menu): string;
/**
* Pass EITHER only $agnostic, OR $bios and/or $efi
* If $agnostic is given, it should be used unconditionally,
* and $bios/$efi should be ignored.
- * @param ExecData $agnostic
- * @param ExecData $bios
- * @param ExecData $efi
- * @return string
*/
- public abstract function execDataToScript($agnostic, $bios, $efi);
+ public abstract function execDataToScript(?ExecData $agnostic, ?ExecData $bios, ?ExecData $efi): string;
} \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderbash.inc.php b/modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderbash.inc.php
index 86b2931f..d6b542ec 100644
--- a/modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderbash.inc.php
+++ b/modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderbash.inc.php
@@ -3,39 +3,39 @@
class ScriptBuilderBash extends ScriptBuilderBase
{
- public function output($string)
+ public function output(string $string): void
{
echo $string;
}
- public function bootstrapLive() { return false; }
+ public function bootstrapLive(): bool { return false; }
- public function getMenu(IPxeMenu $menu, bool $bootstrap)
+ public function getMenu(IPxeMenu $menu, bool $bootstrap): string
{
return $this->menuToScript($menu);
}
- public function getBootEntry($entry)
+ public function getBootEntry(?BootEntry $entry): string
{
- if (!$entry) {
+ if ($entry === null) {
return "echo 'Invalid boot entry id'\nread -n1 -r _\n";
}
return $entry->toScript($this);
}
- public function getMenuEntry($entry, $honorPassword = true)
+ public function getMenuEntry(?MenuEntry $entry, bool $honorPassword = true): string
{
if ($entry === null)
return "echo 'Invalid menu entry id - press any key to continue'\nread -n1 -r _\n";
return $entry->getBootEntryScript($this);
}
- public function getSpecial($special)
+ public function getSpecial(string $special): string
{
return ''; // We can't really do localboot here I guess
}
- public function menuToScript($menu)
+ public function menuToScript(IPxeMenu $menu): string
{
$output = "declare -A items_name items_gap hotkey_item\ndeclare menu_default menu_timeout menu_title\n";
foreach ($menu->items as $entry) {
@@ -59,7 +59,7 @@ class ScriptBuilderBash extends ScriptBuilderBase
. "\nmenu_title=" . $this->bashString($menu->title) . "\n";
}
- public function execDataToScript($agnostic, $bios, $efi) : string
+ public function execDataToScript(?ExecData $agnostic, ?ExecData $bios, ?ExecData $efi): string
{
if ($agnostic !== null)
return $this->execDataToScriptInternal($agnostic);
@@ -86,7 +86,7 @@ class ScriptBuilderBash extends ScriptBuilderBase
return $script;
}
- private function bashString($string)
+ private function bashString(string $string): string
{
if (strpos($string, "'") === false) {
return "'$string'";
diff --git a/modules-available/serversetup-bwlp-ipxe/inc/scriptbuildergrub.inc.php b/modules-available/serversetup-bwlp-ipxe/inc/scriptbuildergrub.inc.php
new file mode 100644
index 00000000..9dce5214
--- /dev/null
+++ b/modules-available/serversetup-bwlp-ipxe/inc/scriptbuildergrub.inc.php
@@ -0,0 +1,330 @@
+<?php
+
+class ScriptBuilderGrub extends ScriptBuilderBase
+{
+
+ /** @var bool */
+ private $confCodeEmited = false;
+
+ public function __construct(?string $platform = null, ?string $serverIp = null)
+ {
+ if (empty($platform)) {
+ $platform = Request::any('platform', null, 'string');
+ if ($platform === 'pc' || stripos($platform, 'bios') !== false) {
+ $platform = 'PCBIOS';
+ }
+ }
+ parent::__construct($platform, $serverIp, false);
+ }
+
+ private function getConfCode(): string
+ {
+ if ($this->confCodeEmited)
+ return '';
+ $this->confCodeEmited = true;
+ $str = '
+if ! [ "$uuid" ] ; then
+ smbios --type 1 --get-uuid 8 --set uuid
+fi
+set serverip="' . $this->getLocalIp() . '"
+';
+ foreach (['mac', 'ip', 'domain', 'hostname'] as $var) {
+ $str .= <<<EOF
+if ! [ "\$$var" ] ; then
+ set $var="\$net_default_$var"
+fi
+if ! [ "\$$var" ] ; then
+ set $var="\$net_efinet0_dhcp_$var"
+fi
+
+EOF;
+
+ }
+ return $str;
+ }
+
+ private function getLocalIp(): string
+ {
+ if (isset($_SERVER['SCRIPT_URI']) && preg_match('#^\w+://([^/]+)#', $_SERVER['SCRIPT_URI'], $out)) {
+ $host = $out[1];
+ } elseif (isset($_SERVER['SERVER_NAME'])) {
+ $host = $_SERVER['SERVER_NAME'];
+ } elseif (isset($_SERVER['SERVER_ADDR'])) {
+ $host = $_SERVER['SERVER_ADDR'];
+ } else {
+ $host = $this->serverIp;
+ }
+ return $host;
+ }
+
+ private function getGrubBase(): string
+ {
+ return '(http,' . $this->getLocalIp() . ')';
+ }
+
+ private function getUrlBase(): string
+ {
+ $host = $this->getGrubBase();
+ if (isset($_SERVER['REQUEST_URI'])) {
+ $url = parse_url($_SERVER['REQUEST_URI']);
+ $path = $url['path'];
+ } else {
+ // Static fallback
+ $path = '/boot/ipxe';
+ }
+ return $host . $path;
+
+ }
+
+ private function getUrlFull(?string $key = null, ?string $value = null): string
+ {
+ $url = parse_url($_SERVER['REQUEST_URI']);
+ $urlbase = $this->getUrlBase();
+ if (empty($url['query'])) {
+ $fromQuery = [];
+ } else {
+ parse_str($url['query'], $fromQuery);
+ foreach ($fromQuery as &$v) {
+ $v = urlencode($v);
+ }
+ unset($v);
+ }
+ unset($fromQuery['entryid'], $fromQuery['special'], $fromQuery['redir']);
+ if ($key !== null) {
+ $fromQuery[$key] = $value;
+ }
+ $required = [
+ 'type' => 'grub',
+ 'uuid' => '$uuid',
+ 'mac' => '$mac',
+ 'platform' => '$grub_platform',
+ ];
+ $fullQuery = '?';
+ foreach ($required + $fromQuery as $k => $v) { // Loop instead of http_build_query since we don't want escaping for the varnames!
+ $fullQuery .= $k . '=' . $v . '&';
+ }
+ return $urlbase . $fullQuery;
+ }
+
+ /**
+ * Redirect to same URL, but add our extended params
+ */
+ private function redirect(string $key = null, string $value = null): string
+ {
+ // Redirect to self with added parameters
+ $urlfull = $this->getUrlFull($key, $value);
+ return $this->getConfCode() . <<<HERE
+
+set self="${urlfull}"
+echo "Chaining to \$self..."
+configfile \${self}redir=1
+
+HERE;
+ }
+
+ /**
+ * Called when we handle a real client request, and don't just generate static data
+ * for whatever use-case that might have. In the latter case, it wouldn't make much sense
+ * to generate a redirect code snippet.
+ *
+ * @return string
+ */
+ public function bootstrapLive()
+ {
+ // Check if required arguments are given; if not, spit out according script and chain to self
+ if ($this->uuid === null || $this->platform === '') {
+ // REQUIRED so we can hide incompatible entries
+ // but avoid redirect cycle
+ if (Request::any('redir', '', 'string') === '') {
+ return $this->redirect();
+ }
+ }
+ return false;
+ }
+
+ public function getBootEntry(?BootEntry $entry): string
+ {
+ if ($entry === null) {
+ return "echo Invalid boot entry id\nsleep --interruptible --verbose 10\n";
+ }
+ return $entry->toScript($this);
+ }
+
+ public function getMenu(IPxeMenu $menu, bool $bootstrap): string
+ {
+ $base = $this->getUrlFull();
+ return $this->getConfCode()
+ . "set self=\"{$base}\"\n"
+ . $this->menuToScript($menu);
+ }
+
+ public function menuToScript(IPxeMenu $menu): string
+ {
+ if ($menu->defaultEntryId === null) {
+ $output = <<<EOF
+set timeout=0
+
+EOF;
+
+ } else {
+ $secs = (int)($menu->timeoutMs / 1000);
+ $output = <<<EOF
+set timeout={$secs}
+set default="id-{$menu->defaultEntryId}"
+
+EOF;
+ }
+ $output .= $this->getConfCode();
+ foreach ($menu->items as $item) {
+ $output .= $this->getMenuItemScript($item);
+ }
+ return $output;
+ }
+
+ private function getMenuItemScript(MenuEntry $entry): string
+ {
+ $str = "menuentry '" . str_replace("'", '', $entry->title) . "' --id 'id-" . $entry->menuentryid . "' {\n";
+ if ($entry->gap) {
+ $str .= "true\n"; // AFAICT, not possible in GRUB
+ } elseif ($entry->bootEntry === null || (!empty($this->platform) && !$entry->bootEntry->supportsMode($this->platform))) {
+ $str .= "echo Type mismatch\n";
+ } elseif ($entry->hidden && $entry->hotkey === false) {
+ $str .= "echo Hidden entries without hotkey are illegal\n"; // Hidden entries without hotkey are illegal
+ } else {
+ if ($entry->hotkey !== false) {
+ // Not supported by grub...
+ }
+ if ($entry->bootEntry instanceof MenuBootEntry) {
+ // Link
+ $str .= "configfile \${self}entryid={$entry->menuentryid}\n";
+ } else {
+ // Embed directly
+ // TODO: Password. Use read etc.; might need hashsum.mod, in that case, don't embed entry directly but use configfile...
+ $str .= $this->getMenuEntry($entry, true);
+ }
+ }
+ return $str . "}\n";
+ }
+
+ public function getSpecial(string $special): string
+ {
+ if ($special === 'localboot') {
+ // Sync this with setup-scripts/grub_localboot occasionally...
+ $output = <<<'EOF'
+insmod chain
+if [ "$grub_platform" = "pc" ] ; then
+ chainloader (hd0)+1
+ chainloader (hd1)+1
+ chainloader (hd2)+1
+fi
+insmod fat
+insmod part_gpt
+echo "Scanning, first pass..."
+for efi in (*,gpt*)/efi/grub/grubx64.efi (*,gpt*)/efi/boot/bootx64.efi (*,gpt*)/efi/*/*/bootmgfw.efi (*,gpt*)/efi/*/*.efi \
+ (*,msdos*)/efi/grub/grubx64.efi (*,msdos*)/efi/boot/bootx64.efi (*,msdos*)/efi/*/*/bootmgfw.efi (*,msdos*)/efi/*/*.efi; do
+ regexp --set=1:efi_device '^\((.*)\)/' "${efi}"
+done
+
+echo "Scanning, second pass..."
+for efi in (*,gpt*)/efi/grub/grubx64.efi (*,gpt*)/efi/boot/bootx64.efi (*,gpt*)/efi/*/*/bootmgfw.efi (*,gpt*)/efi/*/*.efi \
+ (*,msdos*)/efi/grub/grubx64.efi (*,msdos*)/efi/boot/bootx64.efi (*,msdos*)/efi/*/*/bootmgfw.efi (*,msdos*)/efi/*/*.efi; do
+ if [ -e "${efi}" ]; then
+ #regexp --set=1:efi_device '^\((.*)\)/' "${efi}"
+ regexp --set=1:root '^(\(.*\))/' "${efi}"
+ regexp --set=1:efi_path '^\(.*\)(/.*)$' "${efi}"
+ echo " >> Found operating system! <<"
+ echo " Path: '${efi}' on '${root}'"
+ echo " Fallback '${efi_path}'"
+ chainloader "${efi}"
+ boot
+ echo " That failed..."
+ fi
+done
+
+echo "No EFI known OS found. Exiting."
+exit
+EOF;
+
+ } else {
+ $output = <<<EOF
+echo "Unknown special command: $special"
+sleep --interruptible --verbose 10
+EOF;
+ }
+ return $output;
+ }
+
+ public function output(string $string): void
+ {
+ Header('Content-Type: text/plain; charset=UTF-8');
+ echo $string;
+ }
+
+ public function getMenuEntry(?MenuEntry $entry, bool $honorPassword = true): string
+ {
+ if ($entry === null)
+ return "echo Invalid menu entry id\nsleep --interruptible --verbose 10\n";
+ // TODO: Check for password
+ if ($honorPassword && !empty($entry->md5pass)) {
+ return "echo TODO: Implement password check...\nsleep --interruptible --verbose 10\n";
+ }
+ $meid = $entry->menuEntryId();
+ $output = $this->getConfCode() . "set menuentryid=$meid\n";
+ // Output actual entry
+ $output .= str_replace('%fail%', 'fail', $entry->getBootEntryScript($this));
+ return $output;
+ }
+
+ public function execDataToScript(?ExecData $agnostic, ?ExecData $bios, ?ExecData $efi): string
+ {
+ if ($agnostic !== null)
+ return $this->execDataToScriptInternal($agnostic);
+
+ if ($efi !== null && $this->platform === BootEntry::EFI)
+ return $this->execDataToScriptInternal($efi);
+ // Unknown or not EFI, should be BIOS at this point
+ return $this->execDataToScriptInternal($bios ?? $efi ?? new ExecData());
+ }
+
+ private function execDataToScriptInternal(ExecData $entry): string
+ {
+ $entry->sanitize();
+ $base = $this->getGrubBase();
+ $script = '';
+ // Overriding dhcpOpts probably not possible/necessary
+ $initrds = [];
+ if (!empty($entry->initRd)) {
+ foreach ($entry->initRd as $initrd) {
+ if (empty($initrd))
+ continue;
+ $initrds[] = $this->combineUrl($base, $initrd);
+ }
+ }
+ $file = $this->combineUrl($base, $entry->executable);
+ $script .= "linux $file {$entry->commandLine} slx.ipxe.id=\${menuentryid}\n";
+ if (!empty($initrds)) {
+ $script .= "initrd " . implode(' ', $initrds) . "\n";
+ }
+ return $script;
+ }
+
+ private function combineUrl(string $base, string $path): string
+ {
+ $url = parse_url($path);
+ if (isset($url['host'])) {
+ $scheme = $url['scheme'] ?? 'http';
+ $host = $url['host'];
+ $base = "($scheme,$host)";
+ $path = $url['path'] ?? '/';
+ if (isset($url['query'])) {
+ $path .= '?' . $url['query'];
+ }
+ } else {
+ if ($path[0] !== '/') {
+ $path = '/' . $path;
+ }
+ }
+ return $base . $path;
+ }
+
+}
diff --git a/modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderipxe.inc.php b/modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderipxe.inc.php
index 385cd15f..9421684f 100644
--- a/modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderipxe.inc.php
+++ b/modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderipxe.inc.php
@@ -3,7 +3,7 @@
class ScriptBuilderIpxe extends ScriptBuilderBase
{
- private function getUrlBase()
+ private function getUrlBase(): string
{
if (isset($_SERVER['REQUEST_URI'])) {
$url = parse_url($_SERVER['REQUEST_URI']);
@@ -23,7 +23,7 @@ class ScriptBuilderIpxe extends ScriptBuilderBase
}
- private function getUrlFull(&$hasExt, $key = null, $value = null)
+ private function getUrlFull(?bool &$hasExt = null, ?string $key = null, ?string $value = null): string
{
$url = parse_url($_SERVER['REQUEST_URI']);
$urlbase = $this->getUrlBase();
@@ -58,7 +58,7 @@ class ScriptBuilderIpxe extends ScriptBuilderBase
/**
* Redirect to same URL, but add our extended params
*/
- private function redirect($key = null, $value = null)
+ private function redirect(string $key = null, string $value = null): string
{
// Redirect to self with added parameters
$urlfull = $this->getUrlFull($hasExt, $key, $value);
@@ -102,27 +102,24 @@ HERE;
return false;
}
- public function getBootEntry($entry)
+ public function getBootEntry(?BootEntry $entry): string
{
- if (!$entry) {
+ if ($entry === null) {
return "#!ipxe\nprompt --timeout 5000 Invalid boot entry id\n";
}
return $entry->toScript($this);
}
- public function getMenu(IPxeMenu $menu, bool $bootstrap)
+ public function getMenu(IPxeMenu $menu, bool $bootstrap): string
{
if ($bootstrap) {
return "#!ipxe\nimgfree ||\n" . $this->menuToScript($menu);
}
- $base = $this->getUrlFull($he);
+ $base = $this->getUrlFull();
return "#!ipxe\nset self {$base} ||\n" . $this->menuToScript($menu);
}
- /**
- * @param IPxeMenu $menu
- */
- public function menuToScript($menu)
+ public function menuToScript(IPxeMenu $menu): string
{
if ($this->hasExtension) {
$slxConsoleUpdate = '--update';
@@ -136,13 +133,13 @@ HERE;
imgstat bg-menu || imgfetch --name bg-menu /tftp/pxe-menu.png ||
console --left 55 --top 88 --right 63 --bottom 64 --keep --picture bg-menu ||
-colour --rgb 0xffffff 7
-colour --rgb 0xcccccc 5
-colour --rgb 0x000000 0
-colour --rgb 0xdddddd 6
-cpair --foreground 0 --background 4 1
-cpair --foreground 0 --background 5 2
-cpair --foreground 7 --background 9 0
+colour --rgb 0xffffff 7 ||
+colour --rgb 0xcccccc 5 ||
+colour --rgb 0x000000 0 ||
+colour --rgb 0xdddddd 6 ||
+cpair --foreground 0 --background 4 1 ||
+cpair --foreground 0 --background 5 2 ||
+cpair --foreground 7 --background 9 0 ||
:slx_menu
@@ -166,7 +163,11 @@ HERE;
$output .= " selection || goto default || goto fail\n";
$output .= <<<HERE
console --left 60 --top 130 --right 67 --bottom 86 $slxConsoleUpdate ||
+set slx_exit \${} ||
chain -a \${self}&entryid=\${selection} ||
+iseq \${slx_exit} \${} || console ||
+iseq \${slx_exit} \${} || echo Exiting with code \${slx_exit} ||
+iseq \${slx_exit} \${} || exit \${slx_exit}
goto fail || goto start
goto \${target} ||
echo Could not find menu entry in script.
@@ -179,49 +180,10 @@ prompt Boot failed. Press any key to start.
goto start
HERE;
-
- /*
-
- :i5
- chain -a /tftp/memtest.0 passes=1 onepass || goto membad
- prompt Memory OK. Press a key.
- goto init
-
- :i8
- set x:int32 0
- :again
- console --left 60 --top 130 --right 67 --bottom 96 --picture bg-load --keep ||
- console --left 55 --top 88 --right 63 --bottom 64 --picture bg-menu --keep ||
- inc x
- iseq \${x} 20 || goto again
- prompt DONE. Press dein Knie.
- goto slx_menu
-
- :membad
- iseq \${errno} 0x1 || goto memaborted
- params
- param scrot \${vram}
- imgfetch -a http://132.230.8.113/screen.php##params ||
- prompt Memory is bad. Press a key.
- goto init
-
- :memaborted
- params
- param scrot \${vram}
- imgfetch -a http://132.230.8.113/screen.php##params ||
- prompt Memory test aborted. Press a key.
- goto init
-
- */
return $output;
}
- /**
- * @param $requestedDefaultId
- * @param MenuEntry $entry
- * @return string
- */
- private function getMenuItemScript($requestedDefaultId, $entry)
+ private function getMenuItemScript(int $requestedDefaultId, MenuEntry $entry): string
{
$str = 'item ';
if ($entry->gap) {
@@ -250,7 +212,7 @@ HERE;
return $str . " || prompt Could not create menu item for {$entry->menuentryid}\n";
}
- public function getSpecial($special)
+ public function getSpecial(string $special): string
{
if ($special === 'localboot') {
// Get preferred localboot method, depending on system model
@@ -299,11 +261,7 @@ HERE;
}
}
// Convert to actual ipxe code
- if (isset($BOOT_METHODS[$localboot])) {
- $localboot = $BOOT_METHODS[$localboot];
- } else {
- $localboot = 'prompt Localboot not possible';
- }
+ $localboot = $BOOT_METHODS[$localboot] ?? 'prompt Localboot not possible';
$output = <<<BLA
imgfree ||
console ||
@@ -317,17 +275,27 @@ BLA;
return $output;
}
- public function output($string)
+ public function output(string $string): void
{
- setlocale(LC_ALL, 'de_DE.UTF-8', 'de_DE.utf-8', 'de_DE.utf8', 'de_DE', 'de', 'German', 'ge', 'en_US.UTF-8', 'en_US.utf-8');
- if ($this->platform === 'EFI') {
- $cs = 'ASCII';
+ // iPXE introduced UTF-8 support at some point in 2022, and now expects all text/script files to be
+ // encoded as such. Since we still offer to use older versions, we need to detect that here and handle
+ // all non-ASCII chars differently.
+ // Use 'ipxe.compile-time' instead of const from IpxeBuilder to avoid pulling in another include
+ if (!preg_match('/Version: (\d{4})-\d{2}-\d{2}\b/', Property::get('ipxe.compile-time'), $out)
+ || (int)$out[1] >= 2022) {
+ Header('Content-Type: text/plain; charset=UTF-8');
+ echo $string;
} else {
- $cs = 'IBM437';
- }
- Header('Content-Type: text/plain; charset=' . $cs);
+ if ($this->platform === 'EFI') {
+ $cs = 'ASCII';
+ } else {
+ $cs = 'IBM437';
+ }
+ Header('Content-Type: text/plain; charset=' . $cs);
- echo iconv('UTF-8', $cs . '//TRANSLIT//IGNORE', $string);
+ setlocale(LC_ALL, 'de_DE.UTF-8', 'de_DE.utf-8', 'de_DE.utf8', 'de_DE', 'de', 'German', 'ge', 'en_US.UTF-8', 'en_US.utf-8');
+ echo iconv('UTF-8', $cs . '//TRANSLIT//IGNORE', $string);
+ }
}
public function modfilt($str)
@@ -337,28 +305,9 @@ BLA;
return trim(preg_replace('/\s+/', ' ', $str));
}
- /**
- * @param IPxeMenu $menu
- */
- private function menuCheckAutostart($menu)
- {
- // If this is a menu with a single item, treat a timeout of 0 as "boot immediately" instead of "infinite"
- if ($menu->itemCount() === 1 && $menu->timeoutMs() === 0 && ($tmp = $menu->getDefaultEntryId()) !== null) {
- if (empty($menu->items[0]->md5pass)) {
- return $menu->items[0]->getBootEntryScript($this);
- } else {
- return $this->passwordDialog($menu->items[0]);
- }
- }
- return '';
- }
-
const PROP_PW_SALT = 'ipxe.salt.';
- /**
- * @param MenuEntry $menuEntryId
- */
- private function passwordDialog($entry)
+ private function passwordDialog(MenuEntry $entry): string
{
if ($this->hasExtension) {
$salt = dechex(mt_rand(0x100000, 0xFFFFFF));
@@ -384,7 +333,7 @@ chain -a \${self}&entryid={$entry->menuentryid}##params || goto fail ||
HERE;
}
- public function getMenuEntry($entry, $honorPassword = true)
+ public function getMenuEntry(?MenuEntry $entry, bool $honorPassword = true): string
{
if ($entry === null)
return "#!ipxe\nprompt --timeout 10000 Invalid menu entry id\n";
@@ -438,7 +387,7 @@ HERE;
return $output;
}
- public function execDataToScript($agnostic, $bios, $efi) : string
+ public function execDataToScript(?ExecData $agnostic, ?ExecData $bios, ?ExecData $efi) : string
{
if ($agnostic !== null)
return $this->execDataToScriptInternal($agnostic) . "\ngoto fail\n";
diff --git a/modules-available/serversetup-bwlp-ipxe/install.inc.php b/modules-available/serversetup-bwlp-ipxe/install.inc.php
index 983988bb..5af00493 100644
--- a/modules-available/serversetup-bwlp-ipxe/install.inc.php
+++ b/modules-available/serversetup-bwlp-ipxe/install.inc.php
@@ -102,7 +102,7 @@ if (!tableHasColumn('serversetup_localboot', 'pcbios')) {
$result[] = tableAddConstraint('serversetup_menuentry', 'refmenuid', 'serversetup_menu', 'menuid',
'ON UPDATE CASCADE ON DELETE SET NULL');
-if (Module::get('location') !== false) {
+if (Module::get('locations') !== false) {
if (!tableExists('location')) {
$result[] = UPDATE_RETRY;
} else {
@@ -117,7 +117,7 @@ if (!tableHasColumn('serversetup_bootentry', 'module')) {
ADD COLUMN `module` varchar(30) CHARACTER SET ascii DEFAULT NULL AFTER `builtin`") !== false) {
$result[] = UPDATE_DONE;
$res = Database::simpleQuery('SELECT entryid, data FROM serversetup_bootentry WHERE module IS NULL');
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$json = json_decode($row['data'], true);
if (isset($json['script'])) {
Database::exec("UPDATE serversetup_bootentry SET module = '.script' WHERE entryid = :id", ['id' => $row['entryid']]);
diff --git a/modules-available/serversetup-bwlp-ipxe/lang/de/messages.json b/modules-available/serversetup-bwlp-ipxe/lang/de/messages.json
index 339296e7..5446130f 100644
--- a/modules-available/serversetup-bwlp-ipxe/lang/de/messages.json
+++ b/modules-available/serversetup-bwlp-ipxe/lang/de/messages.json
@@ -2,6 +2,7 @@
"boot-entry-created": "Men\u00fceintrag {{0}} erzeugt",
"boot-entry-updated": "Men\u00fceintrag {{0}} aktualisiert",
"bootentry-deleted": "Men\u00fceintrag gel\u00f6scht",
+ "cannot-edit-special": "Der Eintrag {{0}} kann nicht editiert werden",
"error-saving-entry": "Fehler beim Speichern des Eintrags {{0}}: {{1}}",
"image-not-found": "USB-Image nicht gefunden. Generieren Sie das Bootmen\u00fc neu.",
"import-error": "Fehler beim Importieren",
@@ -20,6 +21,7 @@
"missing-bootentry-data": "Fehlende Daten f\u00fcr den Men\u00fceintrag",
"no-ip-addr-set": "Bitte w\u00e4hlen Sie die prim\u00e4re IP-Adresse des Servers",
"no-ip-set": "Kann Import alter Konfiguration nicht ausf\u00fchren. Bitte zuerst die prim\u00e4re IP-Adresse des Servers festlegen.",
+ "nothing-changed-or-protected": "{{0}}: Nichts ver\u00e4ndert, oder gesch\u00fctzter Eintrag",
"unknown-bootentry-type": "Unbekannter Eintrags-Typ: {{0}}",
"unknown-hook-module": "Unbekanntes Modul: {{0}}"
} \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-ipxe/lang/de/module.json b/modules-available/serversetup-bwlp-ipxe/lang/de/module.json
index f95573a2..559b84a5 100644
--- a/modules-available/serversetup-bwlp-ipxe/lang/de/module.json
+++ b/modules-available/serversetup-bwlp-ipxe/lang/de/module.json
@@ -9,9 +9,10 @@
"dl-usb": "USB-Image",
"dl-usbnic": "Mit USB Netzwerktreibern",
"dl-x86_64": "64\u2009Bit",
+ "location-column-header": "Bootmen\u00fc",
"module_name": "iPXE \/ Boot Menu",
"page_title": "PXE- und Boot-Einstellungen",
- "submenu_address": "Server-Adresse festlegen",
+ "submenu_address": "Boot-IP \/ iPXE-Version setzen",
"submenu_bootentry": "Men\u00fceintr\u00e4ge verwalten",
"submenu_download": "Downloads",
"submenu_import": "Importieren",
diff --git a/modules-available/serversetup-bwlp-ipxe/lang/de/template-tags.json b/modules-available/serversetup-bwlp-ipxe/lang/de/template-tags.json
index 4335adc5..eba15cbe 100644
--- a/modules-available/serversetup-bwlp-ipxe/lang/de/template-tags.json
+++ b/modules-available/serversetup-bwlp-ipxe/lang/de/template-tags.json
@@ -36,8 +36,10 @@
"lang_execImageFree": "Andere geladene Images vor dem Ausf\u00fchren entladen (imgfree)",
"lang_execReplace": "Aktuellen iPXE-Stack ersetzen (--replace)",
"lang_execResetConsole": "Konsole vor Ausf\u00fchrung zur\u00fccksetzen",
+ "lang_fetchUpdate": "Updates laden",
"lang_forceRecompile": "Jetzt neu kompilieren",
"lang_generationFailed": "Erzeugen des Bootmen\u00fcs fehlgeschlagen. Der Netzwerkboot von bwLehrpool wird wahrscheinlich nicht funktionieren. Wenn Sie den Fehler nicht selbst beheben k\u00f6nnen, melden Sie bitte die Logausgabe an das bwLehrpool-Projekt.",
+ "lang_gitCheckout": "git-Ausgabe",
"lang_hex": "Hex",
"lang_hookExtraOptionHeading": "Weitere Angaben",
"lang_hookOfModule": "Eintrag von",
@@ -49,6 +51,7 @@
"lang_ipxeSettings": "iPXE-spezifische Einstellungen",
"lang_ipxeWikiUrl": "im iPXE Wiki",
"lang_isDefault": "Standard",
+ "lang_lastBuild": "Letzter erfolgreicher Bauvorgang",
"lang_listOfMenus": "Men\u00fcliste",
"lang_localBootDefault": "Standardm\u00e4\u00dfig verwendete Methode, um von Festplatte zu booten",
"lang_localBootExceptions": "Ausnahmen, pro Rechnermodell definierbar",
@@ -71,9 +74,12 @@
"lang_pxelinuxEntriesOnly": "Nur Eintr\u00e4ge importieren, kein Men\u00fc erzeugen",
"lang_pxelinuxImport": "PXE-Men\u00fc importieren",
"lang_pxelinuxImportIntro": "Hier k\u00f6nnen Sie ein PXE-Men\u00fc einf\u00fcgen und in entsprechende Men\u00fceintr\u00e4ge f\u00fcr iPXE umwandeln lassen.",
- "lang_recompileHint": "iPXE-Binaries jetzt neu kompilieren. Normalerweise wird dieser Vorgang bei \u00c4nderungen automatisch ausgef\u00fchrt. Sollten Bootprobleme auftreten, k\u00f6nnen Sie hier den Vorgang manuell ansto\u00dfen.",
+ "lang_reallyGitReset": "Wollen Sie wirklich alle \u00c4nderungen am iPXE-Sourcecode zur\u00fccksetzen?",
+ "lang_recompileHead": "iPXE-Binaries jetzt neu kompilieren",
+ "lang_recompileHint": "Normalerweise wird dieser Vorgang bei \u00c4nderungen automatisch ausgef\u00fchrt. Sollten Bootprobleme auftreten, k\u00f6nnen Sie hier den Vorgang manuell ansto\u00dfen. Au\u00dferdem k\u00f6nnen Sie hier explizit einen Versionsstand ausw\u00e4hlen. Durch \u00e4nderungen und Bugfixes am iPXE-Code kann es zu Regressionen kommen, d.h., dass bestimmte Hardware m\u00f6glicherweise mit neueren Versionen nicht mehr Bootet. In diesem Fall ist ein Wechsel auf eine \u00e4ltere Version eine vor\u00fcbergehende L\u00f6sung. Ansonsten ist es zu empfehlen, immer bei der aktuellen Version zu bleiben.",
"lang_refCount": "Referenzen",
"lang_referencingMenus": "Verkn\u00fcpfte Men\u00fcs",
+ "lang_resetWorkingTree": "git-Repo zur\u00fccksetzen",
"lang_saveAndReload": "Speichern und neu laden",
"lang_scriptContent": "Skript",
"lang_seconds": "Sekunden",
@@ -87,5 +93,6 @@
"lang_usbImgHelpLinux": "Nutzen Sie dd, um das Image auf einen USB-Stick zu schreiben. Das Image enth\u00e4lt bereits eine Partitionstabelle, achten Sie daher darauf, dass Sie das Image z.B. nach \/dev\/sdx schreiben, und nicht nach \/dev\/sdx1",
"lang_usbImgHelpWindows": "Unter Windows muss zun\u00e4chst ein Programm besorgt werden, mit dem sich Images direkt auf einen USB-Stick schreiben lassen. Es gibt gleich mehrere kostenlose und quelloffene Programme, eines davon ist Rufus. Rufus wurde mit dem bwLehrpool-Image gestetet. Nach dem Starten des Programms ist lediglich das heruntergeladene Image zu \u00f6ffnen, sowie in der Liste der Laufwerke der richtige USB-Stick auszuw\u00e4hlen (damit Sie nicht versehentlich Daten auf dem falschen Laufwerk \u00fcberschreiben!)",
"lang_useDefaultMenu": "\u00dcbergeordnetes Men\u00fc verwenden",
- "lang_useDefaultMenuEntry": "(Vorgabe des Men\u00fcs)"
+ "lang_useDefaultMenuEntry": "(Vorgabe des Men\u00fcs)",
+ "lang_versionSelect": "iPXE-Version ausw\u00e4hlen"
} \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-ipxe/lang/en/messages.json b/modules-available/serversetup-bwlp-ipxe/lang/en/messages.json
index 9e1c0b3e..21fdfdc3 100644
--- a/modules-available/serversetup-bwlp-ipxe/lang/en/messages.json
+++ b/modules-available/serversetup-bwlp-ipxe/lang/en/messages.json
@@ -2,6 +2,7 @@
"boot-entry-created": "Created menu item {{0}}",
"boot-entry-updated": "Updated menu item {{0}}",
"bootentry-deleted": "Deleted menu item",
+ "cannot-edit-special": "Entry {{0}} cannot be edited",
"error-saving-entry": "Error saving item {{0}}: {{1}}",
"image-not-found": "USB image not found. Try regenerating the boot menu first.",
"import-error": "Error importing menu",
@@ -20,6 +21,7 @@
"missing-bootentry-data": "Missing data for menu item",
"no-ip-addr-set": "Please set the server's primary IP address",
"no-ip-set": "Cannot import old configuration. Please set the primary IP address first.",
+ "nothing-changed-or-protected": "{{0}}: Nothing changed, oder protected entry",
"unknown-bootentry-type": "Unknown item type: {{0}}",
"unknown-hook-module": "Unknown module: {{0}}"
} \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-ipxe/lang/en/module.json b/modules-available/serversetup-bwlp-ipxe/lang/en/module.json
index 30865907..ce660fc1 100644
--- a/modules-available/serversetup-bwlp-ipxe/lang/en/module.json
+++ b/modules-available/serversetup-bwlp-ipxe/lang/en/module.json
@@ -9,9 +9,10 @@
"dl-usb": "thumb drive image",
"dl-usbnic": "with USB NIC drivers",
"dl-x86_64": "64 bit",
+ "location-column-header": "Boot menu",
"module_name": "iPXE \/ Boot Menu",
"page_title": "iPXE and boot settings",
- "submenu_address": "Server address",
+ "submenu_address": "Set boot IP \/ iPXE version",
"submenu_bootentry": "Manage menu items",
"submenu_download": "Downloads",
"submenu_import": "Import",
diff --git a/modules-available/serversetup-bwlp-ipxe/lang/en/template-tags.json b/modules-available/serversetup-bwlp-ipxe/lang/en/template-tags.json
index 91d48e5d..e4727ab7 100644
--- a/modules-available/serversetup-bwlp-ipxe/lang/en/template-tags.json
+++ b/modules-available/serversetup-bwlp-ipxe/lang/en/template-tags.json
@@ -3,7 +3,7 @@
"lang_add": "Add",
"lang_addBootentry": "Add Bootentry",
"lang_addMenu": "Add Menu",
- "lang_additionalInfoLink": "Read more",
+ "lang_additionalInfoLink": "More information",
"lang_archAgnostic": "Architecture-agnostic",
"lang_archBoth": "BIOS and EFI",
"lang_archSelector": "Select architecture",
@@ -36,8 +36,10 @@
"lang_execImageFree": "Unload any other images before execution (imgfree)",
"lang_execReplace": "Replace current iPXE stack (--replace)",
"lang_execResetConsole": "Reset console before execution",
+ "lang_fetchUpdate": "Fetch updates",
"lang_forceRecompile": "Force recompile",
"lang_generationFailed": "Could not generate boot menu. The bwLehrpool-System might not work properly. If you can't fix the problem, please report the error log below to the bwLehrpool project.",
+ "lang_gitCheckout": "git output",
"lang_hex": "Hex",
"lang_hookExtraOptionHeading": "Extra Options",
"lang_hookOfModule": "Entry of",
@@ -47,8 +49,9 @@
"lang_import": "Import",
"lang_initRd": "Optional initrd\/initramfs to load",
"lang_ipxeSettings": "iPXE-specific settings",
- "lang_ipxeWikiUrl": "at the iPXE wiki",
+ "lang_ipxeWikiUrl": "in the iPXE wiki",
"lang_isDefault": "Default",
+ "lang_lastBuild": "Last successful build",
"lang_listOfMenus": "Menulist",
"lang_localBootDefault": "Default method to use for booting from disk",
"lang_localBootExceptions": "Exceptions to the local boot method, defined per system model",
@@ -71,9 +74,12 @@
"lang_pxelinuxEntriesOnly": "Import menu items only, don't create menu",
"lang_pxelinuxImport": "Import PXELinux menu",
"lang_pxelinuxImportIntro": "Here you can paste a pxelinux menu to convert it to an iPXE menu.",
- "lang_recompileHint": "Recompile iPXE binaries now. Usually this happens automatically on changes, but if you suspect problems caused by outdated binaries, you can trigger recompilation here.",
+ "lang_reallyGitReset": "Do you really want to reset the iPXE source code and revert any local changes?",
+ "lang_recompileHead": "Recompile iPXE binaries now",
+ "lang_recompileHint": "Usually this happens automatically on changes, but if you suspect problems caused by outdated binaries, you can trigger recompilation here. You can also specify the iPXE version to use here. Although it is generally recommended to use the latest version, this might be useful if a regression occurs, i.e. some bugfix or other change in a newer version breaks booting specific hardware that was previously supported.",
"lang_refCount": "References",
"lang_referencingMenus": "Referencing menus",
+ "lang_resetWorkingTree": "Reset git repository",
"lang_saveAndReload": "Save and Reload",
"lang_scriptContent": "Script content",
"lang_seconds": "Seconds",
@@ -87,5 +93,6 @@
"lang_usbImgHelpLinux": "On Linux you can simply use dd to write the image to a usb stick. The image already contains a partition table, so make sure you write the image to the device itself and not to an already existing partition (e.g. to \/dev\/sdx not \/dev\/sdx1)",
"lang_usbImgHelpWindows": "On Windows you need to use a 3rd party tool that can directly write to usb sticks. There are several free and open source soltions, one of them being Rufus. Rufus has been tested with the bwLehrpool image and is very simple to use. After launching Rufus, just open the downloaded USB image, select the proper USB stick to write to (be careful not to overwrite the wrong drive!), and you're ready to go.",
"lang_useDefaultMenu": "Inherit from parent location",
- "lang_useDefaultMenuEntry": "(Menu default)"
+ "lang_useDefaultMenuEntry": "(Menu default)",
+ "lang_versionSelect": "Select iPXE version"
} \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-ipxe/page.inc.php b/modules-available/serversetup-bwlp-ipxe/page.inc.php
index e31814d1..c9260687 100644
--- a/modules-available/serversetup-bwlp-ipxe/page.inc.php
+++ b/modules-available/serversetup-bwlp-ipxe/page.inc.php
@@ -6,14 +6,13 @@ class Page_ServerSetup extends Page
private $addrListTask;
private $compileTask = null;
private $currentAddress;
- private $currentMenu;
private $hasIpSet = false;
private function getCompileTask()
{
if ($this->compileTask !== null)
return $this->compileTask;
- $this->compileTask = Property::get('ipxe-task-id');
+ $this->compileTask = Property::get(IPxeBuilder::PROP_IPXE_COMPILE_TASKID);
if ($this->compileTask !== false) {
$this->compileTask = Taskmanager::status($this->compileTask);
if (!Taskmanager::isTask($this->compileTask) || Taskmanager::isFinished($this->compileTask)) {
@@ -37,7 +36,10 @@ class Page_ServerSetup extends Page
$this->handleGetImage();
}
- $this->currentMenu = Property::getBootMenu();
+ if (User::hasPermission('edit.address')) {
+ Taskmanager::submit('IpxeVersion',
+ ['id' => IPxeBuilder::VERSION_LIST_TASK, 'action' => 'LIST'], true);
+ }
$action = Request::post('action');
@@ -49,9 +51,20 @@ class Page_ServerSetup extends Page
if ($action === 'compile') {
User::assertPermission("edit.address");
if ($this->getCompileTask() === false) {
- Trigger::ipxe();
+ $taskId = IPxeBuilder::setIpxeVersion(Request::post('version', false, 'string'));
+ Trigger::ipxe($taskId);
}
- Util::redirect('?do=serversetup');
+ Util::redirect('?do=serversetup&show=address&sv=1');
+ }
+ if ($action === 'fetch' || $action === 'reset') {
+ User::assertPermission("edit.address");
+ if ($this->getCompileTask() === false) {
+ $task = Taskmanager::submit('IpxeVersion', ['action' => strtoupper($action)]);
+ if (Taskmanager::isTask($task)) {
+ Property::set(IPxeBuilder::PROP_VERSION_SELECT_TASKID, $task['id'], 2);
+ }
+ }
+ Util::redirect('?do=serversetup&show=address&sv=1');
}
if ($action === 'ip') {
@@ -110,32 +123,33 @@ class Page_ServerSetup extends Page
$addr = false;
if (User::hasPermission('ipxe.menu.view')) {
- Dashboard::addSubmenu('?do=serversetup&show=menu', Dictionary::translate('submenu_menu', true));
+ Dashboard::addSubmenu('?do=serversetup&show=menu', Dictionary::translate('submenu_menu'));
}
if (User::hasPermission('ipxe.bootentry.view')) {
- Dashboard::addSubmenu('?do=serversetup&show=bootentry', Dictionary::translate('submenu_bootentry', true));
+ Dashboard::addSubmenu('?do=serversetup&show=bootentry', Dictionary::translate('submenu_bootentry'));
}
if (User::hasPermission('edit.address')) {
- Dashboard::addSubmenu('?do=serversetup&show=address', Dictionary::translate('submenu_address', true));
+ Dashboard::addSubmenu('?do=serversetup&show=address', Dictionary::translate('submenu_address'));
$addr = true;
}
if (User::hasPermission('download')) {
- Dashboard::addSubmenu('?do=serversetup&show=download', Dictionary::translate('submenu_download', true));
+ Dashboard::addSubmenu('?do=serversetup&show=download', Dictionary::translate('submenu_download'));
}
if (User::hasPermission('ipxe.localboot.*')) {
- Dashboard::addSubmenu('?do=serversetup&show=localboot', Dictionary::translate('submenu_localboot', true));
+ Dashboard::addSubmenu('?do=serversetup&show=localboot', Dictionary::translate('submenu_localboot'));
}
if (User::hasPermission('ipxe.bootentry.edit')) {
- Dashboard::addSubmenu('?do=serversetup&show=import', Dictionary::translate('submenu_import', true));
+ Dashboard::addSubmenu('?do=serversetup&show=import', Dictionary::translate('submenu_import'));
}
if (Request::get('show') === false) {
$subs = Dashboard::getSubmenus();
+ $sv = Request::get('sv') ? '&sv=' . Request::get('sv') : '';
if (empty($subs)) {
User::assertPermission('download');
} elseif ($addr && !preg_match('/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/', Property::getServerIp())) {
- Util::redirect('?do=serversetup&show=address');
+ Util::redirect('?do=serversetup&show=address' . $sv);
} else {
- Util::redirect($subs[0]['url']);
+ Util::redirect($subs[0]['url'] . $sv);
}
}
}
@@ -147,15 +161,22 @@ class Page_ServerSetup extends Page
$show = Request::get('show');
if (in_array($show, ['menu', 'address', 'download'])) {
- $task = $this->getCompileTask();
- if ($task !== false) {
+ $selectTask = Taskmanager::status(Property::get(IPxeBuilder::PROP_VERSION_SELECT_TASKID));
+ $buildTask = $this->getCompileTask();
+ if ($buildTask !== false || Taskmanager::isRunning($selectTask) || Request::get('sv')) {
+ Render::addTemplate('git_task', [
+ 'selectTask' => Property::get(IPxeBuilder::PROP_VERSION_SELECT_TASKID),
+ 'reload' => Taskmanager::isRunning($selectTask),
+ ]);
+ }
+ if ($buildTask !== false) {
$files = [];
- if ($task['data'] && $task['data']['files']) {
- foreach ($task['data']['files'] as $k => $v) {
+ if ($buildTask['data'] && $buildTask['data']['files']) {
+ foreach ($buildTask['data']['files'] as $k => $v) {
$files[] = ['name' => $k, 'namehyphen' => str_replace(['/', '.'], '-', $k)];
}
}
- Render::addTemplate('ipxe_update', array('taskid' => $task['id'], 'files' => $files));
+ Render::addTemplate('ipxe_update', ['taskid' => $buildTask['id'], 'files' => $files]);
}
}
@@ -198,7 +219,6 @@ class Page_ServerSetup extends Page
break;
default:
Util::redirect('?do=serversetup');
- break;
}
}
@@ -210,20 +230,20 @@ class Page_ServerSetup extends Page
});
$files = [];
$strings = [
- 'efi' => [Dictionary::translate('dl-efi', true) => 50],
- 'pcbios' => [Dictionary::translate('dl-pcbios', true) => 51],
- 'usb' => [Dictionary::translate('dl-usb', true) => 80],
- 'hd' => [Dictionary::translate('dl-hd', true) => 81],
- 'lkrn' => [Dictionary::translate('dl-lkrn', true) => 82],
- 'i386' => [Dictionary::translate('dl-i386', true) => 10],
- 'x86_64' => [Dictionary::translate('dl-x86_64', true) => 11],
- 'ecm' => [Dictionary::translate('dl-usbnic', true) => 60],
- 'ncm' => [Dictionary::translate('dl-usbnic', true) => 61],
- 'ipxe' => [Dictionary::translate('dl-pcinic', true) => 62],
- 'snp' => [Dictionary::translate('dl-snp', true) => 63],
+ 'efi' => [Dictionary::translate('dl-efi') => 50],
+ 'pcbios' => [Dictionary::translate('dl-pcbios') => 51],
+ 'usb' => [Dictionary::translate('dl-usb') => 80],
+ 'hd' => [Dictionary::translate('dl-hd') => 81],
+ 'lkrn' => [Dictionary::translate('dl-lkrn') => 82],
+ 'i386' => [Dictionary::translate('dl-i386') => 10],
+ 'x86_64' => [Dictionary::translate('dl-x86_64') => 11],
+ 'ecm' => [Dictionary::translate('dl-usbnic') => 60],
+ 'ncm' => [Dictionary::translate('dl-usbnic') => 61],
+ 'ipxe' => [Dictionary::translate('dl-pcinic') => 62],
+ 'snp' => [Dictionary::translate('dl-snp') => 63],
];
foreach ($list as $file) {
- if ($file{0} === '.')
+ if ($file[0] === '.')
continue;
if (is_file($file)) {
$base = basename($file);
@@ -246,7 +266,10 @@ class Page_ServerSetup extends Page
Render::addTemplate('download', ['files' => $files]);
}
- private function makeSelectArray($list, $defaults)
+ /**
+ * @return array{EFI: array, PCBIOS: array}
+ */
+ private function makeSelectArray(array $list, array $defaults): array
{
$ret = ['EFI' => [], 'PCBIOS' => []];
foreach (['PCBIOS', 'EFI'] as $m) {
@@ -273,7 +296,7 @@ class Page_ServerSetup extends Page
) m
LEFT JOIN serversetup_localboot sl USING (systemmodel)
ORDER BY systemmodel');
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$row['modelesc'] = urlencode($row['systemmodel']);
$row['options'] = $this->makeSelectArray(Localboot::BOOT_METHODS, $row);
$models[] = $row;
@@ -305,7 +328,7 @@ class Page_ServerSetup extends Page
GROUP BY be.entryid, be.title");
if (empty($bootentryTable)) {
- if (Property::getServerIp() === false || Property::getServerIp() === 'invalid') {
+ if (Property::getServerIp() === 'invalid') {
Message::addError('no-ip-set');
Util::redirect('?do=serversetup&show=address');
}
@@ -340,7 +363,7 @@ class Page_ServerSetup extends Page
GROUP BY menuid, title
ORDER BY title");
$menuTable = [];
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if (empty($row['locations'])) {
$locations = [];
$row['allowEdit'] = in_array(0, $allowedEdit);
@@ -358,7 +381,7 @@ class Page_ServerSetup extends Page
));
}
- private function hasMenuPermission($menuid, $permission)
+ private function hasMenuPermission(int $menuid, string $permission): bool
{
$allowedEditLocations = User::getAllowedLocations($permission);
$allowEdit = in_array(0, $allowedEditLocations);
@@ -412,7 +435,7 @@ class Page_ServerSetup extends Page
LEFT JOIN serversetup_bootentry be USING (entryid)
WHERE menuid = :id
ORDER BY sortval ASC", compact('id'));
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if ($row['entryid'] === null && $row['refmenuid'] !== null) {
$row['entryid'] = 'menu:' . $row['refmenuid'];
}
@@ -430,7 +453,7 @@ class Page_ServerSetup extends Page
foreach ($menu['entrylist'] as &$bootentry) {
if (!isset($bootentry['data']) || !isset($bootentry['module']))
continue;
- if ($bootentry['module']{0} !== '.') {
+ if ($bootentry['module'][0] !== '.') {
// Hook from other module
$bootentry['moduleName'] = Dictionary::translateFileModule($bootentry['module'], 'module', 'module_name');
if (!$bootentry['moduleName']) {
@@ -445,7 +468,7 @@ class Page_ServerSetup extends Page
if ($k === 'id')
continue;
$bootentry['otherFields'][] = [
- 'key' => Dictionary::translateFileModule($bootentry['module'], 'module', 'ipxe-' . $k, true),
+ 'key' => Dictionary::translateFileModule($bootentry['module'], 'module', 'ipxe-' . $k),
'value' => is_bool($v) ? Util::boolToString($v) : $v,
];
}
@@ -462,16 +485,16 @@ class Page_ServerSetup extends Page
continue;
// Naming and agnostic
if ($bootentry['data']['arch'] === BootEntry::BIOS) {
- $bootentry['data']['arch'] = Dictionary::translateFile('template-tags','lang_biosOnly', true);
+ $bootentry['data']['arch'] = Dictionary::translateFile('template-tags','lang_biosOnly');
unset($bootentry['data']['EFI']);
} elseif ($bootentry['data']['arch'] === BootEntry::EFI) {
- $bootentry['data']['arch'] = Dictionary::translateFile('template-tags','lang_efiOnly', true);
+ $bootentry['data']['arch'] = Dictionary::translateFile('template-tags','lang_efiOnly');
unset($bootentry['data']['PCBIOS']);
} elseif ($bootentry['data']['arch'] === BootEntry::AGNOSTIC) {
- $bootentry['data']['arch'] = Dictionary::translateFile('template-tags','lang_archAgnostic', true);
+ $bootentry['data']['arch'] = Dictionary::translateFile('template-tags','lang_archAgnostic');
unset($bootentry['data']['EFI']);
} else {
- $bootentry['data']['arch'] = Dictionary::translateFile('template-tags','lang_archBoth', true);
+ $bootentry['data']['arch'] = Dictionary::translateFile('template-tags','lang_archBoth');
}
foreach ($bootentry['data'] as &$e) {
if (isset($e['initRd'])) {
@@ -510,14 +533,13 @@ class Page_ServerSetup extends Page
Message::addError('invalid-boot-entry', $id);
Util::redirect('?do=serversetup');
}
- if ($row['module']{0} === '.') {
+ if ($row['module'] === '.special') {
+ Message::addError('cannot-edit-special', $id);
+ Util::redirect('?do=serversetup');
+ }
+ if ($row['module'][0] === '.') {
// either script or exec entry
- $json = json_decode($row['data'], true);
- if (!is_array($json)) {
- Message::addError('unknown-bootentry-type', $id);
- Util::redirect('?do=serversetup&show=bootentry');
- }
- $entry = BootEntry::fromJson($row['module'], $json);
+ $entry = BootEntry::fromJson($row['module'], $row['data']);
if ($entry === null) {
Message::addError('unknown-bootentry-type', $id);
Util::redirect('?do=serversetup&show=bootentry');
@@ -572,22 +594,42 @@ class Page_ServerSetup extends Page
private function showEditAddress()
{
+ $status = IPxeBuilder::getVersionTaskResult();
+ $versions = false;
+ if ($status === null) {
+ $error = 'Taskmanager down';
+ } elseif (!empty($status['versions'])) {
+ $versions = $status['versions'];
+ foreach ($versions as &$version) {
+ if ($version['hash'] === Property::get(IPxeBuilder::PROP_IPXE_HASH)) {
+ $version['hash_selected'] = 'selected';
+ }
+ $version['date_s'] = date('Y-m-d H:i', $version['date']);
+ $version['hash_s'] = substr($version['hash'], 0, 7);
+ }
+ $error = false;
+ } else {
+ $error = $status['error'] ?? 'Unknown error';
+ }
Render::addTemplate('ipaddress', array(
- 'ips' => $this->addrListTask['data']['addresses'],
+ 'ips' => $this->addrListTask['data']['addresses'] ?? [],
'chooseHintClass' => $this->hasIpSet ? '' : 'alert alert-danger',
'disabled' => ($this->getCompileTask() === false) ? '' : 'disabled',
+ 'versions' => $versions,
+ 'error' => $error,
+ 'lastBuild' => Property::get(IPxeBuilder::PROP_IPXE_BUILDSTRING),
));
}
// -----------------------------------------------------------------------------------------------
- private function getLocalAddresses()
+ private function getLocalAddresses(): void
{
$this->addrListTask = Taskmanager::submit('LocalAddressesList', array());
if ($this->addrListTask === false) {
$this->addrListTask['data']['addresses'] = false;
- return false;
+ return;
}
if (!Taskmanager::isFinished($this->addrListTask)) { // TODO: Async if just displaying
@@ -596,12 +638,12 @@ class Page_ServerSetup extends Page
if (Taskmanager::isFailed($this->addrListTask) || !isset($this->addrListTask['data']['addresses'])) {
$this->addrListTask['data']['addresses'] = false;
- return false;
+ return;
}
$sortIp = array();
foreach (array_keys($this->addrListTask['data']['addresses']) as $key) {
- $item = & $this->addrListTask['data']['addresses'][$key];
+ $item =& $this->addrListTask['data']['addresses'][$key];
if (!isset($item['ip']) || !preg_match('/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/', $item['ip']) || substr($item['ip'], 0, 4) === '127.') {
unset($this->addrListTask['data']['addresses'][$key]);
continue;
@@ -614,7 +656,6 @@ class Page_ServerSetup extends Page
}
unset($item);
array_multisort($sortIp, SORT_STRING, $this->addrListTask['data']['addresses']);
- return true;
}
private function deleteBootEntry()
@@ -757,7 +798,7 @@ class Page_ServerSetup extends Page
|| $defaultEntryId === null)) { // if still null, use whatever as fallback, in case user didn't select any
$defaultEntryId = $newKey;
}
- $keepIds[] = (int)$newKey;
+ $keepIds[] = $newKey;
if (!empty($entry['plainpass'])) {
Database::exec('UPDATE serversetup_menuentry SET md5pass = :md5pass WHERE menuentryid = :id', [
'md5pass' => IPxe::makeMd5Pass($entry['plainpass'], $newKey),
@@ -791,7 +832,7 @@ class Page_ServerSetup extends Page
{
$newAddress = Request::post('ip', 'none', 'string');
$valid = false;
- foreach ($this->addrListTask['data']['addresses'] as $item) {
+ foreach ($this->addrListTask['data']['addresses'] ?? [] as $item) {
if ($item['ip'] !== $newAddress)
continue;
$valid = true;
@@ -832,14 +873,15 @@ class Page_ServerSetup extends Page
Message::addError('missing-bootentry-data');
return;
}
- $module = false;
$type = Request::post('type', false, 'string');
- if ($type{0} === '.') {
+ if ($type[0] === '.') {
// Exec or script
if ($type === '.exec') {
$entry = BootEntry::newStandardBootEntry($data);
} elseif ($type === '.script') {
$entry = BootEntry::newCustomBootEntry($data);
+ } else {
+ $entry = null;
}
if ($entry === null) {
Message::addError('main.empty-field');
@@ -849,7 +891,7 @@ class Page_ServerSetup extends Page
} else {
// Module hook
$hook = Hook::loadSingle($type, 'ipxe-bootentry');
- if ($hook === false) {
+ if ($hook === null) {
Message::addError('unknown-bootentry-type', $type);
return;
}
@@ -883,10 +925,15 @@ class Page_ServerSetup extends Page
} else {
// Edit existing entry
$params['oldid'] = $oldEntryId;
- Database::exec('UPDATE serversetup_bootentry SET
+ // Ignore .special, must never update
+ $aff = Database::exec("UPDATE serversetup_bootentry SET
entryid = If(builtin = 0, :entryid, entryid), title = :title, module = :module, data = :data
- WHERE entryid = :oldid', $params);
- Message::addSuccess('boot-entry-updated', $newId);
+ WHERE entryid = :oldid AND module <> '.special'", $params);
+ if ($aff > 0) {
+ Message::addSuccess('boot-entry-updated', $newId);
+ } else {
+ Message::addWarning('nothing-changed-or-protected', $newId);
+ }
}
if (Request::post('next') === 'reload') {
Util::redirect('?do=serversetup&show=editbootentry&id=' . $newId);
@@ -924,7 +971,7 @@ class Page_ServerSetup extends Page
ORDER BY m.title ASC', ['locationid' => $locationId]);
$menus = [];
$hasDefault = false;
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$eids = explode(',', $row['entries']);
$row['default_entry_title'] = $menuEntries[$row['menu_default']] ?? '';
$row['entries'] = [];
@@ -1023,7 +1070,7 @@ class Page_ServerSetup extends Page
Util::redirect('?do=serversetup&show=import');
}
$menu = PxeLinux::parsePxeLinux($content, false);
- if (empty($menu->sections)) {
+ if ($menu === null) {
Message::addWarning('import-no-entries');
Util::redirect('?do=serversetup&show=import');
}
@@ -1032,8 +1079,8 @@ class Page_ServerSetup extends Page
IPxe::importPxeMenuEntries($menu, $foo);
Util::redirect('?do=serversetup&show=bootentry');
} else {
- $id = IPxe::insertMenu($menu, 'Imported Menu', false, 0, [], []);
- if ($id === false) {
+ $id = IPxe::insertMenu($menu, 'Imported Menu', null, 0, [], []);
+ if ($id === null) {
Message::addError('import-error');
Util::redirect('?do=serversetup&show=import');
} else {
diff --git a/modules-available/serversetup-bwlp-ipxe/templates/download.html b/modules-available/serversetup-bwlp-ipxe/templates/download.html
index c4025d70..ff4e4216 100644
--- a/modules-available/serversetup-bwlp-ipxe/templates/download.html
+++ b/modules-available/serversetup-bwlp-ipxe/templates/download.html
@@ -15,7 +15,11 @@
</table>
<br>
<p>
- {{lang_additionalInfoLink}} <a href="https://ipxe.org/appnote/buildtargets" target="_blank">{{lang_ipxeWikiUrl}}</a>
+ {{lang_additionalInfoLink}}
+ <a href="https://ipxe.org/appnote/buildtargets" target="_blank">
+ {{lang_ipxeWikiUrl}}
+ <span class="glyphicon glyphicon-new-window"></span>
+ </a>
</p>
</div>
</div>
@@ -38,7 +42,10 @@
{{lang_usbImgHelpWindows}}
</p>
<p>
- <a href="https://rufus.akeo.ie/#download" target="_blank">{{lang_downloadRufus}}</a>
+ <a href="https://rufus.akeo.ie/#download" target="_blank">
+ {{lang_downloadRufus}}
+ <span class="glyphicon glyphicon-new-window"></span>
+ </a>
</p>
</div>
</div> \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-ipxe/templates/git_task.html b/modules-available/serversetup-bwlp-ipxe/templates/git_task.html
new file mode 100644
index 00000000..7f199256
--- /dev/null
+++ b/modules-available/serversetup-bwlp-ipxe/templates/git_task.html
@@ -0,0 +1,15 @@
+<div id="tm-select-div" data-tm-id="{{selectTask}}" data-tm-log="error" data-tm-callback="ipxeSelVersionCb">
+ {{lang_gitCheckout}}
+</div>
+<script type="text/javascript">
+ function ipxeSelVersionCb(task) {
+ {{#reload}}
+ if (!task || !task.statusCode)
+ return;
+
+ if (task.statusCode === 'TASK_FINISHED') {
+ window.location.href = '?do=serversetup&show=address&sv=0';
+ }
+ {{/reload}}
+ }
+</script> \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-ipxe/templates/ipaddress.html b/modules-available/serversetup-bwlp-ipxe/templates/ipaddress.html
index 74affb9f..f5a49beb 100644
--- a/modules-available/serversetup-bwlp-ipxe/templates/ipaddress.html
+++ b/modules-available/serversetup-bwlp-ipxe/templates/ipaddress.html
@@ -1,46 +1,72 @@
-<div class="panel panel-default">
- <div class="panel-heading">
- {{lang_bootAddress}}
+<h3>
+ {{lang_bootAddress}}
+</h3>
+<div class="{{chooseHintClass}}">
+ {{lang_chooseIP}}
+</div>
+<form method="post" action="?do=ServerSetup">
+ <input type="hidden" name="action" value="ip">
+ <input type="hidden" name="token" value="{{token}}">
+ <table class="slx-table">
+ {{#ips}}
+ <tr>
+ <td>{{ip}}</td>
+ {{#default}}
+ <td>
+ <span class="btn btn-success btn-xs"><span class="glyphicon glyphicon-ok"></span> {{lang_active}}</span>
+ </td>
+ {{/default}}
+ {{^default}}
+ <td>
+ <button class="btn btn-primary btn-xs" name="ip" value="{{ip}}" {{disabled}}>
+ <span class="glyphicon glyphicon-flag"></span>
+ {{lang_set}}
+ </button>
+ </td>
+ {{/default}}
+ </tr>
+ {{/ips}}
+ </table>
+</form>
+<hr>
+
+<h3>
+ {{lang_recompileHead}}
+</h3>
+<p>
+ {{lang_recompileHint}}
+</p>
+{{#error}}
+<div class="alert alert-danger">{{error}}</div>
+{{/error}}
+<form method="post" action="?do=ServerSetup">
+ <input type="hidden" name="token" value="{{token}}">
+ <div class="form-group">
+ <label>
+ {{lang_versionSelect}}
+ <select class="form-control" name="version">
+ {{#versions}}
+ <option value="{{hash}}" {{hash_selected}}>{{date_s}} ({{hash_s}})</option>
+ {{/versions}}
+ </select>
+ </label>
</div>
- <div class="panel-body">
- <div class="{{chooseHintClass}}">
- {{lang_chooseIP}}
- </div>
- <form method="post" action="?do=ServerSetup">
- <input type="hidden" name="action" value="ip">
- <input type="hidden" name="token" value="{{token}}">
- <table class="slx-table">
- {{#ips}}
- <tr>
- <td>{{ip}}</td>
- {{#default}}
- <td>
- <span class="btn btn-success btn-xs"><span class="glyphicon glyphicon-ok"></span> {{lang_active}}</span>
- </td>
- {{/default}}
- {{^default}}
- <td>
- <button class="btn btn-primary btn-xs" name="ip" value="{{ip}}" {{disabled}}>
- <span class="glyphicon glyphicon-flag"></span>
- {{lang_set}}
- </button>
- </td>
- {{/default}}
- </tr>
- {{/ips}}
- </table>
- </form>
+ <div class="buttonbar">
+ <button class="btn btn-default" name="action" value="compile" {{disabled}}>
+ <span class="glyphicon glyphicon-refresh"></span>
+ {{lang_forceRecompile}}
+ </button>
+ <button class="btn btn-default" name="action" value="fetch" {{disabled}}>
+ <span class="glyphicon glyphicon-arrow-down"></span>
+ {{lang_fetchUpdate}}
+ </button>
+ <button class="btn btn-danger" name="action" value="reset" {{disabled}} data-confirm="{{lang_reallyGitReset}}">
+ <span class="glyphicon glyphicon-trash"></span>
+ {{lang_resetWorkingTree}}
+ </button>
</div>
- <div class="panel-body">
- <p>
- {{lang_recompileHint}}
- </p>
- <form method="post" action="?do=ServerSetup">
- <input type="hidden" name="token" value="{{token}}">
- <button class="btn btn-default" name="action" value="compile" {{disabled}}>
- <span class="glyphicon glyphicon-refresh"></span>
- {{lang_forceRecompile}}
- </button>
- </form>
- </div>
-</div> \ No newline at end of file
+</form>
+<hr>
+
+<h3>{{lang_lastBuild}}</h3>
+{{lastBuild}}
diff --git a/modules-available/serversetup-bwlp-ipxe/templates/ipxe_update.html b/modules-available/serversetup-bwlp-ipxe/templates/ipxe_update.html
index 344d3905..328b61f6 100644
--- a/modules-available/serversetup-bwlp-ipxe/templates/ipxe_update.html
+++ b/modules-available/serversetup-bwlp-ipxe/templates/ipxe_update.html
@@ -19,8 +19,9 @@
</div>
<script type="text/javascript">
+ var $slxFileList;
document.addEventListener('DOMContentLoaded', function() {
- var slxFileList = $('#file-list').find('.glyphicon');
+ $slxFileList = $('#file-list').find('.glyphicon');
});
function ipxeGenCb(task)
@@ -30,25 +31,26 @@
if (task.statusCode === 'TASK_FINISHED') {
$('#tm-compile-div').find('pre').hide();
+ window.location.href = '?do=serversetup&show=address&sv=0';
}
+ // Working or finished
+ if (task.data && task.data.files && task.data.files) {
+ for (var k in task.data.files) {
+ if (!task.data.files[k])
+ continue;
+ var f = '#built-' + k.replace('/', '-').replace('.', '-');
+ var $e = $(f);
+ $e.find('.glyphicon-question-sign').removeClass('glyphicon-question-sign').addClass('glyphicon-ok text-success');
+ }
+ }
+ // On failure, change non-built targets to X
if (task.statusCode === 'TASK_ERROR') {
var $gf = $('#genfailed');
if (task.data && task.data.errors) {
$gf.append($('<pre>').text(task.data.errors));
}
$gf.show('slow');
- slxFileList.find('.glyphicon-question-sign').removeClass('glyphicon-question-sign').addClass('glyphicon-stop');
- } else {
- // Working or finished
- if (task.data && task.data.files && task.data.files) {
- for (var k in task.data.files) {
- if (!task.data.files[k])
- continue;
- var f = '#built-' + k.replace('/', '-').replace('.', '-');
- var $e = $(f);
- $e.find('.glyphicon-question-sign').removeClass('glyphicon-question-sign').addClass('glyphicon-ok text-success');
- }
- }
+ $slxFileList.find('.glyphicon-question-sign').removeClass('glyphicon-question-sign').addClass('glyphicon-stop');
}
}
</script>
diff --git a/modules-available/serversetup-bwlp-pxelinux/config.json b/modules-available/serversetup-bwlp-pxelinux/config.json
deleted file mode 100644
index 36268c6a..00000000
--- a/modules-available/serversetup-bwlp-pxelinux/config.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "category": "main.settings-server"
-} \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-pxelinux/hooks/ipxe-update.inc.php b/modules-available/serversetup-bwlp-pxelinux/hooks/ipxe-update.inc.php
deleted file mode 100644
index baa7a1bf..00000000
--- a/modules-available/serversetup-bwlp-pxelinux/hooks/ipxe-update.inc.php
+++ /dev/null
@@ -1,9 +0,0 @@
-<?php
-
-$data = Property::getBootMenu();
-$data['ipaddress'] = Property::getServerIp();
-$task = Taskmanager::submit('CompileIPxeLegacy', $data);
-if (!isset($task['id']))
- return false;
-Property::set('ipxe-task-id', $task['id'], 15);
-return $task['id']; \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-pxelinux/hooks/main-warning.inc.php b/modules-available/serversetup-bwlp-pxelinux/hooks/main-warning.inc.php
deleted file mode 100644
index a2eba6ff..00000000
--- a/modules-available/serversetup-bwlp-pxelinux/hooks/main-warning.inc.php
+++ /dev/null
@@ -1,6 +0,0 @@
-<?php
-
-if (!preg_match('/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/', Property::getServerIp())) {
- Message::addError('serversetup.no-ip-addr-set', true);
- $needSetup = true;
-}
diff --git a/modules-available/serversetup-bwlp-pxelinux/lang/de/messages.json b/modules-available/serversetup-bwlp-pxelinux/lang/de/messages.json
deleted file mode 100644
index 3e2cc834..00000000
--- a/modules-available/serversetup-bwlp-pxelinux/lang/de/messages.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "image-not-found": "USB-Image nicht gefunden. Generieren Sie das Bootmen\u00fc neu.",
- "invalid-ip": "Kein Interface ist auf die Adresse {{0}} konfiguriert",
- "no-ip-addr-set": "Bitte w\u00e4hlen Sie die prim\u00e4re IP-Adresse des Servers"
-} \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-pxelinux/lang/de/module.json b/modules-available/serversetup-bwlp-pxelinux/lang/de/module.json
deleted file mode 100644
index da71d558..00000000
--- a/modules-available/serversetup-bwlp-pxelinux/lang/de/module.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "module_name": "iPXE \/ Boot Menu",
- "page_title": "PXE- und Boot-Einstellungen"
-} \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-pxelinux/lang/de/permissions.json b/modules-available/serversetup-bwlp-pxelinux/lang/de/permissions.json
deleted file mode 100644
index 98baec3c..00000000
--- a/modules-available/serversetup-bwlp-pxelinux/lang/de/permissions.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "access-page": "Seite sehen.",
- "download": "USB-Image herunterladen.",
- "edit.address": "Boot-Adresse des Servers ausw\u00e4hlen.",
- "edit.menu": "Bootmen\u00fc anpassen."
-} \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-pxelinux/lang/de/template-tags.json b/modules-available/serversetup-bwlp-pxelinux/lang/de/template-tags.json
deleted file mode 100644
index 8d612ab0..00000000
--- a/modules-available/serversetup-bwlp-pxelinux/lang/de/template-tags.json
+++ /dev/null
@@ -1,33 +0,0 @@
-{
- "lang_active": "Aktiv",
- "lang_bootAddress": "Boot-Adresse des Servers",
- "lang_bootBehavior": "Standard-Bootverhalten",
- "lang_bootHint": "Das Bootmen\u00fc muss nach einer \u00c4nderung der IP-Adresse neu generiert werden. In der Regel geschieht dies automatisch, der Vorgang kann in der Sektion Bootmen\u00fc allerdings auch manuell ausgel\u00f6st werden.",
- "lang_bootInfo": "Hier k\u00f6nnen Anpassungen am Erscheinungsbild des Bootmen\u00fcs vorgenommen werden.",
- "lang_bootMenu": "Bootmen\u00fc",
- "lang_bootMenuCreate": "Bootmen\u00fc erzeugen",
- "lang_chooseIP": "Bitte w\u00e4hlen Sie die IP-Adresse, \u00fcber die der Server von den Clients zum Booten angesprochen werden soll.",
- "lang_customEntry": "Eigener Eintrag",
- "lang_downloadImage": "USB-Image herunterladen",
- "lang_downloadRufus": "Rufus herunterladen",
- "lang_example": "Beispiel",
- "lang_generationFailed": "Erzeugen des Bootmen\u00fcs fehlgeschlagen. Der Netzwerkboot von bwLehrpool wird wahrscheinlich nicht funktionieren. Wenn Sie den Fehler nicht selbst beheben k\u00f6nnen, melden Sie bitte die Logausgabe an das bwLehrpool-Projekt.",
- "lang_localHDD": "Lokale HDD",
- "lang_masterPassword": "Master-Passwort",
- "lang_masterPasswordHelp": "Das Master-Passwort wird ben\u00f6tigt, um einen Booteintrag direkt am Client tempor\u00e4r durch Dr\u00fccken der Tab-Taste zu editieren. Da dies f\u00fcr Manipulation am Client genutzt werden kann, sollte diese Funktion unbedingt mit einem Passwort gesch\u00fctzt werden.",
- "lang_menuCustom": "Benutzerdefinierter Men\u00fczusatz",
- "lang_menuCustomHint1": "Hier haben Sie die M\u00f6glichkeit, eigenen Men\u00fc-Code zum angezeigten PXE-Men\u00fc hinzuzuf\u00fcgen, um z.B. auf weitere PXE-Server zu verweisen. Das Format entspricht dem syslinux Men\u00fcformat.",
- "lang_menuCustomHint2": "Sie k\u00f6nnen ein oder mehrere Eintr\u00e4ge erzeugen. Wenn Sie einen Eintrag erzeugen m\u00f6chten, der automatisch gestartet wird, wenn der Benutzer keine Auswahl t\u00e4tigt, vergeben Sie als",
- "lang_menuCustomHint3": "und w\u00e4hlen Sie als Standard-Bootverhalten ebenfalls custom.",
- "lang_menuDisplayTime": "Anzeigedauer des Men\u00fcs",
- "lang_menuGeneration": "Erzeugen des Bootmen\u00fcs",
- "lang_moduleHeading": "iPXE \/ Boot Menu",
- "lang_pxeBuilt": "PXE-Binary gebaut",
- "lang_seconds": "Sekunden",
- "lang_set": "Setzen",
- "lang_usbBuilt": "USB-Image gebaut",
- "lang_usbImage": "USB-Image",
- "lang_usbImgHelp": "Mit dem USB-Image k\u00f6nnen Sie einen bootbaren USB-Stick erstellen, \u00fcber den sich bwLehrpool an Rechnern starten l\u00e4sst, die keinen Netzwerkboot unterst\u00fctzen, bzw. f\u00fcr die keine entsprechende DHCP-Konfiguration vorhanden ist. Dies erfordert dann lediglich, dass in der BIOS-Konfiguration des Rechners USB-Boot zugelassen ist. Der Stick dient dabei lediglich als Einstiegspunkt; es ist nach wie vor ein bwLehrpool-Satellitenserver f\u00fcr den eigentlichen Bootvorgang von N\u00f6ten.",
- "lang_usbImgHelpLinux": "Nutzen Sie dd, um das Image auf einen USB-Stick zu schreiben. Das Image enth\u00e4lt bereits eine Partitionstabelle, achten Sie daher darauf, dass Sie das Image z.B. nach \/dev\/sdx schreiben, und nicht nach \/dev\/sdx1",
- "lang_usbImgHelpWindows": "Unter Windows muss zun\u00e4chst ein Programm besorgt werden, mit dem sich Images direkt auf einen USB-Stick schreiben lassen. Es gibt gleich mehrere kostenlose und quelloffene Programme, eines davon ist Rufus. Rufus wurde mit dem bwLehrpool-Image gestetet. Nach dem Starten des Programms ist lediglich das heruntergeladene Image zu \u00f6ffnen, sowie in der Liste der Laufwerke der richtige USB-Stick auszuw\u00e4hlen (damit Sie nicht versehentlich Daten auf dem falschen Laufwerk \u00fcberschreiben!)"
-} \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-pxelinux/lang/en/messages.json b/modules-available/serversetup-bwlp-pxelinux/lang/en/messages.json
deleted file mode 100644
index d4ba6905..00000000
--- a/modules-available/serversetup-bwlp-pxelinux/lang/en/messages.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "image-not-found": "USB image not found. Try regenerating the boot menu first.",
- "invalid-ip": "No interface is configured with the address {{0}}",
- "no-ip-addr-set": "Please set the server's primary IP address"
-} \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-pxelinux/lang/en/module.json b/modules-available/serversetup-bwlp-pxelinux/lang/en/module.json
deleted file mode 100644
index aeea610c..00000000
--- a/modules-available/serversetup-bwlp-pxelinux/lang/en/module.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "module_name": "iPXE \/ Boot Menu"
-} \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-pxelinux/lang/en/permissions.json b/modules-available/serversetup-bwlp-pxelinux/lang/en/permissions.json
deleted file mode 100644
index 44d1c519..00000000
--- a/modules-available/serversetup-bwlp-pxelinux/lang/en/permissions.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "access-page": "View page.",
- "download": "Download USB Image.",
- "edit.address": "Choose boot address of the server.",
- "edit.menu": "Customize boot menu."
-} \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-pxelinux/lang/en/template-tags.json b/modules-available/serversetup-bwlp-pxelinux/lang/en/template-tags.json
deleted file mode 100644
index 9bb55f93..00000000
--- a/modules-available/serversetup-bwlp-pxelinux/lang/en/template-tags.json
+++ /dev/null
@@ -1,33 +0,0 @@
-{
- "lang_active": "Active",
- "lang_bootAddress": "Boot Address of the Server",
- "lang_bootBehavior": "Default Boot Behavior",
- "lang_bootHint": "The Boot menu must be recreated after changing the IP address. Usually this is done automatically, but the process can also be triggered manually in the section of the boot menu.",
- "lang_bootInfo": "Here adjustments can be made to the appearance of the boot menu.",
- "lang_bootMenu": "Boot Menu",
- "lang_bootMenuCreate": "Create Boot Menu",
- "lang_chooseIP": "Please select the IP address that the client server will use to boot.",
- "lang_customEntry": "Custom entry",
- "lang_downloadImage": "Download USB Image",
- "lang_downloadRufus": "Download Rufus",
- "lang_example": "Example",
- "lang_generationFailed": "Could not generate boot menu. The bwLehrpool-System might not work properly. If you can't fix the problem, please report the error log below to the bwLehrpool project.",
- "lang_localHDD": "Local HDD",
- "lang_masterPassword": "Master Password",
- "lang_masterPasswordHelp": "The master password is required to edit a boot menu entry. This should be set for security reasons.",
- "lang_menuCustom": "Custom Extra Menu",
- "lang_menuCustomHint1": "Here you have the opportunity to add your own menu code to the displayed PXE menu, eg to refer to other PXE server. The format corresponds to the syslinux menu format.",
- "lang_menuCustomHint2": "You can create one or more entries. If you want to create an entry that starts automatically when the user makes a selection, assign as",
- "lang_menuCustomHint3": "and select as the default boot behavior custom as well.",
- "lang_menuDisplayTime": "Menu Display Time",
- "lang_menuGeneration": "Generating boot menu...",
- "lang_moduleHeading": "iPXE \/ Boot Menu",
- "lang_pxeBuilt": "Built PXE binary",
- "lang_seconds": "Seconds",
- "lang_set": "Set",
- "lang_usbBuilt": "Built USB image",
- "lang_usbImage": "USB image",
- "lang_usbImgHelp": "The USB image can be used to create a bootable USB stick, which enables you to boot bwLehrpool without changing your DHCP settings or enabling network boot in the clients. The only requirement is that you enable USB boot in the client's BIOS. The USB stick is only used for bootstrapping, the actual bwLehrpool system is still loaded via network from your local bwLehrpool server.",
- "lang_usbImgHelpLinux": "On Linux you can simply use dd to write the image to a usb stick. The image already contains a partition table, so make sure you write the image to the device itself and not to an already existing partition (e.g. to \/dev\/sdx not \/dev\/sdx1)",
- "lang_usbImgHelpWindows": "On Windows you need to use a 3rd party tool that can directly write to usb sticks. There are several free and open source soltions, one of them being Rufus. Rufus has been tested with the bwLehrpool image and is very simple to use. After launching Rufus, just open the downloaded USB image, select the proper USB stick to write to (be careful not to overwrite the wrong drive!), and you're ready to go."
-} \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-pxelinux/lang/pt/messages.json b/modules-available/serversetup-bwlp-pxelinux/lang/pt/messages.json
deleted file mode 100644
index 65745768..00000000
--- a/modules-available/serversetup-bwlp-pxelinux/lang/pt/messages.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "invalid-ip": "Nenhuma interface est\u00e1 configurada com o endere\u00e7o {{0}}"
-} \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-pxelinux/lang/pt/module.json b/modules-available/serversetup-bwlp-pxelinux/lang/pt/module.json
deleted file mode 100644
index aeea610c..00000000
--- a/modules-available/serversetup-bwlp-pxelinux/lang/pt/module.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "module_name": "iPXE \/ Boot Menu"
-} \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-pxelinux/lang/pt/template-tags.json b/modules-available/serversetup-bwlp-pxelinux/lang/pt/template-tags.json
deleted file mode 100644
index 14788767..00000000
--- a/modules-available/serversetup-bwlp-pxelinux/lang/pt/template-tags.json
+++ /dev/null
@@ -1,38 +0,0 @@
-{
- "lang_active": "Ativo",
- "lang_bootAddress": "Endere\u00e7o Boot do Servidor",
- "lang_bootBehavior": "Comportamento Padr\u00e3o de Boot",
- "lang_bootHint": "O menu de boot deve ser recriado ap\u00f3s alterar o endere\u00e7o IP. Geralmente isso \u00e9 feito automaticamente, mas o processo tamb\u00e9m pode ser acionado manualmente na se\u00e7\u00e3o do menu de boot.",
- "lang_bootInfo": "Aqui ajustes podem ser feitos na apar\u00eancia do menu de boot.",
- "lang_bootMenu": "Menu de Boot",
- "lang_bootMenuCreate": "Criar Menu de Boot",
- "lang_chooseIP": "Por favor, selecione o endere\u00e7o IP que o servidor do cliente utilizar\u00e1 realizar o boot.",
- "lang_close": "Fechar",
- "lang_compile": "Compilar",
- "lang_compileIso": "Compilar .iso",
- "lang_compileKkpxe": "Compilar .kkpxe",
- "lang_compileUsb": "Compilar .usb",
- "lang_compilingIpxe": "Compilando iPXE",
- "lang_customScript": "Script Customizado",
- "lang_download": "Baixar",
- "lang_example": "Exemplo",
- "lang_extension": "Extens\u00e3o",
- "lang_ipxeAdv": "Gerar iPXE no Modo Avan\u00e7ado",
- "lang_ipxeInfo": "Aqui \u00e9 poss\u00edvel compilar e baixar o iPXE utilizando um script customiz\u00e1vel.",
- "lang_ipxeSmp": "Gerar iPXE no Modo Simples",
- "lang_ipxeSmpInfo": "Aqui voc\u00ea pode escolher gerar o iPXE escolhendo apenas uma das extens\u00f5es abaixo",
- "lang_ipxeWarning": "Se esta for a primeira vez compilando, poder\u00e1 levar entre 1 e 4 minutos para que termine.",
- "lang_loading": "Carregando",
- "lang_localHDD": "HDD Local",
- "lang_menuCustom": "Menu Adicional Customizado",
- "lang_menuCustomHint1": "Aqui voc\u00ea tem a oportunidade de adicionar seu pr\u00f3prio c\u00f3digo de menu para o menu PXE exibido, por exemplo, para se referir a outro servidor PXE. O formato corresponde ao formato de menu syslinux.",
- "lang_menuCustomHint2": "Voc\u00ea pode criar uma ou mais entradas. Se voc\u00ea quiser criar uma entrada que \u00e9 iniciada automaticamente quando o usu\u00e1rio faz uma sele\u00e7\u00e3o, atribua como",
- "lang_menuCustomHint3": "e selecione como o comportamento de boot padr\u00e3o tamb\u00e9m my-entry.",
- "lang_menuDisplayTime": "Tempo de Exibi\u00e7\u00e3o do Menu",
- "lang_mountIpxe": "Montar iPXE",
- "lang_restoreDefault": "Restaurar Padr\u00e3o",
- "lang_saveScript": "Salvar Script",
- "lang_seconds": "Segundos",
- "lang_set": "Definir",
- "lang_success": "Arquivo criado com sucesso:"
-} \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-pxelinux/page.inc.php b/modules-available/serversetup-bwlp-pxelinux/page.inc.php
deleted file mode 100644
index 52b3afe4..00000000
--- a/modules-available/serversetup-bwlp-pxelinux/page.inc.php
+++ /dev/null
@@ -1,187 +0,0 @@
-<?php
-
-class Page_ServerSetup extends Page
-{
-
- private $taskStatus;
- private $currentAddress;
- private $currentMenu;
- private $hasIpSet = false;
-
- protected function doPreprocess()
- {
- User::load();
-
- if (!User::isLoggedIn()) {
- Message::addError('main.no-permission');
- Util::redirect('?do=Main');
- }
-
- if (Request::any('action') === 'getimage') {
- User::assertPermission("download");
- $this->handleGetImage();
- }
-
- $this->currentMenu = Property::getBootMenu();
-
- $action = Request::post('action');
-
- if ($action === false) {
- $this->currentAddress = Property::getServerIp();
- $this->getLocalAddresses();
- }
-
- if ($action === 'ip') {
- User::assertPermission("edit.address");
- // New address is to be set
- $this->getLocalAddresses();
- $this->updateLocalAddress();
- }
-
- if ($action === 'ipxe') {
- User::assertPermission("edit.menu");
- // iPXE stuff changes
- $this->updatePxeMenu();
- }
-
- if (Request::isPost()) {
- Util::redirect('?do=serversetup');
- }
-
- User::assertPermission('access-page');
- }
-
- protected function doRender()
- {
- Render::addTemplate("heading");
- $task = Property::get('ipxe-task-id');
- if ($task !== false) {
- $task = Taskmanager::status($task);
- if (!Taskmanager::isTask($task) || Taskmanager::isFinished($task)) {
- $task = false;
- }
- }
- if ($task !== false) {
- Render::addTemplate('ipxe_update', array('taskid' => $task['id']));
- }
-
- Permission::addGlobalTags($perms, null, ['edit.menu', 'edit.address', 'download']);
-
- Render::addTemplate('ipaddress', array(
- 'ips' => $this->taskStatus['data']['addresses'],
- 'chooseHintClass' => $this->hasIpSet ? '' : 'alert alert-danger',
- 'editAllowed' => User::hasPermission("edit.address"),
- 'perms' => $perms,
- ));
- $data = $this->currentMenu;
- if (!User::hasPermission('edit.menu')) {
- unset($data['masterpasswordclear']);
- }
- if (!isset($data['defaultentry'])) {
- $data['defaultentry'] = 'net';
- }
- if ($data['defaultentry'] === 'net') {
- $data['active-net'] = 'checked';
- }
- if ($data['defaultentry'] === 'hdd') {
- $data['active-hdd'] = 'checked';
- }
- if ($data['defaultentry'] === 'custom') {
- $data['active-custom'] = 'checked';
- }
- $data['perms'] = $perms;
- Render::addTemplate('ipxe', $data);
- }
-
- // -----------------------------------------------------------------------------------------------
-
- private function getLocalAddresses()
- {
- $this->taskStatus = Taskmanager::submit('LocalAddressesList', array());
-
- if ($this->taskStatus === false) {
- $this->taskStatus['data']['addresses'] = false;
- return false;
- }
-
- if (!Taskmanager::isFinished($this->taskStatus)) { // TODO: Async if just displaying
- $this->taskStatus = Taskmanager::waitComplete($this->taskStatus['id'], 4000);
- }
-
- if (Taskmanager::isFailed($this->taskStatus) || !isset($this->taskStatus['data']['addresses'])) {
- $this->taskStatus['data']['addresses'] = false;
- return false;
- }
-
- $sortIp = array();
- foreach (array_keys($this->taskStatus['data']['addresses']) as $key) {
- $item = & $this->taskStatus['data']['addresses'][$key];
- if (!isset($item['ip']) || !preg_match('/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/', $item['ip']) || substr($item['ip'], 0, 4) === '127.') {
- unset($this->taskStatus['data']['addresses'][$key]);
- continue;
- }
- if ($this->currentAddress === $item['ip']) {
- $item['default'] = true;
- $this->hasIpSet = true;
- }
- $sortIp[] = $item['ip'];
- }
- unset($item);
- array_multisort($sortIp, SORT_STRING, $this->taskStatus['data']['addresses']);
- return true;
- }
-
- private function updateLocalAddress()
- {
- $newAddress = Request::post('ip', 'none');
- $valid = false;
- foreach ($this->taskStatus['data']['addresses'] as $item) {
- if ($item['ip'] !== $newAddress)
- continue;
- $valid = true;
- break;
- }
- if ($valid) {
- Property::setServerIp($newAddress);
- Util::redirect('?do=ServerSetup');
- } else {
- Message::addError('invalid-ip', $newAddress);
- }
- Util::redirect();
- }
-
- private function updatePxeMenu()
- {
- $timeout = Request::post('timeout', 10);
- if ($timeout === '')
- $timeout = 0;
- if (!is_numeric($timeout) || $timeout < 0) {
- Message::addError('main.value-invalid', 'timeout', $timeout);
- }
- $this->currentMenu['defaultentry'] = Request::post('defaultentry', 'net');
- $this->currentMenu['timeout'] = $timeout;
- $this->currentMenu['custom'] = Request::post('custom', '');
- $this->currentMenu['masterpasswordclear'] = Request::post('masterpassword', '');
- if (empty($this->currentMenu['masterpasswordclear']))
- $this->currentMenu['masterpassword'] = 'invalid';
- else
- $this->currentMenu['masterpassword'] = Crypto::hash6($this->currentMenu['masterpasswordclear']);
- Property::setBootMenu($this->currentMenu);
- Trigger::ipxe();
- Util::redirect('?do=ServerSetup');
- }
-
- private function handleGetImage()
- {
- $file = "/opt/openslx/ipxe/openslx-bootstick.raw";
- if (!is_readable($file)) {
- Message::addError('image-not-found');
- return;
- }
- Header('Content-Type: application/octet-stream');
- Header('Content-Disposition: attachment; filename="openslx-bootstick-' . Property::getServerIp() . '-raw.img"');
- readfile($file);
- exit;
- }
-
-}
diff --git a/modules-available/serversetup-bwlp-pxelinux/permissions/permissions.json b/modules-available/serversetup-bwlp-pxelinux/permissions/permissions.json
deleted file mode 100644
index 44927506..00000000
--- a/modules-available/serversetup-bwlp-pxelinux/permissions/permissions.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "access-page": {
- "location-aware": false
- },
- "download": {
- "location-aware": false
- },
- "edit.address": {
- "location-aware": false
- },
- "edit.menu": {
- "location-aware": false
- }
-} \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-pxelinux/templates/heading.html b/modules-available/serversetup-bwlp-pxelinux/templates/heading.html
deleted file mode 100644
index d68360f1..00000000
--- a/modules-available/serversetup-bwlp-pxelinux/templates/heading.html
+++ /dev/null
@@ -1 +0,0 @@
-<h1>{{lang_moduleHeading}}</h1> \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-pxelinux/templates/ipaddress.html b/modules-available/serversetup-bwlp-pxelinux/templates/ipaddress.html
deleted file mode 100644
index 8d73dfac..00000000
--- a/modules-available/serversetup-bwlp-pxelinux/templates/ipaddress.html
+++ /dev/null
@@ -1,37 +0,0 @@
-<div class="panel panel-default">
- <div class="panel-heading">
- {{lang_bootAddress}}
- </div>
- <div class="panel-body">
- <div class="{{chooseHintClass}}">
- {{lang_chooseIP}}
- </div>
- <form method="post" action="?do=ServerSetup">
- <input type="hidden" name="action" value="ip">
- <input type="hidden" name="token" value="{{token}}">
- <table class="slx-table">
- {{#ips}}
- <tr>
- <td>{{ip}}</td>
- {{#default}}
- <td>
- <span class="btn btn-success btn-xs"><span class="glyphicon glyphicon-ok"></span> {{lang_active}}</span>
- </td>
- {{/default}}
- {{^default}}
- <td>
- <button class="btn btn-primary btn-xs" name="ip" value="{{ip}}" {{perms.edit.address.disabled}}>
- <span class="glyphicon glyphicon-flag"></span>
- {{lang_set}}
- </button>
- </td>
- {{/default}}
- </tr>
- {{/ips}}
- </table>
- <p>
- {{lang_bootHint}}
- </p>
- </form>
- </div>
-</div> \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-pxelinux/templates/ipxe.html b/modules-available/serversetup-bwlp-pxelinux/templates/ipxe.html
deleted file mode 100644
index f4b0b4d3..00000000
--- a/modules-available/serversetup-bwlp-pxelinux/templates/ipxe.html
+++ /dev/null
@@ -1,117 +0,0 @@
-<form method="post" action="?do=ServerSetup">
- <input type="text" name="prevent_autofill" id="prevent_autofill" value="" style="position:absolute;top:-2000px" tabindex="-1">
- <input type="password" name="password_fake" id="password_fake" value="" style="position:absolute;top:-2000px" tabindex="-1">
- <input type="hidden" name="action" value="ipxe">
- <input type="hidden" name="token" value="{{token}}">
- <div class="panel panel-default">
- <div class="panel-heading">
- {{lang_bootMenu}}
- </div>
- <div class="panel-body">
- <p>
- {{lang_bootInfo}}
- </p>
- <br>
-
- <div class="form-group">
- <strong>{{lang_bootBehavior}}</strong>
- <div class="radio">
- <input type="radio" name="defaultentry" value="net" {{active-net}} id="id-net" {{perms.edit.menu.disabled}}>
- <label for="id-net">bwLehrpool</label>
- </div>
- <div class="radio">
- <input type="radio" name="defaultentry" value="hdd" {{active-hdd}} id="id-hdd" {{perms.edit.menu.disabled}}>
- <label for="id-hdd">{{lang_localHDD}}</label>
- </div>
- <div class="radio">
- <input type="radio" name="defaultentry" value="custom" {{active-custom}} id="id-custom" {{perms.edit.menu.disabled}}>
- <label for="id-custom">{{lang_customEntry}} (&quot;custom&quot;)</label>
- </div>
- </div>
-
- <div class="form-group">
- <strong>{{lang_menuDisplayTime}}</strong>
- <div class="input-group form-narrow">
- <input type="text" class="form-control" name="timeout" value="{{timeout}}" pattern="\d+" {{perms.edit.menu.readonly}}>
- <span class="input-group-addon">{{lang_seconds}}</span>
- </div>
- </div>
-
- <div class="form-group">
- <strong>{{lang_masterPassword}}</strong>
- <div class="form-narrow">
- <input type="{{password_type}}" class="form-control" name="masterpassword" value="{{masterpasswordclear}}" {{perms.edit.menu.readonly}}>
- </div>
- <i>{{lang_masterPasswordHelp}}</i>
- </div>
-
- <div class="form-group">
- <strong>{{lang_menuCustom}}</strong> <a class="btn btn-default btn-xs" data-toggle="modal" data-target="#help-custom"><span class="glyphicon glyphicon-question-sign"></span></a>
- <textarea class="form-control" name="custom" rows="8" {{perms.edit.menu.readonly}}>{{custom}}</textarea>
- </div>
- </div>
-
- <div class="panel-footer">
- <button class="btn btn-primary pull-right" name="action" value="ipxe" {{perms.edit.menu.disabled}}>{{lang_bootMenuCreate}}</button>
- <div>
- <div class="btn-group" role="group">
- <a class="btn btn-default {{perms.download.disabled}}" href="?do=ServerSetup&amp;action=getimage">
- <span class="glyphicon glyphicon-download-alt"></span>
- {{lang_downloadImage}}
- </a>
- <span class="btn btn-default" data-toggle="modal" data-target="#help-usbimg"><span class="glyphicon glyphicon-question-sign"></span></span>
- </div>
- </div>
- </div>
- </div>
-</form>
-
-<div class="modal fade" id="help-custom" tabindex="-1" role="dialog">
- <div class="modal-dialog">
- <div class="modal-content">
- <div class="modal-header">
- <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
- {{lang_menuCustom}}
- </div>
- <div class="modal-body">
- {{lang_menuCustomHint1}}
- <br>{{lang_example}}:
- <pre>LABEL custom
- MENU LABEL ^My Boot Entry
- KERNEL http://1.2.3.4/kernel
- INITRD http://1.2.3.4/initramfs-stage31
- APPEND custom=option
- IPAPPEND 3</pre>
- {{lang_menuCustomHint2}} LABEL <strong>custom</strong>
- {{lang_menuCustomHint3}}
- </div>
- </div>
- </div>
-</div>
-
-<div class="modal fade" id="help-usbimg" tabindex="-1" role="dialog">
- <div class="modal-dialog">
- <div class="modal-content">
- <div class="modal-header">
- <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
- {{lang_usbImage}}
- </div>
- <div class="modal-body">
- <p>{{lang_usbImgHelp}}</p>
- <p>
- <b>Linux</b>
- <br>
- {{lang_usbImgHelpLinux}}
- </p>
- <p>
- <b>Windows</b>
- <br>
- {{lang_usbImgHelpWindows}}
- </p>
- <p>
- <a href="https://rufus.akeo.ie/#download">{{lang_downloadRufus}}</a>
- </p>
- </div>
- </div>
- </div>
-</div> \ No newline at end of file
diff --git a/modules-available/serversetup-bwlp-pxelinux/templates/ipxe_update.html b/modules-available/serversetup-bwlp-pxelinux/templates/ipxe_update.html
deleted file mode 100644
index c5aafa1c..00000000
--- a/modules-available/serversetup-bwlp-pxelinux/templates/ipxe_update.html
+++ /dev/null
@@ -1,38 +0,0 @@
-<div class="panel panel-default">
- <div class="panel-heading">{{lang_menuGeneration}}</div>
- <div class="panel-body">
- <div id="built-pxe" class="invisible">
- <span class="glyphicon glyphicon-ok"></span>
- {{lang_pxeBuilt}}
- </div>
- <div id="built-usb" class="invisible">
- <span class="glyphicon glyphicon-ok"></span>
- {{lang_usbBuilt}}
- </div>
- <div id="genfailed" class="collapse">
- <div class="alert alert-danger">
- {{lang_generationFailed}}
- </div>
- </div>
- <div data-tm-id="{{taskid}}" data-tm-log="log" data-tm-log-height="31em" data-tm-callback="ipxeGenCb">{{lang_menuGeneration}}</div>
- </div>
-</div>
-
-<script type="text/javascript">
- function ipxeGenCb(task)
- {
- if (!task || !task.statusCode)
- return;
- if (task.data) {
- if (task.data.pxeDone) $('#built-pxe').removeClass('invisible');
- if (task.data.usbDone) $('#built-usb').removeClass('invisible');
- }
- if (task.statusCode === 'TASK_ERROR') {
- var $gf = $('#genfailed');
- if (task.data && task.data.errors) {
- $gf.append($('<pre>').text(task.data.errors));
- }
- $gf.show('slow');
- }
- }
-</script>
diff --git a/modules-available/session/hooks/cron.inc.php b/modules-available/session/hooks/cron.inc.php
new file mode 100644
index 00000000..e2cd46e6
--- /dev/null
+++ b/modules-available/session/hooks/cron.inc.php
@@ -0,0 +1,6 @@
+<?php
+
+// Clean up old sessions
+if (mt_rand(1, 10) === 4) {
+ Database::exec("DELETE FROM session WHERE dateline < UNIX_TIMESTAMP()");
+} \ No newline at end of file
diff --git a/modules-available/session/lang/de/module.json b/modules-available/session/lang/de/module.json
index 0d2b001c..fb6cf339 100644
--- a/modules-available/session/lang/de/module.json
+++ b/modules-available/session/lang/de/module.json
@@ -1,3 +1,4 @@
{
+ "page-title-session-list": "Aktive Sitzungen",
"page_title": "Anmelden"
} \ No newline at end of file
diff --git a/modules-available/session/lang/de/template-tags.json b/modules-available/session/lang/de/template-tags.json
index c7b6d881..d518e1cb 100644
--- a/modules-available/session/lang/de/template-tags.json
+++ b/modules-available/session/lang/de/template-tags.json
@@ -1,10 +1,15 @@
{
+ "lang_activeSessions": "Bekannte Sitzungen",
"lang_changePassword": "Passwort \u00e4ndern",
"lang_currentPassword": "Aktuelles Passwort",
"lang_enter": "Anmeldung",
+ "lang_expires": "L\u00e4uft bei Inaktivit\u00e4t ab",
+ "lang_fixedIpSession": "Sitzung an IP-Adresse binden",
+ "lang_killOtherSessions": "Alle meine anderen Sitzungen ausloggen",
+ "lang_lastAddress": "Letzter Zugriff von",
"lang_login": "Anmelden",
"lang_newPassword": "Neues Passwort",
"lang_register": "Registrieren",
- "lang_rememberID": "Angemeldet bleiben",
- "lang_repeatPassword": "Passwort wiederholen"
-}
+ "lang_repeatPassword": "Passwort wiederholen",
+ "lang_uid": "User#"
+} \ No newline at end of file
diff --git a/modules-available/session/lang/en/module.json b/modules-available/session/lang/en/module.json
index 5fb22548..44d024c8 100644
--- a/modules-available/session/lang/en/module.json
+++ b/modules-available/session/lang/en/module.json
@@ -1,3 +1,4 @@
{
+ "page-title-session-list": "Active sessions",
"page_title": "Log in"
} \ No newline at end of file
diff --git a/modules-available/session/lang/en/template-tags.json b/modules-available/session/lang/en/template-tags.json
index f9e0b393..e21a1bf9 100644
--- a/modules-available/session/lang/en/template-tags.json
+++ b/modules-available/session/lang/en/template-tags.json
@@ -1,10 +1,15 @@
{
+ "lang_activeSessions": "Known sessions",
"lang_changePassword": "Change password",
"lang_currentPassword": "Current password",
"lang_enter": "Enter",
+ "lang_expires": "Expires on no activity",
+ "lang_fixedIpSession": "Bind session to IP address",
+ "lang_killOtherSessions": "Log out all my other sessions",
+ "lang_lastAddress": "Last access from",
"lang_login": "Login",
"lang_newPassword": "New password",
"lang_register": "Register",
- "lang_rememberID": "Remember ID",
- "lang_repeatPassword": "Repeat password"
-}
+ "lang_repeatPassword": "Repeat password",
+ "lang_uid": "User#"
+} \ No newline at end of file
diff --git a/modules-available/session/page.inc.php b/modules-available/session/page.inc.php
index 0a6eac77..5f5e5d28 100644
--- a/modules-available/session/page.inc.php
+++ b/modules-available/session/page.inc.php
@@ -12,18 +12,18 @@ class Page_Session extends Page
if (User::isLoggedIn()) // and then just redirect
Util::redirect('?do=main');
// Else, try to log in
- if (User::login(Request::post('user'), Request::post('pass')))
+ if (User::login(Request::post('user'),
+ Request::post('pass'),
+ Request::post('fixedip', false, 'bool'))) {
Util::redirect('?do=main');
+ }
// Login credentials wrong - delay and show error message
sleep(1);
Message::addError('loginfail');
- }
- if ($action === 'logout') {
+ } elseif ($action === 'logout') {
// Log user out (or do nothing if not logged in)
User::logout();
- Util::redirect('?do=main');
- }
- if ($action === 'changepw') {
+ } elseif ($action === 'changepw') {
if (!User::isLoggedIn()) {
Util::redirect('?do=main');
}
@@ -47,19 +47,38 @@ class Page_Session extends Page
Message::addError('adduser.password-mismatch');
Util::redirect('?do=session');
}
+ if (Request::post('kill-other-sessions', false, 'bool')) {
+ Session::deleteAllButCurrent();
+ }
if (User::updatePassword($new)) {
Message::addSuccess('password-changed');
} else {
Message::addWarning('password-unchanged');
}
Util::redirect('?do=session');
+ } else {
+ // No action, change title to session list
+ Render::setTitle(Dictionary::translate('page-title-session-list'));
}
}
protected function doRender()
{
if (User::isLoggedIn()) {
- Render::addTemplate('change-password');
+ $res = Database::simpleQuery("SELECT u.login, s.userid, s.dateline, s.lastip, s.fixedip
+ FROM session s
+ INNER JOIN user u USING (userid)
+ ORDER BY dateline DESC");
+ $sessions = [];
+ $perm = User::hasPermission('.adduser.user.*');
+ foreach ($res as $row) {
+ if ($perm || $row['userid'] == User::getId()) {
+ $row['dateline_s'] = Util::prettyTime($row['dateline']);
+ $sessions[] = $row;
+ }
+ }
+ Render::addTemplate('change-password', ['sessions' => $sessions,
+ 'link' => User::hasPermission('.adduser.user.edit')]);
} else {
Render::addTemplate('page-login');
}
diff --git a/modules-available/session/templates/change-password.html b/modules-available/session/templates/change-password.html
index 70ab7b92..9f19c695 100644
--- a/modules-available/session/templates/change-password.html
+++ b/modules-available/session/templates/change-password.html
@@ -5,7 +5,48 @@
<input type="password" name="newpass1" class="form-control" placeholder="{{lang_newPassword}}">
<input type="password" name="newpass2" class="form-control" placeholder="{{lang_repeatPassword}}">
</div>
+ <div class="checkbox">
+ <input type="checkbox" id="kill-other-sessions" name="kill-other-sessions" value="1">
+ <label for="kill-other-sessions">{{lang_killOtherSessions}}</label>
+ </div>
<button class="btn btn-lg btn-primary btn-block" type="submit">{{lang_changePassword}}</button>
<input type="hidden" name="action" value="changepw">
<input type="hidden" name="token" value="{{token}}">
-</form> \ No newline at end of file
+</form>
+
+<h2>{{lang_activeSessions}}</h2>
+<table class="table">
+ <thead>
+ <tr>
+ <th>{{lang_user}}</th>
+ <th>{{lang_expires}}</th>
+ <th>{{lang_lastAddress}}</th>
+ <th class="slx-smallcol">{{lang_fixedIpSession}}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{#sessions}}
+ <tr>
+ <td>
+ {{#link}}
+ <a href="?do=adduser&amp;show=edituser&amp;userid={{userid}}">
+ {{/link}}
+ {{login}}
+ {{#link}}
+ </a>
+ {{/link}}
+ </td>
+ <td>{{dateline_s}}</td>
+ <td>{{lastip}}</td>
+ <td class="text-nowrap">
+ {{#fixedip}}
+ <span class="glyphicon glyphicon-ok"></span>
+ {{/fixedip}}
+ {{^fixedip}}
+ <span class="glyphicon glyphicon-remove"></span>
+ {{/fixedip}}
+ </td>
+ </tr>
+ {{/sessions}}
+ </tbody>
+</table> \ No newline at end of file
diff --git a/modules-available/session/templates/page-login.html b/modules-available/session/templates/page-login.html
index 4be7232a..94b69f7d 100644
--- a/modules-available/session/templates/page-login.html
+++ b/modules-available/session/templates/page-login.html
@@ -3,6 +3,10 @@
<div>
<input type="text" name="user" class="form-control" placeholder="{{lang_username}}" autofocus>
<input type="password" name="pass" class="form-control" placeholder="{{lang_password}}">
+ <div class="checkbox">
+ <input type="checkbox" id="fixed-ip" name="fixedip" value="1" checked>
+ <label for="fixed-ip">{{lang_fixedIpSession}}</label>
+ </div>
</div>
<button class="btn btn-lg btn-primary btn-block" type="submit">{{lang_login}}</button>
<a class="btn btn-lg btn-default btn-block" href="?do=AddUser">{{lang_register}}</a>
diff --git a/modules-available/statistics/api.inc.php b/modules-available/statistics/api.inc.php
index 30d1fda9..18a58a77 100644
--- a/modules-available/statistics/api.inc.php
+++ b/modules-available/statistics/api.inc.php
@@ -11,17 +11,17 @@ if (substr($ip, 0, 7) === '::ffff:') $ip = substr($ip, 7);
* Power/hw/usage stats
*/
-if ($type{0} === '~') {
+if ($type[0] === '~') {
// UUID is mandatory
$uuid = Request::post('uuid', '', 'string');
$macaddr = Request::post('macaddr', false, 'string');
if ($macaddr !== false) {
$macaddr = strtolower(str_replace(':', '-', $macaddr));
- if (strlen($macaddr) !== 17 || $macaddr{2} !== '-') {
+ if (strlen($macaddr) !== 17 || $macaddr[2] !== '-') {
$macaddr = false;
}
}
- if ($macaddr !== false && $uuid{8} !== '-' && substr($uuid, 0, 16) === '000000000000001-') {
+ if ($macaddr !== false && $uuid[8] !== '-' && substr($uuid, 0, 16) === '000000000000001-') {
$uuid = 'baad1d00-9491-4716-b98b-' . str_replace('-', '', $macaddr);
}
if (strlen($uuid) !== 36 || !preg_match('/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i', $uuid)) {
@@ -31,7 +31,8 @@ if ($type{0} === '~') {
// External mode of operation?
$mode = Request::post('mode', false, 'string');
$NOW = time();
- $old = Database::queryFirst('SELECT clientip, logintime, lastseen, lastboot, state, mbram, cpumodel, live_memfree, live_swapfree, live_tmpfree
+ $old = Database::queryFirst('SELECT clientip, locationid, logintime, lastseen, lastboot, state, mbram,
+ cpumodel, live_memfree, live_swapfree, live_tmpfree
FROM machine WHERE machineuuid = :uuid', array('uuid' => $uuid));
if ($old !== false) {
settype($old['logintime'], 'integer');
@@ -61,10 +62,26 @@ if ($type{0} === '~') {
if (!is_string($hostname) || $hostname === $ip) {
$hostname = '';
}
- $data = Util::cleanUtf8(Request::post('data', '', 'string'));
+ $json = false;
+ $data = Util::cleanUtf8(Request::post('json', '', 'string'));
+ if (!empty($data) && $data[0] === '{') {
+ $json = json_decode($data, true);
+ if (!is_array($json)) {
+ $json = false;
+ } else {
+ $json['cpu'] = [
+ 'sockets' => Request::post('sockets', 0, 'int'),
+ 'cores' => $realcores,
+ 'threads' => Request::post('vcores', 0, 'int'),
+ ];
+ }
+ }
+ if ($json === false) {
+ $data = Util::cleanUtf8(Request::post('data', '', 'string'));
+ }
// Prepare insert/update to machine table
$new = array(
- 'uuid' => $uuid,
+ 'machineuuid'=> $uuid,
'macaddr' => $macaddr,
'clientip' => $ip,
'lastseen' => $NOW,
@@ -86,7 +103,7 @@ if ($type{0} === '~') {
$res = Database::exec('INSERT INTO machine '
. '(machineuuid, macaddr, clientip, firstseen, lastseen, logintime, position, lastboot, realcores, mbram,'
. ' kvmstate, cpumodel, systemmodel, id44mb, badsectors, data, hostname, state) VALUES '
- . "(:uuid, :macaddr, :clientip, :firstseen, :lastseen, 0, '', :lastboot, :realcores, :mbram,"
+ . "(:machineuuid, :macaddr, :clientip, :firstseen, :lastseen, 0, '', :lastboot, :realcores, :mbram,"
. ' :kvmstate, :cpumodel, :systemmodel, :id44mb, :badsectors, :data, :hostname, :state)', $new, true);
if ($res === false) {
die("Concurrent insert, ignored. (RESULT=0)\n");
@@ -118,9 +135,9 @@ if ($type{0} === '~') {
. ' id44mb = :id44mb,'
. ' live_tmpsize = 0, live_swapsize = 0, live_memsize = 0, live_cpuload = 255, live_cputemp = 0,'
. ' badsectors = :badsectors,'
- . ' data = :data,'
+ . ' data = ' . ($json !== false ? ':data' : "If(Left(data, 1) = '{', data, :data)") . ','
. ' state = :state '
- . " WHERE machineuuid = :uuid AND state = :oldstate AND lastseen = :oldlastseen", $new);
+ . " WHERE machineuuid = :machineuuid AND state = :oldstate AND lastseen = :oldlastseen", $new);
if ($res === 0) {
die("Concurrent update, ignored. (RESULT=0)\n");
}
@@ -146,7 +163,17 @@ if ($type{0} === '~') {
if (($old === false || $old['clientip'] !== $ip) && Module::isAvailable('locations')) {
// New, or ip changed (dynamic pool?), update subnetlicationid
- Location::updateMapIpToLocation($uuid, $ip);
+ $loc = Location::updateMapIpToLocation($uuid, $ip);
+ $new['locationid'] = $loc; // For Filter Event
+ }
+
+ if ($json !== false) {
+ $ret = HardwareParser::parseMachine($uuid, $json);
+ if ($ret !== null) {
+ // This data is more accurate and ends up in the DB anyways, so use it for event filtering too
+ $new['id44mb'] = $ret['id44mb'];
+ $new['id45mb'] = $ret['id45mb'];
+ }
}
// Check for suspicious hardware changes
@@ -155,16 +182,31 @@ if ($type{0} === '~') {
// Log potential crash
if ($old['state'] === 'IDLE' || $old['state'] === 'OCCUPIED') {
- writeClientLog('machine-mismatch-poweron', 'Poweron event, but previous known state is ' . $old['state']
- . '. Free RAM: ' . Util::readableFileSize($old['live_memfree'], -1, 2)
- . ', free Swap: ' . Util::readableFileSize($old['live_swapfree'], -1, 2)
- . ', free ID44: ' . Util::readableFileSize($old['live_tmpfree'], -1, 2));
+ if (Module::isAvailable('syslog')) {
+ ClientLog::write($new, 'machine-mismatch-poweron',
+ 'Poweron event, but previous known state is ' . $old['state']
+ . '. Free RAM: ' . Util::readableFileSize($old['live_memfree'], -1, 2)
+ . ', free Swap: ' . Util::readableFileSize($old['live_swapfree'], -1, 2)
+ . ', free ID44: ' . Util::readableFileSize($old['live_tmpfree'], -1, 2));
+ }
}
+ // Add anything not present in $new from $old
+ $new += $old;
+ $new['oldlastboot'] = $old['lastboot'];
+ } else {
+ // First boot, mock some important fields for event log filtering
+ $new['oldlastboot'] = 0;
+ $new['oldlastseen'] = 0;
+ $new['oldstate'] = 'OFFLINE';
}
+ unset($new['data']);
+ EventLog::applyFilterRules($type, $new);
+
// Write statistics data
} else if ($type === '~runstate') {
+
// Usage (occupied/free)
$sessionLength = 0;
$strUpdateBoottime = '';
@@ -174,10 +216,14 @@ if ($type{0} === '~') {
}
$used = Request::post('used', 0, 'integer');
$params = array(
- 'uuid' => $uuid,
+ 'machineuuid' => $uuid,
'oldlastseen' => $old['lastseen'],
'oldstate' => $old['state'],
);
+ if ($NOW - $old['lastseen'] < 10 && ($old['state'] === 'OFFLINE' || $old['state'] === 'STANDBY')) {
+ // Avoid racing calls to ~runstate updates while/after we send a ~poweroff or ~suspend
+ die("OK.\n");
+ }
if ($old['state'] === 'OFFLINE') {
// This should never happen -- we expect a poweron event before runstate, which would set the state to IDLE
// So it might be that the poweron event got lost, or that a couple of runstate events got lost, which
@@ -199,14 +245,18 @@ if ($type{0} === '~') {
'memfree', 'tmpfree', 'swapfree', 'id45free',
'cpuload', 'cputemp'] as $item) {
$liveVal = Request::post($item, false, 'int');
- if ($liveVal !== false) {
- $strUpdateBoottime .= ' live_' . $item . ' = :_' . $item . ', ';
+ if ($liveVal !== false && $liveVal >= 0) {
+ $strUpdateBoottime .= ' live_' . $item . ' = :live_' . $item . ', ';
if ($item === 'cpuload' || $item === 'cputemp') {
$liveVal = round($liveVal);
} else {
$liveVal = ceil($liveVal / 1024);
}
- $params['_' . $item] = $liveVal;
+ $max = ($item === 'cpuload') ? 100 : (2 ** 31);
+ if ($liveVal > $max) {
+ $liveVal = $max;
+ }
+ $params['live_' . $item] = $liveVal;
}
}
if (($runmode = Request::post('runmode', false, 'string')) !== false) {
@@ -222,7 +272,7 @@ if ($type{0} === '~') {
$res = Database::exec('UPDATE machine SET lastseen = UNIX_TIMESTAMP(),'
. $strUpdateBoottime
. " logintime = 0, currentuser = NULL, state = 'IDLE' "
- . " WHERE machineuuid = :uuid AND lastseen = :oldlastseen AND state = :oldstate",
+ . " WHERE machineuuid = :machineuuid AND lastseen = :oldlastseen AND state = :oldstate",
$params);
} elseif ($used === 1 && $old['state'] !== 'OCCUPIED') {
// Machine is in use, was free before
@@ -235,7 +285,7 @@ if ($type{0} === '~') {
$res = Database::exec('UPDATE machine SET lastseen = UNIX_TIMESTAMP(),'
. $strUpdateBoottime
. " logintime = UNIX_TIMESTAMP(), currentuser = :user, currentsession = NULL, state = 'OCCUPIED' "
- . " WHERE machineuuid = :uuid AND lastseen = :oldlastseen AND state = :oldstate", $params);
+ . " WHERE machineuuid = :machineuuid AND lastseen = :oldlastseen AND state = :oldstate", $params);
} else {
$res = 0;
}
@@ -243,7 +293,7 @@ if ($type{0} === '~') {
// Nothing changed, simple lastseen update
$res = Database::exec('UPDATE machine SET '
. $strUpdateBoottime
- . ' lastseen = UNIX_TIMESTAMP() WHERE machineuuid = :uuid AND lastseen = :oldlastseen AND state = :oldstate', $params);
+ . ' lastseen = UNIX_TIMESTAMP() WHERE machineuuid = :machineuuid AND lastseen = :oldlastseen AND state = :oldstate', $params);
}
// Did we update, or was there a concurrent update?
if ($res === 0) {
@@ -253,7 +303,12 @@ if ($type{0} === '~') {
if ($mode === false && $sessionLength > 0 && $sessionLength < 86400*2 && $old['logintime'] !== 0) {
Statistics::logMachineState($uuid, $ip, Statistics::SESSION_LENGTH, $old['logintime'], $sessionLength);
}
+ // Client Events
+ $params['newstate'] = ($used === 0) ? 'IDLE' : 'OCCUPIED';
+ EventLog::applyFilterRules($type, $params + $old);
+
} elseif ($type === '~poweroff') {
+
if ($old === false) die("Unknown machine.\n");
if ($old['clientip'] !== $ip) {
updateIp('poweroff', $uuid, $old, $ip);
@@ -267,7 +322,11 @@ if ($type{0} === '~') {
Database::exec("UPDATE machine SET logintime = 0, lastseen = UNIX_TIMESTAMP(), state = 'OFFLINE'
WHERE machineuuid = :uuid AND state = :oldstate AND lastseen = :oldlastseen",
array('uuid' => $uuid, 'oldlastseen' => $old['lastseen'], 'oldstate' => $old['state']));
+
+ EventLog::applyFilterRules($type, $old);
+
} elseif ($mode === false && $type === '~screens') {
+
if ($old === false) die("Unknown machine.\n");
$screens = Request::post('screen', false, 'array');
if (is_array($screens)) {
@@ -279,24 +338,24 @@ if ($type{0} === '~') {
if (!array_key_exists('name', $screen))
continue;
// Filter bogus data
- $screen['name'] = Util::cleanUtf8($screen['name']);
- $port = Util::cleanUtf8($port);
+ $screen['name'] = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $screen['name']);
+ $port = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $port);
if (empty($screen['name']))
continue;
if (array_key_exists($screen['name'], $hwids)) {
$hwid = $hwids[$screen['name']];
} else {
- $hwid = (int)Database::insertIgnore('statistic_hw', 'hwid',
- array('hwtype' => DeviceType::SCREEN, 'hwname' => $screen['name']));
+ $hwid = Database::insertIgnore('statistic_hw', 'hwid',
+ ['hwtype' => HardwareInfo::SCREEN, 'hwname' => $screen['name']]);
$hwids[$screen['name']] = $hwid;
}
// Now add new entries
$keepPair[] = array($hwid, $port);
- $machinehwid = Database::insertIgnore('machine_x_hw', 'machinehwid', array(
+ $machinehwid = Database::insertIgnore('machine_x_hw', 'machinehwid', [
'hwid' => $hwid,
'machineuuid' => $uuid,
'devpath' => $port,
- ), array('disconnecttime' => 0));
+ ], ['disconnecttime' => 0]);
$validProps = array();
if (count($screen) > 1) {
// Screen has additional properties (resolution, size, etc.)
@@ -333,20 +392,27 @@ if ($type{0} === '~') {
Database::exec("UPDATE machine_x_hw x, statistic_hw h
SET x.disconnecttime = UNIX_TIMESTAMP()
WHERE x.machineuuid = :uuid AND x.hwid = h.hwid AND h.hwtype = :type AND x.disconnecttime = 0",
- array('uuid' => $uuid, 'type' => DeviceType::SCREEN));
+ array('uuid' => $uuid, 'type' => HardwareInfo::SCREEN));
} else {
// Some screens connected, make sure old entries get removed
Database::exec("UPDATE machine_x_hw x, statistic_hw h
SET x.disconnecttime = UNIX_TIMESTAMP()
- WHERE (x.hwid, x.devpath) NOT IN (:pairs) AND x.disconnecttime = 0 AND h.hwtype = :type
+ WHERE (x.hwid, x.devpath) NOT IN (:pairs) AND x.hwid = h.hwid AND x.disconnecttime = 0 AND h.hwtype = :type
AND x.machineuuid = :uuid", array(
'pairs' => $keepPair,
'uuid' => $uuid,
- 'type' => DeviceType::SCREEN,
+ 'type' => HardwareInfo::SCREEN,
));
}
+
+ // Benchmarking
+ Database::exec("INSERT INTO statistic (dateline, typeid, clientip, machineuuid, username, `data`)
+ VALUES (UNIX_TIMESTAMP(), :type, :ip, :uuid, '', '')",
+ ['type' => 'graphical-startup', 'ip' => $ip, 'uuid' => $uuid]);
}
+
} else if ($type === '~suspend') {
+
// Client entering suspend
if ($old === false) die("Unknown machine.\n");
if ($old['clientip'] !== $ip) {
@@ -357,10 +423,13 @@ if ($type{0} === '~') {
standbysem = If(standbysem < 6, standbysem + 1, 6)
WHERE machineuuid = :uuid AND state = :oldstate AND lastseen = :oldlastseen",
array('uuid' => $uuid, 'oldlastseen' => $old['lastseen'], 'oldstate' => $old['state']));
+ EventLog::applyFilterRules($type, $old);
} else {
EventLog::info("[suspend] Client $uuid reported switch to standby when it wasn't powered on first. Was: " . $old['state']);
}
+
} else if ($type === '~resume') {
+
// Waking up from suspend
if ($old === false) die("Unknown machine.\n");
if ($old['clientip'] !== $ip) {
@@ -379,6 +448,7 @@ if ($type{0} === '~') {
Statistics::logMachineState($uuid, $ip, Statistics::SUSPEND_LENGTH, $lastSeen, $duration);
}
}
+ EventLog::applyFilterRules($type, $old);
} else {
EventLog::info("[resume] Client $uuid reported wakeup from standby when it wasn't logged as being in standby. Was: " . $old['state']);
}
@@ -413,21 +483,9 @@ function writeStatisticLog($type, $username, $data)
));
}
-function writeClientLog($type, $description)
-{
- global $ip, $uuid;
- Database::exec('INSERT INTO clientlog (dateline, logtypeid, clientip, machineuuid, description, extra) VALUES (UNIX_TIMESTAMP(), :type, :client, :uuid, :description, :longdesc)', array(
- 'type' => $type,
- 'client' => $ip,
- 'description' => $description,
- 'longdesc' => '',
- 'uuid' => $uuid,
- ));
-}
-
-
// For backwards compat, we require the . prefix
-if ($type{0} === '.') {
+if ($type[0] === '.') {
+ $data = false;
if ($type === '.vmchooser-session') {
$user = Util::cleanUtf8(Request::post('user', 'unknown', 'string'));
$loguser = Request::post('loguser', 0, 'int') !== 0;
@@ -437,21 +495,35 @@ if ($type{0} === '.') {
Database::exec("UPDATE machine SET currentuser = :user, currentsession = :session WHERE clientip = :ip",
compact('user', 'session', 'ip'));
writeStatisticLog('.vmchooser-session-name', ($loguser ? $user : 'anonymous'), $sessionName);
+ $data = [
+ 'clientip' => $ip,
+ 'sessionName' => $sessionName,
+ 'sessionUuid' => $sessionUuid,
+ 'session' => $session,
+ ];
} else {
if (!isset($_POST['description'])) die('Missing options..');
$description = $_POST['description'];
// and username embedded in message
if (preg_match('#^\[([^\]]+)\]\s*(.*)$#m', $description, $out)) {
writeStatisticLog($type, $out[1], $out[2]);
+ $data = [
+ 'clientip' => $ip,
+ 'user' => $out[1],
+ 'description' => $out[2],
+ ];
}
}
+ if ($data !== false) {
+ EventLog::applyFilterRules($type, $data);
+ }
}
/**
* @param array $old row from DB with client's old data
* @param array $new new data to be written
*/
-function checkHardwareChange($old, $new)
+function checkHardwareChange(array $old, array $new): void
{
if ($new['mbram'] !== 0) {
if ($new['mbram'] < 6200) {
@@ -463,10 +535,10 @@ function checkHardwareChange($old, $new)
}
if ($ram1 !== $ram2) {
$word = $ram1 > $ram2 ? 'decreased' : 'increased';
- EventLog::warning('[poweron] Client ' . $new['uuid'] . ' (' . $new['clientip'] . "): RAM $word from {$ram1}GB to {$ram2}GB");
+ EventLog::warning('[poweron] Client ' . $new['machineuuid'] . ' (' . $new['clientip'] . "): RAM $word from {$ram1}GB to {$ram2}GB");
}
if (!empty($old['cpumodel']) && !empty($new['cpumodel']) && $new['cpumodel'] !== $old['cpumodel']) {
- EventLog::warning('[poweron] Client ' . $new['uuid'] . ' (' . $new['clientip'] . "): CPU changed from '{$old['cpumodel']}' to '{$new['cpumodel']}'");
+ EventLog::warning('[poweron] Client ' . $new['machineuuid'] . ' (' . $new['clientip'] . "): CPU changed from '{$old['cpumodel']}' to '{$new['cpumodel']}'");
}
}
}
diff --git a/modules-available/statistics/baseconfig/getconfig.inc.php b/modules-available/statistics/baseconfig/getconfig.inc.php
index e8afeffb..f90cd49d 100644
--- a/modules-available/statistics/baseconfig/getconfig.inc.php
+++ b/modules-available/statistics/baseconfig/getconfig.inc.php
@@ -1,36 +1,41 @@
<?php
+/** @var ?string $uuid */
+/** @var ?string $ip */
+
// Location handling: figure out location
if (Request::any('force', 0, 'int') === 1 && Request::any('module', false, 'string') === 'statistics') {
// Force location for testing, but require logged in admin
if (User::load()) {
- $uuid = Request::any('value', '', 'string');
+ $uuid = Request::any('value', null, 'string');
}
}
-if (!$uuid) // Required at this point, bail out if not given
+if ($uuid === null) // Required at this point, bail out if not given
return;
// Query machine specific settings
$res = Database::simpleQuery("SELECT setting, value FROM setting_machine WHERE machineuuid = :uuid", ['uuid' => $uuid]);
-while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+foreach ($res as $row) {
ConfigHolder::add($row['setting'], $row['value'], 500);
}
+if ($ip !== null) {
// Statistics about booted system
-ConfigHolder::addPostHook(function() use ($ip, $uuid) {
- $type = Request::get('type', 'default', 'string');
- // System
- if ($type !== 'default') {
- Database::exec("INSERT INTO statistic (dateline, typeid, clientip, machineuuid, username, data)
+ ConfigHolder::addPostHook(function () use ($ip, $uuid) {
+ $type = Request::get('type', 'default', 'string');
+ // System
+ if ($type !== 'default') {
+ Database::exec("INSERT INTO statistic (dateline, typeid, clientip, machineuuid, username, data)
VALUES (UNIX_TIMESTAMP(), :type, :ip, :uuid, '', :data)",
- ['type' => 'boot-system', 'ip' => $ip, 'uuid' => $uuid, 'data' => $type]);
- }
- // Runmode
- $mode = ConfigHolder::get('SLX_RUNMODE_MODULE');
- if (!empty($mode)) {
- Database::exec("INSERT INTO statistic (dateline, typeid, clientip, machineuuid, username, data)
+ ['type' => 'boot-system', 'ip' => $ip, 'uuid' => $uuid, 'data' => $type]);
+ }
+ // Runmode
+ $mode = ConfigHolder::get('SLX_RUNMODE_MODULE');
+ if (!empty($mode)) {
+ Database::exec("INSERT INTO statistic (dateline, typeid, clientip, machineuuid, username, data)
VALUES (UNIX_TIMESTAMP(), :type, :ip, :uuid, '', :data)",
- ['type' => 'boot-runmode', 'ip' => $ip, 'uuid' => $uuid, 'data' => $mode]);
- }
-});
+ ['type' => 'boot-runmode', 'ip' => $ip, 'uuid' => $uuid, 'data' => $mode]);
+ }
+ });
+} \ No newline at end of file
diff --git a/modules-available/statistics/clientscript.js b/modules-available/statistics/clientscript.js
new file mode 100644
index 00000000..3c166f64
--- /dev/null
+++ b/modules-available/statistics/clientscript.js
@@ -0,0 +1,76 @@
+'use strict';
+
+// All the pie chars
+function makePieChart($parent) {
+ var data = $parent.data('chart');
+ var chartData = {
+ datasets: [{
+ data: data.map(function(x) { return x.value; }),
+ backgroundColor: data.map(function(x) { return x.color; })
+ }]
+ };
+ var $canv = $('<canvas style="width:100%;height:250px">');
+ $parent.append($canv);
+ (function() {
+ var $dest = $parent.data('chart-dest');
+ var cur = null;
+ new Chart($canv[0].getContext('2d'), {
+ type: 'pie', data: chartData, options: {
+ animation: false,
+ onHover: function (_, list) {
+ if (list.length === 0 || list[0].index !== cur) {
+ if (cur !== null) {
+ $($dest + cur).removeClass('slx-bold');
+ cur = null;
+ }
+ }
+ if (list.length !== 0 && list[0].index !== cur) {
+ cur = list[0].index;
+ $($dest + cur).addClass('slx-bold');
+ }
+ },
+ plugins: {
+ tooltip: {enabled: false},
+ legend: {display: false}
+ }
+ }
+ });
+ $canv.mouseout(function() {
+ if (cur !== null) {
+ $($dest + cur).removeClass('slx-bold');
+ cur = null;
+ }
+ });
+ })();
+}
+
+function popupFilter(field) {
+ var $row = addFilter(field, null, null);
+ if ($row !== null) {
+ $row.find('.arg').focus();
+ $row.removeClass('slx-focus')
+ setTimeout(function() { $row.addClass('slx-focus'); }, 10);
+ }
+}
+
+function addFilter(field, op, argument) {
+ if (field === null)
+ return null;
+ var $row = $('#filter-' + field);
+ if ($row.length === 0)
+ return null;
+ if (argument !== null) {
+ $row.find('.op').val(op);
+ $row.find('.arg').val(argument);
+ }
+ // Enable checkbox only if we got a predefined value, or if argument is in a select, as the user might want the preselected item and doesn't notice the checkbox is unchecked
+ if (argument !== null || $row.find('select.arg').length !== 0) {
+ $row.find('.filter-enable').prop('checked', true);
+ }
+ $row.show();
+ return $row;
+}
+
+function refresh() {
+ $('#query-form').submit();
+} \ No newline at end of file
diff --git a/modules-available/statistics/config.json b/modules-available/statistics/config.json
index 11d3fba3..a683ab6a 100644
--- a/modules-available/statistics/config.json
+++ b/modules-available/statistics/config.json
@@ -1,7 +1,6 @@
{
"category": "main.status",
"dependencies": [
- "js_chart",
"bootstrap_datepicker"
],
"permission": "0"
diff --git a/modules-available/statistics/hooks/config-tgz.inc.php b/modules-available/statistics/hooks/config-tgz.inc.php
index 8dffbff6..b732fd7a 100644
--- a/modules-available/statistics/hooks/config-tgz.inc.php
+++ b/modules-available/statistics/hooks/config-tgz.inc.php
@@ -4,12 +4,12 @@ $res = Database::simpleQuery('SELECT h.hwname FROM statistic_hw h'
. " INNER JOIN statistic_hw_prop p ON (h.hwid = p.hwid AND p.prop = :projector)"
. " WHERE h.hwtype = :screen ORDER BY h.hwname ASC", array(
'projector' => 'projector',
- 'screen' => DeviceType::SCREEN,
+ 'screen' => HardwareInfo::SCREEN,
));
if ($res !== false) { // CHeck this in case we're running on old DB during update
$content = '';
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$content .= $row['hwname'] . "=beamer\n";
}
diff --git a/modules-available/statistics/hooks/cron.inc.php b/modules-available/statistics/hooks/cron.inc.php
index 0de233a8..4ba5e2f6 100644
--- a/modules-available/statistics/hooks/cron.inc.php
+++ b/modules-available/statistics/hooks/cron.inc.php
@@ -9,12 +9,42 @@ function logstats()
$join = 'LEFT JOIN runmode r USING (machineuuid)';
$where = 'AND (r.isclient IS NULL OR r.isclient <> 0)';
}
- $known = Database::queryFirst("SELECT Count(*) AS val FROM machine m $join WHERE m.lastseen > $cutoff $where");
- $on = Database::queryFirst("SELECT Count(*) AS val FROM machine m $join WHERE m.state IN ('IDLE', 'OCCUPIED') $where");
- $used = Database::queryFirst("SELECT Count(*) AS val FROM machine m $join WHERE m.state = 'OCCUPIED' $where");
+ // Get total/online/in-use
+ $known = Database::queryKeyValueList("SELECT locationid, Count(*) AS val FROM machine m
+ $join WHERE m.lastseen > $cutoff $where
+ GROUP BY locationid");
+ $on = Database::queryKeyValueList("SELECT locationid, Count(*) AS val FROM machine m
+ $join WHERE m.state IN ('IDLE', 'OCCUPIED') $where
+ GROUP BY locationid");
+ $used = Database::queryKeyValueList("SELECT locationid, Count(*) AS val FROM machine m
+ $join WHERE m.state = 'OCCUPIED' $where
+ GROUP BY locationid");
+ // Get calendar data if available
+ if (Module::isAvailable('locationinfo')) {
+ // Refresh all calendars around 07:00
+ $calendars = LocationInfo::getAllCalendars(date('G') != 7 || date('i') >= 10);
+ }
+ // Mash together
+ $data = ['usage' => []];
+ foreach ($known as $lid => $val) {
+ $entry = ['t' => $val];
+ if (isset($on[$lid])) {
+ $entry['o'] = $on[$lid];
+ }
+ if (isset($used[$lid])) {
+ $entry['u'] = $used[$lid];
+ }
+ if (isset($calendars[$lid])) {
+ $title = LocationInfo::extractCurrentEvent($calendars[$lid]);
+ if (!empty($title)) {
+ $entry['event'] = $title;
+ }
+ }
+ $data['usage'][$lid] = $entry;
+ }
Database::exec("INSERT INTO statistic (dateline, typeid, clientip, username, data) VALUES (:now, '~stats', '', '', :vals)", array(
'now' => $NOW,
- 'vals' => $known['val'] . '#' . $on['val'] . '#' . $used['val'],
+ 'vals' => json_encode($data),
));
}
@@ -26,18 +56,12 @@ function state_cleanup()
// Query for logging
$res = Database::simpleQuery("SELECT machineuuid, clientip, state, logintime, lastseen, live_memfree, live_swapfree, live_tmpfree
FROM machine WHERE lastseen < If(state = 'STANDBY', $standby, $on) AND state <> 'OFFLINE'");
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- Database::exec('INSERT INTO clientlog (dateline, logtypeid, clientip, machineuuid, description, extra)
- VALUES (UNIX_TIMESTAMP(), :type, :client, :uuid, :description, :longdesc)', array(
- 'type' => 'machine-mismatch-cron',
- 'client' => $row['clientip'],
- 'description' => 'Client timed out, last known state is ' . $row['state']
- . '. Free RAM: ' . Util::readableFileSize($row['live_memfree'], -1, 2)
- . ', free Swap: ' . Util::readableFileSize($row['live_swapfree'], -1, 2)
- . ', free ID44: ' . Util::readableFileSize($row['live_tmpfree'], -1, 2),
- 'longdesc' => '',
- 'uuid' => $row['machineuuid'],
- ));
+ foreach ($res as $row) {
+ ClientLog::write($row, 'machine-mismatch-cron',
+ 'Client timed out, last known state is ' . $row['state']
+ . '. Free RAM: ' . Util::readableFileSize($row['live_memfree'], -1, 2)
+ . ', free Swap: ' . Util::readableFileSize($row['live_swapfree'], -1, 2)
+ . ', free ID44: ' . Util::readableFileSize($row['live_tmpfree'], -1, 2));
if ($row['state'] === 'OCCUPIED') {
$length = $row['lastseen'] - $row['logintime'];
if ($length > 0 && $length < 86400 * 7) {
@@ -59,7 +83,7 @@ state_cleanup();
logstats();
if (mt_rand(1, 10) === 1) {
- Database::exec("DELETE FROM statistic WHERE (UNIX_TIMESTAMP() - 86400 * 190) > dateline");
+ Database::exec("DELETE FROM statistic WHERE (UNIX_TIMESTAMP() - 86400 * 365 * 2) > dateline");
if (mt_rand(1, 100) === 1) {
Database::exec("OPTIMIZE TABLE statistic");
}
diff --git a/modules-available/statistics/hooks/locations-column.inc.php b/modules-available/statistics/hooks/locations-column.inc.php
new file mode 100644
index 00000000..51f280be
--- /dev/null
+++ b/modules-available/statistics/hooks/locations-column.inc.php
@@ -0,0 +1,150 @@
+<?php
+
+if (!User::hasPermission('.statistics.view.list')) {
+ return null;
+}
+
+class ClientCountLocationColumn extends AbstractLocationColumn
+{
+
+ private $lookup;
+
+ public function __construct()
+ {
+ $this->lookup = StatisticsColumnGetData([]);
+ }
+
+ public function getColumnHtml(int $locationId): string
+ {
+ if (!isset($this->lookup[$locationId]))
+ return '';
+ if ($this->lookup[$locationId]['hasChild'] ?? false) {
+ $child = <<<EOF
+ (<a href="?do=Statistics&amp;show=list&amp;filters=location~{$locationId}">&downarrow;{$this->lookup[$locationId]['clientCountSum']}</a>)
+EOF;
+ } else {
+ $child = '';
+ }
+
+ return <<<EOF
+ <div class="pull-right">
+ <a href="?do=Statistics&amp;show=list&amp;filters=location={$locationId}">&nbsp;{$this->lookup[$locationId]['clientCount']}&nbsp;</a>
+ <span class="text-right" style="display:inline-block;width:6ex">$child</span>
+ </div>
+EOF;
+ }
+
+ public function getEditUrl(int $locationId): string
+ {
+ return '';
+ }
+
+ public function header(): string
+ {
+ return Dictionary::translateFileModule('statistics', 'module', 'location-column-header-count');
+ }
+
+ public function priority(): int
+ {
+ return 800;
+ }
+
+}
+
+class ClientLoadLocationColumn extends AbstractLocationColumn
+{
+
+ private $lookup;
+
+ public function __construct()
+ {
+ $this->lookup = StatisticsColumnGetData([]);
+ }
+
+ public function getColumnHtml(int $locationId): string
+ {
+ if (!isset($this->lookup[$locationId]) || $this->lookup[$locationId]['clientCount'] === 0)
+ return '';
+ $c =& $this->lookup[$locationId];
+ return <<<EOF
+ <div class="load-col text-right" style="background:linear-gradient(to right, #f97, #f97 {$c['clientLoad']}%,
+ #6fa {$c['clientLoad']}%, #6fa {$c['clientIdle']}%, #eee {$c['clientIdle']}%)">
+ {$c['clientLoad']}&thinsp;%
+ </div>
+EOF;
+
+ }
+
+ public function getEditUrl(int $locationId): string
+ {
+ return '';
+ }
+
+ public function header(): string
+ {
+ return Dictionary::translateFileModule('statistics', 'module', 'location-column-header-load');
+ }
+
+ public function priority(): int
+ {
+ return 900;
+ }
+
+}
+
+function StatisticsColumnGetData(array $allowedLocationIds): array
+{
+ static $data = [];
+ if (!empty($data))
+ return $data;
+ $extra = '';
+ if (in_array(0, $allowedLocationIds)) {
+ $extra = ' OR locationid IS NULL';
+ }
+ $locs = Location::getLocationsAssoc();
+ $res = Database::simpleQuery("SELECT m.locationid, Count(*) AS cnt,
+ Sum(If(m.state = 'OCCUPIED', 1, 0)) AS used, Sum(If(m.state = 'IDLE', 1, 0)) AS idle
+ FROM machine m WHERE (locationid IN (:allowedLocationIds) $extra) GROUP BY locationid", compact('allowedLocationIds'));
+ foreach ($res as $row) {
+ $locId = (int)$row['locationid'];
+ $data[$locId] = [
+ 'clientCount' => $row['cnt'],
+ 'clientLoad' => round(100 * $row['used'] / $row['cnt']),
+ 'clientIdle' => round(100 * ($row['used'] + $row['idle']) / $row['cnt']),
+ ];
+ }
+ foreach ($allowedLocationIds as $locId) {
+ if (isset($data[$locId]))
+ continue;
+ $data[$locId] = [
+ 'clientCount' => 0,
+ 'clientLoad' => 0,
+ 'clientIdle' => 0,
+ ];
+ }
+ foreach ($data as $locId => &$loc) {
+ if (!in_array($locId, $allowedLocationIds))
+ continue;
+ if (!isset($loc['clientCountSum'])) {
+ $loc['clientCountSum'] = 0;
+ }
+ $loc['clientCountSum'] += $loc['clientCount'];
+ if ($locId !== 0) {
+ foreach ($locs[$locId]['parents'] as $pid) {
+ if (!in_array($pid, $allowedLocationIds))
+ continue;
+ $data[$pid]['hasChild'] = true;
+ if (!isset($data[$pid]['clientCountSum'])) {
+ $data[$pid]['clientCountSum'] = 0;
+ }
+ $data[$pid]['clientCountSum'] += $loc['clientCount'];
+ }
+ }
+ }
+ unset($loc);
+ return $data;
+}
+
+StatisticsColumnGetData($allowedLocationIds);
+
+return [new ClientCountLocationColumn(), new ClientLoadLocationColumn()]; \ No newline at end of file
diff --git a/modules-available/statistics/hooks/translation.inc.php b/modules-available/statistics/hooks/translation.inc.php
index 4d09553a..f7a50b0d 100644
--- a/modules-available/statistics/hooks/translation.inc.php
+++ b/modules-available/statistics/hooks/translation.inc.php
@@ -16,10 +16,8 @@ $HANDLER['subsections'] = array(
/**
* Configuration categories.
- * @param \Module $module
- * @return array
*/
-$HANDLER['grep_filters'] = function($module) {
+$HANDLER['grep_filters'] = function (Module $module): array {
if (!$module->activate(1, false))
return array();
$want = StatisticsFilter::$columns;
diff --git a/modules-available/statistics/inc/devicetype.inc.php b/modules-available/statistics/inc/devicetype.inc.php
deleted file mode 100644
index 41ee237d..00000000
--- a/modules-available/statistics/inc/devicetype.inc.php
+++ /dev/null
@@ -1,6 +0,0 @@
-<?php
-
-class DeviceType
-{
- const SCREEN = 'SCREEN';
-}
diff --git a/modules-available/statistics/inc/hardwareinfo.inc.php b/modules-available/statistics/inc/hardwareinfo.inc.php
new file mode 100644
index 00000000..7e0bdba8
--- /dev/null
+++ b/modules-available/statistics/inc/hardwareinfo.inc.php
@@ -0,0 +1,249 @@
+<?php
+
+class HardwareInfo
+{
+
+ // Never change these!
+ const RAM_MODULE = 'RAM';
+ const MAINBOARD = 'MAINBOARD';
+ const DMI_SYSTEM = 'DMI_SYSTEM';
+ const POWER_SUPPLY = 'POWER_SUPPLY';
+ const SYSTEM_SLOT = 'SYSTEM_SLOT';
+ const PCI_DEVICE = 'PCI_DEVICE';
+ const HDD = 'HDD';
+ const CPU = 'CPU';
+ const SCREEN = 'SCREEN';
+
+ /**
+ * Get a KCL modification string for the given machine, enabling GVT, PCI passthrough etc.
+ * You can provide a UUID and/or MAC, or nothing. If nothing is provided,
+ * the "uuid" and "mac" GET parameters will be used. If both are provided,
+ * the resulting machine that has the greatest "lastseen" value will be used.
+ * @param ?string $uuid UUID of machine
+ * @param ?string $mac MAC of machine
+ */
+ public static function getKclModifications(?string $uuid = null, ?string $mac = null): string
+ {
+ if ($uuid === null && $mac === null) {
+ $uuid = Request::get('uuid', '', 'string');
+ $mac = Request::get('mac', '', 'string');
+ $mac = str_replace(':', '-', $mac);
+ }
+ $res = Database::simpleQuery("SELECT machineuuid, lastseen, cpumodel, locationid FROM machine
+ WHERE machineuuid = :uuid OR macaddr = :mac", ['uuid' => $uuid, 'mac' => $mac]);
+ $best = null;
+ foreach ($res as $row) {
+ if ($best === null || $best['lastseen'] < $row['lastseen']) {
+ $best = $row;
+ }
+ }
+ if ($best === null || ((int)$best['locationid']) === 0)
+ return '';
+ $locations = Location::getLocationRootChain($best['locationid']);
+ if (empty($locations))
+ return '';
+ $hw = new HardwareQuery(self::PCI_DEVICE, $best['machineuuid'], true);
+ // TODO: Get list of enabled pass through groups for this client's location
+ $hw->addForeignJoin(true, '@PASSTHROUGH', 'passthrough_group_x_location', 'groupid',
+ 'locationid', $locations);
+ $hw->addGlobalColumn('vendor');
+ $hw->addGlobalColumn('device');
+ $hw->addLocalColumn('slot');
+ $res = $hw->query(['vendor', 'device']);
+ $passthrough = [];
+ $slots = [];
+ $gvt = false;
+ foreach ($res as $row) {
+ if ($row['@PASSTHROUGH'] === 'GVT') {
+ $gvt = true;
+ } else {
+ $passthrough[$row['vendor'] . ':' . $row['device']] = 1;
+ $slots[preg_replace('/\.[0-9]+$/', '', $row['slot'])] = 1;
+ }
+ }
+ $kcl = '';
+ if ($gvt || !empty($passthrough)) {
+ if (strpos($best['cpumodel'], 'Intel') !== false) {
+ $kcl = '-iommu -intel_iommu iommu=pt intel_iommu=on';
+ } elseif (strpos($best['cpumodel'], 'AMD') !== false) {
+ $kcl = '-iommu -amd_iommu iommu=pt amd_iommu=on';
+ } else {
+ error_log("Cannot determine CPU manufacturer from " . $best['cpumodel']);
+ $kcl = '-iommu -intel_iommu iommu=pt intel_iommu=on -amd_iommu amd_iommu=on';
+ }
+ }
+ if (!empty($passthrough)) {
+ foreach (array_keys($slots) as $slot) {
+ //error_log('Querying slot ' . $slot);
+ $hw = new HardwareQuery(self::PCI_DEVICE, $best['machineuuid'], true);
+ $hw->addLocalColumn('slot')->addCondition('LIKE', $slot . '.%');
+ $hw->addGlobalColumn('vendor');
+ $hw->addGlobalColumn('device');
+ foreach ($hw->query() as $row) {
+ $passthrough[$row['vendor'] . ':' . $row['device']] = 1;
+ //error_log('Extra PT: ' . $row['vendor'] . ':' . $row['device']);
+ }
+ }
+ $kcl .= ' vfio-pci.ids=' . implode(',', array_keys($passthrough));
+ }
+ if ($gvt) {
+ $kcl .= ' i915.enable_gvt=1';
+ }
+ return $kcl;
+ }
+
+ // For lookup (from https://en.wikipedia.org/wiki/GUID_Partition_Table)
+ const GPT = [
+ '00000000-0000-0000-0000-000000000000' => 'Unused entry',
+ '024DEE41-33E7-11D3-9D69-0008C781F39F' => 'MBR partition scheme',
+ 'C12A7328-F81F-11D2-BA4B-00A0C93EC93B' => 'EFI System partition',
+ '21686148-6449-6E6F-744E-656564454649' => 'BIOS boot partition',
+ 'D3BFE2DE-3DAF-11DF-BA40-E3A556D89593' => 'Intel Fast Flash (iFFS) partition (for Intel Rapid Start technology)',
+ 'F4019732-066E-4E12-8273-346C5641494F' => 'Sony boot partition',
+ 'BFBFAFE7-A34F-448A-9A5B-6213EB736C22' => 'Lenovo boot partition',
+ 'E3C9E316-0B5C-4DB8-817D-F92DF00215AE' => 'Microsoft Reserved Partition (MSR)',
+ 'EBD0A0A2-B9E5-4433-87C0-68B6B72699C7' => 'Microsoft Basic data partition',
+ '5808C8AA-7E8F-42E0-85D2-E1E90434CFB3' => 'Microsoft Logical Disk Manager (LDM) metadata partition',
+ 'AF9B60A0-1431-4F62-BC68-3311714A69AD' => 'Microsoft Logical Disk Manager data partition',
+ 'DE94BBA4-06D1-4D40-A16A-BFD50179D6AC' => 'Windows Recovery Environment',
+ '37AFFC90-EF7D-4E96-91C3-2D7AE055B174' => 'IBM General Parallel File System (GPFS) partition',
+ 'E75CAF8F-F680-4CEE-AFA3-B001E56EFC2D' => 'Storage Spaces partition',
+ '558D43C5-A1AC-43C0-AAC8-D1472B2923D1' => 'Storage Replica partition',
+ '75894C1E-3AEB-11D3-B7C1-7B03A0000000' => 'HPUX Data partition',
+ 'E2A1E728-32E3-11D6-A682-7B03A0000000' => 'HPUX Service partition',
+ '0FC63DAF-8483-4772-8E79-3D69D8477DE4' => 'Linux filesystem data',
+ 'A19D880F-05FC-4D3B-A006-743F0F84911E' => 'Linux RAID partition',
+ '44479540-F297-41B2-9AF7-D131D5F0458A' => 'Linux Root partition (x86)',
+ '4F68BCE3-E8CD-4DB1-96E7-FBCAF984B709' => 'Linux Root partition (x86-64)',
+ '69DAD710-2CE4-4E3C-B16C-21A1D49ABED3' => 'Linux Root partition (32-bit ARM)',
+ 'B921B045-1DF0-41C3-AF44-4C6F280D3FAE' => 'Linux Root partition (64-bit ARM/AArch64)',
+ 'BC13C2FF-59E6-4262-A352-B275FD6F7172' => 'Linux /boot partition',
+ '0657FD6D-A4AB-43C4-84E5-0933C84B4F4F' => 'Linux Swap partition',
+ 'E6D6D379-F507-44C2-A23C-238F2A3DF928' => 'Logical Volume Manager (LVM) partition',
+ '933AC7E1-2EB4-4F13-B844-0E14E2AEF915' => 'Linux /home partition',
+ '3B8F8425-20E0-4F3B-907F-1A25A76F98E8' => 'Linux /srv (server data) partition',
+ '7FFEC5C9-2D00-49B7-8941-3EA10A5586B7' => 'Linux Plain dm-crypt partition',
+ 'CA7D7CCB-63ED-4C53-861C-1742536059CC' => 'LUKS partition',
+ '8DA63339-0007-60C0-C436-083AC8230908' => 'Linux Reserved',
+ '83BD6B9D-7F41-11DC-BE0B-001560B84F0F' => 'FreeBSD Boot partition',
+ '516E7CB4-6ECF-11D6-8FF8-00022D09712B' => 'FreeBSD disklabel partition',
+ '516E7CB5-6ECF-11D6-8FF8-00022D09712B' => 'FreeBSD Swap partition',
+ '516E7CB6-6ECF-11D6-8FF8-00022D09712B' => 'FreeBSD Unix File System (UFS) partition',
+ '516E7CB8-6ECF-11D6-8FF8-00022D09712B' => 'FreeBSD Vinum volume manager partition',
+ '516E7CBA-6ECF-11D6-8FF8-00022D09712B' => 'FreeBSD ZFS partition',
+ '74BA7DD9-A689-11E1-BD04-00E081286ACF' => 'FreeBSD nandfs partition',
+ '48465300-0000-11AA-AA11-00306543ECAC' => 'Hierarchical File System Plus (HFS+) partition',
+ '7C3457EF-0000-11AA-AA11-00306543ECAC' => 'APFS FileVault volume container',
+ '55465300-0000-11AA-AA11-00306543ECAC' => 'Apple UFS container',
+ '52414944-0000-11AA-AA11-00306543ECAC' => 'Apple RAID partition',
+ '52414944-5F4F-11AA-AA11-00306543ECAC' => 'Apple RAID partition, offline',
+ '426F6F74-0000-11AA-AA11-00306543ECAC' => 'Apple Boot partition (Recovery HD)',
+ '4C616265-6C00-11AA-AA11-00306543ECAC' => 'Apple Label',
+ '5265636F-7665-11AA-AA11-00306543ECAC' => 'Apple TV Recovery partition',
+ '53746F72-6167-11AA-AA11-00306543ECAC' => 'HFS+ FileVault volume container',
+ '69646961-6700-11AA-AA11-00306543ECAC' => 'Apple APFS Preboot partition',
+ '52637672-7900-11AA-AA11-00306543ECAC' => 'Apple APFS Recovery partition',
+ '6A82CB45-1DD2-11B2-99A6-080020736631' => 'Solaris Boot partition',
+ '6A85CF4D-1DD2-11B2-99A6-080020736631' => 'Solaris Root partition',
+ '6A87C46F-1DD2-11B2-99A6-080020736631' => 'Solaris Swap partition',
+ '6A8B642B-1DD2-11B2-99A6-080020736631' => 'Solaris Backup partition',
+ '6A898CC3-1DD2-11B2-99A6-080020736631' => 'Solaris /usr partition',
+ '6A8EF2E9-1DD2-11B2-99A6-080020736631' => 'Solaris /var partition',
+ '6A90BA39-1DD2-11B2-99A6-080020736631' => 'Solaris /home partition',
+ '6A9283A5-1DD2-11B2-99A6-080020736631' => 'Solaris Alternate sector',
+ '6A945A3B-1DD2-11B2-99A6-080020736631' => 'Solaris Reserved partition',
+ '49F48D32-B10E-11DC-B99B-0019D1879648' => 'NetBSD Swap partition',
+ '49F48D5A-B10E-11DC-B99B-0019D1879648' => 'NetBSD FFS partition',
+ '49F48D82-B10E-11DC-B99B-0019D1879648' => 'NetBSD LFS partition',
+ '49F48DAA-B10E-11DC-B99B-0019D1879648' => 'NetBSD RAID partition',
+ '2DB519C4-B10F-11DC-B99B-0019D1879648' => 'NetBSD Concatenated partition',
+ '2DB519EC-B10F-11DC-B99B-0019D1879648' => 'NetBSD Encrypted partition',
+ 'FE3A2A5D-4F32-41A7-B725-ACCC3285A309' => 'Chrome OS kernel',
+ '3CB8E202-3B7E-47DD-8A3C-7FF2A13CFCEC' => 'Chrome OS rootfs',
+ 'CAB6E88E-ABF3-4102-A07A-D4BB9BE3C1D3' => 'Chrome OS firmware',
+ '2E0A753D-9E48-43B0-8337-B15192CB1B5E' => 'Chrome OS future use',
+ '09845860-705F-4BB5-B16C-8A8A099CAF52' => 'Chrome OS miniOS',
+ '3F0F8318-F146-4E6B-8222-C28C8F02E0D5' => 'Chrome OS hibernate',
+ '5DFBF5F4-2848-4BAC-AA5E-0D9A20B745A6' => '/usr partition (coreos-usr)',
+ '3884DD41-8582-4404-B9A8-E9B84F2DF50E' => 'Resizable rootfs (coreos-resize)',
+ 'C95DC21A-DF0E-4340-8D7B-26CBFA9A03E0' => 'OEM customizations (coreos-reserved)',
+ 'BE9067B9-EA49-4F15-B4F6-F36F8C9E1818' => 'Root filesystem on RAID (coreos-root-raid)',
+ '42465331-3BA3-10F1-802A-4861696B7521' => 'Haiku BFS',
+ '85D5E45E-237C-11E1-B4B3-E89A8F7FC3A7' => 'MidnightBSD Boot partition',
+ '85D5E45A-237C-11E1-B4B3-E89A8F7FC3A7' => 'MidnightBSD Data partition',
+ '85D5E45B-237C-11E1-B4B3-E89A8F7FC3A7' => 'MidnightBSD Swap partition',
+ '0394EF8B-237E-11E1-B4B3-E89A8F7FC3A7' => 'MidnightBSD Unix File System (UFS) partition',
+ '85D5E45C-237C-11E1-B4B3-E89A8F7FC3A7' => 'MidnightBSD Vinum volume manager partition',
+ '85D5E45D-237C-11E1-B4B3-E89A8F7FC3A7' => 'MidnightBSD ZFS partition',
+ '45B0969E-9B03-4F30-B4C6-B4B80CEFF106' => 'Cepth Journal',
+ '45B0969E-9B03-4F30-B4C6-5EC00CEFF106' => 'Cepth dm-crypt journal',
+ '4FBD7E29-9D25-41B8-AFD0-062C0CEFF05D' => 'Cepth OSD',
+ '4FBD7E29-9D25-41B8-AFD0-5EC00CEFF05D' => 'Cepth dm-crypt OSD',
+ '89C57F98-2FE5-4DC0-89C1-F3AD0CEFF2BE' => 'Cepth Disk in creation',
+ '89C57F98-2FE5-4DC0-89C1-5EC00CEFF2BE' => 'Cepth dm-crypt disk in creation',
+ 'CAFECAFE-9B03-4F30-B4C6-B4B80CEFF106' => 'Cepth Block',
+ '30CD0809-C2B2-499C-8879-2D6B78529876' => 'Cepth Block DB',
+ '5CE17FCE-4087-4169-B7FF-056CC58473F9' => 'Cepth Block write-ahead log',
+ 'FB3AABF9-D25F-47CC-BF5E-721D1816496B' => 'Cepth Lockbox for dm-crypt keys',
+ '4FBD7E29-8AE0-4982-BF9D-5A8D867AF560' => 'Cepth Multipath OSD',
+ '45B0969E-8AE0-4982-BF9D-5A8D867AF560' => 'Cepth Multipath journal',
+ 'CAFECAFE-8AE0-4982-BF9D-5A8D867AF560' => 'Cepth Multipath block',
+ '7F4A666A-16F3-47A2-8445-152EF4D03F6C' => 'Cepth Multipath block',
+ 'EC6D6385-E346-45DC-BE91-DA2A7C8B3261' => 'Cepth Multipath block DB',
+ '01B41E1B-002A-453C-9F17-88793989FF8F' => 'Cepth Multipath block write-ahead log',
+ 'CAFECAFE-9B03-4F30-B4C6-5EC00CEFF106' => 'Cepth dm-crypt block',
+ '93B0052D-02D9-4D8A-A43B-33A3EE4DFBC3' => 'Cepth dm-crypt block DB',
+ '306E8683-4FE2-4330-B7C0-00A917C16966' => 'Cepth dm-crypt block write-ahead log',
+ '45B0969E-9B03-4F30-B4C6-35865CEFF106' => 'Cepth dm-crypt LUKS journal',
+ 'CAFECAFE-9B03-4F30-B4C6-35865CEFF106' => 'Cepth dm-crypt LUKS block',
+ '166418DA-C469-4022-ADF4-B30AFD37F176' => 'Cepth dm-crypt LUKS block DB',
+ '86A32090-3647-40B9-BBBD-38D8C573AA86' => 'Cepth dm-crypt LUKS block write-ahead log',
+ '4FBD7E29-9D25-41B8-AFD0-35865CEFF05D' => 'Cepth dm-crypt LUKS OSD',
+ '824CC7A0-36A8-11E3-890A-952519AD3F61' => 'OpenBSD Data partition',
+ 'CEF5A9AD-73BC-4601-89F3-CDEEEEE321A1' => 'Power-safe (QNX6) file system',
+ 'C91818F9-8025-47AF-89D2-F030D7000C2C' => 'Plan 9 partition',
+ '9D275380-40AD-11DB-BF97-000C2911D1B8' => 'vmkcore (coredump partition)',
+ 'AA31E02A-400F-11DB-9590-000C2911D1B8' => 'VMFS filesystem partition',
+ '9198EFFC-31C0-11DB-8F78-000C2911D1B8' => 'VMware Reserved',
+ '2568845D-2332-4675-BC39-8FA5A4748D15' => 'Android-x86 Bootloader',
+ '114EAFFE-1552-4022-B26E-9B053604CF84' => 'Android-x86 Bootloader2',
+ '49A4D17F-93A3-45C1-A0DE-F50B2EBE2599' => 'Android-x86 Boot',
+ '4177C722-9E92-4AAB-8644-43502BFD5506' => 'Android-x86 Recovery',
+ 'EF32A33B-A409-486C-9141-9FFB711F6266' => 'Android-x86 Misc',
+ '20AC26BE-20B7-11E3-84C5-6CFDB94711E9' => 'Android-x86 Metadata',
+ '38F428E6-D326-425D-9140-6E0EA133647C' => 'Android-x86 System',
+ 'A893EF21-E428-470A-9E55-0668FD91A2D9' => 'Android-x86 Cache',
+ 'DC76DDA9-5AC1-491C-AF42-A82591580C0D' => 'Android-x86 Data',
+ 'EBC597D0-2053-4B15-8B64-E0AAC75F4DB1' => 'Android-x86 Persistent',
+ 'C5A0AEEC-13EA-11E5-A1B1-001E67CA0C3C' => 'Android-x86 Vendor',
+ 'BD59408B-4514-490D-BF12-9878D963F378' => 'Android-x86 Config',
+ '8F68CC74-C5E5-48DA-BE91-A0C8C15E9C80' => 'Android-x86 Factory',
+ '9FDAA6EF-4B3F-40D2-BA8D-BFF16BFB887B' => 'Android-x86 Factory (alt)',
+ '767941D0-2085-11E3-AD3B-6CFDB94711E9' => 'Android-x86 Fastboot / Tertiary',
+ 'AC6D7924-EB71-4DF8-B48D-E267B27148FF' => 'Android-x86 OEM',
+ '19A710A2-B3CA-11E4-B026-10604B889DCF' => 'Android Meta',
+ '193D1EA4-B3CA-11E4-B075-10604B889DCF' => 'Android EXT',
+ '7412F7D5-A156-4B13-81DC-867174929325' => 'ONIE Boot',
+ 'D4E6E2CD-4469-46F3-B5CB-1BFF57AFC149' => 'ONIE Config',
+ '9E1A2D38-C612-4316-AA26-8B49521E5A8B' => 'PReP boot',
+ '734E5AFE-F61A-11E6-BC64-92361F002671' => 'Atari TOS Basic data partition (GEM, BGM, F32)',
+ '8C8F8EFF-AC95-4770-814A-21994F2DBC8F' => 'VeraCrypt Encrypted data',
+ '90B6FF38-B98F-4358-A21F-48F35B4A8AD3' => 'ArcaOS Type 1',
+ '7C5222BD-8F5D-4087-9C00-BF9843C7B58C' => 'SPDK block device',
+ '4778ED65-BF42-45FA-9C5B-287A1DC4AAB1' => 'barebox-state',
+ '3DE21764-95BD-54BD-A5C3-4ABE786F38A8' => 'U-Boot environment',
+ 'B6FA30DA-92D2-4A9A-96F1-871EC6486200' => 'SoftRAID_Status',
+ '2E313465-19B9-463F-8126-8A7993773801' => 'SoftRAID_Scratch',
+ 'FA709C7E-65B1-4593-BFD5-E71D61DE9B02' => 'SoftRAID_Volume',
+ 'BBBA6DF5-F46F-4A89-8F59-8765B2727503' => 'SoftRAID_Cache',
+ 'FE8A2634-5E2E-46BA-99E3-3A192091A350' => 'Fuchsia Bootloader (slot A/B/R)',
+ 'D9FD4535-106C-4CEC-8D37-DFC020CA87CB' => 'Fuchsia Durable mutable encrypted system data',
+ 'A409E16B-78AA-4ACC-995C-302352621A41' => 'Fuchsia Durable mutable bootloader data (including A/B/R metadata)',
+ 'F95D940E-CABA-4578-9B93-BB6C90F29D3E' => 'Fuchsia Factory-provisioned read-only system data',
+ '10B8DBAA-D2BF-42A9-98C6-A7C5DB3701E7' => 'Fuchsia Factory-provisioned read-only bootloader data',
+ '49FD7CB8-DF15-4E73-B9D9-992070127F0F' => 'Fuchsia Volume Manager',
+ '421A8BFC-85D9-4D85-ACDA-B64EEC0133E9' => 'Fuchsia Verified boot metadata (slot A/B/R)',
+ '9B37FFF6-2E58-466A-983A-F7926D0B04E0' => 'Fuchsia Zircon boot image (slot A/B/R)',
+ ];
+
+}
diff --git a/modules-available/statistics/inc/hardwareparser.inc.php b/modules-available/statistics/inc/hardwareparser.inc.php
new file mode 100644
index 00000000..428f7d55
--- /dev/null
+++ b/modules-available/statistics/inc/hardwareparser.inc.php
@@ -0,0 +1,789 @@
+<?php
+
+class HardwareParser
+{
+
+ const SIZE_LOOKUP = ['T' => 1099511627776, 'G' => 1073741824, 'M' => 1048576, 'K' => 1024, '' => 1];
+ const SI_LOOKUP = ['T' => 1000000000000, 'G' => 1000000000, 'M' => 1000000, 'K' => 1000, '' => 1];
+
+ /**
+ * Convert/format size unit. Input string can be a size like
+ * 8 GB or 1024 MB and will be converted according to passed parameters.
+ * @param string $string Input string
+ * @param string $scale 'a' for auto, T/G/M/K/'' for according units
+ * @param bool $appendUnit append unit string, e.g. 'GiB'
+ * @return false|string|int Formatted result
+ */
+ public static function convertSize(string $string, string $scale = 'a', bool $appendUnit = true)
+ {
+ if (!preg_match('/(\d+)\s*([TGMK]?)/i', $string, $out)) {
+ //error_log("Not size: $string");
+ return false;
+ }
+ $val = (int)$out[1] * self::SIZE_LOOKUP[strtoupper($out[2])];
+ if (!array_key_exists($scale, self::SIZE_LOOKUP)) {
+ foreach (self::SIZE_LOOKUP as $k => $v) {
+ if ($k === '' || $val / 8 >= $v || abs($val - $v) < 50) {
+ $scale = $k;
+ break;
+ }
+ }
+ }
+ $val = (int)round($val / self::SIZE_LOOKUP[$scale]);
+ if ($appendUnit) {
+ $val .= ' ' . ($scale === '' ? 'Byte' : $scale . 'iB'); // NBSP!!
+ }
+ return $val;
+ }
+
+ /**
+ * Decode JEDEC ID to according manufacturer
+ */
+ public static function decodeJedec(string $string): string
+ {
+ // JEDEC ID:7F 7F 9E 00 00 00 00 00
+ // or the ID as 8 hex digits with no spacing and prefix
+ $id = null;
+ if (preg_match('/JEDEC(?:\s*ID)?\s*:?\s*([0-9a-f\s]{8,23})\s*$/i', $string, $out)
+ || preg_match('/^([0-9a-f]{14}00)$/i', $string, $out)) {
+ preg_match_all('/[0-9a-f]{2}/i', $out[1], $out);
+ $bank = 0;
+ foreach ($out[0] as $id) {
+ $bank++;
+ $id = hexdec($id) & 0x7f; // Let's just ignore the parity bit, and any potential error
+ if ($id !== 0x7f)
+ break;
+ }
+ if ($id !== null) {
+ $id = self::lookupJedec($bank, $id);
+ }
+ } elseif (preg_match('/Unknown.{0,16}[\[(](?:0x)?([0-9a-fA-F]{2,4})[\])]/', $string, $out)) {
+ // First byte (big endian) is id-in-bank, low byte is bank
+ $id = self::decodeBankAndId($out, false);
+ } elseif (preg_match('/JEDEC(?:\s*ID)?\s*:?\s*([0-9a-f]{2}\s?[0-9a-f]{2})/i', $string, $out)
+ || (preg_match('/^([0-9A-F]{4})([0-9A-F]{4})([0-9A-F]{4})$/', $string, $out) && $out[2] === '0000')) {
+ // First byte is bank, second byte is id-in-bank
+ $id = self::decodeBankAndId($out, true);
+ } elseif (preg_match('/^([0-9a-f]{4})$/i', $string, $out)) {
+ // This one was seen with both endianesses
+ $id = self::decodeBankAndId($out, true);
+ if ($id === null) {
+ $id = self::decodeBankAndId($out, false);
+ }
+ }
+
+ if ($id !== null)
+ return $id;
+ return $string;
+ }
+
+ private static function decodeBankAndId(array $out, bool $bankFirst): ?string
+ {
+ // 16bit encoding from DDR3+: lower byte is number of 0x7f bytes, upper byte is id within bank
+ $id = hexdec(str_replace(' ', '', $out[1]));
+ // Our bank counting starts at one. Also ignore parity bit.
+ $bank = ($id & 0x7f);
+ // Shift down id, get rid of parity bit
+ $id = ($id >> 8) & 0x7f;
+ if ($bankFirst) {
+ // Observed second case, on OptiPlex 5050, is 80AD000080AD, but here endianness is reversed
+ $tmp = $id;
+ $id = $bank;
+ $bank = $tmp;
+ }
+ $bank++;
+ return self::lookupJedec($bank, $id);
+ }
+
+ private static function lookupJedec(int $bank, int $id): ?string
+ {
+ static $data = false;
+ if ($data === false) {
+ $data = json_decode(file_get_contents(dirname(__FILE__) . '/jedec.json'), true);
+ }
+ if (array_key_exists('bank' . $bank, $data) && array_key_exists('id' . $id, $data['bank' . $bank]))
+ return $data['bank' . $bank]['id' . $id];
+ return null;
+ }
+
+ /**
+ * Turn several numeric measurements like Size, Speed, Voltage into a unitless
+ * base representation, meant for comparison. For example, Voltages are converted
+ * to Millivolts, Anything measured in [KMGT]Bytes (per second) to bytes, GHz to
+ * Hz, and so on.
+ * @return ?int value, or null if not numeric
+ */
+ private static function toNumeric(string $key, string $val): ?int
+ {
+ $key = strtolower($key);
+ // Normalize voltage to mV
+ if ((strpos($key, 'volt') !== false || strpos($key, 'current') !== false)
+ && preg_match('/^([0-9]+(?:\.[0-9]+)?)\s+(m?)V/', $val, $out)) {
+ return (int)($out[1] * ($out[2] === 'm' ? 1 : 1000));
+ }
+ if (preg_match('/speed|width|size|capacity/', $key)
+ && preg_match('#^([0-9]+(?:\.[0-9]+)?)\s*([TGMK]?)i?([BT](?:it|yte|))s?(?:/s)?#i',
+ $val, $out)) {
+ // Matched (T/G/M) Bits, Bytes, etc...
+ // For bits, use SI
+ if ($out[3] !== 'B' && strtolower($out[3]) !== 'byte')
+ return (int)($out[1] * self::SI_LOOKUP[strtoupper($out[2])]);
+ // For bytes, use 1024
+ return (int)($out[1] * self::SIZE_LOOKUP[strtoupper($out[2])]);
+ }
+ // Speed in Hz
+ if (preg_match('#^([0-9]+(?:\.[0-9]+)?)\s*([TGMK]?)Hz#i',
+ $val, $out)) {
+ return (int)($out[1] * self::SI_LOOKUP[strtoupper($out[2])]);
+ }
+ // Count, size (unitless)
+ if (is_numeric($val) && preg_match('/^-?[0-9]+$/', $val)
+ && preg_match('/used|occupied|count|number|speed|width|size|capacity|temperature|_start|_value|_thresh|_worst|_time|_rate/', $key)) {
+ return (int)$val;
+ }
+ // Date
+ if (preg_match('#^(?:[0-9]{2}/[0-9]{2}/[0-9]{4}|[0-9]{4}-[0-9]{2}-[0-9]{2})$#', $val)) {
+ return (int)strtotime($val);
+ }
+ return null;
+ }
+
+ /**
+ * Takes hwinfo json, then looks up and returns all sections from the
+ * dmidecode subtree that represent the given dmi table entry type,
+ * e.g. 17 for memory. It will then return an array of 'props' subtrees.
+ *
+ * @param array $data hwinfo tree
+ * @param int $type dmi type
+ * @return array [ <props>, <props>, ... ]
+ */
+ public static function getDmiHandles(array $data, int $type): array
+ {
+ if (empty($data['dmidecode']))
+ return [];
+ $ret = [];
+ foreach ($data['dmidecode'] as $section) {
+ if ($section['handle']['type'] == $type) {
+ $ret[] = $section['props'];
+ }
+ }
+ return $ret;
+ }
+
+ /**
+ * Takes key-value-array, returns a concatenated string of all the values with the keys given in $fields.
+ * The items are separated by spaces, and returned in the order they were given in $fields. Missing keys
+ * are silently omitted.
+ */
+ private static function idFromArray(array $array, string ...$fields): string
+ {
+ $out = '';
+ foreach ($fields as $field) {
+ if (!isset($array[$field]))
+ continue;
+ if (empty($out)) {
+ $out = $array[$field];
+ } else {
+ $out .= ' ' . $array[$field];
+ }
+ }
+ return $out;
+ }
+
+ /**
+ * Establish a mapping between a client and some hardware device.
+ * Optionally writes hardware properties specific to a hardware instance of a client
+ *
+ * @param string $uuid client
+ * @param int $hwid hw global hw id
+ * @param string $pathId unique identifier for the local instance of this hw, e.q. PCI slot, /dev path, something that handles the case that there are multiple instances of the same hardware in one machine
+ * @param array $props KV-pairs of properties to write for this instance; can be empty
+ * @return int ID of mapping in DB
+ */
+ private static function writeLocalHardwareData(string $uuid, int $hwid, string $pathId, array $props): int
+ {
+ // Add mapping between hw entity and machine
+ $pathId = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $pathId);
+ $mappingId = Database::insertIgnore('machine_x_hw', 'machinehwid',
+ ['hwid' => $hwid, 'machineuuid' => $uuid, 'devpath' => $pathId],
+ ['disconnecttime' => 0]);
+ // And all the properties specific to this entity instance (e.g. serial number)
+ if (!empty($props)) {
+ $vals = [];
+ foreach ($props as $k => $v) {
+ $vals[] = [$mappingId, $k, $v, self::toNumeric($k, $v)];
+ }
+ Database::exec("INSERT INTO machine_x_hw_prop (machinehwid, prop, `value`, `numeric`)
+ VALUES :vals
+ ON DUPLICATE KEY UPDATE `value` = VALUES(`value`), `numeric` = VALUES(`numeric`)", ['vals' => $vals]);
+ }
+ return $mappingId;
+ }
+
+ /**
+ * Takes an array of type [ key1 => [ 'values' => [ <val1.1>, <val1.2>, ... ] ], key2 => ... ]
+ * and turns it into [ key1 => <val1.1>, key2 => <val2.1>, ... ]
+ *
+ * Along the way:
+ * 1) any fields with bogus values, or values analogous to empty will get removed
+ */
+ public static function prepareDmiProperties(array $data): array
+ {
+ $ret = [];
+ foreach ($data as $key => $vals) {
+ $val = trim($vals['values'][0] ?? 'NULL');
+ if ($val === '[Empty]' || $val === 'NULL')
+ continue;
+ $val = preg_replace('/[^a-z0-9]/', '', strtolower($val));
+ if ($val === '' || $val === 'notspecified' || $val === 'tobefilledbyoem' || $val === 'unknown'
+ || $val === 'chassismanufacture' || $val === 'chassismanufacturer' || $val === 'chassisversion'
+ || $val === 'chassisserialnumber' || $val === 'defaultstring' || $val === 'productname'
+ || $val === 'manufacturer' || $val === 'systemmodel' || $val === 'fillbyoem' || $val === 'none') {
+ continue;
+ }
+ $val = trim($vals['values'][0] ?? '');
+ if ($key === 'Manufacturer') {
+ $val = self::fixManufacturer($val);
+ }
+ $ret[$key] = $val;
+ }
+ return $ret;
+ }
+
+ /**
+ * Mark all devices of a given type disconnected from the given machine, with an optional
+ * exclude list of machine-client-mapping IDs
+ *
+ * @param string $uuid client
+ * @param string $dbType type, eg HDD
+ * @param array $excludedHwIds mappingIDs to exclude, ie. devices that are still connected
+ */
+ private static function markDisconnected(string $uuid, string $dbType, array $excludedHwIds)
+ {
+ //error_log("Marking disconnected for $dbType except " . implode(', ', $excludedHwIds));
+ if (empty($excludedHwIds)) {
+ Database::exec("UPDATE machine_x_hw mxh, statistic_hw h
+ SET mxh.disconnecttime = UNIX_TIMESTAMP()
+ WHERE h.hwtype = :type AND h.hwid = mxh.hwid AND mxh.machineuuid = :uuid
+ AND mxh.disconnecttime = 0",
+ ['type' => $dbType, 'uuid' => $uuid]);
+ } else {
+ Database::exec("UPDATE machine_x_hw mxh, statistic_hw h
+ SET mxh.disconnecttime = UNIX_TIMESTAMP()
+ WHERE h.hwtype = :type AND h.hwid = mxh.hwid AND mxh.machineuuid = :uuid
+ AND mxh.disconnecttime = 0 AND mxh.machinehwid NOT IN (:hwids)",
+ ['type' => $dbType, 'uuid' => $uuid, 'hwids' => $excludedHwIds]);
+ }
+ }
+
+ /**
+ * Insert some hardware into database. $global is supposed to contain key-value-pairs of properties
+ * this hardware has that is the same for every instance of this hardware, like model number, speed
+ * or size. Individual properties, like a serial number, are considered local properties, and go
+ * into a different table, that would contain a row for each client that has this hardware.
+ * @param string $dbType Hardware typ (HDD, RAM, ...)
+ * @param array $global associative array of properties this hardware has
+ * @return int id of this hardware; primary key of row in statistic_hw_prop
+ */
+ private static function writeGlobalHardwareData(string $dbType, array $global, array $globalExtra = []): int
+ {
+ static $cache = [];
+ // Since the global properties are supposed to be unique for a specific piece of hardware, use them all
+ // to generate a combined ID for this hardware entity, as opposed to $localProps, which should differ
+ // between instances of the same hardware entity, e.g. one specific HDD model has different serial numbers.
+ $id = md5(implode(' ', $global));
+ // But don't include our "fake" fields in this as we might add more there later, which would
+ // change the ID then.
+ $global += $globalExtra;
+ if (!isset($cache[$id])) {
+ // Cache lookup, make sure we insert this only once for every run, as this is supposed to be general
+ // information about the hardware, e.g. model number, max. resolution, capacity, ...
+ $hwid = Database::insertIgnore('statistic_hw', 'hwid', ['hwtype' => $dbType, 'hwname' => $id]);
+ $vals = [];
+ foreach ($global as $k => $v) {
+ $vals[] = [$hwid, $k, $v, self::toNumeric($k, $v)];
+ }
+ if (!empty($vals)) {
+ Database::exec("INSERT INTO statistic_hw_prop (hwid, prop, `value`, `numeric`)
+ VALUES :vals
+ ON DUPLICATE KEY UPDATE `value` = VALUES(`value`), `numeric` = VALUES(`numeric`)",
+ ['vals' => $vals]);
+ }
+ $cache[$id] = $hwid;
+ }
+ return $cache[$id];
+ }
+
+ /**
+ * Process hardware info for given client.
+ * @param string $uuid System-UUID of client
+ * @param array $data Hardware info, deserialized assoc array.
+ * @return ?array id44mb and id45mb as calculated from given HDD data
+ */
+ public static function parseMachine(string $uuid, array $data): ?array
+ {
+ $version = $data['version'] ?? 0;
+ if ($version != 2) {
+ error_log("Received unsupported hw json v$version");
+ return null;
+ }
+ // determine misc stuff first
+ $globalCpuExtra = [];
+ $globalMainboardExtra = [];
+ $localMainboardExtra = [];
+ // physical memory array
+ $memArrays = self::getDmiHandles($data, 16);
+ // We mostly have a seprate hardware type for all the dmi types, but not for memory arrays.
+ // These get added to the mainboard hw-type as it's practically a property of the mainboard.
+ // While we can have multiple physical memory arrays, we only ever have one mainboard per
+ // client. Add up the data from all arrays.
+ $globalMainboardExtra['Memory Slot Count'] = 0;
+ $globalMainboardExtra['Memory Maximum Capacity'] = 0;
+ foreach ($memArrays as $mem) {
+ $mem = self::prepareDmiProperties($mem);
+ // Not all memory arrays are for RAM....
+ if (($mem['Use'] ?? 0) !== 'System Memory')
+ continue;
+ if (isset($mem['Number Of Devices'])) {
+ $globalMainboardExtra['Memory Slot Count'] += $mem['Number Of Devices'];
+ }
+ if (isset($mem['Maximum Capacity'])) {
+ // Temporary unit is MB
+ $globalMainboardExtra['Memory Maximum Capacity']
+ += self::convertSize($mem['Maximum Capacity'], 'M', false);
+ }
+ }
+ // Now finally convert to GB
+ $globalMainboardExtra['Memory Maximum Capacity']
+ = self::convertSize($globalMainboardExtra['Memory Maximum Capacity'] . ' MB', 'G');
+ // BIOS section - need to combine this with mainboard or system model, as it doesn't have a meaningful
+ // identifier on its own. So again like above, we add this to the mainboard data.
+ $bios = self::prepareDmiProperties(self::getDmiHandles($data, 0)[0] ?? []);
+ foreach (['Version', 'Release Date', 'Firmware Revision'] as $k) {
+ if (isset($bios[$k])) {
+ // Prefix with "BIOS" to clarify, since it's added to the mainboard meta-data
+ $localMainboardExtra['BIOS ' . $k] = $bios[$k];
+ }
+ }
+ if (isset($bios['BIOS Revision'])) { // This one already has the BIOS prefix
+ $localMainboardExtra['BIOS Revision'] = $bios['BIOS Revision'];
+ }
+ // Vendor and ROM size of BIOS *should* always be the same for a specific mainboard
+ foreach (['Vendor', 'ROM Size'] as $k) {
+ if (isset($bios[$k])) {
+ $globalMainboardExtra['BIOS ' . $k] = $bios[$k];
+ }
+ }
+ // "Normal" dmi entries - these map directly to one of our hardware types
+ // RAM modules
+ $capa = 0;
+ $ramModCount = self::updateHwTypeFromDmi($uuid, $data, 17, HardwareInfo::RAM_MODULE,
+ // Filter callback - we can modify the entry, or return false to ignore it
+ function (array $flat) use (&$capa): bool {
+ $size = self::convertSize(($flat['Size'] ?? 0), '', false);
+ // Let's assume we're never running on old HW with <=128MB modules, so this
+ // might be a hint that this is some other kind of memory. The proper way would be
+ // to check if the related physical memory array (16) has "Use" = "System Memory"
+ if ($size > 129 * 1024 * 1024) {
+ $capa += $size;
+ return true;
+ }
+ return false;
+ },
+ ['Locator'],
+ ['Data Width',
+ 'Size',
+ 'Form Factor',
+ 'Type',
+ 'Type Detail',
+ 'Speed',
+ 'Manufacturer',
+ 'Part Number',
+ 'Minimum Voltage',
+ 'Maximum Voltage'],
+ ['Locator', 'Bank Locator', 'Serial Number', 'Asset Tag', 'Configured Memory Speed', 'Configured Voltage']
+ );
+ // Put RAM slots used/total etc. into mainboard data
+ $localMainboardExtra['Memory Slot Occupied'] = $ramModCount;
+ $localMainboardExtra['Memory Installed Capacity'] = self::convertSize($capa, 'G', true);
+ // Also add generic socket, core and thread count to mainboard data. This doesn't seem to make too much sense
+ // at first since it's not a property of the mainboard. But we can get away with it since we make it a local
+ // property, i.e. specific to a client. This is just aggregated, so it's not super well suited for the CPU
+ // hardware type, referenced below. In fact, on some systems the dmi/smbios tables don't contain all that much
+ // information about the CPU at all, so we have at least this.
+ foreach (['sockets', 'cores', 'threads'] as $key) {
+ if (!isset($data['cpu'][$key]))
+ continue;
+ $localMainboardExtra['cpu-' . $key] = $data['cpu'][$key];
+ }
+ if ($data['cpu']['vmx-legacy'] ?? false) {
+ $globalCpuExtra['vmx-legacy'] = 1;
+ }
+ // Do the same hack with the primary NIC's speed and duplex. Even if it's not an onboard NIC, we only have one
+ // primary boot interface
+ $bootNic = $data['net']['boot0'] ?? $data['net']['eth0'] ?? null;
+ if ($bootNic !== null) {
+ $localMainboardExtra['nic-speed'] = $bootNic['speed'] ?? 0;
+ $localMainboardExtra['nic-duplex'] = $bootNic['duplex'] ?? 'unknown';
+ }
+ // Finally handle mainbard data, with all of our gathered extra fields
+ self::updateHwTypeFromDmi($uuid, $data, 2, HardwareInfo::MAINBOARD, [],
+ [],
+ ['Manufacturer', 'Product Name', 'Type', 'Version'], // Global props, don't change
+ ['Serial Number', 'Asset Tag', 'Location In Chassis'],
+ $globalMainboardExtra, $localMainboardExtra
+ );
+ // System information, mostly set by OEMs, might be empty/bogus on custom systems
+ self::updateHwTypeFromDmi($uuid, $data, 1, HardwareInfo::DMI_SYSTEM, ['Manufacturer', 'Product Name'],
+ [],
+ ['Manufacturer', 'Product Name', 'Version', 'Wake-up Type'], // Global props, don't change
+ ['Serial Number', 'UUID', 'SKU Number']
+ );
+ // Might contain more or less accurate information. Mostly works on servers and OEM systems
+ self::updateHwTypeFromDmi($uuid, $data, 39, HardwareInfo::POWER_SUPPLY, ['Manufacturer'],
+ ['Location',
+ 'Power Unit Group',
+ 'Name'], // Location might be empty/"Unknown", but Name can be something like "PSU 2"
+ ['Manufacturer', 'Product Name', 'Model Part Number', 'Revision', 'Max Power Capacity'], // Global props, don't change
+ ['Serial Number', 'Asset Tag', 'Status', 'Plugged', 'Hot Replaceable']
+ );
+ // On some more recent systems this contains quite some useful information
+ self::updateHwTypeFromDmi($uuid, $data, 4, HardwareInfo::CPU, ['Version'],
+ ['Socket Designation'],
+ ['Type', 'Family', 'Manufacturer', 'Signature', 'Version', 'Core Count', 'Thread Count'], // Global props, don't change
+ ['Voltage', 'Current Speed', 'Upgrade', 'Core Enabled'],
+ $globalCpuExtra);
+ // Information about system slots
+ self::updateHwTypeFromDmi($uuid, $data, 9, HardwareInfo::SYSTEM_SLOT,
+ function (array &$entry): bool {
+ // Use a callback filter to extract PCIe slot metadata into unique fields
+ if (!isset($entry['Type']))
+ return false;
+ // Split up PCIe info – gen, electrical width and physical width are mashed into one field
+ if (preg_match('/^x(?<b>\d+) PCI Express( (?<g>\d+)( x(?<s>\d+))?)?$/', $entry['Type'], $out)) {
+ $entry['Type'] = 'PCI Express';
+ $entry['PCIe Bus Width'] = $out['b'];
+ if (!empty($out['g'])) {
+ $entry['PCIe Gen'] = $out['g'];
+ }
+ if (!empty($out['s'])) {
+ $entry['PCIe Slot Width'] = $out['s'];
+ }
+ }
+ return true;
+ },
+ ['Designation', 'ID', 'Bus Address'],
+ ['Type', 'PCIe Bus Width', 'PCIe Gen', 'PCIe Slot Width'], // Global props, don't change
+ ['Current Usage', 'Designation']
+ );
+ // dmidecode end
+ // ---- lspci ------------------------------------
+ $pciHwIds = [];
+ foreach (($data['lspci'] ?? []) as $dev) {
+ // $props is global props, don't change or the ID will change
+ $props = self::propsFromArray($dev, 'vendor', 'device', 'rev', 'class');
+ if (!isset($props['vendor']) || !isset($props['device']))
+ continue;
+ $hwid = self::writeGlobalHardwareData(HardwareInfo::PCI_DEVICE, $props);
+ $mappingId = self::writeLocalHardwareData($uuid, $hwid, $dev['slot'] ?? 'unknown',
+ self::propsFromArray($dev, 'slot', 'subsystem', 'subsystem_vendor', 'iommu_group'));
+ $pciHwIds[] = $mappingId;
+ }
+ self::markDisconnected($uuid, HardwareInfo::PCI_DEVICE, $pciHwIds);
+ // ---- Disks ------------------------------------
+ $excludedHddHwIds = [];
+ // Sum of all ID44/45 partitions in bytes
+ $id44 = $id45 = 0;
+ foreach (($data['drives'] ?? []) as $dev) {
+ if (($dev['type'] ?? 'drive') !== 'drive')
+ continue; // TODO: Handle CD/DVD drives? Still relevant?
+ if (empty($dev['readlink'])) // This is the canonical entry name directly under /dev/, e.g. /dev/sda
+ continue;
+ // Use smartctl as the source of truth, lsblk as fallback if data is missing
+ if (!isset($dev['smartctl']) || !is_array($dev['smartctl'])) {
+ $smart = [];
+ } else {
+ $smart =& $dev['smartctl'];
+ }
+ if (!isset($dev['lsblk']['blockdevices'][0]) || !is_array($dev['lsblk']['blockdevices'][0])) {
+ $lsblk = [];
+ } else {
+ $lsblk =& $dev['lsblk']['blockdevices'][0];
+ }
+ if (!isset($smart['rotation_rate']) && isset($lsblk['rota']) && !$lsblk['rota']) {
+ // smartctl didn't report on it, lsblk says it's non-rotational
+ $smart['rotation_rate'] = 0;
+ }
+ $size = $lsblk['size'] ?? $smart['user_capacity']['bytes'] ?? -1;
+ // Don't change the global props, it would change the HW ID
+ $hwid = self::writeGlobalHardwareData(HardwareInfo::HDD, [
+ // Try to use the model name as the unique identifier
+ 'model' => $smart['model_name'] ?? $lsblk['model'] ?? 'unknown',
+ // Append device size as some kind of fallback, in case model is unknown
+ 'size' => $size,
+ 'physical_block_size' => $smart['physical_block_size'] ?? $lsblk['phy-sec'] ?? 0,
+ 'logical_block_size' => $smart['logical_block_size'] ?? $lsblk['log-sec'] ?? 0,
+ ] + self::propsFromArray($smart, 'rotation_rate', 'sata_version//string',
+ 'interface_speed//max//string', 'model_family'));
+ // Mangle smart attribute table
+ // TODO: Handle used endurance indicator for (SATA) SSDs
+ $table = [];
+ foreach (($smart['ata_smart_attributes']['table'] ?? []) as $attr) {
+ if (!isset($attr['id']))
+ continue;
+ $id = 'attr_' . $attr['id'] . '_';
+ foreach (['value', 'worst', 'thresh', 'when_failed'] as $item) {
+ if (isset($attr[$item])) {
+ $table[$id . $item] = $attr[$item];
+ }
+ }
+ if (isset($attr['raw']['value'])) {
+ if ($attr['id'] === 194) {
+ if (!isset($smart['temperature'])) {
+ $smart['temperature'] = [];
+ }
+ if (!isset($smart['temperature']['current'])) {
+ $smart['temperature']['current'] = $attr['raw']['value'] & 0xffff;
+ }
+ $smart['temperature']['min'] = ($attr['raw']['value'] >> 16) & 0xffff;
+ $smart['temperature']['max'] = ($attr['raw']['value'] >> 32) & 0xffff;
+ }
+ $table[$id . 'raw'] = $attr['raw']['value'];
+ }
+ }
+ if (isset($smart['nvme_smart_health_information_log'])
+ && is_array($smart['nvme_smart_health_information_log'])) {
+ $table += array_filter($smart['nvme_smart_health_information_log'], function ($v, $k) {
+ return !is_array($v) && $k !== 'temperature' && $k !== 'temperature_sensors';
+ }, ARRAY_FILTER_USE_BOTH);
+ }
+ // Partitions
+ $used = 0;
+ if (isset($dev['sfdisk']['partitiontable'])) {
+ $table['partition_table'] = $dev['sfdisk']['partitiontable']['label'] ?? 'none';
+ switch ($dev['sfdisk']['partitiontable']['unit'] ?? 'sectors') {
+ case 'sectors':
+ $fac = 512;
+ break;
+ case 'bytes':
+ $fac = 1;
+ break;
+ default:
+ $fac = 0;
+ }
+ $i = 0;
+ foreach (($dev['sfdisk']['partitiontable']['partitions'] ?? []) as $part) {
+ if (!isset($part['size']))
+ continue;
+ if ($table['partition_table'] === 'dos') {
+ $type = hexdec($part['type'] ?? '0');
+ if ($type === 0x0 || $type === 0x5 || $type === 0xf || $type === 0x15 || $type === 0x1f
+ || $type === 0x85 || $type === 0xc5 || $type == 0xcf) {
+ // Extended partition, ignore
+ continue;
+ }
+ }
+ $used += $part['size'] * $fac;
+ if (isset($part['node']) && preg_match('/-part(\d+)$/', $part['node'], $out)) {
+ $id = 'part_' . $out[1] . '_';
+ } else {
+ $id = 'part_' . ($i + 1) . '_';
+ }
+ foreach (['start', 'size', 'type', 'uuid', 'name'] as $item) {
+ if (!isset($part[$item]))
+ continue;
+ if ($item === 'size' || $item === 'start') {
+ // Turn size and start into byte offsets
+ $table[$id . $item] = $part[$item] * $fac;
+ } else {
+ $table[$id . $item] = $part[$item];
+ }
+ }
+ $type = $table[$id . 'type'] ?? 0;
+ $name = $table[$id . 'name'] ?? '';
+ if ($type == '44' || strtolower($type) === '87f86132-ff94-4987-b250-444444444444'
+ || $name === 'OpenSLX-ID44') {
+ $table[$id . 'slxtype'] = '44';
+ $id44 += $part['size'] * $fac;
+ } elseif ($type == '45' || strtolower($type) === '87f86132-ff94-4987-b250-454545454545'
+ || $name === 'OpenSLX-ID45') {
+ $table[$id . 'slxtype'] = '45';
+ $id45 += $part['size'] * $fac;
+ }
+ //
+ ++$i;
+ }
+ }
+ $table['unused'] = $size - $used;
+ $table['dev'] = $dev['readlink'];
+ $table += self::propsFromArray($smart + $lsblk,
+ 'serial_number', 'firmware_version',
+ 'interface_speed//current//string',
+ 'smart_status//passed', 'temperature//current', 'temperature//min', 'temperature//max',
+ 'power_on_time//hours');
+ $mappingId = self::writeLocalHardwareData($uuid, $hwid, $dev['readlink'],
+ $table);
+ // Delete old partition and smart attribute entries
+ Database::exec("DELETE FROM machine_x_hw_prop WHERE machinehwid = :id AND prop NOT IN (:keep)
+ AND prop NOT LIKE '@%'", [
+ 'id' => $mappingId,
+ 'keep' => array_keys($table),
+ ]);
+ $excludedHddHwIds[] = $mappingId;
+ unset($smart, $lsblk);
+ } // End loop over disks
+ self::markDisconnected($uuid, HardwareInfo::HDD, $excludedHddHwIds);
+ //
+ // Mark parse date
+ $params = [
+ 'uuid' => $uuid,
+ 'id44mb' => round($id44 / (1024 * 1024)),
+ 'id45mb' => round($id45 / (1024 * 1024)),
+ ];
+ Database::exec("UPDATE machine SET dataparsetime = UNIX_TIMESTAMP(), id44mb = :id44mb, id45mb = :id45mb
+ WHERE machineuuid = :uuid", $params);
+ return $params;
+ }
+
+ /**
+ * Unify different variants of manufacturer names
+ */
+ private static function fixManufacturer(string $in): string
+ {
+ $in = self::decodeJedec($in);
+ switch (strtolower($in)) {
+ case 'advanced micro devices, inc.':
+ case 'advanced micro devices':
+ case 'authenticamd':
+ return 'AMD';
+ case 'apple inc.':
+ return 'Apple';
+ case 'asustek computer inc.':
+ return 'ASUS';
+ case 'crucial';
+ case 'crucial technology':
+ return 'Crucial';
+ case 'dell inc.':
+ return 'Dell';
+ case 'fujitsu':
+ case 'fujitsu client computing limited':
+ return 'Fujitsu';
+ case 'hewlett packard':
+ case 'hewlett-packard':
+ return 'HP';
+ case 'genuineintel':
+ case 'intel corporation':
+ case 'intel(r) corp.':
+ case 'intel(r) corporation':
+ return 'Intel';
+ case 'micron technology':
+ return 'Micron';
+ case 'ramaxel technology':
+ return 'Ramaxel';
+ case 'samsung sdi':
+ return 'Samsung';
+ case 'hynix semiconduc':
+ case 'hynix/hyundai':
+ case 'hyundai electronics hynix semiconductor inc':
+ case 'hynix semiconductor inc sk hynix':
+ return 'SK Hynix';
+ }
+ return $in;
+ }
+
+ /**
+ * Takes key-value-array, returns a new array with only the keys listed in $fields.
+ * Checks if the given key is not an array. If it's an array, it will be ignored.
+ * Supports nested arrays. Nested keys are separated by '//', so to query
+ * $array['x']['y'], add 'x//y' to $fields. The value will be added to the return
+ * value as key 'x//y'.
+ */
+ private static function propsFromArray(array $array, string ...$fields): array
+ {
+ $ret = [];
+ foreach ($fields as $field) {
+ if (strpos($field, '//') === false) {
+ if (isset($array[$field]) && !is_array($array[$field])) {
+ $ret[$field] = $array[$field];
+ }
+ } else {
+ $parts = explode('//', $field);
+ $elem = $array;
+ foreach ($parts as $part) {
+ if (isset($elem[$part])) {
+ $elem = $elem[$part];
+ } else {
+ $elem = false;
+ break;
+ }
+ }
+ if ($elem !== false && !is_array($elem)) {
+ $ret[preg_replace('~//(value|string)$~', '', $field)] = $elem;
+ }
+ }
+ }
+ return $ret;
+ }
+
+ /**
+ * Extract data from dmi/smbios and write to DB.
+ * This is a pretty involved function that does several things, among them is splitting
+ * up the data by global and local properties, create new hardware entry if new, and
+ * making sure other hardware of same type gets marked as disconnected from given client.
+ * @param string $uuid client uuid
+ * @param array $data dmidecode part of hardware info
+ * @param int $type dmi type to extract from $data
+ * @param string $dbType hardware type to write this to DB as
+ * @param array|callable $requiredPropsOrCallback either a list of properties that are
+ * mandatory for this hwtype, or a callback function that returns true/false for
+ * valid/invalid dmi entries
+ * @param array $pathFields fields from entry that define the path or location of the
+ * hardware in the client
+ * @param array $globalProps properties of entry that are considered the same for all
+ * instances of that hardware, e.g. model name
+ * @param array $localProps properties of entry that are considered different for each
+ * instance of this hardware per client, e.g. serial number, power-on hours
+ * @param array $globalExtra additional key-value-pairs to write to DB as being global
+ * @param array $localExtra additional key-value-pairs to write to DB as being local
+ * @return int number of table entries written to DB, i.e. passed $requiredPropsOrCallback
+ */
+ private static function updateHwTypeFromDmi(
+ string $uuid, array $data, int $type, string $dbType,
+ $requiredPropsOrCallback, array $pathFields, array $globalProps, array $localProps,
+ array $globalExtra = [], array $localExtra = []
+ ): int
+ {
+ $sections = self::getDmiHandles($data, $type);
+ if (empty($sections) && !empty($globalExtra) || !empty($localExtra)) {
+ // Section not found, but as we want to store additional artificial columms,
+ // just create one empty fake section so the loop will be executed
+ $sections = [[]];
+ }
+ $thisMachineHwIds = [];
+ foreach ($sections as $section) {
+ $flat = self::prepareDmiProperties($section);
+ if (is_array($requiredPropsOrCallback)) {
+ foreach ($requiredPropsOrCallback as $prop) {
+ if (!isset($flat[$prop]))
+ continue 2;
+ }
+ }
+ if (is_callable($requiredPropsOrCallback)) {
+ if (!$requiredPropsOrCallback($flat))
+ continue;
+ }
+ // Global
+ $props = self::propsFromArray($flat, ...$globalProps);
+ $hwid = self::writeGlobalHardwareData($dbType, $props, $globalExtra);
+ // Local
+ $pathId = md5(self::idFromArray($flat, ...$pathFields));
+ $props = self::propsFromArray($flat, ...$localProps);
+ $mappingId = self::writeLocalHardwareData($uuid, $hwid, $pathId, $props + $localExtra);
+ $thisMachineHwIds[] = $mappingId;
+ }
+ // Any hw <-> client mappings not in that list get marked as disconnected
+ self::markDisconnected($uuid, $dbType, $thisMachineHwIds);
+ return count($thisMachineHwIds);
+ }
+
+}
diff --git a/modules-available/statistics/inc/hardwareparserlegacy.inc.php b/modules-available/statistics/inc/hardwareparserlegacy.inc.php
new file mode 100644
index 00000000..a6ac6d5e
--- /dev/null
+++ b/modules-available/statistics/inc/hardwareparserlegacy.inc.php
@@ -0,0 +1,285 @@
+<?php
+
+class HardwareParserLegacy
+{
+
+ public static function parseHdd(&$row, $data)
+ {
+ $hdds = [];
+ // Could have more than one disk - linear scan
+ $lines = preg_split("/[\r\n]+/", $data);
+ $i = 0;
+ $mbrToByteFactor = $sectorToByteFactor = 0;
+ foreach ($lines as $line) {
+ if (preg_match('/^Disk (\S+):.* (\d+) bytes/i', $line, $out)) {
+ // --- Beginning of MBR disk ---
+ unset($hdd);
+ if ($out[2] < 10000) // sometimes vmware reports lots of 512byte disks
+ continue;
+ if (preg_match('#^/dev/(dm-|x?loop|d?nbd)#', $out[1])) // Ignore device mapper etc.
+ continue;
+ // disk total size and name
+ $mbrToByteFactor = 0; // This is != 0 for mbr
+ $sectorToByteFactor = 0; // This is != for gpt
+ $hdd = [
+ 'devid' => 'devid-' . ++$i,
+ 'dev' => $out[1],
+ 'sectors' => 0,
+ 'size' => $out[2],
+ 'used' => 0,
+ 'partitions' => [],
+ ];
+ $hdds[] = &$hdd;
+ } elseif (preg_match('/^Disk (\S+):\s+(\d+)\s+sectors,/i', $line, $out)) {
+ // --- Beginning of GPT disk ---
+ unset($hdd);
+ if ($out[2] < 1000) // sometimes vmware reports lots of 512byte disks
+ continue;
+ if (preg_match('#^/dev/(dm-|x?loop|d?nbd)#', $out[1])) // Ignore device mapper etc.
+ continue;
+ // disk total size and name
+ $mbrToByteFactor = 0; // This is != 0 for mbr
+ $sectorToByteFactor = 0; // This is != for gpt
+ $hdd = [
+ 'devid' => 'devid-' . ++$i,
+ 'dev' => $out[1],
+ 'sectors' => $out[2],
+ 'size' => 0,
+ 'used' => 0,
+ 'partitions' => [],
+ ];
+ $hdds[] = &$hdd;
+ } elseif (preg_match('/^Units =.*= (\d+) bytes/i', $line, $out)) {
+ // --- MBR: Line that tells us how to interpret units for the partition lines ---
+ // Unit for start and end
+ $mbrToByteFactor = $out[1]; // Convert so that multiplying by unit yields MiB
+ } elseif (preg_match('/^Logical sector size:\s*(\d+)/i', $line, $out)) {
+ // --- GPT: Line that tells us the logical sector size used everywhere ---
+ $sectorToByteFactor = $out[1];
+ } elseif (isset($hdd) && preg_match('/^First usable sector.* is (\d+)$/i', $line, $out)) {
+ // --- Some fdisk versions are messed up and report 2^32 as the sector count in the first line,
+ // but the correct value in the "last usable sector is xxxx" line below ---
+ if ($out[1] > $hdd['sectors']) {
+ $hdd['sectors'] = $out[1];
+ }
+ } elseif (isset($hdd) && $mbrToByteFactor !== 0 && preg_match(',
+ ^/dev/(\S+) # device
+ \s+.*\s(\d+)[+\-]? # start
+ \s+(\d+)[+\-]? # end
+ \s+\d+[+\-]? # size
+ \s+([0-9a-f]+) # typeid
+ \s+(.*)$ # type name
+ ,ix', $line, $out)) {
+ // --- MBR: Partition entry ---
+ // Some partition
+ $type = strtolower($out[4]);
+ if ($type === '5' || $type === 'f' || $type === '85') {
+ continue;
+ } elseif ($type === '44') {
+ $out[5] = 'OpenSLX-ID44';
+ } elseif ($type === '45') {
+ $out[5] = 'OpenSLX-ID45';
+ }
+
+ $start = $out[2] * $mbrToByteFactor;
+ $partsize = ($out[3] - $out[2]) * $mbrToByteFactor;
+ $hdd['partitions'][] = [
+ 'id' => $out[1],
+ 'index' => $out[1],
+ 'start' => $start,
+ 'size' => $partsize,
+ 'name' => $out[5],
+ 'slxtype' => $type,
+ ];
+ $hdd['used'] += $partsize;
+ } elseif (isset($hdd) && $sectorToByteFactor !== 0 && preg_match(',
+ ^\s*(\d+) # index
+ \s+(\d+)[+\-]? # start
+ \s+(\d+)[+\-]? # end
+ \s+\S+ # human readable size
+ \s+([0-9a-f]{2})[0-9a-f]* # pseudo-type-id
+ \s+(.*)$ # PartLabel
+ ,ix', $line, $out)) {
+ // --- GPT: Partition entry ---
+ // Some partition
+ $slxtype = $out[4];
+ if ($out[5] === 'OpenSLX-ID44') {
+ $slxtype = '44';
+ } elseif ($out[5] === 'OpenSLX-ID45') {
+ $slxtype = '45';
+ } elseif ($out[5] === 'Linux swap') {
+ $slxtype = '82';
+ }
+ $id = $hdd['devid'] . '-' . $out[1];
+ $start = $out[2] * $sectorToByteFactor;
+ $partsize = ($out[3] - $out[2]) * $sectorToByteFactor;
+ $hdd['partitions'][] = [
+ 'id' => $id,
+ 'index' => $out[1],
+ 'start' => $start,
+ 'size' => $partsize,
+ 'name' => $out[5],
+ 'slxtype' => $slxtype,
+ ];
+ $hdd['used'] += $partsize;
+ }
+ }
+ unset($hdd);
+ foreach ($hdds as &$hdd) {
+ if ($hdd['size'] === 0 && $hdd['sectors'] !== 0) {
+ $hdd['size'] = round($hdd['sectors'] * $sectorToByteFactor);
+ }
+ }
+ unset($hdd);
+ $row['hdds'] = &$hdds;
+ }
+
+ public static function parsePci(string $data): array
+ {
+ preg_match_all('/[a-f0-9:.]{7}\s+"(Class\s*)?(?<class>[a-f0-9]{4})"\s+"(?<vendor>[a-f0-9]{4})"\s+"(?<device>[a-f0-9]{4})".*(:?-r(?<rev>[0-9a-f]+))?/is', $data, $out, PREG_SET_ORDER);
+ return $out;
+ }
+
+ public static function parseSmartctl(&$hdds, $data)
+ {
+ $lines = preg_split("/[\r\n]+/", $data);
+ foreach ($lines as $line) {
+ if (preg_match('/^NEXTHDD=(.+)$/', $line, $out)) {
+ unset($dev);
+ foreach ($hdds as &$hdd) {
+ if ($hdd['dev'] === $out[1]) {
+ $dev = &$hdd;
+ }
+ }
+ continue;
+ }
+ if (!isset($dev)) {
+ continue;
+ }
+ if (preg_match('/^([A-Z][^:]+):\s*(.*)$/', $line, $out)) {
+ $key = preg_replace('/\s|-|_/', '', $out[1]);
+ if ($key === 'ModelNumber' || $key === 'DeviceModel') {
+ $dev['model'] = $out[2];
+ } elseif ($key === 'ModelFamily') {
+ $dev['model_family'] = $out[2];
+ } elseif ($key === 'SerialNumber') {
+ $dev['serial_number'] = $out[2];
+ }
+ } elseif (preg_match('/
+ ^\s*(?<id>\d+)\s+\S+ # flags
+ \s+\S+\s+(?<v>\d+)
+ \s+(?<w>\d+)
+ \s+(?<t>\S+)\s+\S+ # fail
+ \s+(?<raw>\d+)(\s|$)/x', $line, $out)) {
+ $dev['attr_' . $out['id']] = [
+ 'value' => $out['v'],
+ 'worst' => $out['w'],
+ 'thresh' => $out['t'],
+ 'raw' => $out['raw'],
+ ];
+ if ($out['id'] == 194) {
+ $dev['temperature'] = $out['raw'];
+ }
+ }
+ }
+ }
+
+ public static function parseCpu(&$row, $data)
+ {
+ if (0 >= preg_match_all('/^(.+):\s+(\d+)$/im', $data, $out, PREG_SET_ORDER)) {
+ return;
+ }
+ $tmp = [];
+ foreach ($out as $entry) {
+ $tmp[str_replace(' ', '', $entry[1])] = $entry[2];
+ }
+ $row['cpu-sockets'] = $tmp['Sockets'];
+ $row['cpu-cores'] = $tmp['Realcores'];
+ $row['cpu-threads'] = $tmp['Virtualcores'];
+ }
+
+ public static function parseDmiDecode(&$row, $data)
+ {
+ $lines = preg_split("/[\r\n]+/", $data);
+ $section = false;
+ $ramOk = false;
+ $ramForm = $ramType = false;
+ $ramslot = [];
+ $row['ram'] = $row['system'] = $row['mainboard'] = $row['bios'] = [];
+ $row['Memory Slot Count'] = $row['Memory Maximum Capacity'] = 0;
+ foreach ($lines as $line) {
+ if (empty($line)) {
+ continue;
+ }
+ if ($line[0] !== "\t" && $line[0] !== ' ') {
+ if (isset($ramslot['Size'])) {
+ $row['ram'][] = $ramslot;
+ }
+ $ramslot = [];
+ $section = $line;
+ $ramOk = false;
+ if ($ramForm || $ramType) {
+ if (isset($row['ramtype'])) {
+ continue;
+ }
+ $row['ramtype'] = $ramType . '-' . $ramForm;
+ $ramForm = false;
+ $ramType = false;
+ }
+ continue;
+ }
+ if ($section === 'Base Board Information') {
+ if (preg_match('/^\s*([^:]+):\s*(.*?)\s*$/i', $line, $out)
+ && $out[2] !== 'Unknown' && $out[2] !== '' && $out[2] !== 'Not Specified') {
+ $row['mainboard'][$out[1]] = $out[2];
+ }
+ } elseif ($section === 'System Information') {
+ if (preg_match('/^\s*([^:]+):\s*(.*?)\s*$/i', $line, $out)
+ && $out[2] !== 'Unknown' && $out[2] !== '' && $out[2] !== 'Not Specified') {
+ $row['system'][$out[1]] = $out[2];
+ }
+ } elseif ($section === 'Physical Memory Array') {
+ if (!$ramOk && preg_match('/Use: System Memory/i', $line)) {
+ $ramOk = true;
+ }
+ if ($ramOk && preg_match('/^\s*Number Of Devices:\s+(\d+)\s*$/i', $line, $out)) {
+ $row['Memory Slot Count'] += (int)$out[1];
+ }
+ if ($ramOk && preg_match('/^\s*Maximum Capacity:\s+(\d.+)/i', $line, $out)) {
+ /** @var array{"Memory Slot Count": int} $row */
+ $row['Memory Maximum Capacity'] += (int)HardwareParser::convertSize($out[1], 'G', false);
+ }
+ } elseif ($section === 'Memory Device') {
+ if (preg_match('/^\s*Size:\s*(.*?)\s*$/i', $line, $out)) {
+ if (preg_match('/(\d+)\s*(\w)i?B/i', $out[1])) {
+ if (HardwareParser::convertSize($out[1], 'M', false) < 35)
+ continue; // TODO: Parsing this line by line is painful. Check for other indicators, like Locator
+ $ramslot['Size'] = HardwareParser::convertSize($out[1], 'G');
+ }
+ } elseif (preg_match('/^\s*Manufacturer:\s*(.*?)\s*$/i', $line, $out) && $out[1] !== 'Unknown') {
+ $ramslot['Manufacturer'] = HardwareParser::decodeJedec($out[1]);
+ } elseif (preg_match('/^\s*Form Factor:\s*(.*?)\s*$/i', $line, $out) && $out[1] !== 'Unknown') {
+ $ramForm = $out[1];
+ } elseif (preg_match('/^\s*Type:\s*(.*?)\s*$/i', $line, $out) && $out[1] !== 'Unknown') {
+ $ramType = $out[1];
+ } elseif (preg_match('/^\s*Configured Memory Speed:\s*(.*?)\s*$/i', $line, $out) && $out[1] !== 'Unknown') {
+ $ramslot['Configured Clock Speed'] = $out[1];
+ } elseif (preg_match('/^\s*([^:]+):\s*(.*?)\s*$/i', $line, $out)
+ && $out[2] !== 'Unknown' && $out[2] !== '' && $out[2] !== 'Not Specified' && $out[2] !== 'None') {
+ $ramslot[$out[1]] = $out[2];
+ }
+ } elseif ($section === 'BIOS Information') {
+ if (preg_match('/^\s*([^:]+):\s*(.*?)\s*$/i', $line, $out)
+ && $out[2] !== 'Unknown' && $out[2] !== '' && $out[2] !== 'Not Specified') {
+ $row['bios'][$out[1]] = $out[2];
+ }
+ }
+ }
+ if (empty($row['Memory Slot Count']) || (isset($row['ramslot']) && $row['Memory Slot Count'] < count($row['ramslot']))) {
+ $row['Memory Slot Count'] = isset($row['ramslot']) ? count($row['ramslot']) : 0;
+ }
+ if ($row['Memory Maximum Capacity'] > 0) {
+ $row['Memory Maximum Capacity'] .= ' GiB';
+ }
+ }
+} \ No newline at end of file
diff --git a/modules-available/statistics/inc/hardwarequery.inc.php b/modules-available/statistics/inc/hardwarequery.inc.php
new file mode 100644
index 00000000..6b1b5043
--- /dev/null
+++ b/modules-available/statistics/inc/hardwarequery.inc.php
@@ -0,0 +1,169 @@
+<?php
+
+class HardwareQuery
+{
+
+ private $id = 0;
+ private $joins = [];
+ private $where = [];
+ private $args = [];
+ private $columns = [];
+
+ /**
+ * @param string $type hardware type form HardwareInfo
+ * @param ?string $uuid If set, only return data for specific client
+ */
+ public function __construct(string $type, string $uuid = null, $connectedOnly = true)
+ {
+ if ($connectedOnly) {
+ $this->joins['mxhw_join'] = "INNER JOIN machine_x_hw mxhw ON (mxhw.hwid = shw.hwid AND mxhw.disconnecttime = 0)";
+ } else {
+ $this->joins['mxhw_join'] = "INNER JOIN machine_x_hw mxhw ON (mxhw.hwid = shw.hwid)";
+ }
+ if ($uuid !== null) {
+ $this->where[] = 'mxhw.machineuuid = :uuid';
+ $this->args['uuid'] = $uuid;
+ }
+ $this->where[] = 'shw.hwtype = :hwtype';
+ $this->args['hwtype'] = $type;
+ }
+
+ private function id(): string
+ {
+ return 'b' . (++$this->id);
+ }
+
+ /**
+ * Add join of a virtual column (hw property) to an arbitrary table and column.
+ * @param bool $global Is the virtual column global or local to machine?
+ * @param string $prop Name of property/virtual column
+ * @param string $jTable Table to join on
+ * @param string $jColumn Column to join on
+ * @param string $condColumn optionally, another column from the joined table to match against $condVal
+ * @param string|array $condVal optionally, a literal, or array of literals, to match foreign column against
+ * @return void
+ */
+ public function addForeignJoin(bool $global, string $prop, string $jTable, string $jColumn, string $condColumn = '', $condVal = null)
+ {
+ if (isset($this->columns["$jTable.$prop"]))
+ return;
+ if ($global) {
+ $srcTable = 'shw';
+ $table = 'statistic_hw_prop';
+ $column = 'hwid';
+ } else {
+ $srcTable = 'mxhw';
+ $table = 'machine_x_hw_prop';
+ $column = 'machinehwid';
+ }
+ $tid = $this->id();
+ $pid = $this->id();
+ $this->joins[$prop] = "INNER JOIN $table $tid ON ($srcTable.$column = $tid.$column
+ AND $tid.prop = :$pid)";
+ $this->args[$pid] = $prop;
+ $this->columns[$prop] = "$tid.`value` AS `$prop`";
+ $jtid = $this->id();
+ $cond = '';
+ if (!empty($condColumn)) {
+ $vid = $this->id();
+ if (is_array($condVal)) {
+ $cond = " AND $jtid.`$condColumn` IN (:$vid)";
+ } else {
+ $cond = " AND $jtid.`$condColumn` = :$vid";
+ }
+ $this->args[$vid] = $condVal;
+ }
+ $this->joins[$jTable] = "INNER JOIN $jTable $jtid ON ($jtid.$jColumn = $tid.`value` $cond)";
+ }
+
+ public function addMachineWhere(string $column, string $op, $value)
+ {
+ if (isset($this->columns[$column]))
+ return;
+ $vid = $this->id();
+ $this->joins['machine'] = 'INNER JOIN machine m USING (machineuuid)';
+ $this->where[] = "m.$column $op (:$vid)";
+ $this->args[$vid] = $value;
+ $this->columns[$column] = "m.$column";
+ }
+
+ public function addGlobalColumn(string $prop): HardwareQueryColumn
+ {
+ return $this->addColumn(true, $prop);
+ }
+
+ public function addLocalColumn(string $prop): HardwareQueryColumn
+ {
+ return $this->addColumn(false, $prop);
+ }
+
+ public function addColumn(bool $global, string $prop, string $alias = null): HardwareQueryColumn
+ {
+ return $this->columns[] = new HardwareQueryColumn($global, $prop, $alias);
+ }
+
+ /**
+ * Join the machine table and add the given column from it to the SELECT
+ */
+ public function addMachineColumn(string $column): void
+ {
+ if (isset($this->columns[$column]))
+ return;
+ $this->joins['machine'] = 'INNER JOIN machine m USING (machineuuid)';
+ $this->columns[$column] = "m.$column";
+ }
+
+ /**
+ * @return false|PDOStatement
+ */
+ public function query($groupBy = '')
+ {
+ return Database::simpleQuery($this->buildQuery($groupBy), $this->args);
+ }
+
+ /**
+ * Build query string
+ * @param string[]|string $groupBy Column to group by
+ */
+ public function buildQuery($groupBy = ''): string
+ {
+ if (empty($groupBy)) {
+ $groupBy = [];
+ } elseif (!is_array($groupBy)) {
+ $groupBy = [$groupBy];
+ }
+ foreach ($groupBy as &$gb) {
+ if ($gb[0] !== '`') {
+ $gb = "`$gb`";
+ }
+ }
+ $columns = [];
+ foreach ($this->columns as $column) {
+ if ($column instanceof HardwareQueryColumn) {
+ $column->generate($this->joins, $columns, $this->args, $groupBy);
+ } else {
+ $columns[] = $column;
+ }
+ }
+ $columns[] = 'mxhw.machineuuid';
+ $columns[] = 'shw.hwid';
+ // TODO: Untangle this implicit magic
+ if (empty($groupBy) || $groupBy[0] === 'mxhw.machinehwid') {
+ $columns[] = 'mxhw.disconnecttime';
+ } else {
+ $columns[] = 'Sum(If(mxhw.disconnecttime = 0, 1, 0)) AS connected_count';
+ }
+ if (!empty($groupBy)) {
+ $columns[] = 'Count(*) AS group_count';
+ $groupBy = " GROUP BY " . implode(', ', $groupBy);
+ } else {
+ $groupBy = '';
+ }
+ return 'SELECT ' . implode(', ', $columns)
+ . ' FROM statistic_hw shw '
+ . implode(' ', $this->joins)
+ . ' WHERE ' . implode(' AND ', $this->where)
+ . $groupBy;
+ }
+
+}
diff --git a/modules-available/statistics/inc/hardwarequerycolumn.inc.php b/modules-available/statistics/inc/hardwarequerycolumn.inc.php
new file mode 100644
index 00000000..01e32978
--- /dev/null
+++ b/modules-available/statistics/inc/hardwarequerycolumn.inc.php
@@ -0,0 +1,94 @@
+<?php
+
+class HardwareQueryColumn
+{
+ /** @var int For unique table names in join */
+ private static $id = 0;
+
+ private $global;
+ private $tableAlias;
+ private $virtualColumnName;
+ private $alias;
+ private $conditions = [];
+ private $params = [];
+ private $classId;
+
+ private static function getId(): string
+ {
+ return 't' . ++self::$id;
+ }
+
+ public function __construct(bool $global, string $column, string $alias = null)
+ {
+ $this->classId = ++self::$id;
+ $this->global = $global;
+ $this->tableAlias = self::getId();
+ $this->virtualColumnName = $column;
+ $this->alias = '`' . ($alias ?? $column) . '`';
+ }
+
+ /**
+ * Add necessary conditions, joins, columns to final SQL arrays. To be called
+ * from HardwareQuery::buildQuery().
+ * @param string[] $groupConcat if column name is NOT in this array, add as distinct GROUP_CONCAT to column.
+ */
+ public function generate(array &$joins, array &$columns, array &$params, array $groupConcat = [], string $globalSrcTableAlias = null)
+ {
+ if ($this->global) {
+ $srcTable = $globalSrcTableAlias ?? 'shw';
+ $table = 'statistic_hw_prop';
+ $column = 'hwid';
+ } else {
+ $srcTable = 'mxhw';
+ $table = 'machine_x_hw_prop';
+ $column = 'machinehwid';
+ }
+ $tid = $this->tableAlias;
+ $pid = self::getId();
+ $this->conditions[] = "$srcTable.$column = $tid.$column AND $tid.prop = :$pid";
+ $params[$pid] = $this->virtualColumnName; // value of property column is our virtual column
+ // If we have just one condition, it's the join condition itself. Since we pretend we're just adding
+ // a column to the query, do a left join, so the "column" is NULL if the join doesn't match.
+ // If however any conditions were added to this class via the addCondition() method, do a regular
+ // INNER JOIN, so the result will be empty if the condition doesn't match.
+ $type = count($this->conditions) === 1 ? 'LEFT' : 'INNER';
+ $joins[] = "$type JOIN $table $tid ON (" . implode(' AND ', $this->conditions) . ")";
+ if (!empty($groupConcat) && !in_array($this->alias, $groupConcat)) {
+ $columns[] = "Group_Concat(DISTINCT $tid.`value` SEPARATOR ', ') AS {$this->alias}";
+ } else {
+ $columns[] = "$tid.`value` AS {$this->alias}";
+ }
+ $params += $this->params;
+ }
+
+ /**
+ * @param string $op Operator (<>=, IN, LIKE)
+ * @param string|string[]|HardwareQueryColumn $other value to compare with.
+ * Can be a literal, an array (if opererator is IN), or another Column
+ * @return void
+ */
+ public function addCondition(string $op, $other)
+ {
+ $valueCol = ($op === '<' || $op === '>' || $op === '<=' || $op === '>=') ? 'numeric' : 'value';
+ if ($other instanceof HardwareQueryColumn) {
+ $cond = "{$this->tableAlias}.`$valueCol` $op {$other->tableAlias}.`$valueCol`";
+ // Don't reference a column of a table that hasn't been joined yet
+ if ($this->classId > $other->classId) {
+ $this->conditions[] = $cond;
+ } else {
+ $other->conditions[] = $cond;
+ }
+ } elseif ($op === '~' || $op === '!~') {
+ $op = $op === '~' ? 'LIKE' : 'NOT LIKE';
+ $other = str_replace(array('=', '_', '%', '*', '?'), array('==', '=_', '=%', '%', '_'), $other);
+ $pid = self::getId();
+ $this->conditions[] = "{$this->tableAlias}.`$valueCol` $op (:$pid) ESCAPE '='";
+ $this->params[$pid] = $other;
+ } else {
+ $pid = self::getId();
+ $this->conditions[] = "{$this->tableAlias}.`$valueCol` $op (:$pid)";
+ $this->params[$pid] = $other;
+ }
+ }
+
+}
diff --git a/modules-available/statistics/inc/parser.inc.php b/modules-available/statistics/inc/parser.inc.php
deleted file mode 100644
index bdf021a6..00000000
--- a/modules-available/statistics/inc/parser.inc.php
+++ /dev/null
@@ -1,410 +0,0 @@
-<?php
-
-class Parser {
- public static function parseCpu(&$row, $data)
- {
- if (0 >= preg_match_all('/^(.+):\s+(\d+)$/im', $data, $out, PREG_SET_ORDER)) {
- return;
- }
- foreach ($out as $entry) {
- $row[str_replace(' ', '', $entry[1])] = $entry[2];
- }
- }
-
- public static function parseDmiDecode(&$row, $data)
- {
- $lines = preg_split("/[\r\n]+/", $data);
- $section = false;
- $ramOk = false;
- $ramForm = $ramType = $ramSpeed = $ramClockSpeed = false;
- $ramslot = [];
- $row['ramslotcount'] = $row['maxram'] = 0;
- foreach ($lines as $line) {
- if (empty($line)) {
- continue;
- }
- if ($line{0} !== "\t" && $line{0} !== ' ') {
- if (isset($ramslot['size'])) {
- $row['ramslot'][] = $ramslot;
- $ramslot = [];
- }
- $section = $line;
- $ramOk = false;
- if (($ramForm || $ramType) && ($ramSpeed || $ramClockSpeed)) {
- if (isset($row['ramtype']) && !$ramClockSpeed) {
- continue;
- }
- $row['ramtype'] = $ramType . ' ' . $ramForm;
- if ($ramClockSpeed) {
- $row['ramtype'] .= ', ' . $ramClockSpeed;
- } elseif ($ramSpeed) {
- $row['ramtype'] .= ', ' . $ramSpeed;
- }
- $ramForm = false;
- $ramType = false;
- $ramClockSpeed = false;
- }
- continue;
- }
- if ($section === 'Base Board Information') {
- if (preg_match('/^\s*Product Name: +(\S.+?) *$/i', $line, $out)) {
- $row['mobomodel'] = $out[1];
- }
- if (preg_match('/^\s*Manufacturer: +(\S.+?) *$/i', $line, $out)) {
- $row['mobomanufacturer'] = $out[1];
- }
- } elseif ($section === 'System Information') {
- if (preg_match('/^\s*Product Name: +(\S.+?) *$/i', $line, $out)) {
- $row['pcmodel'] = $out[1];
- }
- if (preg_match('/^\s*Manufacturer: +(\S.+?) *$/i', $line, $out)) {
- $row['pcmanufacturer'] = $out[1];
- }
- } elseif ($section === 'Physical Memory Array') {
- if (!$ramOk && preg_match('/Use: System Memory/i', $line)) {
- $ramOk = true;
- }
- if ($ramOk && preg_match('/^\s*Number Of Devices:\s+(\d+)\s*$/i', $line, $out)) {
- $row['ramslotcount'] += $out[1];
- }
- if ($ramOk && preg_match('/^\s*Maximum Capacity:\s+(\d.+)/i', $line, $out)) {
- $row['maxram'] += self::convertSize($out[1], 'G', false);
- }
- } elseif ($section === 'Memory Device') {
- if (preg_match('/^\s*Size:\s*(.*?)\s*$/i', $line, $out)) {
- $row['extram'] = true;
- if (preg_match('/(\d+)\s*(\w)i?B/i', $out[1])) {
- if (self::convertSize($out[1], 'M', false) < 35)
- continue; // TODO: Parsing this line by line is painful. Check for other indicators, like Locator
- $ramslot['size'] = self::convertSize($out[1], 'G');
- } elseif (!isset($row['ramslot']) || (count($row['ramslot']) < 8 && (!isset($row['ramslotcount']) || $row['ramslotcount'] <= 8))) {
- $ramslot['size'] = '_____';
- }
- }
- if (preg_match('/^\s*Manufacturer:\s*(.*?)\s*$/i', $line, $out) && $out[1] !== 'Unknown') {
- $ramslot['manuf'] = self::decodeJedec($out[1]);
- }
- if (preg_match('/^\s*Form Factor:\s*(.*?)\s*$/i', $line, $out) && $out[1] !== 'Unknown') {
- $ramForm = $out[1];
- }
- if (preg_match('/^\s*Type:\s*(.*?)\s*$/i', $line, $out) && $out[1] !== 'Unknown') {
- $ramType = $out[1];
- }
- if (preg_match('/^\s*Speed:\s*(\d.*?)\s*$/i', $line, $out)) {
- $ramSpeed = $out[1];
- }
- if (preg_match('/^\s*Configured (?:Clock|Memory) Speed:\s*(\d.*?)\s*$/i', $line, $out)) {
- $ramClockSpeed = $out[1];
- }
- } elseif ($section === 'BIOS Information') {
- if (preg_match(',^\s*Release Date:\s*(\d{2}/\d{2}/\d{4})\s*$,i', $line, $out)) {
- $row['biosdate'] = date('d.m.Y', strtotime($out[1]));
- } elseif (preg_match('/^\s*BIOS Revision:\s*(.*?)\s*$/i', $line, $out)) {
- $row['biosrevision'] = $out[1];
- } elseif (preg_match('/^\s*Version:\s*(.*?)\s*$/i', $line, $out)) {
- $row['biosversion'] = $out[1];
- }
- }
- }
- if (empty($row['ramslotcount']) || (isset($row['ramslot']) && $row['ramslotcount'] < count($row['ramslot']))) {
- $row['ramslotcount'] = isset($row['ramslot']) ? count($row['ramslot']) : 0;
- }
- if ($row['maxram'] > 0) {
- $row['maxram'] .= ' GiB';
- }
- }
-
- const LOOKUP = ['T' => 1099511627776, 'G' => 1073741824, 'M' => 1048576, 'K' => 1024, '' => 1];
-
- /**
- * Convert/format size unit. Input string can be a size like
- * 8 GB or 1024 MB and will be converted according to passed parameters.
- * @param string $string Input string
- * @param string $scale 'a' for auto, T/G/M/K for according units
- * @param bool $appendUnit append unit string, e.g. 'GiB'
- * @return string|int Formatted result
- */
- private static function convertSize($string, $scale = 'a', $appendUnit = true)
- {
- if (!preg_match('/(\d+)\s*([TGMK]?)/i', $string, $out))
- return false;
- $val = (int)$out[1] * self::LOOKUP[strtoupper($out[2])];
- if (!array_key_exists($scale, self::LOOKUP)) {
- foreach (self::LOOKUP as $k => $v) {
- if ($k === '' || $val / 8 >= $v || abs($val - $v) < 50) {
- $scale = $k;
- break;
- }
- }
- }
- $val = round($val / self::LOOKUP[$scale]);
- if ($appendUnit) {
- $val .= ' ' . ($scale === '' ? 'Byte' : $scale . 'iB'); // NBSP!!
- }
- return $val;
- }
-
- public static function parseHdd(&$row, $data)
- {
- $hdds = array();
- // Could have more than one disk - linear scan
- $lines = preg_split("/[\r\n]+/", $data);
- $i = 0;
- $mbrToMbFactor = $sectorToMbFactor = 0;
- foreach ($lines as $line) {
- if (preg_match('/^Disk (\S+):.* (\d+) bytes/i', $line, $out)) {
- // --- Beginning of MBR disk ---
- unset($hdd);
- if ($out[2] < 10000) // sometimes vmware reports lots of 512byte disks
- continue;
- if (substr($out[1], 0, 8) === '/dev/dm-') // Ignore device mapper
- continue;
- // disk total size and name
- $mbrToMbFactor = 0; // This is != 0 for mbr
- $sectorToMbFactor = 0; // This is != for gpt
- $hdd = array(
- 'devid' => 'devid-' . ++$i,
- 'dev' => $out[1],
- 'sectors' => 0,
- 'size' => round($out[2] / (1024 * 1024 * 1024)),
- 'used' => 0,
- 'partitions' => array(),
- 'json' => array(),
- );
- $hdds[] = &$hdd;
- } elseif (preg_match('/^Disk (\S+):\s+(\d+)\s+sectors,/i', $line, $out)) {
- // --- Beginning of GPT disk ---
- unset($hdd);
- if ($out[2] < 1000) // sometimes vmware reports lots of 512byte disks
- continue;
- if (substr($out[1], 0, 8) === '/dev/dm-') // Ignore device mapper
- continue;
- // disk total size and name
- $mbrToMbFactor = 0; // This is != 0 for mbr
- $sectorToMbFactor = 0; // This is != for gpt
- $hdd = array(
- 'devid' => 'devid-' . ++$i,
- 'dev' => $out[1],
- 'sectors' => $out[2],
- 'size' => 0,
- 'used' => 0,
- 'partitions' => array(),
- 'json' => array(),
- );
- $hdds[] = &$hdd;
- } elseif (preg_match('/^Units =.*= (\d+) bytes/i', $line, $out)) {
- // --- MBR: Line that tells us how to interpret units for the partition lines ---
- // Unit for start and end
- $mbrToMbFactor = $out[1] / (1024 * 1024); // Convert so that multiplying by unit yields MiB
- } elseif (preg_match('/^Logical sector size:\s*(\d+)/i', $line, $out)) {
- // --- GPT: Line that tells us the logical sector size used everywhere ---
- $sectorToMbFactor = $out[1] / (1024 * 1024);
- } elseif (isset($hdd) && preg_match('/^First usable sector.* is (\d+)$/i', $line, $out)) {
- // --- Some fdisk versions are messed up and report 2^32 as the sector count in the first line,
- // but the correct value in the "last usable sector is xxxx" line below ---
- if ($out[1] > $hdd['sectors']) {
- $hdd['sectors'] = $out[1];
- }
- } elseif (isset($hdd) && $mbrToMbFactor !== 0 && preg_match(',^/dev/(\S+)\s+.*\s(\d+)[\+\-]?\s+(\d+)[\+\-]?\s+\d+[\+\-]?\s+([0-9a-f]+)\s+(.*)$,i', $line, $out)) {
- // --- MBR: Partition entry ---
- // Some partition
- $type = strtolower($out[4]);
- if ($type === '5' || $type === 'f' || $type === '85') {
- continue;
- } elseif ($type === '44') {
- $out[5] = 'OpenSLX-ID44';
- $color = '#5c1';
- } elseif ($type === '45') {
- $out[5] = 'OpenSLX-ID45';
- $color = '#0d7';
- } elseif ($type === '82') {
- $color = '#48f';
- } else {
- $color = '#e55';
- }
-
- $partsize = round(($out[3] - $out[2]) * $mbrToMbFactor);
- $hdd['partitions'][] = array(
- 'id' => $out[1],
- 'name' => $out[1],
- 'size' => round($partsize / 1024, $partsize < 1024 ? 1 : 0),
- 'type' => $out[5],
- );
- $hdd['json'][] = array(
- 'label' => $out[1],
- 'value' => $partsize,
- 'color' => $color,
- );
- $hdd['used'] += $partsize;
- } elseif (isset($hdd) && $sectorToMbFactor !== 0 && preg_match(',^\s*(\d+)\s+(\d+)[\+\-]?\s+(\d+)[\+\-]?\s+\S+\s+([0-9a-f]+)\s+(.*)$,i', $line, $out)) {
- // --- GPT: Partition entry ---
- // Some partition
- $type = $out[5];
- if ($type === 'OpenSLX-ID44') {
- $color = '#5c1';
- } elseif ($type === 'OpenSLX-ID45') {
- $color = '#0d7';
- } elseif ($type === 'Linux swap') {
- $color = '#48f';
- } else {
- $color = '#e55';
- }
- $id = $hdd['devid'] . '-' . $out[1];
- $partsize = round(($out[3] - $out[2]) * $sectorToMbFactor);
- $hdd['partitions'][] = array(
- 'id' => $id,
- 'name' => $out[1],
- 'size' => round($partsize / 1024, $partsize < 1024 ? 1 : 0),
- 'type' => $type,
- );
- $hdd['json'][] = array(
- 'label' => $id,
- 'value' => $partsize,
- 'color' => $color,
- );
- $hdd['used'] += $partsize;
- }
- }
- unset($hdd);
- $i = 0;
- foreach ($hdds as &$hdd) {
- $hdd['used'] = round($hdd['used'] / 1024);
- if ($hdd['size'] === 0 && $hdd['sectors'] !== 0) {
- $hdd['size'] = round(($hdd['sectors'] * $sectorToMbFactor) / 1024);
- }
- $free = $hdd['size'] - $hdd['used'];
- if ($hdd['size'] > 0 && ($free > 5 || ($free / $hdd['size']) > 0.1)) {
- $hdd['partitions'][] = array(
- 'id' => 'free-id-' . $i,
- 'name' => Dictionary::translate('unused'),
- 'size' => $free,
- 'type' => '-',
- );
- $hdd['json'][] = array(
- 'label' => 'free-id-' . $i,
- 'value' => $free * 1024,
- 'color' => '#aaa',
- );
- ++$i;
- }
- $hdd['json'] = json_encode($hdd['json']);
- }
- unset($hdd);
- $row['hdds'] = &$hdds;
- }
-
- public static function parsePci(&$pci1, &$pci2, $data)
- {
- preg_match_all('/[a-f0-9\:\.]{7}\s+"(Class\s*)?(?<class>[a-f0-9]{4})"\s+"(?<ven>[a-f0-9]{4})"\s+"(?<dev>[a-f0-9]{4})"/is', $data, $out, PREG_SET_ORDER);
- $NOW = time();
- $pci = array();
- foreach ($out as $entry) {
- if (!isset($pci[$entry['class']])) {
- $class = 'c.' . $entry['class'];
- $res = Page_Statistics::getPciId('CLASS', $class);
- if ($res === false || $res['dateline'] < $NOW) {
- $pci[$entry['class']]['lookupClass'] = 'do-lookup';
- $pci[$entry['class']]['class'] = $class;
- } else {
- $pci[$entry['class']]['class'] = $res['value'];
- }
- }
- $new = array(
- 'ven' => $entry['ven'],
- 'dev' => $entry['ven'] . ':' . $entry['dev'],
- );
- $res = Page_Statistics::getPciId('VENDOR', $new['ven']);
- if ($res === false || $res['dateline'] < $NOW) {
- $new['lookupVen'] = 'do-lookup';
- } else {
- $new['ven'] = $res['value'];
- }
- $res = Page_Statistics::getPciId('DEVICE', $new['dev']);
- if ($res === false || $res['dateline'] < $NOW) {
- $new['lookupDev'] = 'do-lookup';
- } else {
- $new['dev'] = $res['value'] . ' (' . $new['dev'] . ')';
- }
- $pci[$entry['class']]['entries'][] = $new;
- }
- ksort($pci);
- foreach ($pci as $class => $entry) {
- if ($class === '0300' || $class === '0200' || $class === '0403') {
- $pci1[] = $entry;
- } else {
- $pci2[] = $entry;
- }
- }
- }
-
- public static function parseSmartctl(&$hdds, $data)
- {
- $lines = preg_split("/[\r\n]+/", $data);
- foreach ($lines as $line) {
- if (preg_match('/^NEXTHDD=(.+)$/', $line, $out)) {
- unset($dev);
- foreach ($hdds as &$hdd) {
- if ($hdd['dev'] === $out[1]) {
- $dev = &$hdd;
- }
- }
- continue;
- }
- if (!isset($dev)) {
- continue;
- }
- if (preg_match('/^([A-Z][^:]+):\s*(.*)$/', $line, $out)) {
- $key = preg_replace('/\s|-|_/', '', $out[1]);
- if ($key === 'ModelNumber') {
- $key = 'DeviceModel';
- }
- $dev['s_' . $key] = $out[2];
- } elseif (preg_match('/^\s*\d+\s+(\S+)\s+\S+\s+\d+\s+\d+\s+\S+\s+\S+\s+(\d+)(\s|$)/', $line, $out)) {
- $dev['s_' . preg_replace('/\s|-|_/', '', $out[1])] = $out[2];
- }
- }
- // Format strings
- foreach ($hdds as &$hdd) {
- if (isset($hdd['s_PowerOnHours'])) {
- $hdd['PowerOnTime'] = '';
- $val = (int)str_replace('.', '', $hdd['s_PowerOnHours']);
- if ($val > 8760) {
- $hdd['PowerOnTime'] .= floor($val / 8760) . 'Y, ';
- $val %= 8760;
- }
- if ($val > 720) {
- $hdd['PowerOnTime'] .= floor($val / 720) . 'M, ';
- $val %= 720;
- }
- if ($val > 24) {
- $hdd['PowerOnTime'] .= floor($val / 24) . 'd, ';
- $val %= 24;
- }
- $hdd['PowerOnTime'] .= $val . 'h';
- }
- }
- }
-
- public static function decodeJedec($string)
- {
- // JEDEC ID:7F 7F 9E 00 00 00 00 00
- if (preg_match('/JEDEC(?:\s*ID)?\s*:\s*([0-9a-f\s]+)/i', $string, $out)) {
- preg_match_all('/[0-9a-f]{2}/i', $out[1], $out);
- $bank = 0;
- foreach ($out[0] as $id) {
- $bank++;
- $id = hexdec($id) & 0x7f; // Let's just ignore the parity bit, and any potential error
- if ($id !== 0x7f)
- break;
- }
- if ($id !== 0) {
- static $data = false;
- if ($data === false) $data = json_decode(file_get_contents(dirname(__FILE__) . '/jedec.json'), true);
- if (array_key_exists('bank' . $bank, $data) && array_key_exists('id' . $id, $data['bank' . $bank]))
- return $data['bank' . $bank]['id' . $id];
- }
- }
- return $string;
- }
-
-}
diff --git a/modules-available/statistics/inc/pciid.inc.php b/modules-available/statistics/inc/pciid.inc.php
new file mode 100644
index 00000000..38a2c56d
--- /dev/null
+++ b/modules-available/statistics/inc/pciid.inc.php
@@ -0,0 +1,82 @@
+<?php
+
+class PciId
+{
+
+ const DEVICE = 'DEVICE';
+ const VENDOR = 'VENDOR';
+ const DEVCLASS = 'CLASS';
+ const AUTO = 'AUTO';
+
+
+ /**
+ * @param string $cat type of query - self::DEVICE, self::VENDOR, self::DEVCLASS or self::AUTO for auto detection
+ * @param string $id the id to query - depends on $cat
+ * @return string|false Name of Class/Vendor/Device, false if not found
+ */
+ public static function getPciId(string $cat, string $id, bool $dnsQuery = false)
+ {
+ static $cache = [];
+ if ($cat === self::DEVCLASS && $id[1] === '.') {
+ $id = substr($id, 2);
+ }
+ if ($cat === self::AUTO) {
+ if (preg_match('/^([a-f0-9]{4})[:._-]?([a-f0-9]{4})$/', $id, $out)) {
+ $cat = 'DEVICE';
+ $host = $out[2] . '.' . $out[1];
+ $id = $out[1] . ':' . $out[2];
+ } elseif (preg_match('/^[a-f0-9]{4}$/', $id)) {
+ $cat = 'VENDOR';
+ $host = $id;
+ } elseif (preg_match('/^c[.-]([a-f0-9]{2})([a-f0-9]{2})$/', $id)) {
+ $cat = 'CLASS';
+ $host = $out[2] . '.' . $out[1] . '.c';
+ $id = substr($id, 2);
+ } else {
+ error_log('Invalid PCIID lookup format: ' . $id);
+ return false;
+ }
+ } elseif ($cat === self::DEVICE && preg_match('/^([a-f0-9]{4})[:._-]?([a-f0-9]{4})$/', $id, $out)) {
+ $host = $out[2] . '.' . $out[1];
+ $id = $out[1] . ':' . $out[2];
+ } elseif ($cat === self::VENDOR && preg_match('/^([a-f0-9]{4})$/', $id)) {
+ $host = $id;
+ } elseif ($cat === self::DEVCLASS && preg_match('/^(?:c[.-])?([a-f0-9]{2})([a-f0-9]{2})$/', $id, $out)) {
+ $host = $out[2] . '.' . $out[1] . '.c';
+ $id = 'c.' . $out[1] . $out[2];
+ } else {
+ error_log("getPciId called with unknown format: ($cat) ($id)");
+ return false;
+ }
+ $key = $cat . '-' . $id;
+ if (isset($cache[$key]))
+ return $cache[$key];
+ $row = Database::queryFirst('SELECT value, dateline FROM pciid WHERE category = :cat AND id = :id LIMIT 1',
+ array('cat' => $cat, 'id' => $id));
+ if ($row !== false && $row['dateline'] >= time()) {
+ return $cache[$key] = $row['value'];
+ }
+ if (!$dnsQuery)
+ return false;
+ // Unknown, query
+ $res = dns_get_record($host . '.pci.id.ucw.cz', DNS_TXT);
+ if (!is_array($res))
+ return false;
+ foreach ($res as $entry) {
+ if (isset($entry['txt']) && substr($entry['txt'], 0, 2) === 'i=') {
+ $string = substr($entry['txt'], 2);
+ Database::exec('INSERT INTO pciid (category, id, value, dateline) VALUES (:cat, :id, :value, :timeout)'
+ . ' ON DUPLICATE KEY UPDATE value = VALUES(value), dateline = VALUES(dateline)',
+ array(
+ 'cat' => $cat,
+ 'id' => $id,
+ 'value' => $string,
+ 'timeout' => time() + mt_rand(10, 30) * 86400,
+ ), true);
+ return $cache[$key] = $string;
+ }
+ }
+ return $cache[$key] = ($row['value'] ?? false);
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/statistics/inc/statistics.inc.php b/modules-available/statistics/inc/statistics.inc.php
index 1f8a081a..c12f5be4 100644
--- a/modules-available/statistics/inc/statistics.inc.php
+++ b/modules-available/statistics/inc/statistics.inc.php
@@ -7,7 +7,7 @@ class Statistics
private static $machineFields = false;
- private static function initFields($returnData)
+ private static function initFields(int $returnData): string
{
if (self::$machineFields === false) {
$r = new ReflectionClass('Machine');
@@ -19,23 +19,21 @@ class Statistics
} elseif ($returnData === Machine::RAW_DATA) {
self::$machineFields['data'] = true;
} else {
- Util::traceError('Invalid $returnData option passed');
+ ErrorHandler::traceError('Invalid $returnData option passed');
}
return implode(',', array_keys(self::$machineFields));
}
/**
- * @param string $machineuuid
* @param int $returnData What kind of data to return Machine::NO_DATA, Machine::RAW_DATA, ...
- * @return \Machine|false
*/
- public static function getMachine($machineuuid, $returnData)
+ public static function getMachine(string $machineuuid, int $returnData): ?Machine
{
$fields = self::initFields($returnData);
$row = Database::queryFirst("SELECT $fields FROM machine WHERE machineuuid = :machineuuid", compact('machineuuid'));
if ($row === false)
- return false;
+ return null;
$m = new Machine();
foreach ($row as $key => $val) {
$m->{$key} = $val;
@@ -44,23 +42,22 @@ class Statistics
}
/**
- * @param string $ip
* @param int $returnData What kind of data to return Machine::NO_DATA, Machine::RAW_DATA, ...
* @param string $sort something like 'lastseen ASC' - not sanitized, don't pass user input!
- * @return \Machine[] list of matches
+ * @return Machine[] list of matches
*/
- public static function getMachinesByIp($ip, $returnData, $sort = false)
+ public static function getMachinesByIp(string $ip, int $returnData, string $sort = null): array
{
$fields = self::initFields($returnData);
- if ($sort === false) {
+ if ($sort === null) {
$sort = '';
} else {
$sort = "ORDER BY $sort";
}
$res = Database::simpleQuery("SELECT $fields FROM machine WHERE clientip = :ip $sort", compact('ip'));
$list = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$m = new Machine();
foreach ($row as $key => $val) {
$m->{$key} = $val;
@@ -74,7 +71,7 @@ class Statistics
const OFFLINE_LENGTH = '~offline-length';
const SUSPEND_LENGTH = '~suspend-length';
- public static function logMachineState($uuid, $ip, $type, $start, $length, $username = '')
+ public static function logMachineState(string $uuid, string $ip, string $type, int $start, int $length, string $username = ''): int
{
return Database::exec('INSERT INTO statistic (dateline, typeid, machineuuid, clientip, username, data)'
. " VALUES (:start, :type, :uuid, :clientip, :username, :length)", array(
diff --git a/modules-available/statistics/inc/statisticsfilter.inc.php b/modules-available/statistics/inc/statisticsfilter.inc.php
index 4a4899e2..5e6448c7 100644
--- a/modules-available/statistics/inc/statisticsfilter.inc.php
+++ b/modules-available/statistics/inc/statisticsfilter.inc.php
@@ -10,8 +10,10 @@ abstract class StatisticsFilter
*/
const LEGACY_DELIMITER = '~,~';
- const SIZE_ID44 = array(0, 8, 16, 24, 30, 40, 50, 60, 80, 100, 120, 150, 180, 250, 300, 400, 500, 1000, 2000, 4000);
- const SIZE_RAM = array(1, 2, 3, 4, 6, 8, 10, 12, 16, 24, 32, 48, 64, 96, 128, 192, 256, 320, 480, 512, 768, 1024);
+ const SIZE_PARTITION = [0, 8, 16, 24, 30, 40, 50, 60, 80, 100, 120, 150, 180, 250, 300, 400, 500, 1000, 1500, 2000, 3000,
+ 4000, 6000, 8000, 10000];
+ const SIZE_RAM = [1, 2, 3, 4, 6, 8, 10, 12, 16, 24, 32, 48, 64, 96, 128, 192, 256, 320, 480, 512, 768, 1024, 1536,
+ 2048];
private static $keyCounter = 0;
@@ -56,22 +58,57 @@ abstract class StatisticsFilter
$this->placeholder = $placeholder;
}
- public function type()
+ public function type(): string
{
return ($this->ops === self::OP_ORDINAL || $this->ops === self::OP_FUZZY_ORDINAL) ? 'int' : 'string';
}
- /* returns a where clause and adds needed operators to the passed arrays */
- public abstract function whereClause(string $operator, $argument, array &$args, array &$joins);
+ /**
+ * Needed for joins with the hardware tables, to use the HardwareQueryColumn afterwards.
+ * The HardwareQuery class should probably be extended/rewritten to be more versatile in
+ * this regard.
+ */
+ public static function addHardwareJoin(array &$args, array &$joins, string $hwtype = null): string
+ {
+ $joins['mxhw'] = ' INNER JOIN machine_x_hw mxhw ON (mxhw.disconnecttime = 0 AND mxhw.machineuuid = m.machineuuid)';
+ $key = self::getNewKey('foo');
+ $shw = self::getNewKey('shw');
+ if ($hwtype === null) {
+ $joins[] = " INNER JOIN statistic_hw $shw ON (mxhw.hwid = {$shw}.hwid)";
+ } else {
+ $joins[] = " INNER JOIN statistic_hw $shw ON (mxhw.hwid = {$shw}.hwid AND {$shw}.hwtype = :$key)";
+ $args[$key] = $hwtype;
+ }
+ return $shw;
+ }
- public function bind(string $op, $argument) { return new DatabaseFilter($this, $op, $argument); }
+ /**
+ * To be called by DatabaseFilter::whereClause() when building actual query.
+ * @param string $operator operator to use
+ * @param string[]|string $argument argument to compare against
+ * @param string[] $args assoc array to add parametrized version of $argument to
+ * @param string[] $joins any optional joins can be added to this array
+ * @return string where clause
+ */
+ public abstract function whereClause(string $operator, $argument, array &$args, array &$joins): string;
+ /**
+ * Called to get an instance of DatabaseFilter that binds the given $op and $argument to this filter.
+ * @param string[]|string $argument
+ */
+ public function bind(string $op, $argument): DatabaseFilter { return new DatabaseFilter($this, $op, $argument); }
+
+ /**
+ * Check if given $operator is valid for this filter. Throws error and halts if not.
+ * @return void
+ */
public final function validateOperator(string $operator)
{
if (empty($this->ops))
return;
if (!in_array($operator, $this->ops)) {
- Util::traceError("Invalid op '$operator' for " . get_class($this) . '::' . $this->column);
+ // Yes keep $this in this call, get_class() !== get_class($this)
+ ErrorHandler::traceError("Invalid op '$operator' for " . get_class($this) . '::' . $this->column);
}
}
@@ -100,7 +137,7 @@ abstract class StatisticsFilter
return ($array[$best] + $array[$best - 1]) / 2;
}
- public static function getNewKey($colname)
+ public static function getNewKey($colname): string
{
return $colname . '_' . (self::$keyCounter++);
}
@@ -108,7 +145,7 @@ abstract class StatisticsFilter
/**
* @return DatabaseFilter[]
*/
- public static function parseQuery()
+ public static function parseQuery(): array
{
// Get current settings from GET
$ops = Request::get('op', [], 'array');
@@ -141,10 +178,7 @@ abstract class StatisticsFilter
return $filters;
}
- /**
- * @param \StatisticsFilterSet $filterSet
- */
- public static function renderFilterBox($show, $filterSet)
+ public static function renderFilterBox(string $show, StatisticsFilterSet $filterSet): void
{
// Build location list, with permissions
if (Module::isAvailable('locations')) {
@@ -156,7 +190,7 @@ abstract class StatisticsFilter
foreach (self::$columns as $key => $filter) {
$col = [
'key' => $key,
- 'name' => Dictionary::translateFile('filters', $key, true),
+ 'name' => Dictionary::translateFile('filters', $key),
'placeholder' => $filter->placeholder,
];
$bind = $filterSet->hasFilterKey($key);
@@ -169,8 +203,9 @@ abstract class StatisticsFilter
$col['inputclass'] = 'is-date';
} elseif ($filter->type() === 'enum') {
$col['enum'] = true;
+ /** @var EnumStatisticsFilter $filter */
$col['values'] = $filter->values;
- if ($bind !== false) {
+ if ($bind !== null) {
// Current value from GET
foreach ($col['values'] as &$value) {
if ($value['key'] == $bind->argument) {
@@ -180,7 +215,7 @@ abstract class StatisticsFilter
}
}
// current value from GET
- if ($bind !== false) {
+ if ($bind !== null) {
$col['currentvalue'] = $bind->argument;
$col['checked'] = 'checked';
$showCount++;
@@ -190,7 +225,7 @@ abstract class StatisticsFilter
$col['op'] = $filter->ops;
foreach ($col['op'] as &$value) {
$value = ['op' => $value];
- if ($bind !== false && $bind->op === $value['op']) {
+ if ($bind !== null && $bind->op === $value['op']) {
$value['selected'] = 'selected';
}
}
@@ -218,16 +253,16 @@ abstract class StatisticsFilter
'clientip' => new IpStatisticsFilter(),
'hostname' => new SimpleStatisticsFilter('hostname', self::OP_STRCMP, 'pc.fqdn.example.com'),
'machineuuid' => new SimpleStatisticsFilter('machineuuid', self::OP_STRCMP, '88888888-4444-4444-121212121212'),
- 'macaddr' => new SimpleStatisticsFilter('macaddr', self::OP_STRCMP, '11-22-33-44-55-66'),
+ 'macaddr' => new MacAddressStatisticsFilter(),
'firstseen' => new DateStatisticsFilter('firstseen', '2020-10-15 14:00'),
'lastseen' => new DateStatisticsFilter('lastseen', '2020-10-15 14:00'),
- 'logintime' => new DateStatisticsFilter('logintime', '2020-10-15 14:00'),
'lastboot' => new DateStatisticsFilter('lastboot', '2020-10-15 14:00'),
'runtime' => new RuntimeStatisticsFilter(),
'realcores' => new SimpleStatisticsFilter('realcores', self::OP_ORDINAL, ''),
- 'systemmodel' => new SimpleStatisticsFilter('systemmodel', self::OP_STRCMP, 'PC-365 (IBM)'),
+ 'systemmodel' => new SystemModelStatisticsFilter(),
'cpumodel' => new SimpleStatisticsFilter('cpumodel', self::OP_STRCMP, 'Pentium Pro 200 MHz'),
- 'hddgb' => new Id44GbStatisticsFilter(),
+ 'hddgb' => new PartitionGbStatisticsFilter('id44mb'),
+ 'persistentgb' => new PartitionGbStatisticsFilter('id45mb'),
'gbram' => new RamGbStatisticsFilter(),
'kvmstate' => new EnumStatisticsFilter('kvmstate', ['ENABLED', 'DISABLED', 'UNSUPPORTED']),
'badsectors' => new SimpleStatisticsFilter('badsectors', self::OP_ORDINAL, ''),
@@ -236,6 +271,12 @@ abstract class StatisticsFilter
'live_swapfree' => new SimpleStatisticsFilter('live_swapfree', self::OP_ORDINAL, 'MiB'),
'live_memfree' => new SimpleStatisticsFilter('live_memfree', self::OP_ORDINAL, 'MiB'),
'live_tmpfree' => new SimpleStatisticsFilter('live_tmpfree', self::OP_ORDINAL, 'MiB'),
+ 'live_id45free' => new SimpleNotZeroStatisticsFilter('live_id45free', self::OP_ORDINAL, 'MiB'),
+ 'standbycrash' => new StandbyCrashStatisticsFilter(),
+ 'pcidev' => new PciDeviceStatisticsFilter(),
+ 'nicspeed' => new NicSpeedStatisticsFilter(),
+ 'hddrpm' => new HddRpmStatisticsFilter(),
+ //'anydev' => new AnyHardwarePropStatisticsFilter(),
];
if (Module::isAvailable('locations')) {
self::$columns['location'] = new LocationStatisticsFilter();
@@ -247,14 +288,14 @@ abstract class StatisticsFilter
class SimpleStatisticsFilter extends StatisticsFilter
{
- public function whereClause(string $operator, $argument, array &$args, array &$joins)
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
{
$addendum = '';
$key = self::getNewKey($this->column);
$args[$key] = $argument;
if (is_array($argument)) {
- if ($operator{0} === '!') {
+ if ($operator[0] === '!') {
$op = 'NOT IN';
} else {
$op = 'IN';
@@ -277,6 +318,20 @@ class SimpleStatisticsFilter extends StatisticsFilter
}
+class SimpleNotZeroStatisticsFilter extends SimpleStatisticsFilter
+{
+
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
+ {
+ $str = parent::whereClause($operator, $argument, $args, $joins);
+ if ((int)$argument !== 0 || $operator !== '=') {
+ $str = "($str AND {$this->column} != 0)";
+ }
+ return $str;
+ }
+
+}
+
class EnumStatisticsFilter extends SimpleStatisticsFilter
{
@@ -301,28 +356,55 @@ class EnumStatisticsFilter extends SimpleStatisticsFilter
$this->values = $values;
}
- public function type() { return 'enum'; }
+ public function type(): string { return 'enum'; }
- public function whereClause(string $operator, $argument, array &$args, array &$joins)
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
{
- $keys = ArrayUtil::flattenByKey($this->values, 'key');
- if (is_array($argument)) {
- $ok = true;
- foreach ($argument as $e) {
- if (!in_array($e, $keys)) {
- $ok = false;
+ if ($this->validateArgument()) {
+ $keys = ArrayUtil::flattenByKey($this->values, 'key');
+ if (is_array($argument)) {
+ $ok = true;
+ foreach ($argument as $e) {
+ if (!in_array($e, $keys)) {
+ $ok = false;
+ }
}
+ } else {
+ $ok = in_array($argument, $keys);
+ }
+ if (!$ok) {
+ Message::addError('invalid-enum-item', $this->column, $argument);
+ return '0';
}
- } else {
- $ok = in_array($argument, $keys);
}
- if (!$ok) {
- Message::addError('invalid-enum-item', $this->column, $argument);
- return '0';
+ return parent::whereClause($operator, $argument, $args, $joins);
+ }
+
+ protected function validateArgument(): bool { return true; }
+
+}
+
+class StandbyCrashStatisticsFilter extends EnumStatisticsFilter
+{
+
+ public function __construct()
+ {
+ parent::__construct('standbysem', ['NONE', 'MANY']);
+ }
+
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
+ {
+ if ($argument === 'NONE') {
+ $argument = 0;
+ } else { // MANY
+ $argument = 3;
+ $operator = $operator === '=' ? '>' : '<=';
}
return parent::whereClause($operator, $argument, $args, $joins);
}
+ protected function validateArgument(): bool { return false; }
+
}
class DateStatisticsFilter extends StatisticsFilter
@@ -333,12 +415,11 @@ class DateStatisticsFilter extends StatisticsFilter
parent::__construct($column, self::OP_ORDINAL, $placeholder);
}
- public function type() { return 'date'; }
+ public function type(): string { return 'date'; }
- public function whereClause(string $operator, $argument, array &$args, array &$joins)
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
{
$key = self::getNewKey($this->column);
- $addendum = '';
if (!preg_match('/^(?<date>\d{4}-\d{2}-\d{2})(\s+(?<h>\d{1,2})(:(?<m>\d{2})(:\d+)?)?)?$/', $argument, $out)) {
Message::addError('invalid-date-format', $argument);
@@ -364,7 +445,7 @@ class DateStatisticsFilter extends StatisticsFilter
$args[$key] = strtotime('+1 ' . $span . ' -1 second', $args[$key]);
}
- return 'm.' . $this->column . ' ' . $operator . ' :' . $key . $addendum;
+ return 'm.' . $this->column . ' ' . $operator . ' :' . $key;
}
}
@@ -377,7 +458,7 @@ class RuntimeStatisticsFilter extends StatisticsFilter
parent::__construct('lastboot', self::OP_ORDINAL);
}
- public function whereClause(string $operator, $argument, array &$args, array &$joins)
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
{
$upper = time() - (int)$argument * 3600;
$lower = $upper - 3600;
@@ -401,7 +482,7 @@ class RuntimeStatisticsFilter extends StatisticsFilter
abstract class GbToMbRangeStatisticsFilter extends StatisticsFilter
{
- protected function rangeClause(string $operator, $argument, array $fuzzyVals)
+ protected function rangeClause(string $operator, $argument, array $fuzzyVals): string
{
if ($operator === '~' || $operator === '!~') {
$lower = (int)floor(StatisticsFilter::findBestValue($fuzzyVals, (int)$argument, false) * 1024 - 500);
@@ -434,24 +515,24 @@ class RamGbStatisticsFilter extends GbToMbRangeStatisticsFilter
parent::__construct('mbram', self::OP_FUZZY_ORDINAL, 'GiB');
}
- public function whereClause(string $operator, $argument, array &$args, array &$joins)
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
{
return parent::rangeClause($operator, $argument, self::SIZE_RAM);
}
}
-class Id44GbStatisticsFilter extends GbToMbRangeStatisticsFilter
+class PartitionGbStatisticsFilter extends GbToMbRangeStatisticsFilter
{
- public function __construct()
+ public function __construct(string $column)
{
- parent::__construct('id44mb', self::OP_FUZZY_ORDINAL,'GiB');
+ parent::__construct($column, self::OP_FUZZY_ORDINAL, 'GiB');
}
- public function whereClause(string $operator, $argument, array &$args, array &$joins)
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
{
- return parent::rangeClause($operator, $argument, self::SIZE_ID44);
+ return parent::rangeClause($operator, $argument, self::SIZE_PARTITION);
}
}
@@ -463,18 +544,17 @@ class StateStatisticsFilter extends EnumStatisticsFilter
parent::__construct('state', ['on', 'off', 'idle', 'occupied', 'standby']);
}
- public function whereClause(string $operator, $argument, array &$args, array &$joins)
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
{
$map = [ 'on' => ['IDLE', 'OCCUPIED'], 'off' => ['OFFLINE'], 'idle' => ['IDLE'], 'occupied' => ['OCCUPIED'], 'standby' => ['STANDBY'] ];
- $neg = $operator == '!=' ? 'NOT ' : '';
+ $neg = $operator === '!=' ? 'NOT ' : '';
if (array_key_exists($argument, $map)) {
$key = StatisticsFilter::getNewKey($this->column);
$args[$key] = $map[$argument];
return " m.state $neg IN ( :$key ) ";
- } else {
- Message::addError('invalid-filter-argument', 'state', $argument);
- return ' 1';
}
+ Message::addError('invalid-filter-argument', 'state', $argument);
+ return ' 1';
}
}
@@ -493,15 +573,15 @@ class LocationStatisticsFilter extends EnumStatisticsFilter
parent::__construct('locationid', $locs, self::OP_LOCATIONS);
}
- public function type() { return 'enum'; }
+ public function type(): string { return 'enum'; }
- public function whereClause(string $operator, $argument, array &$args, array &$joins)
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
{
$recursive = (substr($operator, -1) === '~');
$operator = str_replace('~', '=', $operator);
if ($recursive && is_array($argument)) {
- Util::traceError('Cannot use ~ operator for location with array');
+ ErrorHandler::traceError('Cannot use ~ operator for location with array');
}
if ($recursive) {
$argument = array_keys(Location::getRecursiveFlat($argument));
@@ -539,21 +619,13 @@ class IpStatisticsFilter extends StatisticsFilter
} elseif (strpos($argument, '/') !== false) {
// TODO: IPv6 CIDR
$range = IpUtil::parseCidr($argument);
- if ($range === false) {
+ if ($range === null) {
Message::addError('invalid-cidr-notion', $argument);
return '0';
}
return 'INET_ATON(clientip) BETWEEN ' . $range['start'] . ' AND ' . $range['end'];
} elseif (($num = substr_count($argument, ':')) !== 0 && $num <= 7) {
- // IPv6, not yet in DB but let's prepare
- if ($num > 7 || strpos($argument, '::') !== false) { // Too many :, or invalid compressed format
- Message::addError('invalid-ip-address', $argument);
- return '0';
- } elseif ($num <= 7 && substr($argument, -1) === ':') {
- $argument .= '*';
- } elseif ($num < 7) {
- $argument .= ':*';
- }
+ // TODO: Probably valid IPv6, not yet in DB
} elseif (($num = substr_count($argument, '.')) !== 0 && $num <= 3) {
if (substr($argument, -1) === '.') {
$argument .= '*';
@@ -564,7 +636,8 @@ class IpStatisticsFilter extends StatisticsFilter
Message::addError('invalid-ip-address', $argument);
return '0';
}
- return "clientip LIKE '" . str_replace('*', '%', $argument) . "'";
+ $operator = $operator[0] === '!' ? 'NOT LIKE' : 'LIKE';
+ return "clientip $operator '" . str_replace('*', '%', $argument) . "'";
}
}
@@ -576,18 +649,170 @@ class IsClientStatisticsFilter extends StatisticsFilter
parent::__construct(null, []);
}
- public function whereClause(string $operator, $argument, array &$args, array &$joins)
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
{
if ($argument) {
- $joins[] = ' LEFT JOIN runmode USING (machineuuid)';
+ $joins[] = ' LEFT JOIN runmode ON (m.machineuuid = runmode.machineuuid)';
return "(runmode.isclient <> 0 OR runmode.isclient IS NULL)";
}
- $joins[] = ' INNER JOIN runmode USING (machineuuid)';
+ $joins[] = ' INNER JOIN runmode ON (m.machineuuid = runmode.machineuuid)';
return "runmode.isclient = 0";
}
}
+class PciDeviceStatisticsFilter extends StatisticsFilter
+{
+
+ public function __construct()
+ {
+ parent::__construct(null, ['='], 'vvvv[:dddd][,cccc]');
+ }
+
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
+ {
+ // vendor[:device][,class]
+ if (!preg_match('/^(?<v>[0-9a-f]{4})(?::(?<d>[0-9a-f]{4}))?(?:,(?<c>[0-9a-f]{4}))?$/i', $argument, $out)) {
+ Message::addError('invalid-pciid', $argument);
+ return '0';
+ }
+ $vendor = $out['v'];
+ $device = $out['d'] ?? '';
+ $class = $out['c'] ?? '';
+ // basic join for hw_x_machine
+ $shw = StatisticsFilter::addHardwareJoin($args, $joins, HardwareInfo::PCI_DEVICE);
+ $_ = [];
+ $c = new HardwareQueryColumn(true, 'vendor');
+ $c->addCondition($operator, $vendor);
+ $c->generate($joins, $_, $args, [], $shw);
+ if (!empty($device)) {
+ $c = new HardwareQueryColumn(true, 'device');
+ $c->addCondition($operator, $device);
+ $c->generate($joins, $_, $args, [], $shw);
+ }
+ if (!empty($class)) {
+ $c = new HardwareQueryColumn(true, 'class');
+ $c->addCondition($operator, $class);
+ $c->generate($joins, $_, $args, [], $shw);
+ }
+ return '1';
+ }
+
+}
+
+class NicSpeedStatisticsFilter extends StatisticsFilter
+{
+
+ public function __construct()
+ {
+ parent::__construct(null, StatisticsFilter::OP_ORDINAL, 'MBit/s');
+ }
+
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
+ {
+ $shw = StatisticsFilter::addHardwareJoin($args, $joins, HardwareInfo::MAINBOARD);
+ $_ = [];
+ $c = new HardwareQueryColumn(false, 'nic-speed');
+ $c->addCondition($operator, $argument);
+ $c->generate($joins, $_, $args, [], $shw);
+ return '1';
+ }
+
+}
+
+class HddRpmStatisticsFilter extends StatisticsFilter
+{
+
+ public function __construct()
+ {
+ parent::__construct(null, StatisticsFilter::OP_ORDINAL, '7200');
+ }
+
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
+ {
+ $shw = StatisticsFilter::addHardwareJoin($args, $joins, HardwareInfo::HDD);
+ $_ = [];
+ $c = new HardwareQueryColumn(true, 'rotation_rate');
+ $c->addCondition($operator, $argument);
+ $c->generate($joins, $_, $args, [], $shw);
+ return '1';
+ }
+
+}
+
+class SystemModelStatisticsFilter extends StatisticsFilter
+{
+
+ public function __construct()
+ {
+ parent::__construct(null, StatisticsFilter::OP_STRCMP, 'PC-365 (IBM)');
+ }
+
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
+ {
+ $shw = StatisticsFilter::addHardwareJoin($args, $joins, HardwareInfo::DMI_SYSTEM);
+ $_ = [];
+ $manufacturer = null;
+ $model = $argument;
+ if (preg_match('/^(.*)\((.*)\)\s*$/', $model, $out)) {
+ $manufacturer = trim($out[2]);
+ $model = trim($out[1]);
+ }
+ $c = new HardwareQueryColumn(true, 'Product Name');
+ $c->addCondition($operator, $model);
+ $c->generate($joins, $_, $args, [], $shw);
+ if ($manufacturer !== null) {
+ $c = new HardwareQueryColumn(true, 'Manufacturer');
+ $c->addCondition($operator, $manufacturer);
+ $c->generate($joins, $_, $args, [], $shw);
+ }
+ return '1';
+ }
+
+}
+
+class MacAddressStatisticsFilter extends SimpleStatisticsFilter
+{
+ public function __construct()
+ {
+ parent::__construct('macaddr', self::OP_STRCMP, '11-22-33-44-55-66');
+ }
+
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
+ {
+ // Allow just 12 hex digits, and convert ':' to '-', which we unfortunately settled on for the DB format
+ if (preg_match('/^([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i',
+ $argument, $out)) {
+ $argument = $out[1] . '-' . $out[2] . '-' . $out[3] . '-' . $out[4] . '-' . $out[5] . '-' . $out[6];
+ } elseif (strpos($argument, ':') !== false) {
+ $argument = str_replace(':', '-', $argument);
+ }
+ return parent::whereClause($operator, $argument, $args, $joins);
+ }
+}
+
+class AnyHardwarePropStatisticsFilter extends StatisticsFilter
+{
+
+ public function __construct()
+ {
+ parent::__construct(null, ['~']);
+ }
+
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
+ {
+ $shw = StatisticsFilter::addHardwareJoin($args, $joins);
+ $val = self::getNewKey('val');
+ $key1 = self::getNewKey('hw');
+ $joins[] = "LEFT JOIN statistic_hw_prop $key1 ON (`$key1`.`value` LIKE :$val AND `$key1`.hwid = `$shw`.hwid)";
+ $key2 = self::getNewKey('hw');
+ $joins[] = "LEFT JOIN machine_x_hw_prop $key2 ON (`$key2`.`value` LIKE :$val AND `$key2`.machinehwid = mxhw.machinehwid)";
+ $args[$val] = '%' . str_replace(['%', '*'], ['_', '%'], $argument) . '%';
+ return "((`$key1`.`value` IS NOT NULL) OR (`$key2`.`value` IS NOT NULL))";
+ }
+
+}
+
class DatabaseFilter
{
/** @var StatisticsFilter
@@ -595,6 +820,10 @@ class DatabaseFilter
private $inst;
public $op;
public $argument;
+
+ /**
+ * Called by StatisticsFilter::bind().
+ */
public function __construct(StatisticsFilter $inst, string $op, $argument)
{
$inst->validateOperator($op);
@@ -602,16 +831,25 @@ class DatabaseFilter
$this->op = $op;
$this->argument = $argument;
}
- public function whereClause(array &$args, array &$joins)
+
+ /**
+ * Called from StatisticsFilterSet::makeFragments() to build the final query.
+ */
+ public function whereClause(array &$args, array &$joins): string
{
return $this->inst->whereClause($this->op, $this->argument, $args, $joins);
}
- public function isClass($what)
+ public function isClass(string $what): bool
{
return get_class($this->inst) === $what;
}
+ public function getClass(): string
+ {
+ return get_class($this->inst);
+ }
+
}
StatisticsFilter::initConstants();
diff --git a/modules-available/statistics/inc/statisticsfilterset.inc.php b/modules-available/statistics/inc/statisticsfilterset.inc.php
index a38f9d3f..26595e93 100644
--- a/modules-available/statistics/inc/statisticsfilterset.inc.php
+++ b/modules-available/statistics/inc/statisticsfilterset.inc.php
@@ -9,7 +9,10 @@ class StatisticsFilterSet
private $cache = false;
- public function __construct($filters)
+ /**
+ * @param DatabaseFilter[] $filters
+ */
+ public function __construct(array $filters)
{
$this->filters = $filters;
}
@@ -37,16 +40,10 @@ class StatisticsFilterSet
$join = implode(' ', array_unique($joins));
$this->cache = compact('where', 'join', 'args');
}
-
- public function isNoId44Filter()
- {
- $filter = $this->hasFilter('Id44GbStatisticsFilter');
- return $filter !== false && $filter->argument == 0;
- }
public function filterNonClients()
{
- if (Module::get('runmode') === false || $this->hasFilter('IsClientStatisticsFilter') !== false)
+ if (Module::get('runmode') === false || $this->hasFilter('IsClientStatisticsFilter') !== null)
return;
$this->cache = false;
// Runmode module exists, add filter
@@ -55,27 +52,27 @@ class StatisticsFilterSet
/**
* @param string $type filter type (class name)
- * @return false|DatabaseFilter The filter, false if not found
+ * @return ?DatabaseFilter The filter, null if not found
*/
- public function hasFilter($type)
+ public function hasFilter(string $type): ?DatabaseFilter
{
foreach ($this->filters as $filter) {
if ($filter->isClass($type)) {
return $filter;
}
}
- return false;
+ return null;
}
/**
* @param string $type filter type key/id
- * @return false|DatabaseFilter The filter, false if not found
+ * @return ?DatabaseFilter The filter, null if not found
*/
- public function hasFilterKey($type)
+ public function hasFilterKey(string $type): ?DatabaseFilter
{
if (isset($this->filters[$type]))
return $this->filters[$type];
- return false;
+ return null;
}
/**
@@ -85,7 +82,7 @@ class StatisticsFilterSet
* @param string $permission permission to use
* @return bool false if no permission for any location, true otherwise
*/
- public function setAllowedLocationsFromPermission($permission)
+ public function setAllowedLocationsFromPermission(string $permission): bool
{
if (!Module::isAvailable('locations'))
return true;
@@ -108,9 +105,35 @@ class StatisticsFilterSet
*/
public function getAllowedLocations()
{
- if (isset($this->filters['permissions']->argument) && is_array($this->filters['permissions']->argument))
- return $this->filters['permissions']->argument;
+ if (isset($this->filters['permissions']) && is_array($this->filters['permissions']->argument))
+ return (array)$this->filters['permissions']->argument;
return false;
}
+ public function suitableForUsageGraph(): bool
+ {
+ foreach ($this->filters as $filter) {
+ switch ($filter->getClass()) {
+ case 'LocationStatisticsFilter':
+ case 'IsClientStatisticsFilter':
+ break;
+ case 'DateStatisticsFilter':
+ if ($filter->op !== '>' && $filter->op !== '>=')
+ return false;
+ if (strtotime($filter->argument) + 3*86400 > time())
+ return false;
+ break;
+ case 'RuntimeStatisticsFilter':
+ if ($filter->op !== '>' && $filter->op !== '>=')
+ return false;
+ if ($filter->argument < 3 * 24)
+ return false;
+ break;
+ default:
+ return false;
+ }
+ }
+ return true;
+ }
+
}
diff --git a/modules-available/statistics/inc/statisticshooks.inc.php b/modules-available/statistics/inc/statisticshooks.inc.php
index 746bdabf..6b9dfa21 100644
--- a/modules-available/statistics/inc/statisticshooks.inc.php
+++ b/modules-available/statistics/inc/statisticshooks.inc.php
@@ -5,7 +5,7 @@ class StatisticsHooks
private static $row = false;
- private static function getRow($machineuuid)
+ private static function getRow(string $machineuuid)
{
if (self::$row !== false)
return;
@@ -13,15 +13,22 @@ class StatisticsHooks
['machineuuid' => $machineuuid]);
}
- public static function getBaseconfigName($machineuuid)
+ /**
+ * Hook for baseconfig.
+ * @return false|string Client name, or false if invalid
+ */
+ public static function getBaseconfigName(string $machineuuid)
{
self::getRow($machineuuid);
if (self::$row === false)
return false;
- return self::$row['hostname'] ? self::$row['hostname'] : self::$row['clientip'];
+ return self::$row['hostname'] ?: self::$row['clientip'];
}
- public static function baseconfigLocationResolver($machineuuid)
+ /**
+ * Hook for baseconfig.
+ */
+ public static function baseconfigLocationResolver(string $machineuuid): int
{
self::getRow($machineuuid);
if (self::$row === false)
@@ -30,16 +37,17 @@ class StatisticsHooks
}
/**
- * Hook to get inheritance tree for all config vars
- * @param int $machineuuid MachineUUID currently being edited
+ * Hook to get inheritance tree for all config vars.
+ *
+ * @param string $machineuuid MachineUUID currently being edited
*/
- public static function baseconfigInheritance($machineuuid)
+ public static function baseconfigInheritance(string $machineuuid): array
{
self::getRow($machineuuid);
if (self::$row === false)
return [];
BaseConfig::prepareWithOverrides([
- 'locationid' => self::$row['locationid']
+ 'locationid' => self::$row['locationid'] ?? 0
]);
return ConfigHolder::getRecursiveConfig(true);
}
diff --git a/modules-available/statistics/inc/statisticsstyling.inc.php b/modules-available/statistics/inc/statisticsstyling.inc.php
index 1fd1d326..0e158026 100644
--- a/modules-available/statistics/inc/statisticsstyling.inc.php
+++ b/modules-available/statistics/inc/statisticsstyling.inc.php
@@ -3,19 +3,19 @@
class StatisticsStyling
{
- public static function ramColorClass($mb)
+ public static function ramColorClass(int $mb): string
{
- if ($mb < 1500) {
+ if ($mb < 2500) {
return 'danger';
}
- if ($mb < 2500) {
+ if ($mb < 5100) {
return 'warning';
}
return '';
}
- public static function kvmColorClass($state)
+ public static function kvmColorClass(string $state): string
{
if ($state === 'DISABLED') {
return 'danger';
@@ -27,7 +27,7 @@ class StatisticsStyling
return '';
}
- public static function hddColorClass($gb)
+ public static function hddColorClass(int $gb): string
{
if ($gb < 7) {
return 'danger';
@@ -39,4 +39,24 @@ class StatisticsStyling
return '';
}
+ /**
+ * Take a machine state enum value, return a matching glyphicon class.
+ * @param string $state State value (OFFLINE, IDLE, ...)
+ */
+ public static function machineStateToIcon(string $state): string
+ {
+ switch ($state) {
+ case 'OFFLINE':
+ return 'glyphicon-off';
+ case 'IDLE':
+ return 'glyphicon-ok green';
+ case 'OCCUPIED':
+ return 'glyphicon-user red';
+ case 'STANDBY':
+ return 'glyphicon-off green';
+ default:
+ return 'glyphicon-question-sign';
+ }
+ }
+
} \ No newline at end of file
diff --git a/modules-available/statistics/install.inc.php b/modules-available/statistics/install.inc.php
index 3becce8f..bc8a5c91 100644
--- a/modules-available/statistics/install.inc.php
+++ b/modules-available/statistics/install.inc.php
@@ -11,7 +11,7 @@ $res[] = tableCreate('statistic', "
`clientip` varchar(40) NOT NULL,
`machineuuid` char(36) CHARACTER SET ascii DEFAULT NULL,
`username` varchar(30) NOT NULL,
- `data` varchar(255) NOT NULL,
+ `data` BLOB NOT NULL,
PRIMARY KEY (`logid`),
KEY `dateline` (`dateline`),
KEY `logtypeid` (`typeid`,`dateline`),
@@ -40,8 +40,10 @@ $res[] = tableCreate('machine', "
`cpumodel` varchar(120) NOT NULL,
`systemmodel` varchar(120) NOT NULL DEFAULT '',
`id44mb` int(10) unsigned NOT NULL,
+ `id45mb` int(10) unsigned NOT NULL DEFAULT 0,
`badsectors` int(10) unsigned NOT NULL,
- `data` mediumtext NOT NULL,
+ `data` mediumblob NOT NULL,
+ `dataparsetime` int(10) unsigned NOT NULL DEFAULT 0,
`hostname` varchar(200) NOT NULL DEFAULT '',
`currentsession` varchar(120) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL,
`currentuser` varchar(50) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL,
@@ -64,7 +66,7 @@ $res[] = $machineHwCreate = tableCreate('machine_x_hw', "
`machinehwid` int(10) unsigned NOT NULL AUTO_INCREMENT,
`hwid` int(10) unsigned NOT NULL,
`machineuuid` char(36) CHARACTER SET ascii NOT NULL,
- `devpath` char(50) CHARACTER SET ascii NOT NULL,
+ `devpath` char(32) CHARACTER SET ascii NOT NULL,
`disconnecttime` int(10) unsigned NOT NULL COMMENT 'time the device was not connected to the pc anymore for the first time, 0 if it is connected',
PRIMARY KEY (`machinehwid`),
UNIQUE KEY `hwid` (`hwid`,`machineuuid`,`devpath`),
@@ -74,23 +76,25 @@ $res[] = $machineHwCreate = tableCreate('machine_x_hw', "
$res[] = tableCreate('machine_x_hw_prop', "
`machinehwid` int(10) unsigned NOT NULL,
- `prop` char(16) CHARACTER SET ascii NOT NULL,
+ `prop` varchar(64) CHARACTER SET ascii NOT NULL,
`value` varchar(500) NOT NULL,
+ `numeric` bigint(20) DEFAULT NULL,
PRIMARY KEY (`machinehwid`,`prop`)
");
$res[] = tableCreate('statistic_hw', "
`hwid` int(10) unsigned NOT NULL AUTO_INCREMENT,
- `hwtype` char(11) CHARACTER SET ascii NOT NULL,
- `hwname` varchar(200) NOT NULL,
+ `hwtype` char(16) CHARACTER SET ascii NOT NULL,
+ `hwname` char(32) CHARACTER SET ascii NOT NULL,
PRIMARY KEY (`hwid`),
UNIQUE KEY `hwtype` (`hwtype`,`hwname`)
");
$res[] = tableCreate('statistic_hw_prop', "
`hwid` int(10) unsigned NOT NULL,
- `prop` char(16) CHARACTER SET ascii NOT NULL,
+ `prop` varchar(64) CHARACTER SET ascii NOT NULL,
`value` varchar(500) NOT NULL,
+ `numeric` bigint(20) DEFAULT NULL,
PRIMARY KEY (`hwid`,`prop`)
");
@@ -248,7 +252,7 @@ if (!tableHasColumn('machine', 'live_tmpsize')) {
// 2019-02-20: Convert bogus UUIDs
$res2 = Database::simpleQuery("SELECT machineuuid, macaddr FROM machine WHERE machineuuid LIKE '00000000000000_-%'");
-while ($row = $res2->fetch(PDO::FETCH_ASSOC)) {
+foreach ($res2 as $row) {
$new = strtoupper('baad1d00-9491-4716-b98b-' . preg_replace('/[^0-9a-f]/i', '', $row['macaddr']));
error_log('Replacing ' . $row['machineuuid'] . ' with ' . $new);
if (strlen($new) === 36) {
@@ -299,5 +303,69 @@ if (!tableHasColumn('machine', 'live_id45size')) {
$res[] = UPDATE_DONE;
}
+// 2021-08-19 Enhanced machine property indexing
+if (stripos(tableColumnType('statistic_hw_prop', 'prop'), 'varchar(64)') === false) {
+ Database::exec("DELETE FROM statistic_hw_prop WHERE prop NOT REGEXP BINARY '^[a-zA-Z0-9_ =@*!.:/\\\\-]+$'");
+ $ret = Database::exec("ALTER TABLE statistic_hw_prop
+ MODIFY `prop` varchar(64) CHARACTER SET ascii NOT NULL,
+ ADD `numeric` bigint(20) DEFAULT NULL");
+ if ($ret === false) {
+ finalResponse(UPDATE_FAILED, 'Changing prop of statistic_hw_prop failed: ' . Database::lastError());
+ }
+ $res[] = UPDATE_DONE;
+}
+if (stripos(tableColumnType('machine_x_hw_prop', 'prop'), 'varchar(64)') === false) {
+ Database::exec("DELETE FROM machine_x_hw_prop WHERE prop NOT REGEXP BINARY '^[a-zA-Z0-9_ =@*!.:/\\\\-]+$'");
+ $ret = Database::exec("ALTER TABLE machine_x_hw_prop
+ MODIFY `prop` varchar(64) CHARACTER SET ascii NOT NULL,
+ ADD `numeric` bigint(20) DEFAULT NULL");
+ if ($ret === false) {
+ finalResponse(UPDATE_FAILED, 'Changing prop of machine_x_hw_prop failed: ' . Database::lastError());
+ }
+ $res[] = UPDATE_DONE;
+}
+if (stripos(tableColumnType('statistic_hw', 'hwname'), 'char(32)') === false) {
+ Database::exec("DELETE FROM statistic_hw WHERE hwname NOT REGEXP BINARY '^[a-zA-Z0-9_ =@*!.:/\\\\-]+$'");
+ $ret = Database::exec("ALTER TABLE statistic_hw MODIFY `hwname` char(32) CHARACTER SET ascii NOT NULL,
+ MODIFY `hwtype` char(16) CHARACTER SET ascii NOT NULL");
+ if ($ret === false) {
+ finalResponse(UPDATE_FAILED, 'Changing hwname/hwtype of statistic_hw failed: ' . Database::lastError());
+ }
+ $res[] = UPDATE_DONE;
+}
+if (stripos(tableColumnType('machine_x_hw', 'devpath'), 'char(32)') === false) {
+ Database::exec("DELETE FROM machine_x_hw WHERE devpath NOT REGEXP BINARY '^[a-zA-Z0-9_ =@*!.:/\\\\-]+$'");
+ $ret = Database::exec("ALTER TABLE machine_x_hw MODIFY `devpath` char(32) CHARACTER SET ascii NOT NULL");
+ if ($ret === false) {
+ finalResponse(UPDATE_FAILED, 'Changing devpath of machine_x_hw failed: ' . Database::lastError());
+ }
+ $res[] = UPDATE_DONE;
+}
+if (!tableHasColumn('machine', 'dataparsetime')) {
+ $ret = Database::exec("ALTER TABLE `machine`
+ ADD COLUMN `dataparsetime` int(10) unsigned NOT NULL DEFAULT '0' AFTER `data`");
+ if ($ret === false) {
+ finalResponse(UPDATE_FAILED, 'Adding dateparsetime column to machine table failed: ' . Database::lastError());
+ }
+ $res[] = UPDATE_DONE;
+}
+if (!tableHasColumn('machine', 'id45mb')) {
+ $ret = Database::exec("ALTER TABLE `machine`
+ ADD COLUMN `id45mb` int(10) unsigned NOT NULL DEFAULT 0 AFTER `id44mb`");
+ if ($ret === false) {
+ finalResponse(UPDATE_FAILED, 'Adding id45mb column to machine table failed: ' . Database::lastError());
+ }
+ $res[] = UPDATE_DONE;
+}
+
+// 2022-11-22 Change data column of statistic table from varchar(255) to blob
+if (stripos(tableColumnType('statistic', 'data'), 'blob') === false) {
+ $ret = Database::exec("ALTER TABLE `statistic` MODIFY COLUMN `data` BLOB NOT NULL");
+ if ($ret === false) {
+ finalResponse(UPDATE_FAILED, 'Changing statistic.data to blob failed: ' . Database::lastError());
+ }
+ $res[] = UPDATE_DONE;
+}
+
// Create response
responseFromArray($res);
diff --git a/modules-available/statistics/lang/de/filters.json b/modules-available/statistics/lang/de/filters.json
index 3fe97532..ef423daa 100644
--- a/modules-available/statistics/lang/de/filters.json
+++ b/modules-available/statistics/lang/de/filters.json
@@ -6,19 +6,24 @@
"firstseen": "Erster Boot",
"gbram": "RAM (GB)",
"hddgb": "ID44 (GB)",
+ "hddrpm": "HDD U\/min",
"hostname": "Hostname",
"kvmstate": "Virtualisierung",
"lastboot": "Letzter Boot",
"lastseen": "Letzte Aktivit\u00e4t",
+ "live_id45free": "ID45 frei (MB)",
"live_memfree": "RAM frei (MB)",
"live_swapfree": "swap frei (MB)",
"live_tmpfree": "ID44 frei (MB)",
"location": "Raum\/Ort",
- "logintime": "Letzter Login",
"macaddr": "MAC-Adresse",
"machineuuid": "System-UUID",
+ "nicspeed": "NIC-Geschwindigkeit",
+ "pcidev": "PCI-Ger\u00e4t",
+ "persistentgb": "ID45 (GB)",
"realcores": "CPU-Kerne (real)",
"runtime": "Laufzeit (Stunden)",
+ "standbycrash": "Crashes im Standby",
"state": "Zustand",
"systemmodel": "System-Modell"
} \ No newline at end of file
diff --git a/modules-available/statistics/lang/de/messages.json b/modules-available/statistics/lang/de/messages.json
index e1688cbf..023dac4c 100644
--- a/modules-available/statistics/lang/de/messages.json
+++ b/modules-available/statistics/lang/de/messages.json
@@ -2,12 +2,14 @@
"cleared-n-machines": "{{0}} Clients zur\u00fcckgesetzt",
"deleted-n-machines": "{{0}} Clients gel\u00f6scht",
"ignored-both-in-use": "Rechnerpaar ignoriert, da beide noch in Betrieb zu sein scheinen. ({{0}} und {{1}})",
+ "ignored-no-permission": "{{0}} wurde ignoriert: Keine Berechtigung",
"invalid-cidr-notion": "Ung\u00fcltiges CIDR-Format: {{0}}",
"invalid-date-format": "Ung\u00fcltige Datumsangabe: {{0}}",
"invalid-enum-item": "Die Auswahl {{1}} ist ung\u00fcltig f\u00fcr {{0}}",
"invalid-filter-argument": "Das Argument {{1}} ist nicht g\u00fcltig f\u00fcr den Filter {{0}}",
"invalid-filter-key": "{{0}} ist kein g\u00fcltiges Filterkriterium",
"invalid-ip-address": "Ung\u00fcltige IP-Adresse: {{0}}",
+ "invalid-pciid": "Ung\u00fcltige PCI-ID {{0}}",
"invalid-replace-format": "Ung\u00fcltiges Parameterformat ({{0}})",
"no-replacement-matches": "Keine Rechner gefunden, die den oben genannten Kriterien entsprechen",
"notes-saved": "Anmerkungen gespeichert",
diff --git a/modules-available/statistics/lang/de/module.json b/modules-available/statistics/lang/de/module.json
index 902a9573..23fc52df 100644
--- a/modules-available/statistics/lang/de/module.json
+++ b/modules-available/statistics/lang/de/module.json
@@ -1,5 +1,10 @@
{
+ "location-column-header-count": "Rechner",
+ "location-column-header-load": "Besetzt",
"module_name": "Client-Statistiken",
"page_title": "Client-Statistiken",
+ "submenu_hints": "Hinweise",
+ "submenu_projectors": "Beamer",
+ "submenu_replace": "Rechner ersetzen",
"unused": "Ungenutzt"
} \ No newline at end of file
diff --git a/modules-available/statistics/lang/de/permissions.json b/modules-available/statistics/lang/de/permissions.json
index cd1e7a4e..7e5c880d 100644
--- a/modules-available/statistics/lang/de/permissions.json
+++ b/modules-available/statistics/lang/de/permissions.json
@@ -1,10 +1,12 @@
{
"hardware.projectors.edit": "Beamerzuweisung bearbeiten",
"hardware.projectors.view": "Beamerzuweisung anzeigen",
+ "hints": "Hinweise",
"machine.delete": "Rechner l\u00f6schen.",
"machine.note.edit": "Anmerkungen bearbeiten",
"machine.note.view": "Anmerkungen anzeigen",
"machine.view-details": "Clientinformationen anzeigen",
+ "replace": "Ersetzen",
"view.list": "Clientliste anzeigen",
"view.summary": "Visualisierung anzeigen"
} \ No newline at end of file
diff --git a/modules-available/statistics/lang/de/template-tags.json b/modules-available/statistics/lang/de/template-tags.json
index 43665a78..064805c2 100644
--- a/modules-available/statistics/lang/de/template-tags.json
+++ b/modules-available/statistics/lang/de/template-tags.json
@@ -1,13 +1,16 @@
{
"lang_64bitSupport": "64\u2009Bit Gast-Support",
+ "lang_MbitPerSecond": "MBit\/s",
"lang_address": "Adresse",
"lang_apply": "Anwenden",
+ "lang_baseSystem": "Grundsystem",
"lang_biosDate": "Ver\u00f6ffentlichungsdatum",
"lang_biosFixes": "BIOS-Fehlerkorrekturen",
"lang_biosUpdate": "BIOS Update",
"lang_biosUpdateLink": "Zur Herstellerseite",
"lang_biosVersion": "BIOS-Version",
"lang_bootedWithoutAnyRunmode": "Ohne besonderen Betriebsmodus gestartet",
+ "lang_boottimeTooltip": "Bootzeit, Kernel-Init bis Login-Screen",
"lang_clientInDifferentRunmode": "Aktueller Betriebsmodus",
"lang_clientList": "Liste ausgew\u00e4hlter Rechner",
"lang_configVars": "Konfigurationsvariablen",
@@ -19,17 +22,26 @@
"lang_cputemp": "CPU-Temperatur",
"lang_details": "Details",
"lang_devices": "Ger\u00e4te",
+ "lang_duplex": "Duplex",
"lang_duration": "Dauer",
"lang_event": "Ereignis",
"lang_eventType": "Typ",
"lang_firstSeen": "Erste Aktivit\u00e4t",
"lang_free": "frei",
+ "lang_fullInfo": "Alle Werte",
"lang_gbRam": "RAM",
+ "lang_graphLectureTitle": "Blaue F\u00e4rbung = Anzahl der Veranstaltungen",
"lang_hardwareSummary": "Hardware",
"lang_hasNotes": "Zu diesem Rechner wurden Notizen hinterlegt",
+ "lang_hddUnused": "Ungenutzter Festplattenspeicher",
+ "lang_hddUnusedId44": "Mit unpartitioniertem Speicherbereich auf HDD\/SSD, und ohne ID44",
+ "lang_hddUnusedId45": "Mit unpartitioniertem Speicherbereich auf einer SSD, und ohne ID45",
"lang_hdds": "Festplatten",
"lang_hostname": "Hostname",
+ "lang_id44size": "ID44 Gr\u00f6\u00dfe",
+ "lang_id45size": "ID45 Gr\u00f6\u00dfe",
"lang_inUseMachines": "In Verwendung",
+ "lang_installedCountMax": "Slots belegt \/ frei",
"lang_ip": "IP-Adresse",
"lang_knownMachines": "Bekannte Clients",
"lang_kvmState": "Status",
@@ -38,6 +50,8 @@
"lang_labelFilter": "Aktive Filter (UND-Logik)",
"lang_lastBoot": "Letzter Boot",
"lang_lastSeen": "Zuletzt gesehen",
+ "lang_legacyCpuVmx": "Veraltete CPU (VMware)",
+ "lang_legacyCpuVmxText": "Diese Rechner haben eine CPU, die von neueren VMware-Versionen nicht mehr unterst\u00fctzt wird. Um VMware-VMs auf diesen Rechnern zu nutzen, m\u00fcssen diese mit einem Grundsystem betrieben werden, welches noch den VMware Player 12.5.x enth\u00e4lt, z.B. 30r1. Bedenken Sie jedoch, dass \u00e4ltere Grundsysteme neuere bwLehrpool-Funktionen nicht enthalten, und somit in ihrer Funktionalit\u00e4t eingeschr\u00e4nkt sein k\u00f6nnen.",
"lang_listDropdown": "Als Text",
"lang_location": "Ort",
"lang_logHeadline": "Logging",
@@ -50,6 +64,7 @@
"lang_machineOff": "Der Rechner ist ausgeschaltet, oder hat kein bwLehrpool gebootet",
"lang_machineStandby": "Im Standby",
"lang_machineSummary": "Zusammenfassung",
+ "lang_manufacturer": "Hersteller",
"lang_maximumAbbrev": "Max.",
"lang_mediaIntegrityErrors": "\"Media Integrity Errors\"",
"lang_memoryStats": "Arbeitsspeicher",
@@ -61,6 +76,10 @@
"lang_moduleHeading": "Client-Statistiken",
"lang_more": "Mehr",
"lang_newMachines": "Neue Ger\u00e4te",
+ "lang_nicDuplex": "Duplex",
+ "lang_nicSlowSpeed": "Langsame Netzwerkkarte",
+ "lang_nicSlowSpeedText": "Diese Rechner sind mit weniger als Gigabit Ethernet mit dem Netzwerk verbunden. Bootvorg\u00e4nge und VM-Starts k\u00f6nnen sp\u00fcrbar verlangsamt sein. Wenn sich die Anbindung dieser Rechner nicht verbessern l\u00e4sst, versuchen Sie, eine ID45-Partition auf diesen Rechnern einzurichten, und lokales Caching in den Konfigurationsvariablen zu aktivieren, um die Performance von konsekutiven Bootvorg\u00e4ngen und VM-Starts zu verbessern.",
+ "lang_nicSpeed": "Geschwindigkeit",
"lang_noEdid": "Kein EDID",
"lang_noProjectorsDefined": "Keine Beamer-Overrides definiert",
"lang_notes": "Anmerkungen",
@@ -72,12 +91,18 @@
"lang_partitionSize": "Gr\u00f6\u00dfe",
"lang_pcmodel": "PC-Modell",
"lang_pendingSectors": "Potentiell defekte Sektoren",
+ "lang_persistentPart": "Persistent",
+ "lang_persistentPartID": "ID45",
"lang_powerOnTime": "Betriebszeit",
"lang_projector": "Beamer",
"lang_projectors": "Beamer",
"lang_ram": "Arbeitsspeicher",
"lang_ramSize": "Gr\u00f6\u00dfe",
- "lang_ramSlots": "Speicher-Slots",
+ "lang_ramSizeCurrentMax": "Gr\u00f6\u00dfe \/ Maximal",
+ "lang_ramUnderclocked": "Unter Maximaltakt laufener RAM",
+ "lang_ramUnderclockedText": "Dies sind Rechner mit Speicherriegeln, die unter ihrem Maximaltakt laufen. Entweder l\u00e4sst sich die Leistung des Rechners steigern, indem im BIOS der Takt angehoben wird, oder die Speicherriegel k\u00f6nnen mit anderen Rechnern getauscht werden, um eine homogene Best\u00fcckung zu erreichen.",
+ "lang_ramUpgrade": "RAM aufr\u00fcsten",
+ "lang_ramUpgradeText": "Die folgenden Rechner haben wenig RAM. F\u00fcr den Betrieb mit VMs wird das Aufr\u00fcsten des Speichers empfohlen.",
"lang_realCores": "Kerne",
"lang_reallocatedSectors": "Defekte Sektoren",
"lang_reboot": "Neustart",
@@ -85,32 +110,43 @@
"lang_rebootKexecCheck": "Schneller Reboot direkt in bwLehrpool (kexec)",
"lang_remoteActions": "Ferngesteuerte Aktionen",
"lang_remoteExec": "Befehl Ausf\u00fchren",
+ "lang_remoteSpeedcheck": "Geschwindigkeitstest (Netzwerk)",
"lang_replace": "Ersetzen",
"lang_replaceInstructions": "Hier k\u00f6nnen Sie Metadaten automatisch \u00fcbertragen, wenn in einem Raum die Rechner ausgetauscht wurden. Dies setzt voraus, dass alle neuen Rechner die gleiche IP Adresse erhalten haben wie der Rechner, der zuvor am entsprechenden Platz stand, und die neuen Rechner alle einmal gestartet wurden. In der Liste unten sehen Sie alle Rechnerpaare, auf die folgendes zutrifft: 1) Die IP-Adressen sind identisch 2) Der letzte Boot des einen Rechners liegt vor dem ersten Boot des anderen Rechners. W\u00e4hlen Sie alle Rechnerpaare aus, f\u00fcr die eine Ersetzung stattfinden soll. Bei der Ersetzung werden alle Logeintr\u00e4ge, Sitzungslogs, Position im Raumplan und evtl. spezielle Betriebsmodi vom alten Rechner auf den neuen \u00dcbertragen.",
"lang_replaceMachinesHeading": "Rechner ersetzen",
- "lang_replaceNew": "Alter Rechner",
- "lang_replaceOld": "Neuer Rechner",
+ "lang_replaceNew": "Neuer Rechner",
+ "lang_replaceOld": "Alter Rechner",
"lang_resetClearIp": "IP-Adresse zur\u00fccksetzen",
"lang_roomplan": "Raumplan",
"lang_runMode": "Betriebsmodus",
"lang_runmodeMachines": "Mit besonderem Betriebsmodus",
"lang_screens": "Bildschirme",
+ "lang_selectColumns": "Spalten ein\/ausblenden",
"lang_serialNo": "Serien-Nr",
"lang_showList": "Liste",
"lang_showVisualization": "Visualisierung",
"lang_shutdown": "Herunterfahren",
"lang_shutdownConfirm": "Ausgew\u00e4hlte Rechner wirklich herunterfahren?",
+ "lang_slot": "Slot",
+ "lang_slots": "Slots",
+ "lang_smartSelfTestFailed": "SMART Status FAILED",
"lang_sockets": "Sockel",
+ "lang_speed": "Geschwindigkeit",
+ "lang_speedCurrent": "Aktuelle Geschwindigkeit",
+ "lang_speedDesign": "Maximalgeschwindigkeit",
"lang_sureClearIp": "Die IP-Adresse der ausgew\u00e4hlten Rechner wird auf 0.0.0.0 gesetzt, wodurch die Zuordnung zum aktuellen Raum aufgehoben wird.\r\nDie Rechner bleiben mit ihren sonstigen Daten in der Datenbank vorhanden, und sobald ein Rechner das n\u00e4chste mal startet, wird die IP-Adresse wieder aktualisiert. Diese Funktion ist dann n\u00fctzlich, wenn einige Rechner in einem Raum abgebaut wurden, und in der Zukunft in einem anderen Raum wieder aufgebaut werden sollen. Durch zur\u00fccksetzen der IP-Adresse werden die Rechner in der Zwischenzeit nicht mehr im alten Raum angezeigt, was die \u00dcbersicht verbessern kann, bleiben aber \u00fcber ihre sonstigen Merkmale weiterhin in den Statistiken aufsuchbar.",
"lang_sureDeletePermanent": "M\u00f6chten Sie diese(n) Rechner wirklich unwiderruflich aus der Datenbank entfernen?\r\n\r\nWichtig: L\u00f6schen verhindert nicht, dass ein Rechner nach erneutem Starten von bwLehrpool wieder in die Datenbank aufgenommen wird.",
"lang_sureReplaceNoUndo": "Wollen Sie die Daten der ausgew\u00e4hlten Rechner \u00fcbertragen? Diese Aktion kann nicht r\u00fcckg\u00e4ngig gemacht werden.",
"lang_swap": "Swap",
- "lang_tempPart": "Temp. Partition",
+ "lang_tempPart": "Tempor\u00e4r",
+ "lang_tempPartID": "ID44",
"lang_tempPartStats": "Tempor\u00e4re Partition",
"lang_thoseAreProjectors": "Diese Modellnamen werden als Beamer behandelt, auch wenn die EDID-Informationen des Ger\u00e4tes anderes berichten.",
"lang_timebarDesc": "Visuelle Darstellung der letzten Tage. Rote Abschnitte zeigen, wann der Rechner belegt war, gr\u00fcne, wann er nicht verwendet wurde, aber eingeschaltet war. Die leicht abgedunkelten Abschnitte markieren N\u00e4chte (22 bis 8 Uhr).",
"lang_tmpGb": "Temp-HDD",
"lang_total": "Gesamt",
+ "lang_type": "Typ",
+ "lang_unused": "Ungenutzt",
"lang_usageDetails": "Nutzungsdetails",
"lang_usageState": "Zustand",
"lang_uuid": "UUID",
diff --git a/modules-available/statistics/lang/en/filters.json b/modules-available/statistics/lang/en/filters.json
index bd262d32..79372115 100644
--- a/modules-available/statistics/lang/en/filters.json
+++ b/modules-available/statistics/lang/en/filters.json
@@ -6,19 +6,24 @@
"firstseen": "First boot",
"gbram": "RAM (GB)",
"hddgb": "ID44 (GB)",
+ "hddrpm": "HDD rpm",
"hostname": "Host name",
"kvmstate": "Virtualization",
"lastboot": "Last boot",
"lastseen": "Last activity",
+ "live_id45free": "ID45 free (MB)",
"live_memfree": "RAM free (MB)",
"live_swapfree": "swap free (MB)",
"live_tmpfree": "ID44 free (MB)",
"location": "Room\/Location",
- "logintime": "Last login",
"macaddr": "MAC address",
"machineuuid": "System UUID",
+ "nicspeed": "NIC speed",
+ "pcidev": "PCI device",
+ "persistentgb": "ID45 (GB)",
"realcores": "CPU cores (real)",
"runtime": "Uptime (hours)",
+ "standbycrash": "Crashes in Standby",
"state": "State",
"systemmodel": "System model"
} \ No newline at end of file
diff --git a/modules-available/statistics/lang/en/messages.json b/modules-available/statistics/lang/en/messages.json
index e4974923..139076d2 100644
--- a/modules-available/statistics/lang/en/messages.json
+++ b/modules-available/statistics/lang/en/messages.json
@@ -2,12 +2,14 @@
"cleared-n-machines": "Reset {{0}} clients",
"deleted-n-machines": "Deleted {{0}} clients",
"ignored-both-in-use": "Ignoring machine pair as both still seem to be in use. ({{0}} and {{1}})",
+ "ignored-no-permission": "Ignoring {{0}}: No permission",
"invalid-cidr-notion": "Invalid CIDR argument: {{0}}",
"invalid-date-format": "Invalid date format: {{0}}",
"invalid-enum-item": "Selection {{1}} is invalid for {{0}}",
"invalid-filter-argument": "{{1}} is not a vald argument for filter {{0}}",
"invalid-filter-key": "{{0}} is not a valid filter",
"invalid-ip-address": "Invalid IP address",
+ "invalid-pciid": "Invalid PCI-ID {{0}}",
"invalid-replace-format": "Invalid parameter format ({{0}})",
"no-replacement-matches": "No machines match the criteria from above",
"notes-saved": "Notes have been saved",
diff --git a/modules-available/statistics/lang/en/module.json b/modules-available/statistics/lang/en/module.json
index d923ce7b..6e8ffa82 100644
--- a/modules-available/statistics/lang/en/module.json
+++ b/modules-available/statistics/lang/en/module.json
@@ -1,4 +1,10 @@
{
+ "location-column-header-count": "Clients",
+ "location-column-header-load": "Used",
"module_name": "Client Statistics",
+ "page_title": "Client statistics",
+ "submenu_hints": "Hints",
+ "submenu_projectors": "Projectors",
+ "submenu_replace": "Replace machines",
"unused": "Unused"
} \ No newline at end of file
diff --git a/modules-available/statistics/lang/en/permissions.json b/modules-available/statistics/lang/en/permissions.json
index 373fcf07..4bfd92a2 100644
--- a/modules-available/statistics/lang/en/permissions.json
+++ b/modules-available/statistics/lang/en/permissions.json
@@ -1,10 +1,12 @@
{
"hardware.projectors.edit": "Edit beamer assignment",
"hardware.projectors.view": "Show beamer assignment",
+ "hints": "Hints",
"machine.delete": "Delete clients.",
"machine.note.edit": "Edit notes",
"machine.note.view": "Show notes",
"machine.view-details": "Show client details",
+ "replace": "Replace",
"view.list": "Show client list",
"view.summary": "Show visualization"
} \ No newline at end of file
diff --git a/modules-available/statistics/lang/en/template-tags.json b/modules-available/statistics/lang/en/template-tags.json
index 3fcbc049..10acfdb1 100644
--- a/modules-available/statistics/lang/en/template-tags.json
+++ b/modules-available/statistics/lang/en/template-tags.json
@@ -1,13 +1,16 @@
{
"lang_64bitSupport": "64\u2009Bit guest support",
+ "lang_MbitPerSecond": "MBit\/s",
"lang_address": "Address",
"lang_apply": "Apply",
+ "lang_baseSystem": "Base system",
"lang_biosDate": "Release date",
"lang_biosFixes": "BIOS fixes",
"lang_biosUpdate": "BIOS update",
"lang_biosUpdateLink": "Go to vendor's site",
"lang_biosVersion": "BIOS version",
"lang_bootedWithoutAnyRunmode": "Booted without any mode",
+ "lang_boottimeTooltip": "Startup duration, kernel init to login screen",
"lang_clientInDifferentRunmode": "Current mode",
"lang_clientList": "List of selected machines",
"lang_configVars": "Config Variables",
@@ -19,17 +22,26 @@
"lang_cputemp": "CPU temperature",
"lang_details": "Details",
"lang_devices": "Devices",
+ "lang_duplex": "Duplex",
"lang_duration": "Duration",
"lang_event": "Event",
"lang_eventType": "Type",
"lang_firstSeen": "First seen",
"lang_free": "free",
+ "lang_fullInfo": "All values",
"lang_gbRam": "RAM",
+ "lang_graphLectureTitle": "Blue shading = number of active lectures",
"lang_hardwareSummary": "Hardware",
"lang_hasNotes": "Notes have been added to this client",
+ "lang_hddUnused": "Unused hard drive space",
+ "lang_hddUnusedId44": "With unpartitioned space on HDD\/SSD, and without any ID44 partition",
+ "lang_hddUnusedId45": "With unpartitioned space on SSD, and without any ID45 partition",
"lang_hdds": "Hard disk drives",
"lang_hostname": "Hostname",
+ "lang_id44size": "ID44 size",
+ "lang_id45size": "ID45 size",
"lang_inUseMachines": "In use",
+ "lang_installedCountMax": "Slots in use \/ free",
"lang_ip": "IP address",
"lang_knownMachines": "Known clients",
"lang_kvmState": "State",
@@ -38,6 +50,8 @@
"lang_labelFilter": "Active filters (AND logic)",
"lang_lastBoot": "Last boot",
"lang_lastSeen": "Last seen",
+ "lang_legacyCpuVmx": "Legacy CPU (VMware)",
+ "lang_legacyCpuVmxText": "These machines have CPUs that are not supported by recent VMware versions. To run VMware VMs on these machines, you need to switch to an older netboot system that still contains VMware Player 12.5.x, e.g. 30r1. Please keep in mind that those older versions lack newer bwLwlehrpool features, so using those might lead to functionality missing or being buggy.",
"lang_listDropdown": "As text",
"lang_location": "Location",
"lang_logHeadline": "Logging",
@@ -50,6 +64,7 @@
"lang_machineOff": "Machine is powered down, or is not running bwLehrpool",
"lang_machineStandby": "In standby mode",
"lang_machineSummary": "Summary",
+ "lang_manufacturer": "Manufacturer",
"lang_maximumAbbrev": "max.",
"lang_mediaIntegrityErrors": "Media Integrity Errors",
"lang_memoryStats": "Memory",
@@ -61,6 +76,10 @@
"lang_moduleHeading": "Client Statistics",
"lang_more": "More",
"lang_newMachines": "New machines",
+ "lang_nicDuplex": "Duplex",
+ "lang_nicSlowSpeed": "Slow network link",
+ "lang_nicSlowSpeedText": "These machines are not connected via Gigabit ethernet. Bootup and VM startup can be notably slower. If you cannot improve the connectivity, try adding an ID45 partition to these clients, and enable local caching under \"config variables\".",
+ "lang_nicSpeed": "Speed",
"lang_noEdid": "No EDID",
"lang_noProjectorsDefined": "No projector overrides defined",
"lang_notes": "Notes",
@@ -72,12 +91,18 @@
"lang_partitionSize": "Size",
"lang_pcmodel": "System model",
"lang_pendingSectors": "Sectors pending reallocation",
+ "lang_persistentPart": "Persistent",
+ "lang_persistentPartID": "ID45",
"lang_powerOnTime": "Power on time",
"lang_projector": "Projector",
"lang_projectors": "Projectors",
"lang_ram": "Memory",
"lang_ramSize": "Size",
- "lang_ramSlots": "Memory slots",
+ "lang_ramSizeCurrentMax": "Current \/ Max",
+ "lang_ramUnderclocked": "Memory running slower than rated for",
+ "lang_ramUnderclockedText": "These clients are equipped with memory sticks that are running slower than what they are rated for. Maybe you can increase the memory speed in the BIOS setup, or swap them with those from another machine that can benefit from faster memory.",
+ "lang_ramUpgrade": "Memory upgrade",
+ "lang_ramUpgradeText": "These machines have little memory. To run VMs on these, we recommend adding more memory..",
"lang_realCores": "Cores",
"lang_reallocatedSectors": "Bad sectors",
"lang_reboot": "Reboot",
@@ -85,32 +110,43 @@
"lang_rebootKexecCheck": "Quick reboot to bwLehrpool (kexec)",
"lang_remoteActions": "Remote actions",
"lang_remoteExec": "Execute command",
+ "lang_remoteSpeedcheck": "Test network speed",
"lang_replace": "Replace",
"lang_replaceInstructions": "If some PCs\/clients have been physically replaced, you can re-assign log entries, session data, position information etc. from the old machine to the new one. This requires that the new machine gets assigned the same IP address as the old one and, if the room planner is used -- that it is placed in the same spot as the old one. The list below shows all machine pairs where 1) the last boot of one machine lies before the first boot of the other one 2) both machines had the same IP address last time they booted. The replacement action will reassign all log events, room plan location and special run mode from the old machine to the new machine.",
"lang_replaceMachinesHeading": "Replace machines",
- "lang_replaceNew": "Old machine",
- "lang_replaceOld": "New machine",
+ "lang_replaceNew": "New machine",
+ "lang_replaceOld": "Old machine",
"lang_resetClearIp": "Reset IP address",
"lang_roomplan": "Location",
"lang_runMode": "Mode of operation",
"lang_runmodeMachines": "With special mode of operation",
"lang_screens": "Screens",
+ "lang_selectColumns": "Show\/hide columns",
"lang_serialNo": "Serial no",
"lang_showList": "List",
"lang_showVisualization": "Visualization",
"lang_shutdown": "Shutdown",
"lang_shutdownConfirm": "Shutdown selected machines?",
+ "lang_slot": "Slot",
+ "lang_slots": "Slots",
+ "lang_smartSelfTestFailed": "SMART Status FAILED",
"lang_sockets": "Sockets",
+ "lang_speed": "Speed",
+ "lang_speedCurrent": "Current speed",
+ "lang_speedDesign": "Maximum speed",
"lang_sureClearIp": "The IP address of the selected machine(s) is set to 0.0.0.0, which removes the assignment to the current room\/location.\r\nThe computers otherwise remain in the database, and as soon as a computer starts the next time, the IP address is updated again. This function is useful if some computers have been removed from one room and are to be set up in another room in the future. By resetting the IP address now, those machines are no longer displayed in the old room, which de-clutters the list view, but they remain searchable in the statistics via their other characteristics.",
"lang_sureDeletePermanent": "Are your sure you want to delete the selected machine(s) from the database? This cannot be undone.\r\n\r\nNote: Deleting machines from the database does not prevent booting up bwLehrpool again, which would recreate their respective database entries.",
"lang_sureReplaceNoUndo": "Are you sure you want to replace the selected machine pairs? This action cannot be undone.",
"lang_swap": "swap",
- "lang_tempPart": "Temp. partition",
+ "lang_tempPart": "Temporary",
+ "lang_tempPartID": "ID44",
"lang_tempPartStats": "Temporary partition",
"lang_thoseAreProjectors": "These model names will always be treated as beamers, even if the device's EDID data says otherwise.",
"lang_timebarDesc": "Visual representation of the last few days. Red parts mark periods where the client was occupied, green parts where the client was idle. Dimmed parts mark nights (10pm to 8am).",
"lang_tmpGb": "Temp HDD",
"lang_total": "Total",
+ "lang_type": "Type",
+ "lang_unused": "Unused",
"lang_usageDetails": "Detailed usage",
"lang_usageState": "State",
"lang_uuid": "UUID",
diff --git a/modules-available/statistics/page.inc.php b/modules-available/statistics/page.inc.php
index 20ff929a..4f11e835 100644
--- a/modules-available/statistics/page.inc.php
+++ b/modules-available/statistics/page.inc.php
@@ -2,7 +2,6 @@
class Page_Statistics extends Page
{
- private $query;
private $show;
/**
@@ -22,6 +21,17 @@ class Page_Statistics extends Page
$this->transformLegacyQuery();
}
+ /*
+ Dictionary::translate('submenu_projectors');
+ Dictionary::translate('submenu_replace');
+ Dictionary::translate('submenu_hints');
+ */
+
+ foreach (['projectors', 'replace', 'hints'] as $section) {
+ Dashboard::addSubmenu('?do=statistics&show=' . $section,
+ Dictionary::translate('submenu_' . $section));
+ }
+
$this->show = Request::any('show', false, 'string');
if ($this->show === false && Request::isGet()) {
if (Request::get('uuid') !== false) {
@@ -81,6 +91,8 @@ class Page_Statistics extends Page
$this->rebootControl(false);
} elseif ($action === 'wol') {
$this->wol();
+ } elseif ($action === 'benchmark') {
+ $this->vmstoreBenchmark();
} elseif ($action === 'prepare-exec') {
if (Module::isAvailable('rebootcontrol')) {
RebootControl::prepareExec();
@@ -133,7 +145,7 @@ class Page_Statistics extends Page
/**
* @param bool $reboot true = reboot, false = shutdown
*/
- private function rebootControl($reboot)
+ private function rebootControl(bool $reboot)
{
if (!Module::isAvailable('rebootcontrol'))
return;
@@ -159,6 +171,25 @@ class Page_Statistics extends Page
}
}
+ /**
+ * @param bool $reboot true = reboot, false = shutdown
+ */
+ private function vmstoreBenchmark()
+ {
+ if (!Module::isAvailable('vmstore'))
+ return;
+ $ids = Request::post('uuid', [], 'array');
+ $ids = array_values($ids);
+ if (empty($ids)) {
+ Message::addError('main.parameter-empty', 'uuid');
+ return;
+ }
+ $this->getAllowedMachines(".vmstore.benchmark", $ids, $allowedMachines);
+ if (empty($allowedMachines))
+ return;
+ VmStoreBenchmark::prepareSelectDialog($allowedMachines);
+ }
+
private function getAllowedMachines($permission, $ids, &$allowedMachines)
{
$allowedLocations = User::getAllowedLocations($permission);
@@ -171,7 +202,7 @@ class Page_Statistics extends Page
$ids = array_flip($ids);
$allowedMachines = [];
$seenLocations = [];
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
unset($ids[$row['machineuuid']]);
settype($row['locationid'], 'int');
if (in_array($row['locationid'], $allowedLocations)) {
@@ -202,7 +233,7 @@ class Page_Statistics extends Page
$res = Database::simpleQuery('SELECT machineuuid, locationid FROM machine WHERE machineuuid IN (:ids)', compact('ids'));
$ids = array_flip($ids);
$delete = [];
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
unset($ids[$row['machineuuid']]);
if (in_array($row['locationid'], $allowedLocations)) {
$delete[] = $row['machineuuid'];
@@ -236,38 +267,31 @@ class Page_Statistics extends Page
return;
}
- $sortColumn = Request::any('sortColumn');
- $sortDirection = Request::any('sortDirection');
-
- $filters = StatisticsFilter::parseQuery();
- $filterSet = new StatisticsFilterSet($filters);
- $filterSet->setSort($sortColumn, $sortDirection);
-
- if (!$filterSet->setAllowedLocationsFromPermission('view.' . $this->show)) {
- Message::addError('main.no-permission');
- Util::redirect('?do=main');
- }
Message::addError('main.value-invalid', 'show', $this->show);
}
- private function redirectFirst($where, $join, $args)
- {
- // TODO Annoying at times, restore this?
- $res = Database::queryFirst("SELECT machineuuid FROM machine $join WHERE ($where) LIMIT 1", $args);
- if ($res !== false) {
- Util::redirect('?do=statistics&uuid=' . $res['machineuuid']);
- }
- }
-
protected function doAjax()
{
if (!User::load())
return;
- if (Request::any('action') === 'bios') {
+ $action = Request::any('action');
+ if ($action === 'bios') {
require_once 'modules/statistics/pages/machine.inc.php';
SubPage::ajaxCheckBios();
return;
}
+ if ($action === 'json-lookup') {
+ $reply = [];
+ foreach (Request::post('list', [], 'array') as $item) {
+ $name = PciId::getPciId(PciId::AUTO, $item, true);
+ if ($name === false) {
+ $name = '?????';
+ }
+ $reply[$item] = $name;
+ }
+ header('Content-Type: application/json');
+ die(json_encode($reply));
+ }
$param = Request::any('lookup', false, 'string');
if ($param === false) {
@@ -275,61 +299,14 @@ class Page_Statistics extends Page
}
$add = '';
if (preg_match('/^([a-f0-9]{4}):([a-f0-9]{4})$/', $param, $out)) {
- $cat = 'DEVICE';
- $host = $out[2] . '.' . $out[1];
$add = ' (' . $param . ')';
- } elseif (preg_match('/^([a-f0-9]{4})$/', $param, $out)) {
- $cat = 'VENDOR';
- $host = $out[1];
- } elseif (preg_match('/^c\.([a-f0-9]{2})([a-f0-9]{2})$/', $param, $out)) {
- $cat = 'CLASS';
- $host = $out[2] . '.' . $out[1] . '.c';
- } else {
- die('Invalid format requested');
}
- $cached = Page_Statistics::getPciId($cat, $param);
- if ($cached !== false && $cached['dateline'] > time()) {
- echo $cached['value'], $add;
- exit;
+ $cached = PciId::getPciId(PciId::AUTO, $param, true);
+ if ($cached === false) {
+ $cached = 'Unknown';
}
- $res = dns_get_record($host . '.pci.id.ucw.cz', DNS_TXT);
- if (is_array($res)) {
- foreach ($res as $entry) {
- if (isset($entry['txt']) && substr($entry['txt'], 0, 2) === 'i=') {
- $string = substr($entry['txt'], 2);
- Page_Statistics::setPciId($cat, $param, $string);
- echo $string, $add;
- exit;
- }
- }
- }
- if ($cached !== false) {
- echo $cached['value'], $add;
- exit;
- }
- die('Not found');
- }
-
- public static function getPciId($cat, $id)
- {
- static $cache = [];
- $key = $cat . '-' . $id;
- if (isset($cache[$key]))
- return $cache[$key];
- return $cache[$key] = Database::queryFirst('SELECT value, dateline FROM pciid WHERE category = :cat AND id = :id LIMIT 1',
- array('cat' => $cat, 'id' => $id));
- }
-
- private static function setPciId($cat, $id, $value)
- {
- Database::exec('INSERT INTO pciid (category, id, value, dateline) VALUES (:cat, :id, :value, :timeout)'
- . ' ON DUPLICATE KEY UPDATE value = VALUES(value), dateline = VALUES(dateline)',
- array(
- 'cat' => $cat,
- 'id' => $id,
- 'value' => $value,
- 'timeout' => time() + mt_rand(10, 30) * 86400,
- ), true);
+ echo $cached, $add;
+ exit;
}
}
diff --git a/modules-available/statistics/pages/hints.inc.php b/modules-available/statistics/pages/hints.inc.php
new file mode 100644
index 00000000..bfb28c24
--- /dev/null
+++ b/modules-available/statistics/pages/hints.inc.php
@@ -0,0 +1,221 @@
+<?php
+
+class SubPage
+{
+
+ public static function doPreprocess()
+ {
+ User::assertPermission('hints');
+ }
+
+ public static function doRender()
+ {
+ $locs = User::getAllowedLocations('hints');
+ if (in_array(0, $locs)) {
+ $locs = [];
+ }
+ self::showLegacyCpu($locs);
+ self::showMemoryUpgrade($locs);
+ self::showSlowNics($locs);
+ self::showUnusedSpace($locs);
+ self::showMemorySlow($locs);
+ }
+
+ private static function isNonClientRunmode(string $machineUuid): bool
+ {
+ static $cache = null;
+ if ($cache === null) {
+ if (!Module::isAvailable('runmode')) {
+ $cache = [];
+ } else {
+ $cache = RunMode::getAllClients(false, false);
+ }
+ }
+ return isset($cache[$machineUuid]);
+ }
+
+ /**
+ * Machines that have less than 8GB of RAM. Highlight those
+ * that still have free memory slots.
+ */
+ private static function showMemoryUpgrade(array $locs)
+ {
+ $q = new HardwareQuery(HardwareInfo::MAINBOARD);
+ if (!empty($locs)) {
+ $q->addMachineWhere('locationid', 'IN', $locs);
+ }
+ $q->addMachineWhere('lastseen', '>', strtotime('-60 days'));
+ $q->addLocalColumn('Memory Slot Occupied');
+ $q->addGlobalColumn('Memory Slot Count');
+ $q->addGlobalColumn('Memory Maximum Capacity');
+ $q->addMachineColumn('clientip');
+ $q->addMachineColumn('hostname');
+ $q->addMachineColumn('state');
+ $q->addLocalColumn('Memory Installed Capacity')->addCondition('<', 8 * 1024 * 1024 * 1024);
+ $list = [];
+ foreach ($q->query() as $row) {
+ if (self::isNonClientRunmode($row['machineuuid']))
+ continue;
+ if (HardwareParser::convertSize($row['Memory Installed Capacity'], 'M', false)
+ >= HardwareParser::convertSize($row['Memory Maximum Capacity'], 'M', false)) {
+ $row['size_class'] = 'danger';
+ }
+ if ($row['Memory Slot Occupied'] >= $row['Memory Slot Count']) {
+ $row['count_class'] = 'warning';
+ }
+ $row['icon'] = StatisticsStyling::machineStateToIcon($row['state']);
+ $list[] = $row;
+ }
+ if (empty($list))
+ return;
+ ArrayUtil::sortByColumn($list, 'hostname');
+ Render::addTemplate('hints-ram-upgrade', ['list' => $list]);
+ }
+
+ /**
+ * Show machines where RAM modules are running slower
+ * than their design speed.
+ */
+ private static function showMemorySlow(array $locs)
+ {
+ $q = new HardwareQuery(HardwareInfo::RAM_MODULE);
+ if (!empty($locs)) {
+ $q->addMachineWhere('locationid', 'IN', $locs);
+ }
+ $q->addMachineWhere('lastseen', '>', strtotime('-60 days'));
+ //$q->addLocalColumn('Locator');
+ //$q->addLocalColumn('Bank Locator');
+ $q->addGlobalColumn('Form Factor');
+ $q->addGlobalColumn('Type');
+ $q->addGlobalColumn('Size');
+ $q->addGlobalColumn('Manufacturer');
+ $q->addLocalColumn('Serial Number');
+ $q->addMachineColumn('clientip');
+ $q->addMachineColumn('hostname');
+ $q->addMachineColumn('state');
+ $col = $q->addGlobalColumn('Speed');
+ $col->addCondition('>', $q->addLocalColumn('Configured Memory Speed'));
+ $list = [];
+ foreach ($q->query(['machineuuid', 'Size', 'Manufacturer', 'Speed', 'Configured Memory Speed']) as $row) {
+ // Sometimes configured speed reports as 2666 while rated speed is 2667
+ // Cast as these have a MT/s suffic, triggering a PHP notice about malformed numbers
+ if ((int)$row['Configured Memory Speed'] + 33 >= (int)$row['Speed'])
+ continue;
+ $row['icon'] = StatisticsStyling::machineStateToIcon($row['state']);
+ $list[] = $row;
+ }
+ if (empty($list))
+ return;
+ ArrayUtil::sortByColumn($list, 'hostname');
+ Render::addTemplate('hints-ram-underclocked', ['list' => $list]);
+ }
+
+ /**
+ * Show machines that have unpartitioned space available,
+ * and no ID44 or ID45.
+ */
+ private static function showUnusedSpace(array $locs)
+ {
+ $id44 = $id45 = [];
+ // ID44
+ $q = new HardwareQuery(HardwareInfo::HDD);
+ if (!empty($locs)) {
+ $q->addMachineWhere('locationid', 'IN', $locs);
+ }
+ $q->addMachineWhere('lastseen', '>', strtotime('-60 days'));
+ $q->addMachineColumn('clientip');
+ $q->addMachineColumn('hostname');
+ $q->addLocalColumn('unused')->addCondition('>', 2000000000); // 2 GB
+ $q->addMachineWhere('id44mb', '<', 20000); // 20 GB
+ $q->addMachineColumn('state');
+ foreach ($q->query() as $row) {
+ $row['unused_s'] = Util::readableFileSize($row['unused']);
+ $row['id44mb_s'] = Util::readableFileSize($row['id44mb'], -1, 2);
+ $row['icon'] = StatisticsStyling::machineStateToIcon($row['state']);
+ $id44[] = $row;
+ }
+ // ID45
+ $q = new HardwareQuery(HardwareInfo::HDD);
+ if (!empty($locs)) {
+ $q->addMachineWhere('locationid', 'IN', $locs);
+ }
+ $q->addMachineWhere('lastseen', '>', strtotime('-60 days'));
+ $q->addMachineColumn('clientip');
+ $q->addMachineColumn('hostname');
+ $q->addLocalColumn('unused')->addCondition('>', 50000000000); // 50 GB
+ $q->addMachineWhere('id44mb', '>', 20000); // 20 GB
+ $q->addMachineWhere('id45mb', '<', 20000); // 20 GB
+ $q->addMachineColumn('state');
+ // Only suggest SSD based systems, caching on spinning rust is usually slower than GBit
+ $q->addGlobalColumn('rotation_rate')->addCondition('=', 0);
+ foreach ($q->query() as $row) {
+ $row['unused_s'] = Util::readableFileSize($row['unused']);
+ $row['id44mb_s'] = Util::readableFileSize($row['id44mb'], -1, 2);
+ $row['id45mb_s'] = Util::readableFileSize($row['id45mb'], -1, 2);
+ $row['icon'] = StatisticsStyling::machineStateToIcon($row['state']);
+ $id45[] = $row;
+ }
+ if (empty($id44) && empty($id45))
+ return;
+ ArrayUtil::sortByColumn($id44, 'hostname');
+ ArrayUtil::sortByColumn($id45, 'hostname');
+ Render::addTemplate('hints-hdd-grow', [
+ 'id44' => $id44,
+ 'id45' => $id45,
+ ]);
+ }
+
+ private static function showSlowNics(array $locs)
+ {
+ $list = [];
+ $q = new HardwareQuery(HardwareInfo::MAINBOARD);
+ if (!empty($locs)) {
+ $q->addMachineWhere('locationid', 'IN', $locs);
+ }
+ $q->addMachineWhere('lastseen', '>', strtotime('-60 days'));
+ $q->addMachineColumn('clientip');
+ $q->addMachineColumn('hostname');
+ $q->addMachineColumn('state');
+ $q->addLocalColumn('nic-speed')->addCondition('<', 1000);
+ $q->addLocalColumn('nic-duplex');
+ foreach ($q->query() as $row) {
+ if ($row['nic-speed'] == 0) {
+ $row['nic-speed'] = '???';
+ }
+ $row['icon'] = StatisticsStyling::machineStateToIcon($row['state']);
+ $list[] = $row;
+ }
+ if (empty($list))
+ return;
+ ArrayUtil::sortByColumn($list, 'hostname');
+ Render::addTemplate('hints-nic-speed', ['list' => $list]);
+ }
+
+ /**
+ * Show machines that have a CPU that is only supported by VMware 12.5.x,
+ * but not newer versions.
+ */
+ private static function showLegacyCpu(array $locs)
+ {
+ $list = [];
+ $q = new HardwareQuery(HardwareInfo::CPU);
+ if (!empty($locs)) {
+ $q->addMachineWhere('locationid', 'IN', $locs);
+ }
+ $q->addMachineWhere('lastseen', '>', strtotime('-60 days'));
+ $q->addMachineColumn('clientip');
+ $q->addMachineColumn('hostname');
+ $q->addMachineColumn('state');
+ $q->addMachineColumn('cpumodel');
+ $q->addGlobalColumn('vmx-legacy')->addCondition('<>', 0);
+ foreach ($q->query() as $row) {
+ $row['icon'] = StatisticsStyling::machineStateToIcon($row['state']);
+ $list[] = $row;
+ }
+ if (empty($list))
+ return;
+ ArrayUtil::sortByColumn($list, 'hostname');
+ Render::addTemplate('hints-cpu-legacy', ['list' => $list]);
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/statistics/pages/list.inc.php b/modules-available/statistics/pages/list.inc.php
index e9af994a..f08cd71c 100644
--- a/modules-available/statistics/pages/list.inc.php
+++ b/modules-available/statistics/pages/list.inc.php
@@ -22,31 +22,44 @@ class SubPage
}
- /**
- * @param \StatisticsFilterSet $filterSet
- */
- private static function showMachineList($filterSet)
+ private static function showMachineList(StatisticsFilterSet $filterSet): void
{
Module::isAvailable('js_stupidtable');
$filterSet->makeFragments($where, $join, $args);
$xtra = '';
- if ($filterSet->isNoId44Filter()) {
- $xtra .= ', data';
- }
if (Module::isAvailable('runmode')) {
$xtra .= ', runmode.module AS rmmodule, runmode.isclient';
if (strpos($join, 'runmode') === false) {
- $join .= ' LEFT JOIN runmode USING (machineuuid) ';
+ $join .= ' LEFT JOIN runmode ON (m.machineuuid = runmode.machineuuid) ';
}
}
- $res = Database::simpleQuery("SELECT m.machineuuid, m.locationid, m.macaddr, m.clientip, m.lastseen,
+ $allRows = Database::queryAll("SELECT m.machineuuid, m.locationid, m.macaddr, m.clientip, m.lastseen,
m.logintime, m.state, m.currentuser, m.currentrunmode, m.realcores, m.mbram, m.kvmstate, m.cpumodel, m.id44mb,
- m.hostname, m.notes IS NOT NULL AS hasnotes,
+ m.id45mb, m.hostname, m.notes IS NOT NULL AS hasnotes,
m.badsectors, Count(s.machineuuid) AS confvars $xtra FROM machine m
- LEFT JOIN setting_machine s USING (machineuuid)
+ LEFT JOIN setting_machine s ON (m.machineuuid = s.machineuuid)
$join WHERE $where GROUP BY m.machineuuid", $args);
- $rows = array();
- $singleMachine = 'none';
+ // If filter results in just one result, redirect to machine details
+ if (count($allRows) === 1) {
+ Util::redirect('?do=statistics&uuid=' . $allRows[0]['machineuuid']);
+ }
+ // Gather additional info that would be ugly to fetch via joins above
+ $uuids = array_column($allRows, 'machineuuid');
+ $machineWithHdds = Database::queryKeyValueList("SELECT mxx.machineuuid, Count(s.hwid) AS num
+ FROM statistic_hw s
+ INNER JOIN machine_x_hw AS mxx ON (s.hwid = mxx.hwid AND s.hwtype = :type
+ AND mxx.disconnecttime = 0 AND mxx.machineuuid IN (:ids))
+ GROUP BY mxx.machineuuid",
+ ['type' => HardwareInfo::HDD, 'ids' => $uuids]);
+ $machineNicSpeed = Database::queryKeyValueList("SELECT mxx.machineuuid, Max(mxhp.`numeric`) AS num
+ FROM statistic_hw s
+ INNER JOIN machine_x_hw AS mxx ON (s.hwid = mxx.hwid AND s.hwtype = :type
+ AND mxx.disconnecttime = 0 AND mxx.machineuuid IN (:ids))
+ INNER JOIN machine_x_hw_prop mxhp ON (mxx.machinehwid = mxhp.machinehwid AND mxhp.prop = :prop)
+ GROUP BY mxx.machineuuid",
+ ['type' => HardwareInfo::MAINBOARD, 'prop' => 'nic-speed', 'ids' => $uuids]);
+ $machineWithConfigOverrides = Database::queryKeyValueList("SELECT machineuuid, Count(machineuuid) AS num
+ FROM setting_machine WHERE machineuuid IN (:ids) GROUP BY machineuuid", ['ids' => $uuids]);
// TODO: Cannot disable checkbox for those where user has no permission, since we got multiple actions now
// We should pass these lists to the output and add some JS magic
// Either disable the delete/reboot/... buttons as soon as at least one "forbidden" client is selected (potentially annoying)
@@ -56,38 +69,54 @@ class SubPage
$shutdownAllowedLocations = User::getAllowedLocations('.rebootcontrol.action.reboot');
$wolAllowedLocations = User::getAllowedLocations('.rebootcontrol.action.wol');
$execAllowedLocations = User::getAllowedLocations('.rebootcontrol.action.exec');
+ $benchmarkAllowedLocations = User::getAllowedLocations('.vmstore.benchmark');
// Only make client clickable if user is allowed to view details page
$detailsAllowedLocations = User::getAllowedLocations("machine.view-details");
$location = self::buildLocationLookup();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- if ($singleMachine === 'none') {
- $singleMachine = $row['machineuuid'];
- } else {
- $singleMachine = false;
- }
+ $rows = [];
+ $colValCount = []; // Count unique values for several columns
+ foreach ($allRows as &$row) {
+ settype($row['locationid'], 'int');
$row['link_details'] = in_array($row['locationid'], $detailsAllowedLocations);
//$row['firstseen'] = Util::prettyTime($row['firstseen']);
$row['lastseen_int'] = $row['lastseen'];
$row['lastseen'] = Util::prettyTime($row['lastseen']);
//$row['lastboot'] = Util::prettyTime($row['lastboot']);
- $row['gbram'] = round(ceil($row['mbram'] / 512) / 2, 1); // Trial and error until we got "expected" rounding..
- $row['gbtmp'] = round($row['id44mb'] / 1024);
+ $row['gbram'] = Dictionary::number(ceil($row['mbram'] / 512) / 2, 1); // Trial and error until we got "expected" rounding..
+ $row['gbtmp'] = Dictionary::number($row['id44mb'] / 1024);
+ $row['gbpersist'] = Dictionary::number($row['id45mb'] / 1024);
$octets = explode('.', $row['clientip']);
if (count($octets) === 4) {
$row['subnet'] = "$octets[0].$octets[1].$octets[2]";
$row['lastoctet'] = $octets[3];
}
- $row['ramclass'] = StatisticsStyling::ramColorClass($row['mbram']);
+ $row['ramclass'] = StatisticsStyling::ramColorClass((int)$row['mbram']);
$row['kvmclass'] = StatisticsStyling::kvmColorClass($row['kvmstate']);
- $row['hddclass'] = StatisticsStyling::hddColorClass($row['gbtmp']);
+ $row['hddclass'] = StatisticsStyling::hddColorClass((int)$row['gbtmp']);
if (empty($row['hostname'])) {
$row['hostname'] = $row['clientip'];
}
- if (isset($row['data'])) {
- if (!preg_match('#^Disk.* /dev/[^d].* (bytes$|sectors,)#m', $row['data'])) {
- $row['nohdd'] = true;
- }
+ if (isset($machineWithConfigOverrides[$row['machineuuid']])) {
+ $row['confvars'] = $machineWithConfigOverrides[$row['machineuuid']];
+ }
+ if (isset($machineWithHdds[$row['machineuuid']])) {
+ $row['hddcount'] = $machineWithHdds[$row['machineuuid']];
+ } else if ($row['id44mb'] > 0) {
+ // This might be a machine that wasn't booted with a recent system, and hence doesn't have HWinfo in DB
+ // If we have ID44 space in our main table, we most likely got an HDD, so fake a count of 1
+ $row['hddcount'] = 1;
+ }
+ if (!isset($machineNicSpeed[$row['machineuuid']])) {
+ $row['nic-speed'] = 0;
+ $row['nic-speed_s'] = '???';
+ } else {
+ $row['nic-speed'] = $machineNicSpeed[$row['machineuuid']];
+ $row['nic-speed_s'] = Dictionary::number($machineNicSpeed[$row['machineuuid']]);
}
+ if (isset($row['data']) && !$row['data']) {
+ $row['nohdd'] = true;
+ }
+ // Shorten CPU names a bit for prettier display in small column
$row['cpumodel'] = preg_replace('/\(R\)|\(TM\)|\bintel\b|\bamd\b|\bcpu\b|dual-core|\bdual\s+core\b|\bdual\b|\bprocessor\b/i', ' ', $row['cpumodel']);
if (!empty($row['rmmodule'])) {
$data = RunMode::getRunMode($row['machineuuid'], RunMode::DATA_STRINGS);
@@ -116,10 +145,48 @@ class SubPage
if ($row['locationid'] > 0) {
$row['location'] = $location[$row['locationid']];
}
- $rows[] = $row;
+ foreach (['locationid', 'cpumodel', 'nic-speed_s', 'gbram', 'gbtmp'] as $key) {
+ if (!isset($colValCount[$key][$row[$key]])) {
+ $colValCount[$key][$row[$key]] = [];
+ }
+ $colValCount[$key][$row[$key]][] = $row['machineuuid'];
+ }
+ $rows[] =& $row;
}
- if ($singleMachine !== false && $singleMachine !== 'none') {
- Util::redirect('?do=statistics&uuid=' . $singleMachine);
+ // Now if all machines are from the same location, try to load the roomplan
+ // Also, collect all properties that are the same across all machines for display in the sidebar
+ $roomsvg = null;
+ $side = [];
+ if (!empty($rows) && !empty($colValCount)) {
+ if (count($colValCount['locationid']) === 1
+ && ($lid = array_key_first($colValCount['locationid'])) > 0
+ && Module::isAvailable('roomplanner')) {
+ $roomsvg = PvsGenerator::generateSvg($lid, false, 0, 1, true, $colValCount['locationid'][$lid]);
+ }
+ // Handle our selected attributes
+ foreach (['locationid', 'cpumodel', 'nic-speed_s', 'gbram', 'gbtmp'] as $key) {
+ if (count($colValCount[$key]) === 1) {
+ $val = array_key_first($colValCount[$key]);
+ // Suffixes are not localized, but hopefully generic enough for now
+ switch ($key) {
+ case 'locationid':
+ if (!isset($location[$val]))
+ continue 2;
+ $val = $location[$val]['name'];
+ break;
+ case 'gbram':
+ $val .= ' GiB RAM';
+ break;
+ case 'gbtmp':
+ $val .= ' GiB ID-44';
+ break;
+ case 'nic-speed_s':
+ $val .= ' MBit/s';
+ break;
+ }
+ $side[] = $val;
+ }
+ }
}
$data = array(
'rowCount' => count($rows),
@@ -133,11 +200,14 @@ class SubPage
'canDelete' => !empty($deleteAllowedLocations),
'canWol' => !empty($wolAllowedLocations),
'canExec' => !empty($execAllowedLocations),
+ 'canBenchmark' => !empty($benchmarkAllowedLocations),
+ 'roomsvg' => $roomsvg,
+ 'sidebar' => $side,
);
Render::addTemplate('clientlist', $data);
}
- private static function buildLocationLookup()
+ private static function buildLocationLookup(): array
{
$ret = [];
$i = 0;
@@ -147,4 +217,4 @@ class SubPage
return $ret;
}
-}
+} \ No newline at end of file
diff --git a/modules-available/statistics/pages/machine.inc.php b/modules-available/statistics/pages/machine.inc.php
index ea545b16..1d46b523 100644
--- a/modules-available/statistics/pages/machine.inc.php
+++ b/modules-available/statistics/pages/machine.inc.php
@@ -5,7 +5,9 @@ class SubPage
public static function doPreprocess()
{
-
+ if (!Module::isAvailable('js_chart')) {
+ ErrorHandler::traceError('js_chart not available');
+ }
}
public static function doRender()
@@ -36,7 +38,7 @@ class SubPage
'end' => $row['logintime'] + 300,
));
$session = false;
- while ($r = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $r) {
if ($session === false || abs($session['dateline'] - $row['logintime']) > abs($r['dateline'] - $row['logintime'])) {
$session = $r;
}
@@ -54,22 +56,106 @@ class SubPage
$client = Database::queryFirst('SELECT machineuuid, locationid, macaddr, clientip, firstseen, lastseen, logintime, lastboot, state,
mbram, live_tmpsize, live_tmpfree, live_id45size, live_id45free, live_swapsize, live_swapfree,
live_memsize, live_memfree, live_cpuload, live_cputemp,
- Length(position) AS hasroomplan, kvmstate, cpumodel, id44mb, data, hostname, currentuser, currentsession, notes
+ Length(position) AS hasroomplan, kvmstate, cpumodel, id44mb, id45mb, data, hostname, currentuser, currentsession, notes
FROM machine WHERE machineuuid = :uuid',
array('uuid' => $uuid));
if ($client === false) {
Message::addError('unknown-machine', $uuid);
return;
}
- if (Module::isAvailable('locations') && !Location::isLeaf($client['locationid'])) {
- $client['hasroomplan'] = false;
+ Render::setTitle(empty($client['hostname']) ? $client['clientip'] : $client['hostname']);
+ $locations = [];
+ if ($client['locationid'] > 0 && Module::isAvailable('locations')) {
+ if (!Location::isLeaf($client['locationid'])) {
+ $client['hasroomplan'] = false;
+ }
+ $locations = Location::getLocationRootChain($client['locationid']);
+ }
+ if ($client['locationid'] && $client['hasroomplan'] && Module::isAvailable('roomplanner')) {
+ $client['roomsvg'] = PvsGenerator::generateSvg($client['locationid'], $client['machineuuid'],
+ 0, 1, true);
}
User::assertPermission('machine.view-details', (int)$client['locationid']);
// Hack: Get raw collected data
if (Request::get('raw', false)) {
- Header('Content-Type: text/plain; charset=utf-8');
+ Header('Content-Type: application/json');
die($client['data']);
}
+ // Parse data
+ $hdds = array();
+ if ($client['data'][0] === '{') {
+ $json = json_decode($client['data'], true);
+ if (is_array($json)) {
+ $client += self::parseJson($uuid, $json);
+ $hdds['hdds'] = self::queryHddData($uuid);
+ }
+ } else {
+ self::parseLegacy($client, $hdds);
+ }
+ unset($client['data']);
+ // Get rid of configured speed, if equal to maximum speed
+ foreach ($client['ram'] as &$item) {
+ if (isset($item['Configured Memory Speed']) && $item['Configured Memory Speed'] === $item['Speed']) {
+ unset($item['Configured Memory Speed']);
+ }
+ }
+ unset($item);
+ // PCI
+ // 1) get passthrough groups
+ $passthroughTypes = [];
+ if (!empty($locations)) {
+ $hw = new HardwareQuery(HardwareInfo::PCI_DEVICE, $uuid, true);
+ // TODO: Get list of enabled pass through groups for this client's location
+ $hw->addForeignJoin(true, '@PASSTHROUGH', 'passthrough_group_x_location', 'groupid',
+ 'locationid', $locations);
+ $hw->addGlobalColumn('vendor');
+ $hw->addGlobalColumn('device');
+ $hw->addGlobalColumn('rev');
+ $res = $hw->query();
+ foreach ($res as $row) {
+ $devId = $row['vendor'] . ':' . $row['device'] . ':' . $row['rev'];
+ if (!isset($passthroughTypes[$devId])) {
+ $passthroughTypes[$devId] = [];
+ }
+ $passthroughTypes[$devId][$row['@PASSTHROUGH']] = $row['@PASSTHROUGH'];
+ }
+ }
+ // 2) Sort and mangle list
+ $client['lspci1'] = $client['lspci2'] = [];
+ foreach ($client['lspci'] as $item) {
+ $devId = $item['vendor'] . ':' . $item['device'];
+ $item['vendor_s'] = PciId::getPciId(PciId::VENDOR, $item['vendor']);
+ $item['device_s'] = PciId::getPciId(PciId::DEVICE, $item['vendor'] . $item['device']);
+ if ($item['vendor_s'] === false) {
+ $pciLookup[$item['vendor']] = true;
+ }
+ if ($item['device_s'] === false) {
+ $pciLookup[$devId] = true;
+ }
+ // Passthrough enabled?
+ if (isset($passthroughTypes[$devId . ':' . ($item['rev'] ?? '')])) {
+ $item['pt'] = implode(', ', $passthroughTypes[$devId . ':' . ($item['rev'] ?? '')]);
+ }
+ $class = $item['class'];
+ if ($class === '0300' || $class === '0200' || $class === '0403' || !empty($item['pt'])) {
+ $dst =& $client['lspci1'];
+ } else {
+ $dst =& $client['lspci2'];
+ }
+ if (!isset($dst[$class])) {
+ $dst[$class] = [
+ 'class' => $class,
+ 'class_s' => PciId::getPciId(PciId::DEVCLASS, $class, true),
+ 'entries' => [],
+ ];
+ }
+ $dst[$class]['entries'][] = $item;
+ }
+ unset($dst, $client['lspci']);
+ ksort($client['lspci1']);
+ ksort($client['lspci2']);
+ $client['lspci1'] = array_values($client['lspci1']);
+ $client['lspci2'] = array_values($client['lspci2']);
// Runmode
if (Module::isAvailable('runmode')) {
$data = RunMode::getRunMode($uuid, RunMode::DATA_STRINGS);
@@ -119,8 +205,9 @@ class SubPage
$client['lastboot_s'] .= ' (Up ' . floor($uptime / 86400) . 'd ' . gmdate('H:i', $uptime) . ')';
}
}
- $client['gbram'] = round(ceil($client['mbram'] / 512) / 2, 1);
- $client['gbtmp'] = round($client['id44mb'] / 1024);
+ $client['gbram'] = Dictionary::number(ceil($client['mbram'] / 512) / 2, 1);
+ $client['gbtmp'] = Dictionary::number($client['id44mb'] / 1024);
+ $client['gbid45'] = Dictionary::number($client['id45mb'] / 1024);
foreach (['tmp', 'id45', 'swap', 'mem'] as $item) {
if ($client['live_' . $item . 'size'] == 0)
continue;
@@ -132,53 +219,59 @@ class SubPage
$client['live_cpuidle'] = 100 - $client['live_cpuload'];
}
$client['live_cputemppercent'] = max(0, min(100, 110 - $client['live_cputemp']));
- $client['ramclass'] = StatisticsStyling::ramColorClass($client['mbram']);
+ $client['ramclass'] = StatisticsStyling::ramColorClass((int)$client['mbram']);
$client['kvmclass'] = StatisticsStyling::kvmColorClass($client['kvmstate']);
- $client['hddclass'] = StatisticsStyling::hddColorClass($client['gbtmp']);
- // Parse the giant blob of data
- if (strpos($client['data'], "\r") !== false) {
- $client['data'] = str_replace("\r", "\n", $client['data']);
- }
- $hdds = array();
- if (preg_match_all('/##### ([^#]+) #+$(.*?)^#####/ims', $client['data'] . '########', $out, PREG_SET_ORDER)) {
- foreach ($out as $section) {
- if ($section[1] === 'CPU') {
- Parser::parseCpu($client, $section[2]);
- }
- if ($section[1] === 'dmidecode') {
- Parser::parseDmiDecode($client, $section[2]);
- }
- if ($section[1] === 'Partition tables') {
- Parser::parseHdd($hdds, $section[2]);
- }
- if ($section[1] === 'PCI ID') {
- $client['lspci1'] = $client['lspci2'] = array();
- Parser::parsePci($client['lspci1'], $client['lspci2'], $section[2]);
- }
- if (isset($hdds['hdds']) && $section[1] === 'smartctl') {
- // This currently requires that the partition table section comes first...
- Parser::parseSmartctl($hdds['hdds'], $section[2]);
- }
- }
+ $client['hddclass'] = StatisticsStyling::hddColorClass((int)$client['gbtmp']);
+ // Format HDD data to strings
+ foreach ($hdds['hdds'] as &$hdd) {
+ $hdd['smart_status_failed'] = !($client['smart_status//passed'] ?? 1);
+ self::mangleHdd($hdd);
}
- unset($client['data']);
// BIOS update check
- if (!empty($client['biosrevision'])) {
- $mainboard = $client['mobomanufacturer'] . '##' . $client['mobomodel'];
- $system = $client['pcmanufacturer'] . '##' . $client['pcmodel'];
- $ret = self::checkBios($mainboard, $system, $client['biosdate'], $client['biosrevision']);
+ if (!empty($client['bios']['BIOS Revision']) || !empty($client['bios']['Release Date'])) {
+ if (preg_match('#^(\d{1,2})/(\d{1,2})/(\d{4})#', $client['bios']['Release Date'] ?? '', $out)) {
+ $client['bios']['Release Date'] = $out[2] . '.' . $out[1] . '.' . $out[3];
+ }
+ $mainboard = ($client['mainboard']['Manufacturer'] ?? '') . '##' . ($client['mainboard']['Product Name'] ?? '');
+ $system = ($client['system']['Manufacturer'] ?? '') . '##' . ($client['system']['Product Name'] ?? '');
+ $ret = self::checkBios($mainboard, $system,
+ $client['bios']['Release Date'] ?? null,
+ $client['bios']['BIOS Revision'] ?? null);
if ($ret === false) { // Not loaded, use AJAX
$params = [
'mainboard' => $mainboard,
'system' => $system,
- 'date' => $client['biosdate'],
- 'revision' => $client['biosrevision'],
+ 'date' => $client['bios']['Release Date'] ?? null,
+ 'revision' => $client['bios']['BIOS Revision'] ?? null,
];
$client['biosurl'] = '?do=statistics&action=bios&' . http_build_query($params);
} elseif (!isset($ret['status']) || $ret['status'] !== 0) {
$client['bioshtml'] = Render::parse('machine-bios-update', $ret);
}
}
+ // Last booted system. The boot-system entry is created when the client fetches the config, so
+ // early on, *before* we get the ~poweron event. But in the ~poweron event, the client provides the
+ // kernel uptime, which is subtracted from what we write to lastboot, so it is actually *before*
+ // boot-system.
+ $os = Database::queryFirst("SELECT `data` AS `system`, `dateline`
+ FROM statistic
+ WHERE (dateline >= :lastboot) AND typeid = 'boot-system' AND machineuuid = :uuid
+ ORDER BY dateline ASC LIMIT 1",
+ ['lastboot' => $client['lastboot'], 'uuid' => $uuid]);
+ if ($os !== false) {
+ $client['minilinux'] = $os['system'];
+ $graphical = Database::queryFirst("SELECT `dateline`
+ FROM statistic
+ WHERE (dateline >= :lastboot) AND typeid = 'graphical-startup' AND machineuuid = :uuid
+ ORDER BY dateline ASC LIMIT 1",
+ ['lastboot' => $client['lastboot'], 'uuid' => $uuid]);
+ if ($graphical !== false) {
+ $boottime = $graphical['dateline'] - $client['lastboot'];
+ if ($boottime < 400) { // Sanity-check
+ $client['boottime_s'] = gmdate('i:s', $boottime);
+ }
+ }
+ }
// Get locations
if (Module::isAvailable('locations')) {
$locs = Location::getLocationsAssoc();
@@ -198,10 +291,10 @@ class SubPage
. " LEFT JOIN machine_x_hw_prop p ON (m.machinehwid = p.machinehwid AND p.prop = 'resolution')"
. " LEFT JOIN statistic_hw_prop q ON (m.hwid = q.hwid AND q.prop = 'projector')"
. " WHERE m.machineuuid = :uuid",
- array('screen' => DeviceType::SCREEN, 'uuid' => $uuid));
+ array('screen' => HardwareInfo::SCREEN, 'uuid' => $uuid));
$client['screens'] = array();
$ports = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if ($row['disconnecttime'] != 0)
continue;
$ports[] = $row['connector'];
@@ -211,6 +304,10 @@ class SubPage
Permission::addGlobalTags($client['perms'], null, ['hardware.projectors.edit', 'hardware.projectors.view']);
// Throw output at user
Render::addTemplate('machine-main', $client);
+ if (!empty($pciLookup)) {
+ Render::addTemplate('js-pciquery',
+ ['missing_ids' => json_encode(array_keys($pciLookup))]);
+ }
// Sessions
$NOW = time();
$cutoff = $NOW - 86400 * 7;
@@ -227,7 +324,7 @@ class SubPage
$spans['graph'] = '';
$last = false;
$first = true;
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if (!$client['isclient'] && $row['typeid'] === Statistics::SESSION_LENGTH)
continue; // Don't differentiate between session and idle for non-clients
if ($first && $row['dateline'] > $cutoff && $client['lastboot'] > $cutoff) {
@@ -284,7 +381,6 @@ class SubPage
'duration' => '-',
'glyph' => 'user',
];
- $row['duration'] = floor($row['data'] / 86400) . 'd ' . gmdate('H:i', $row['data']);
} elseif ($client['state'] === 'OFFLINE') {
$spans['graph'] .= '<div style="background:#444;left:' . round(($client['lastseen'] - $cutoff) * $scale, 2) . '%;width:' . round(($NOW - $client['lastseen'] + 900) * $scale, 2) . '%">&nbsp;</div>';
$spans['rows'][] = [
@@ -311,8 +407,8 @@ class SubPage
}
if (count($spans['rows']) > 10) {
$spans['hasrows2'] = true;
- $spans['rows2'] = array_slice($spans['rows'], ceil(count($spans['rows']) / 2));
- $spans['rows'] = array_slice($spans['rows'], 0, ceil(count($spans['rows']) / 2));
+ $spans['rows2'] = array_slice($spans['rows'], (int)ceil(count($spans['rows']) / 2));
+ $spans['rows'] = array_slice($spans['rows'], 0, (int)ceil(count($spans['rows']) / 2));
}
$spans['isclient'] = $client['isclient'];
Render::addTemplate('machine-usage', $spans);
@@ -326,7 +422,7 @@ class SubPage
. ' WHERE machineuuid = :uuid ORDER BY logid DESC LIMIT 25', array('uuid' => $client['machineuuid']));
$count = 0;
$log = array();
- while ($row = $lres->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($lres as $row) {
if (substr($row['description'], -5) === 'on :0' && strpos($row['description'], 'root logged') === false) {
continue;
}
@@ -349,7 +445,197 @@ class SubPage
}
}
- private static function eventToIconName($event)
+ private static function parseLegacy(array &$client, array &$hdds)
+ {
+ // Parse the giant blob of data
+ if (strpos($client['data'], "\r") !== false) {
+ $client['data'] = str_replace("\r", "\n", $client['data']);
+ }
+ if (preg_match_all('/##### ([^#]+) #+$(.*?)^#####/ims', $client['data'] . '########', $out, PREG_SET_ORDER)) {
+ foreach ($out as $section) {
+ if ($section[1] === 'CPU') {
+ HardwareParserLegacy::parseCpu($client, $section[2]);
+ }
+ if ($section[1] === 'dmidecode') {
+ HardwareParserLegacy::parseDmiDecode($client, $section[2]);
+ }
+ if ($section[1] === 'Partition tables') {
+ HardwareParserLegacy::parseHdd($hdds, $section[2]);
+ }
+ if ($section[1] === 'PCI ID') {
+ $client['lspci'] = HardwareParserLegacy::parsePci($section[2]);
+ }
+ if (isset($hdds['hdds']) && $section[1] === 'smartctl') {
+ // This currently requires that the partition table section comes first...
+ HardwareParserLegacy::parseSmartctl($hdds['hdds'], $section[2]);
+ }
+ }
+ }
+ }
+
+ private static function parseJson(string $uuid, array $json): array
+ {
+ $return = [
+ 'lspci' => $json['lspci'] ?? [],
+ 'ram' => array_map(function($item) {
+ return HardwareParser::prepareDmiProperties($item);
+ }, HardwareParser::getDmiHandles($json, 17)),
+ ];
+ foreach ($return['ram'] as $ram) {
+ if (!empty($ram['Form Factor']) && !empty($ram['Type'])) {
+ $return['ramtype'] = $ram['Type'] . '-' . $ram['Form Factor'];
+ break;
+ }
+ }
+ $need = [
+ 'bios' => 0,
+ 'system' => 1,
+ 'mainboard' => 2,
+ ];
+ foreach ($need as $name => $id) {
+ $return[$name] = HardwareParser::prepareDmiProperties(
+ HardwareParser::getDmiHandles($json, $id)[0] ?? []);
+ }
+ $q = new HardwareQuery(HardwareInfo::MAINBOARD, $uuid);
+ $q->addGlobalColumn('Memory Maximum Capacity');
+ $q->addGlobalColumn('Memory Slot Count');
+ $q->addLocalColumn('cpu-sockets');
+ $q->addLocalColumn('cpu-cores');
+ $q->addLocalColumn('cpu-threads');
+ $q->addLocalColumn('nic-speed');
+ $q->addLocalColumn('nic-duplex');
+ $res = $q->query()->fetch();
+ if (is_array($res)) {
+ $return += $res;
+ }
+ return $return;
+ }
+
+ private static function queryHddData(string $uuid): array
+ {
+ $hdds = [];
+ $ret = Database::simpleQuery("SELECT mp.`machinehwid`, mp.`prop`, mp.`value`, mp.`numeric`
+ FROM machine_x_hw_prop mp
+ INNER JOIN machine_x_hw mxhw ON (mp.machinehwid = mxhw.machinehwid AND mxhw.machineuuid = :uuid AND mxhw.disconnecttime = 0)
+ INNER JOIN statistic_hw sh ON (mxhw.hwid = sh.hwid AND sh.hwtype = :type)
+ UNION SELECT mxhw.`machinehwid`, hwp.`prop`, hwp.`value`, hwp.`numeric`
+ FROM statistic_hw_prop hwp
+ INNER JOIN machine_x_hw mxhw ON (hwp.hwid = mxhw.hwid AND mxhw.machineuuid = :uuid AND mxhw.disconnecttime = 0)
+ INNER JOIN statistic_hw sh ON (mxhw.hwid = sh.hwid AND sh.hwtype = :type)
+ ",
+ ['type' => HardwareInfo::HDD, 'uuid' => $uuid]);
+ foreach ($ret as $row) {
+ if (!isset($hdds[$row['machinehwid']])) {
+ $hdds[$row['machinehwid']] = ['partitions' => []];
+ }
+ $hdd =& $hdds[$row['machinehwid']];
+ if (preg_match('/^(attr_[0-9]+)_(.*)$/', $row['prop'], $out)) {
+ // SMART attributes
+ if (!isset($hdd[$out[1]])) {
+ $hdd[$out[1]] = [];
+ }
+ $hdd[$out[1]][$out[2]] = $row['numeric'] ?? $row['value'];
+ } elseif (preg_match('/^part_([0-9]+)_(.*)$/', $row['prop'], $out)) {
+ // Partitions
+ if (!isset($hdd['partitions'][$out[1]])) {
+ $hdd['partitions'][$out[1]] = ['id' => 'dev-' . count($hdds) . '-' . $out[1], 'index' => $out[1]];
+ }
+ $hdd['partitions'][$out[1]][$out[2]] = $row['numeric'] ?? $row['value'];
+ } else {
+ $hdd[$row['prop']] = $row['numeric'] ?? $row['value'];
+ }
+ }
+ $result = [];
+ foreach ($hdds as $k => &$hdd) {
+ if (substr($hdd['dev'] ?? '/dev/sr', 0, 7) === '/dev/sr')
+ continue;
+ $hdd['devid'] = 'k' . $k;
+ $hdd['partitions'] = array_values($hdd['partitions']);
+ $result[] = $hdd;
+ }
+ return $result;
+ }
+
+ private static function mangleHdd(array &$hdd)
+ {
+ static $hddidx = 0;
+ if (!isset($hdd['size']) || !is_numeric($hdd['size'])) {
+ $hdd['size'] = 0;
+ }
+ $hdd['hddidx'] = $hddidx++;
+ $hours = $hdd['power_on_time//hours'] ?? $hdd['attr_9']['raw'] ?? $hdd['power_on_hours']
+ ?? $hdd['power_on_time']['hours'] ?? null;
+ if ($hours !== null) {
+ $hdd['PowerOnTime'] = '';
+ $val = (int)str_replace('.', '', $hours);
+ if ($val > 8760) {
+ $hdd['PowerOnTime'] .= floor($val / 8760) . 'Y, ';
+ $val %= 8760;
+ }
+ if ($val > 720) {
+ $hdd['PowerOnTime'] .= floor($val / 720) . 'M, ';
+ $val %= 720;
+ }
+ if ($val > 24) {
+ $hdd['PowerOnTime'] .= floor($val / 24) . 'd, ';
+ $val %= 24;
+ }
+ $hdd['PowerOnTime'] .= $val . 'h';
+ }
+ // Sort by start for building pie-chart
+ $xx = array_column($hdd['partitions'], 'start');
+ array_multisort($xx, SORT_ASC, SORT_NUMERIC,
+ $hdd['partitions']);
+ $used = 0;
+ $json = [];
+ $lastEnd = 0;
+ $minDisplaySize = $hdd['size'] / 150;
+ $i = 0;
+ foreach ($hdd['partitions'] as &$part) {
+ $dist = $part['start'] - $lastEnd;
+ if ($dist > $minDisplaySize) {
+ $json[] = ['value' => $dist, 'color' => '#aaa'];
+ $i++;
+ }
+ if ($part['size'] > $minDisplaySize) {
+ $json[] = ['value' => $part['size'], 'color' => self::typeToColor($part)];
+ $part['idx'] = $i++;
+ }
+ $part['size_s'] = Util::readableFileSize($part['size']);
+ $used += $part['size'];
+ $lastEnd = $part['start'] + $part['size'];
+ if (!isset($part['name']) || isset($part['slxtype'])) {
+ $part['name'] = self::partTypeToName($part['slxtype'] ?? $part['type']);
+ }
+ }
+ $dist = $hdd['size'] - $lastEnd;
+ if ($dist > $minDisplaySize) {
+ $json[] = ['value' => $dist, 'color' => '#aaa'];
+ }
+ $hdd['json'] = json_encode($json);
+ $hdd['size_s'] = Util::readableFileSize($hdd['size']);
+ if ($hdd['size'] - $used > 1000000000) {
+ $hdd['unused_s'] = Util::readableFileSize($hdd['size'] - $used);
+ }
+ // Finally sort by index for table display
+ array_multisort(array_column($hdd['partitions'], 'index'), SORT_ASC,
+ $hdd['partitions']);
+ }
+
+ private static function typeToColor(array $part): string
+ {
+ switch ($part['slxtype'] ?? $part['type']) {
+ case 44:
+ return '#5c1';
+ case 45:
+ return '#0d7';
+ case 82:
+ return '#48f';
+ }
+ return '#e55';
+ }
+
+ private static function eventToIconName($event): string
{
switch ($event) {
case 'session-open':
@@ -393,7 +679,7 @@ class SubPage
die(Render::parse('machine-bios-update', $reply));
}
- private static function checkBios($mainboard, $system, $date, $revision, $json = null)
+ private static function checkBios(string $mainboard, string $system, ?string $date, ?string $revision, $json = null)
{
if ($json === null) {
if (!file_exists(self::BIOS_CACHE) || filemtime(self::BIOS_CACHE) + 3600 < time())
@@ -402,18 +688,18 @@ class SubPage
}
if (!is_array($json) || !isset($json['system']))
return ['error' => 'Malformed JSON, no system key'];
- if (isset($json['system'][$system]) && isset($json['system'][$system]['fixes']) && isset($json['system'][$system]['match'])) {
+ if (isset($json['system'][$system]['fixes']) && isset($json['system'][$system]['match'])) {
$match =& $json['system'][$system];
- } elseif (isset($json['mainboard'][$mainboard]) && isset($json['mainboard'][$mainboard]['fixes']) && isset($json['mainboard'][$mainboard]['match'])) {
+ } elseif (isset($json['mainboard'][$mainboard]['fixes']) && isset($json['mainboard'][$mainboard]['match'])) {
$match =& $json['mainboard'][$mainboard];
} else {
return ['status' => 0];
}
$key = $match['match'];
- if ($key === 'revision') {
+ if ($key === 'revision' && $revision !== null) {
$cmp = function ($item) { $s = explode('.', $item); return $s[0] * 0x10000 + $s[1]; };
$reference = $cmp($revision);
- } elseif ($key === 'date') {
+ } elseif ($key === 'date' && $date !== null) {
$cmp = function ($item) { $s = explode('.', $item); return $s[2] * 10000 + $s[1] * 100 + $s[0]; };
$reference = $cmp($date);
} else {
@@ -442,4 +728,26 @@ class SubPage
return $retval;
}
-} \ No newline at end of file
+ /**
+ * @param string $type MBR-type or GPT UUID
+ * @return string Name of partition type if known, otherwise, $type is returned
+ */
+ private static function partTypeToName(string $type): string
+ {
+ switch ($type) {
+ case '44':
+ case '45':
+ return 'OpenSLX-ID' . $type;
+ case '82':
+ return 'Linux Swap';
+ case '83':
+ return 'Linux';
+ case '7':
+ return 'NTFS/Windows';
+ case 'ef':
+ return 'EFI';
+ }
+ return HardwareInfo::GPT[$type] ?? $type;
+ }
+
+}
diff --git a/modules-available/statistics/pages/projectors.inc.php b/modules-available/statistics/pages/projectors.inc.php
index cc808cf0..01be2971 100644
--- a/modules-available/statistics/pages/projectors.inc.php
+++ b/modules-available/statistics/pages/projectors.inc.php
@@ -16,7 +16,7 @@ class SubPage
User::assertPermission('hardware.projectors.edit');
$hwid = Request::post('hwid', false, 'int');
if ($hwid === false) {
- Util::traceError('Param hwid missing');
+ ErrorHandler::traceError('Param hwid missing');
}
if ($action === 'addprojector') {
Database::exec('INSERT IGNORE INTO statistic_hw_prop (hwid, prop, value)'
@@ -49,10 +49,10 @@ class SubPage
. " INNER JOIN statistic_hw_prop p ON (h.hwid = p.hwid AND p.prop = :projector)"
. " WHERE h.hwtype = :screen ORDER BY h.hwname ASC", array(
'projector' => 'projector',
- 'screen' => DeviceType::SCREEN,
+ 'screen' => HardwareInfo::SCREEN,
));
$data = array(
- 'projectors' => $res->fetchAll(PDO::FETCH_ASSOC)
+ 'projectors' => $res->fetchAll()
);
Render::addTemplate('projector-list', $data);
}
diff --git a/modules-available/statistics/pages/replace.inc.php b/modules-available/statistics/pages/replace.inc.php
index 9c16aed7..50bfd6cf 100644
--- a/modules-available/statistics/pages/replace.inc.php
+++ b/modules-available/statistics/pages/replace.inc.php
@@ -5,6 +5,7 @@ class SubPage
public static function doPreprocess()
{
+ User::assertPermission('replace');
$action = Request::post('action', false, 'string');
if ($action === 'replace') {
self::handleReplace();
@@ -17,11 +18,13 @@ class SubPage
private static function handleReplace()
{
$replace = Request::post('replace', false, 'array');
- if ($replace === false || empty($replace)) {
+ if (empty($replace)) {
Message::addError('main.parameter-empty', 'replace');
return;
}
$list = [];
+ $allowed = User::getAllowedLocations('replace');
+ // Loop through passed machines, filter out unsuited pairs (both in use) and those without permission
foreach ($replace as $p) {
$split = explode('x', $p);
if (count($split) !== 2) {
@@ -29,13 +32,13 @@ class SubPage
continue;
}
$entry = ['old' => $split[0], 'new' => $split[1]];
- $old = Database::queryFirst('SELECT lastseen FROM machine WHERE machineuuid = :old',
+ $old = Database::queryFirst('SELECT locationid, lastseen FROM machine WHERE machineuuid = :old',
['old' => $entry['old']]);
if ($old === false) {
Message::addError('unknown-machine', $entry['old']);
continue;
}
- $new = Database::queryFirst('SELECT firstseen FROM machine WHERE machineuuid = :new',
+ $new = Database::queryFirst('SELECT locationid, firstseen FROM machine WHERE machineuuid = :new',
['new' => $entry['new']]);
if ($new === false) {
Message::addError('unknown-machine', $entry['new']);
@@ -45,6 +48,16 @@ class SubPage
Message::addWarning('ignored-both-in-use', $entry['old'], $entry['new']);
continue;
}
+ if (!in_array(0, $allowed)) {
+ if (!in_array($old['locationid'], $allowed)) {
+ Message::addWarning('ignored-no-permission', $entry['old']);
+ continue;
+ }
+ if (!in_array($new['locationid'], $allowed)) {
+ Message::addWarning('ignored-no-permission', $entry['new']);
+ continue;
+ }
+ }
$entry['datelimit'] = min($new['firstseen'], $old['lastseen']);
$list[] = $entry;
}
@@ -106,7 +119,10 @@ class SubPage
FROM machine old INNER JOIN machine new ON (old.clientip = new.clientip AND old.lastseen < new.firstseen AND old.lastseen > $oldCutoff AND new.firstseen > $newCutoff)
ORDER BY oldhost ASC, oldip ASC");
$list = [];
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ $allowed = User::getAllowedLocations('replace');
+ foreach ($res as $row) {
+ if (!in_array(0, $allowed) && (!in_array($row['oldlid'], $allowed) || !in_array($row['newlid'], $allowed)))
+ continue;
$row['oldlastseen_s'] = Util::prettyTime($row['oldlastseen']);
$row['newfirstseen_s'] = Util::prettyTime($row['newfirstseen']);
$list[] = $row;
diff --git a/modules-available/statistics/pages/summary.inc.php b/modules-available/statistics/pages/summary.inc.php
index ce67070e..905f5d90 100644
--- a/modules-available/statistics/pages/summary.inc.php
+++ b/modules-available/statistics/pages/summary.inc.php
@@ -8,6 +8,9 @@ class SubPage
public static function doPreprocess()
{
User::assertPermission('view.summary');
+ if (!Module::isAvailable('js_chart')) {
+ ErrorHandler::traceError('js_chart not available');
+ }
}
public static function doRender()
@@ -23,7 +26,9 @@ class SubPage
// Prepare chart colors
self::$STATS_COLORS = [];
for ($i = 0; $i < 10; ++$i) {
- self::$STATS_COLORS[] = '#55' . sprintf('%02s%02s', dechex((($i + 1) * ($i + 1)) / .3922), dechex(abs((5 - $i) * 51)));
+ self::$STATS_COLORS[] = '#55' . sprintf('%02s%02s', dechex(
+ (int)((($i + 1) * ($i + 1)) / .3922)),
+ dechex((int)(abs((5 - $i) * 51))));
}
$filterSet->filterNonClients();
@@ -38,10 +43,7 @@ class SubPage
Render::closeTag('div');
}
- /**
- * @param \StatisticsFilterSet $filterSet
- */
- private static function showSummary($filterSet)
+ private static function showSummary(StatisticsFilterSet $filterSet): void
{
$filterSet->makeFragments($where, $join, $args);
$known = Database::queryFirst("SELECT Count(*) AS val FROM machine m $join WHERE $where", $args);
@@ -53,36 +55,82 @@ class SubPage
} else {
$usedpercent = 0;
}
- $data = array(
+ $data = [
'known' => $known['val'],
'online' => $on['val'],
'used' => $used['val'],
'usedpercent' => $usedpercent,
'badhdd' => $hdd['val'],
- );
+ ];
// Graph
- $cutoff = time() - 2 * 86400;
- $res = Database::simpleQuery("SELECT dateline, data FROM statistic WHERE typeid = '~stats' AND dateline > $cutoff ORDER BY dateline ASC");
- $labels = array();
- $points1 = array('data' => array(), 'label' => 'Online', 'fillColor' => '#efe', 'strokeColor' => '#aea', 'pointColor' => '#7e7', 'pointStrokeColor' => '#fff', 'pointHighlightFill' => '#fff', 'pointHighlightStroke' => '#7e7');
- $points2 = array('data' => array(), 'label' => 'In use', 'fillColor' => '#fee', 'strokeColor' => '#eaa', 'pointColor' => '#e77', 'pointStrokeColor' => '#fff', 'pointHighlightFill' => '#fff', 'pointHighlightStroke' => '#e77');
- $sum = 0;
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- $x = explode('#', $row['data']);
- if ($sum === 0) {
- $labels[] = date('H:i', $row['dateline']);
+ $labels = [];
+ $points1 = [];
+ $points2 = [];
+ $lectures = [];
+ // Get locations
+ if ($filterSet->suitableForUsageGraph()) {
+ $locFilter = $filterSet->hasFilter('LocationStatisticsFilter');
+ if ($locFilter === null
+ || ($locFilter->op === '~' && ($locFilter->argument == 0
+ || (is_array($locFilter->argument) && in_array(0, $locFilter->argument))))) {
+ $locations = null;
+ $op = null;
+ } elseif ($locFilter->op === '~') {
+ $locations = array_keys(Location::getRecursiveFlat($locFilter->argument));
+ $op = $locFilter->op;
} else {
- $x[1] = max($x[1], array_pop($points1['data']));
- $x[2] = max($x[2], array_pop($points2['data']));
+ if (is_array($locFilter->argument)) {
+ $locations = $locFilter->argument;
+ } else {
+ $locations = [$locFilter->argument];
+ }
+ $op = $locFilter->op;
}
- $points1['data'][] = $x[1];
- $points2['data'][] = $x[2];
- ++$sum;
- if ($sum === 12) {
- $sum = 0;
+ //error_log($op . ' ' . print_r($locations, true));
+ $cutoff = time() - 2 * 86400;
+ $res = Database::simpleQuery("SELECT dateline, data FROM statistic
+ WHERE typeid = '~stats' AND dateline > $cutoff ORDER BY dateline DESC");
+ // Get max from 4 consecutive values, which should be 4*5 = 20m
+ $sum = 0;
+ foreach ($res as $row) {
+ if ($row['data'][0] === '{') {
+ $x = json_decode($row['data'], true);
+ if (!is_array($x) || !isset($x['usage']))
+ continue;
+ $x = self::mangleStatsJson($x, $locations, $op);
+ } else if ($locations === null) {
+ $x = explode('#', $row['data']);
+ if (count($x) < 3)
+ continue;
+ $x[] = 0;
+ } else {
+ continue;
+ }
+ if ($sum % 4 === 0) {
+ $labels[] = date('H:i', $row['dateline']);
+ } else {
+ $x[1] = max($x[1], array_pop($points1));
+ $x[2] = max($x[2], array_pop($points2));
+ $x[3] += array_pop($lectures);
+ }
+ $points1[] = $x[1];
+ $points2[] = $x[2];
+ $lectures[] = $x[3];
+ ++$sum;
}
}
- $data['json'] = json_encode(array('labels' => $labels, 'datasets' => array($points1, $points2)));
+ if (!empty($points1) && max($points1) > 0) {
+ $labels = array_reverse($labels);
+ $points1 = array_reverse($points1);
+ $points2 = array_reverse($points2);
+ $lectures = array_reverse($lectures);
+ $data['json'] = json_encode(['labels' => $labels,
+ 'datasets' => [
+ ['data' => $points1, 'label' => 'Online', 'borderColor' => '#8f3'],
+ ['data' => $points2, 'label' => 'In use', 'borderColor' => '#e76'],
+ ]]);
+ $data['markings'] = json_encode($lectures);
+ }
if (Module::get('runmode') !== false) {
$res = Database::queryFirst('SELECT Count(*) AS cnt FROM machine m INNER JOIN runmode r USING (machineuuid)'
. " $join WHERE $where", $args);
@@ -92,74 +140,71 @@ class SubPage
Render::addTemplate('summary', $data);
}
- /**
- * @param \StatisticsFilterSet $filterSet
- */
- private static function showSystemModels($filterSet)
+ private static function showSystemModels(StatisticsFilterSet $filterSet): void
{
$filterSet->makeFragments($where, $join, $args);
$res = Database::simpleQuery('SELECT systemmodel, Round(AVG(realcores)) AS cores, Count(*) AS `count` FROM machine m'
. " $join WHERE $where GROUP BY systemmodel ORDER BY `count` DESC, systemmodel ASC", $args);
- $lines = array();
- $json = array();
+ $lines = [];
+ $json = [];
$id = 0;
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if (empty($row['systemmodel'])) {
continue;
}
settype($row['count'], 'integer');
- $row['id'] = 'systemid' . $id;
$row['urlsystemmodel'] = urlencode($row['systemmodel']);
+ $row['idx'] = count($lines);
$lines[] = $row;
- $json[] = array(
+ $json[] = [
'color' => self::$STATS_COLORS[$id % count(self::$STATS_COLORS)],
- 'label' => 'systemid' . $id,
'value' => $row['count'],
- );
+ ];
++$id;
}
self::capChart($json, $lines, 0.92);
- Render::addTemplate('cpumodels', array('rows' => $lines, 'json' => json_encode($json)));
+ Render::addTemplate('cpumodels', ['rows' => $lines, 'json' => json_encode($json)]);
}
- /**
- * @param \StatisticsFilterSet $filterSet
- */
- private static function showMemory($filterSet)
+ private static function alignBySteps(int $value, array $steps): int
+ {
+ for ($i = 1; $i < count($steps); ++$i) {
+ if ($steps[$i] < $value) {
+ continue;
+ }
+ if ($steps[$i] - $value >= $value - $steps[$i - 1]) {
+ --$i;
+ }
+ return $steps[$i];
+ }
+ return $value;
+ }
+
+ private static function showMemory(StatisticsFilterSet $filterSet): void
{
$filterSet->makeFragments($where, $join, $args);
$res = Database::simpleQuery("SELECT mbram, Count(*) AS `count` FROM machine m $join
WHERE $where GROUP BY mbram", $args);
- $lines = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- $gb = (int)ceil($row['mbram'] / 1024);
- for ($i = 1; $i < count(StatisticsFilter::SIZE_RAM); ++$i) {
- if (StatisticsFilter::SIZE_RAM[$i] < $gb) {
- continue;
- }
- if (StatisticsFilter::SIZE_RAM[$i] - $gb >= $gb - StatisticsFilter::SIZE_RAM[$i - 1]) {
- --$i;
- }
- $gb = StatisticsFilter::SIZE_RAM[$i];
- break;
- }
- if (isset($lines[$gb])) {
- $lines[$gb] += $row['count'];
- } else {
- $lines[$gb] = $row['count'];
- }
+ $lines = [];
+ foreach ($res as $row) {
+ $gb = self::alignBySteps((int)ceil($row['mbram'] / 1024), StatisticsFilter::SIZE_RAM);
+ $lines[$gb] = ($lines[$gb] ?? 0) + $row['count'];
}
asort($lines);
- $data = array('rows' => array());
- $json = array();
+ $data = ['rows' => []];
+ $json = [];
$id = 0;
foreach (array_reverse($lines, true) as $k => $v) {
- $data['rows'][] = array('gb' => $k, 'count' => $v, 'class' => StatisticsStyling::ramColorClass($k * 1024));
- $json[] = array(
+ $data['rows'][] = [
+ 'idx' => count($data['rows']),
+ 'gb' => $k,
+ 'count' => $v,
+ 'class' => StatisticsStyling::ramColorClass($k * 1024),
+ ];
+ $json[] = [
'color' => self::$STATS_COLORS[$id % count(self::$STATS_COLORS)],
- 'label' => (string)$k,
'value' => $v,
- );
+ ];
++$id;
}
self::capChart($json, $data['rows'], 0.92);
@@ -167,62 +212,47 @@ class SubPage
Render::addTemplate('memory', $data);
}
- /**
- * @param \StatisticsFilterSet $filterSet
- */
- private static function showKvmState($filterSet)
+ private static function showKvmState(StatisticsFilterSet $filterSet): void
{
$filterSet->makeFragments($where, $join, $args);
- $colors = array('UNKNOWN' => '#666', 'UNSUPPORTED' => '#ea5', 'DISABLED' => '#e55', 'ENABLED' => '#6d6');
+ $colors = ['UNKNOWN' => '#666', 'UNSUPPORTED' => '#ea5', 'DISABLED' => '#e55', 'ENABLED' => '#6d6'];
$res = Database::simpleQuery("SELECT kvmstate, Count(*) AS `count` FROM machine m $join
WHERE $where GROUP BY kvmstate ORDER BY `count` DESC", $args);
- $lines = array();
- $json = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ $lines = [];
+ $json = [];
+ foreach ($res as $row) {
+ $row['idx'] = count($lines);
$lines[] = $row;
$json[] = array(
- 'color' => isset($colors[$row['kvmstate']]) ? $colors[$row['kvmstate']] : '#000',
- 'label' => $row['kvmstate'],
+ 'color' => $colors[$row['kvmstate']] ?? '#000',
'value' => $row['count'],
);
}
Render::addTemplate('kvmstate', array('rows' => $lines, 'json' => json_encode($json)));
}
- /**
- * @param \StatisticsFilterSet $filterSet
- */
- private static function showId44($filterSet)
+ private static function showId44(StatisticsFilterSet $filterSet): void
{
$filterSet->makeFragments($where, $join, $args);
$res = Database::simpleQuery("SELECT id44mb, Count(*) AS `count` FROM machine m $join WHERE $where GROUP BY id44mb", $args);
$lines = array();
$total = 0;
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$total += $row['count'];
- $gb = (int)ceil($row['id44mb'] / 1024);
- for ($i = 1; $i < count(StatisticsFilter::SIZE_ID44); ++$i) {
- if (StatisticsFilter::SIZE_ID44[$i] < $gb) {
- continue;
- }
- if (StatisticsFilter::SIZE_ID44[$i] - $gb >= $gb - StatisticsFilter::SIZE_ID44[$i - 1]) {
- --$i;
- }
- $gb = StatisticsFilter::SIZE_ID44[$i];
- break;
- }
- if (isset($lines[$gb])) {
- $lines[$gb] += $row['count'];
- } else {
- $lines[$gb] = $row['count'];
- }
+ $gb = self::alignBySteps((int)ceil($row['id44mb'] / 1024), StatisticsFilter::SIZE_PARTITION);
+ $lines[$gb] = ($lines[$gb] ?? 0) + $row['count'];
}
asort($lines);
$data = array('rows' => array());
$json = array();
$id = 0;
foreach (array_reverse($lines, true) as $k => $v) {
- $data['rows'][] = array('gb' => $k, 'count' => $v, 'class' => StatisticsStyling::hddColorClass($k));
+ $data['rows'][] = [
+ 'idx' => count($data['rows']),
+ 'gb' => $k,
+ 'count' => $v,
+ 'class' => StatisticsStyling::hddColorClass($k),
+ ];
if ($k === 0) {
$color = '#e55';
} else {
@@ -230,7 +260,6 @@ class SubPage
}
$json[] = array(
'color' => $color,
- 'label' => (string)$k,
'value' => $v,
);
}
@@ -239,19 +268,18 @@ class SubPage
Render::addTemplate('id44', $data);
}
- /**
- * @param \StatisticsFilterSet $filterSet
- */
- private static function showLatestMachines($filterSet)
+ private static function showLatestMachines(StatisticsFilterSet $filterSet): void
{
$filterSet->makeFragments($where, $join, $args);
$args['cutoff'] = ceil(time() / 3600) * 3600 - 86400 * 10;
- $res = Database::simpleQuery("SELECT machineuuid, clientip, hostname, firstseen, mbram, kvmstate, id44mb FROM machine m $join"
- . " WHERE firstseen > :cutoff AND $where ORDER BY firstseen DESC LIMIT 32", $args);
+ $res = Database::simpleQuery("SELECT m.machineuuid, m.clientip, m.hostname, m.firstseen, m.mbram, m.kvmstate, m.id44mb
+ FROM machine m $join
+ WHERE firstseen > :cutoff AND $where
+ ORDER BY firstseen DESC LIMIT 32", $args);
$rows = array();
$count = 0;
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if (empty($row['hostname'])) {
$row['hostname'] = $row['clientip'];
}
@@ -259,9 +287,9 @@ class SubPage
$row['firstseen'] = Util::prettyTime($row['firstseen']);
$row['gbram'] = round(round($row['mbram'] / 500) / 2, 1); // Trial and error until we got "expected" rounding..
$row['gbtmp'] = round($row['id44mb'] / 1024);
- $row['ramclass'] = StatisticsStyling::ramColorClass($row['mbram']);
+ $row['ramclass'] = StatisticsStyling::ramColorClass((int)$row['mbram']);
$row['kvmclass'] = StatisticsStyling::kvmColorClass($row['kvmstate']);
- $row['hddclass'] = StatisticsStyling::hddColorClass($row['gbtmp']);
+ $row['hddclass'] = StatisticsStyling::hddColorClass((int)$row['gbtmp']);
$row['kvmicon'] = $row['kvmstate'] === 'ENABLED' ? '✓' : '✗';
if (++$count > 5) {
$row['collapse'] = 'collapse';
@@ -277,7 +305,7 @@ class SubPage
- private static function capChart(&$json, &$rows, $cutoff, $minSlice = 0.015)
+ private static function capChart(array &$json, array &$rows, float $cutoff, float $minSlice = 0.015): void
{
$total = 0;
foreach ($json as $entry) {
@@ -309,4 +337,28 @@ class SubPage
}
}
+ /**
+ * @param array $json decoded json ~stats data
+ * @param ?int[] $locations
+ */
+ private static function mangleStatsJson(array $json, ?array $locations, ?string $op): array
+ {
+ // Total, On, InUse, Lectures
+ $retval = [0, 0, 0, 0];
+ foreach ($json['usage'] as $lid => $data) {
+ $lid = (int)$lid;
+ if ($locations === null
+ || ($op === '!=' && !in_array($lid, $locations))
+ || ($op !== '!=' && in_array($lid, $locations))) {
+ $retval[0] += $data['t'];
+ $retval[1] += $data['o'] ?? 0;
+ $retval[2] += $data['u'] ?? 0;
+ if (isset($data['event'])) {
+ $retval[3] += 1;
+ }
+ }
+ }
+ return $retval;
+ }
+
}
diff --git a/modules-available/statistics/permissions/permissions.json b/modules-available/statistics/permissions/permissions.json
index 663a8dc4..a5823775 100644
--- a/modules-available/statistics/permissions/permissions.json
+++ b/modules-available/statistics/permissions/permissions.json
@@ -22,5 +22,11 @@
},
"view.list": {
"location-aware": true
+ },
+ "replace": {
+ "location-aware": true
+ },
+ "hints": {
+ "location-aware": true
}
} \ No newline at end of file
diff --git a/modules-available/statistics/style.css b/modules-available/statistics/style.css
index 7e1539ec..7bd60b44 100644
--- a/modules-available/statistics/style.css
+++ b/modules-available/statistics/style.css
@@ -93,3 +93,25 @@
50% { background: #f2dede }
100% { background: unset }
}
+
+.slx-right {
+ float: right;
+}
+@media (min-width: 1650px) {
+ .slx-right {
+ position: fixed;
+ right: 10px;
+ display: block;
+ min-width: 140px;
+ width: calc(100vw - 1550px);
+ float: none !important;
+ }
+}
+
+.infobox {
+ border: 1px solid #aaa;
+ background: #eee;
+ border-radius: 3px;
+ margin: 3px auto;
+ padding: 0 2px;
+} \ No newline at end of file
diff --git a/modules-available/statistics/templates/clientlist.html b/modules-available/statistics/templates/clientlist.html
index af349437..fcb98774 100644
--- a/modules-available/statistics/templates/clientlist.html
+++ b/modules-available/statistics/templates/clientlist.html
@@ -1,46 +1,23 @@
+<div class="slx-right">
+ {{{roomsvg}}}
+ {{#sidebar}}
+ <div class="infobox">{{.}}</div>
+ {{/sidebar}}
+</div>
+
<h2>{{lang_clientList}} ({{rowCount}})</h2>
+<button class="btn btn-default" type="button" data-toggle="modal"
+ data-target="#column-selector">{{lang_selectColumns}}</button>
+
+<div class="clearfix"></div>
<form method="post" action="?do=statistics" id="list-form">
<input type="hidden" name="token" value="{{token}}">
<input type="hidden" name="redirect" value="?{{redirect}}">
-<table class="stupidtable table table-condensed table-striped">
+<table id="client-list" class="stupidtable table table-condensed table-striped">
<thead>
- <tr>
- <td></td>
- <td></td>
- <td class="text-right">
- <button type="button" class="btn btn-default btn-xs" onclick="popupFilter('lastseen')">
- <span class="glyphicon glyphicon-filter"></span>
- </button>
- </td>
- <td>
- <button type="button" class="btn btn-default btn-xs" onclick="popupFilter('kvmstate')">
- <span class="glyphicon glyphicon-filter"></span>
- </button>
- </td>
- <td class="text-right">
- <button type="button" class="btn btn-default btn-xs" onclick="popupFilter('gbram')">
- <span class="glyphicon glyphicon-filter"></span>
- </button>
- </td>
- <td class="text-right">
- <button type="button" class="btn btn-default btn-xs" onclick="popupFilter('hddgb')">
- <span class="glyphicon glyphicon-filter"></span>
- </button>
- </td>
- <td>
- <button type="button" class="btn btn-default btn-xs" onclick="popupFilter('realcores')">
- <span class="glyphicon glyphicon-filter"></span>
- </button>
- </td>
- <td>
- <button type="button" class="btn btn-default btn-xs" onclick="popupFilter('location')">
- <span class="glyphicon glyphicon-filter"></span>
- </button>
- </td>
- </tr>
- <tr>
+ <tr id="thead">
<th data-sort="string">
<div class="checkbox checkbox-inline">
<input type="checkbox" id="toggle-all">
@@ -48,13 +25,15 @@
</div>
{{lang_machine}}
</th>
- <th data-sort="ipv4">{{lang_address}}</th>
- <th data-sort="int" class="text-right">{{lang_lastSeen}}</th>
- <th data-sort="string">{{lang_kvmSupport}}</th>
- <th data-sort="int" class="text-right">{{lang_gbRam}}</th>
- <th data-sort="int" class="text-right">{{lang_tmpGb}}</th>
- <th data-sort="int">{{lang_cpuModel}}</th>
- <th data-sort="string">{{lang_location}}</th>
+ <th data-sort="ipv4" data-column="clientip">{{lang_address}}</th>
+ <th data-sort="int" data-column="nicspeed" class="text-right">{{lang_nicSpeed}}</th>
+ <th data-sort="int" data-column="lastseen" class="text-right">{{lang_lastSeen}}</th>
+ <th data-sort="string" data-column="kvmstate">{{lang_kvmSupport}}</th>
+ <th data-sort="int" data-column="gbram" class="text-right">{{lang_gbRam}}</th>
+ <th data-sort="int" data-column="hddgb" class="text-right">{{lang_tmpGb}}</th>
+ <th data-sort="int" data-column="persistentgb" class="text-right">{{lang_persistentPart}}</th>
+ <th data-sort="int" data-column="realcores">{{lang_cpuModel}}</th>
+ <th data-sort="string" data-column="location">{{lang_location}}</th>
</tr>
</thead>
<tbody>
@@ -118,22 +97,34 @@
{{/currentuser}}
</td>
<td data-sort-value="{{clientip}}">
- <b><a href="?do=Statistics&amp;show=list&amp;filters=clientip={{subnet}}/24">{{subnet}}.</a>{{lastoctet}}</b>
+ <b><a href="?do=statistics&amp;show=list&amp;filters=clientip={{subnet}}/24">{{subnet}}.</a>{{lastoctet}}</b>
<div class="mac text-nowrap">{{macaddr}}</div>
<div class="hidden ip">{{clientip}}</div>
</td>
+ <td data-sort-value="{{nic-speed}}" class="text-right">
+ {{nic-speed_s}}&thinsp;MBit/s
+ </td>
<td data-sort-value="{{lastseen_int}}" class="text-right text-nowrap">{{lastseen}}</td>
<td class="{{kvmclass}}">{{kvmstate}}</td>
- <td data-sort-value="{{gbram}}" class="text-right {{ramclass}}">{{gbram}}&thinsp;GiB</td>
- <td data-sort-value="{{gbtmp}}" class="text-right {{hddclass}}">
+ <td data-sort-value="{{mbram}}" class="text-right {{ramclass}}">{{gbram}}&thinsp;GiB</td>
+ <td data-sort-value="{{id44mb}}" class="text-right {{hddclass}}">
{{gbtmp}}&thinsp;GiB
{{#badsectors}}<div><span data-toggle="tooltip" title="{{lang_reallocatedSectors}}" data-placement="left">
<span class="glyphicon glyphicon-exclamation-sign"></span>
{{badsectors}}
</span></div>{{/badsectors}}
- {{#nohdd}}<div>
+ {{^hddcount}}<div>
<span class="glyphicon glyphicon-hdd red"></span>
- </div>{{/nohdd}}
+ </div>{{/hddcount}}
+ {{#hddcount}}<div>
+ <span class="badge">
+ <span class="glyphicon glyphicon-hdd"></span>
+ {{hddcount}}
+ </span>
+ </div>{{/hddcount}}
+ </td>
+ <td data-sort-value="{{id45mb}}" class="text-right">
+ {{gbpersist}}&thinsp;GiB
</td>
<td data-sort-value="{{realcores}}">{{lang_realCores}}: {{realcores}}<div class="small">{{cpumodel}}</div></td>
<td data-sort-value="{{location.sort}}">{{location.name}}</td>
@@ -146,7 +137,7 @@
<span class="glyphicon glyphicon-refresh"></span>
{{lang_reset}}
</button>
- <div class="btn-group">
+ <div class="btn-group dropup">
<button class="btn btn-default dropdown-toggle btn-machine-action" type="button" id="dropdownMenu1"
data-toggle="dropdown" aria-haspopup="true">
<span class="glyphicon glyphicon-list"></span>
@@ -170,10 +161,15 @@
data-target="#mac-list">
{{lang_uuid}}
</a></li>
+ <hr style="margin: 0px">
+ <li><a href="#" class="list-btn" data-what="hostname ip mac uuid" data-toggle="modal"
+ data-target="#mac-list">
+ {{lang_fullInfo}}
+ </a></li>
</ul>
</div>
{{#rebootcontrol}}
- <div class="btn-group">
+ <div class="btn-group dropup">
<button class="btn btn-default dropdown-toggle btn-machine-action" type="button" id="dropdownMenu2"
data-toggle="dropdown" aria-haspopup="true">
<span class="glyphicon glyphicon-list"></span>
@@ -208,12 +204,18 @@
{{lang_remoteExec}}
</button>
{{/canExec}}
+ {{#canBenchmark}}
+ <button type="submit" name="action" value="benchmark" class="btn btn-primary btn-machine-action">
+ <span class="glyphicon glyphicon-dashboard"></span>
+ {{lang_remoteSpeedcheck}}
+ </button>
+ {{/canBenchmark}}
</div>
</div>
</div>
{{/rebootcontrol}}
{{#canDelete}}
- <div class="btn-group">
+ <div class="btn-group dropup">
<button type="submit" name="action" value="delmachines" class="btn btn-danger btn-machine-action"
data-confirm="{{lang_sureDeletePermanent}}">
<span class="glyphicon glyphicon-trash"></span>
@@ -260,8 +262,25 @@
</div>
</div>
+<div class="modal" id="column-selector" tabindex="-1" role="dialog">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <button type="button" class="close" data-dismiss="modal">&times;</button>
+ </div>
+ <div class="modal-body"></div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-default"
+ data-dismiss="modal">{{lang_close}}</button>
+ </div>
+ </div>
+ </div>
+</div>
+
<script type="application/javascript"><!--
+var lookupTable = {};
+
document.addEventListener("DOMContentLoaded", function () {
var $buttons = $('.btn-machine-action');
var $fn = function () {
@@ -302,17 +321,76 @@ document.addEventListener("DOMContentLoaded", function () {
$fn();
});
$('.list-btn').click(function() {
- var what = $(this).data('what');
+ var what = $(this).data('what').split(" ");
var $el = $('#mac-list-content');
$el.empty();
var result = '';
- var num = $('.machine').has('input[type=checkbox]:checked').find('.' + what).each(function() {
- var text = this.innerText;
- if (what === 'mac') text = text.replace(/-/g, ':');
- result += text + "\n";
+ var num = $('.machine').has('input[type=checkbox]:checked').each(function(index, element) {
+ what.forEach(function (w) {
+ $(element).find('.' + w).each(function() {
+ var text = this.innerText;
+ if (w === 'mac') text = text.replace(/-/g, ':');
+ result += text + "\t";
+ });
+ });
+ result += "\n";
}).length;
$el.text(result).prop('rows', Math.min(24, Math.max(5, num)));
});
+ // Generate list for column selection
+ var $cs = $('#column-selector .modal-body');
+ var $filters = $('<tr>');
+ $('#client-list > thead').prepend($filters);
+ var idx = 0;
+ $cs.empty();
+ $('#client-list > thead > tr#thead > th').each(function() {
+ idx++;
+ var $th = $(this);
+ var $td = $('<td>');
+ $filters.append($td);
+ var column = $th.data('column');
+ if (!column)
+ return;
+ $cs.append($('<div class="checkbox">')
+ .append($('<input id="shc-' + column + '" type="checkbox" onclick="toggleColumn(this, \'' + column + '\')" checked="checked">'))
+ .append($('<label for="shc-' + column + '">').text($th.text())));
+ $td.append($('<button type="button" class="btn btn-default btn-xs" onclick="popupFilter(\'' + column + '\')">'
+ + '<span class="glyphicon glyphicon-filter"></span></button>'));
+ lookupTable[column] = idx;
+ });
+ // Load previous visibility settings
+ var colConf;
+ if (window.localStorage && (colConf = window.localStorage.getItem('cl-col-conf'))) {
+ colConf = JSON.parse(colConf);
+ if (colConf) {
+ for (var k in colConf) {
+ if (k.substring(0, 4) === 'shc-' && colConf[k]) {
+ var $cb = $('#' + k);
+ if ($cb.prop('checked')) {
+ $cb.click();
+ }
+ }
+ }
+ }
+ }
});
+function toggleColumn(e, column)
+{
+ var $el = $(e);
+ if (!(column in lookupTable))
+ return;
+ var idx = lookupTable[column];
+ $('#client-list tr > td:nth-child(' + idx + '), #client-list tr > th:nth-child(' + idx + ')')
+ .css('display', $el.is(':checked') ? '' : 'none');
+ var data = {};
+ $('#column-selector .modal-body .checkbox input').each(function() {
+ var $el = $(this);
+ if (!$el.is(':checked')) {
+ data[$el[0].id] = 1;
+ }
+ });
+ window.localStorage.setItem('cl-col-conf', JSON.stringify(data));
+}
+
//--></script>
diff --git a/modules-available/statistics/templates/cpumodels.html b/modules-available/statistics/templates/cpumodels.html
index 91464031..e133bec6 100644
--- a/modules-available/statistics/templates/cpumodels.html
+++ b/modules-available/statistics/templates/cpumodels.html
@@ -16,7 +16,7 @@
</thead>
<tbody>
{{#rows}}
- <tr id="{{id}}" class="{{collapse}}">
+ <tr id="sysmdl-{{idx}}" class="{{collapse}}">
<td data-sort-value="{{systemmodel}}" class="text-left text-nowrap filter-col" data-filter-col="systemmodel">
<table class="slx-ellipsis"><tr><td>
<a class="filter-val" data-filter-val="{{systemmodel}}" href="#">{{systemmodel}}</a>
@@ -40,28 +40,7 @@
</tbody>
</table>
</div>
- <div class="col-md-4">
- <canvas id="cpumodelchart" style="width:100%;height:380px"></canvas>
- <script type="text/javascript">
- document.addEventListener("DOMContentLoaded", function() {
- var data = {{{json}}};
- var sel = false;
- new Chart(document.getElementById('cpumodelchart').getContext('2d')).Pie(data, {
- animation: false,
- tooltipTemplate: "<%if (label){%><%=label%><%}%>",
- customTooltips: function(tooltip) {
- if (sel !== false) sel.removeClass('slx-bold');
- if (!tooltip) {
- sel = false;
- return;
- }
- sel = $('#' + tooltip.text);
- sel.addClass('slx-bold');
- }
- });
- }, false);
- </script>
- </div>
+ <div class="col-md-4 auto-chart" data-chart="{{json}}" data-chart-dest="#sysmdl-"></div>
</div>
</div>
</div>
diff --git a/modules-available/statistics/templates/filterbox.html b/modules-available/statistics/templates/filterbox.html
index e7c1cd9b..f62c4d7c 100644
--- a/modules-available/statistics/templates/filterbox.html
+++ b/modules-available/statistics/templates/filterbox.html
@@ -106,47 +106,23 @@ document.addEventListener("DOMContentLoaded", function () {
e.find('.filter-val').each(function(idx, elem) {
var e = $(elem);
var val = e.data('filter-val');
+ var op = e.data('filter-op');
+ if (!op) op = '=';
if (val === null || val === undefined) return;
e.click(function(ev) {
ev.preventDefault();
- addFilter(col, '=', val);
+ addFilter(col, op, val);
refresh();
});
});
});
+ $('.auto-chart').each(function() {
+ makePieChart($(this));
+ });
+
}, false);
-function popupFilter(field) {
- var $row = addFilter(field, null, null);
- if ($row !== null) {
- $row.find('.arg').focus();
- $row.removeClass('slx-focus')
- setTimeout(function() { $row.addClass('slx-focus'); }, 10);
- }
-}
-
-function addFilter(field, op, argument) {
- if (field === null)
- return null;
- var $row = $('#filter-' + field);
- if ($row.length === 0)
- return null;
- if (argument !== null) {
- $row.find('.op').val(op);
- $row.find('.arg').val(argument);
- }
- // Enable checkbox only if we got a predefined value, or if argument is in a select, as the user might want the preselected item and doesn't notice the checkbox is unchecked
- if (argument !== null || $row.find('select.arg').length !== 0) {
- $row.find('.filter-enable').prop('checked', true);
- }
- $row.show();
- return $row;
-}
-
-function refresh() {
- $('#query-form').submit();
-}
// --></script>
diff --git a/modules-available/statistics/templates/hints-cpu-legacy.html b/modules-available/statistics/templates/hints-cpu-legacy.html
new file mode 100644
index 00000000..44a5b166
--- /dev/null
+++ b/modules-available/statistics/templates/hints-cpu-legacy.html
@@ -0,0 +1,28 @@
+<h2>{{lang_legacyCpuVmx}}</h2>
+
+<p>{{lang_legacyCpuVmxText}}</p>
+
+<table class="table">
+ <thead>
+ <tr>
+ <th>{{lang_machine}}</th>
+ <th>{{lang_cpuModel}}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{#list}}
+ <tr>
+ <td>
+ <span class="glyphicon {{icon}}"></span>
+ <a class="slx-bold" href="?do=statistics&amp;uuid={{machineuuid}}">
+ {{hostname}}{{^hostname}}{{clientip}}{{/hostname}}
+ </a>
+ <div class="small">{{machineuuid}}</div>
+ </td>
+ <td class="text-nowrap">
+ {{cpumodel}}
+ </td>
+ </tr>
+ {{/list}}
+ </tbody>
+</table> \ No newline at end of file
diff --git a/modules-available/statistics/templates/hints-hdd-grow.html b/modules-available/statistics/templates/hints-hdd-grow.html
new file mode 100644
index 00000000..b7c5bff4
--- /dev/null
+++ b/modules-available/statistics/templates/hints-hdd-grow.html
@@ -0,0 +1,67 @@
+<h2>{{lang_hddUnused}}</h2>
+
+<p>{{lang_hddUnusedId44}}</p>
+
+<table class="table">
+ <thead>
+ <tr>
+ <th>{{lang_machine}}</th>
+ <th>{{lang_unused}}</th>
+ <th>{{lang_id44size}}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{#id44}}
+ <tr>
+ <td>
+ <span class="glyphicon {{icon}}"></span>
+ <a class="slx-bold" href="?do=statistics&amp;uuid={{machineuuid}}">
+ {{hostname}}{{^hostname}}{{clientip}}{{/hostname}}
+ </a>
+ <div class="small">{{machineuuid}}</div>
+ </td>
+ <td>
+ {{unused_s}}
+ </td>
+ <td>
+ {{id44mb_s}}
+ </td>
+ </tr>
+ {{/id44}}
+ </tbody>
+</table>
+
+<p>{{lang_hddUnusedId45}}</p>
+
+<table class="table">
+ <thead>
+ <tr>
+ <th>{{lang_machine}}</th>
+ <th>{{lang_unused}}</th>
+ <th>{{lang_id45size}}</th>
+ <th>{{lang_id44size}}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{#id45}}
+ <tr>
+ <td>
+ <span class="glyphicon {{icon}}"></span>
+ <a class="slx-bold" href="?do=statistics&amp;uuid={{machineuuid}}">
+ {{hostname}}{{^hostname}}{{clientip}}{{/hostname}}
+ </a>
+ <div class="small">{{machineuuid}}</div>
+ </td>
+ <td>
+ {{unused_s}}
+ </td>
+ <td>
+ {{id45mb_s}}
+ </td>
+ <td>
+ {{id44mb_s}}
+ </td>
+ </tr>
+ {{/id45}}
+ </tbody>
+</table>
diff --git a/modules-available/statistics/templates/hints-nic-speed.html b/modules-available/statistics/templates/hints-nic-speed.html
new file mode 100644
index 00000000..963213cd
--- /dev/null
+++ b/modules-available/statistics/templates/hints-nic-speed.html
@@ -0,0 +1,32 @@
+<h2>{{lang_nicSlowSpeed}}</h2>
+
+<p>{{lang_nicSlowSpeedText}}</p>
+
+<table class="table">
+ <thead>
+ <tr>
+ <th>{{lang_machine}}</th>
+ <th>{{lang_nicSpeed}}</th>
+ <th>{{lang_nicDuplex}}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{#list}}
+ <tr>
+ <td>
+ <span class="glyphicon {{icon}}"></span>
+ <a class="slx-bold" href="?do=statistics&amp;uuid={{machineuuid}}">
+ {{hostname}}{{^hostname}}{{clientip}}{{/hostname}}
+ </a>
+ <div class="small">{{machineuuid}}</div>
+ </td>
+ <td class="text-nowrap">
+ {{nic-speed}}&thinsp;{{lang_MbitPerSecond}}
+ </td>
+ <td>
+ {{nic-duplex}}
+ </td>
+ </tr>
+ {{/list}}
+ </tbody>
+</table> \ No newline at end of file
diff --git a/modules-available/statistics/templates/hints-ram-underclocked.html b/modules-available/statistics/templates/hints-ram-underclocked.html
new file mode 100644
index 00000000..35bdd857
--- /dev/null
+++ b/modules-available/statistics/templates/hints-ram-underclocked.html
@@ -0,0 +1,49 @@
+<h2>{{lang_ramUnderclocked}}</h2>
+
+<p>{{lang_ramUnderclockedText}}</p>
+
+<table class="table">
+ <thead>
+ <tr>
+ <th>{{lang_machine}}</th>
+ <th>{{lang_type}}</th>
+ <th>{{lang_speedCurrent}}</th>
+ <th>{{lang_speedDesign}}</th>
+ <th>{{lang_manufacturer}}</th>
+ <th>{{lang_serialNo}}</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ {{#list}}
+ <tr>
+ <td>
+ <span class="glyphicon {{icon}}"></span>
+ <a class="slx-bold" href="?do=statistics&amp;uuid={{machineuuid}}">
+ {{hostname}}{{^hostname}}{{clientip}}{{/hostname}}
+ </a>
+ <div class="small">{{machineuuid}}</div>
+ </td>
+ <td class="text-nowrap">
+ {{Type}} {{Form Factor}}
+ <div>{{Size}}</div>
+ </td>
+ <td class="text-nowrap">
+ {{Configured Memory Speed}}
+ </td>
+ <td class="text-nowrap">
+ {{Speed}}
+ </td>
+ <td class="text-nowrap">
+ {{Manufacturer}}
+ </td>
+ <td>
+ {{Serial Number}}
+ </td>
+ <td class="text-right">
+ <span class="badge">{{group_count}}</span>
+ </td>
+ </tr>
+ {{/list}}
+ </tbody>
+</table>
diff --git a/modules-available/statistics/templates/hints-ram-upgrade.html b/modules-available/statistics/templates/hints-ram-upgrade.html
new file mode 100644
index 00000000..7b60d419
--- /dev/null
+++ b/modules-available/statistics/templates/hints-ram-upgrade.html
@@ -0,0 +1,32 @@
+<h2>{{lang_ramUpgrade}}</h2>
+
+<p>{{lang_ramUpgradeText}}</p>
+
+<table class="table">
+ <thead>
+ <tr>
+ <th>{{lang_machine}}</th>
+ <th>{{lang_installedCountMax}}</th>
+ <th>{{lang_ramSizeCurrentMax}}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{#list}}
+ <tr>
+ <td>
+ <span class="glyphicon {{icon}}"></span>
+ <a class="slx-bold" href="?do=statistics&amp;uuid={{machineuuid}}">
+ {{hostname}}{{^hostname}}{{clientip}}{{/hostname}}
+ </a>
+ <div class="small">{{machineuuid}}</div>
+ </td>
+ <td class="{{count_class}}">
+ {{Memory Slot Occupied}} / {{Memory Slot Count}}
+ </td>
+ <td class="{{size_class}}">
+ {{Memory Installed Capacity}} / {{Memory Maximum Capacity}}
+ </td>
+ </tr>
+ {{/list}}
+ </tbody>
+</table> \ No newline at end of file
diff --git a/modules-available/statistics/templates/id44.html b/modules-available/statistics/templates/id44.html
index ec0dac09..7851ba87 100644
--- a/modules-available/statistics/templates/id44.html
+++ b/modules-available/statistics/templates/id44.html
@@ -15,9 +15,9 @@
</thead>
<tbody>
{{#rows}}
- <tr id="tmpid{{gb}}" class="{{class}} {{collapse}}">
+ <tr id="id44-{{idx}}" class="{{class}} {{collapse}}">
<td data-sort-value="{{gb}}" class="text-left text-nowrap">
- <a class="filter-val" data-filter-val="{{gb}}" href="#">{{gb}}&thinsp;GiB</a>
+ <a class="filter-val" data-filter-val="{{gb}}" data-filter-op="~" href="#">{{gb}}&thinsp;GiB</a>
</td>
<td class="text-right">{{count}}</td>
</tr>
@@ -34,28 +34,7 @@
</tbody>
</table>
</div>
- <div class="col-sm-6">
- <canvas id="temppartchart" style="width:100%;height:250px"></canvas>
- <script type="text/javascript">
- document.addEventListener("DOMContentLoaded", function() {
- var data = {{{json}}};
- var sel = false;
- new Chart(document.getElementById('temppartchart').getContext('2d')).Pie(data, {
- animation: false,
- tooltipTemplate: "<%if (label){%><%=label%><%}%>",
- customTooltips: function(tooltip) {
- if (sel !== false) sel.removeClass('slx-bold');
- if (!tooltip) {
- sel = false;
- return;
- }
- sel = $('#tmpid' + String(tooltip.text));
- sel.addClass('slx-bold');
- }
- });
- }, false);
- </script>
- </div>
+ <div class="col-sm-6 auto-chart" data-chart="{{json}}" data-chart-dest="#id44-"></div>
</div>
</div>
</div>
diff --git a/modules-available/statistics/templates/js-pciquery.html b/modules-available/statistics/templates/js-pciquery.html
new file mode 100644
index 00000000..5d4df867
--- /dev/null
+++ b/modules-available/statistics/templates/js-pciquery.html
@@ -0,0 +1,24 @@
+<script>
+ document.addEventListener('DOMContentLoaded', function() {
+ var missing = {{{missing_ids}}};
+ var doQuery = function() {
+ if (missing && missing.length > 0) {
+ $.ajax({
+ url: '?do=statistics', dataType: "json", method: "POST", data: {
+ token: TOKEN,
+ action: 'json-lookup',
+ list: missing.splice(0, 10) // Query 10 at a time max
+ }
+ }).done(function (data) {
+ if (!data)
+ return;
+ for (var k in data) {
+ $('.query-' + k.replace(':', '-')).text(data[k]);
+ }
+ doQuery();
+ });
+ }
+ }
+ doQuery();
+ });
+</script> \ No newline at end of file
diff --git a/modules-available/statistics/templates/kvmstate.html b/modules-available/statistics/templates/kvmstate.html
index 4f8994d1..b3c65733 100644
--- a/modules-available/statistics/templates/kvmstate.html
+++ b/modules-available/statistics/templates/kvmstate.html
@@ -15,7 +15,7 @@
</thead>
<tbody>
{{#rows}}
- <tr id="kvm{{kvmstate}}">
+ <tr id="kvm-{{idx}}">
<td data-sort-value="{{kvmstate}}" class="text-left text-nowrap">
<a class="filter-val" data-filter-val="{{kvmstate}}" href="#">{{kvmstate}}</a>
</td>
@@ -25,28 +25,7 @@
</tbody>
</table>
</div>
- <div class="col-sm-6">
- <canvas id="kvmchart" style="width:100%;height:250px"></canvas>
- <script type="text/javascript">
- document.addEventListener("DOMContentLoaded", function() {
- var data = {{{json}}};
- var sel = false;
- new Chart(document.getElementById('kvmchart').getContext('2d')).Pie(data, {
- animation: false,
- tooltipTemplate: "<%if (label){%><%=label%><%}%>",
- customTooltips: function(tooltip) {
- if (sel !== false) sel.removeClass('slx-bold');
- if (!tooltip) {
- sel = false;
- return;
- }
- sel = $('#kvm' + tooltip.text);
- sel.addClass('slx-bold');
- }
- });
- }, false);
- </script>
- </div>
+ <div class="col-sm-6 auto-chart" data-chart="{{json}}" data-chart-dest="#kvm-"></div>
</div>
</div>
</div>
diff --git a/modules-available/statistics/templates/machine-hdds.html b/modules-available/statistics/templates/machine-hdds.html
index 4d0409f9..57786510 100644
--- a/modules-available/statistics/templates/machine-hdds.html
+++ b/modules-available/statistics/templates/machine-hdds.html
@@ -4,24 +4,27 @@
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
- <b>{{s_ModelFamily}}</b> {{dev}}
+ <b>{{model_family}}{{^model_family}}{{model}}{{/model_family}}</b> {{dev}}
</div>
<div class="panel-body">
- {{#s_DeviceModel}}
- <div>{{lang_modelNo}}: {{s_DeviceModel}}, {{lang_serialNo}}: {{s_SerialNumber}}</div>
- {{/s_DeviceModel}}
- {{#s_ReallocatedSectorCt}}
- <div class="red">{{lang_reallocatedSectors}}: {{s_ReallocatedSectorCt}}</div>
- {{/s_ReallocatedSectorCt}}
- {{#s_CurrentPendingSector}}
- <div class="red">{{lang_pendingSectors}}: {{s_CurrentPendingSector}}</div>
- {{/s_CurrentPendingSector}}
- {{#s_PowerOnHours}}
- <div>{{lang_powerOnTime}}: {{s_PowerOnHours}}&thinsp;{{lang_hours}} ({{PowerOnTime}})</div>
- {{/s_PowerOnHours}}
- {{#s_MediaandDataIntegrityErrors}}
- <div class="red">{{lang_mediaIntegrityErrors}}: {{s_MediaandDataIntegrityErrors}}</div>
- {{/s_MediaandDataIntegrityErrors}}
+ {{#model}}
+ <div>{{lang_modelNo}}: {{model}}, {{lang_serialNo}}: {{serial_number}}</div>
+ {{/model}}
+ {{#smart_status_failed}}
+ <div class="red">{{lang_smartSelfTestFailed}}</div>
+ {{/smart_status_failed}}
+ {{#attr_5.raw}}
+ <div class="red">{{lang_reallocatedSectors}}: {{attr_5.raw}}</div>
+ {{/attr_5.raw}}
+ {{#attr_197.raw}}
+ <div class="red">{{lang_pendingSectors}}: {{attr_197.raw}}</div>
+ {{/attr_197.raw}}
+ {{#PowerOnTime}}
+ <div>{{lang_powerOnTime}}: {{PowerOnTime}}</div>
+ {{/PowerOnTime}}
+ {{#media_errors}}
+ <div class="red">{{lang_mediaIntegrityErrors}}: {{media_errors}}</div>
+ {{/media_errors}}
<div class="row">
<div class="col-sm-7">
<table class="table table-condensed table-striped table-responsive">
@@ -31,40 +34,32 @@
<th>{{lang_partType}}</th>
</tr>
{{#partitions}}
- <tr id="{{id}}">
- <td>{{name}}</td>
- <td class="text-right text-nowrap">{{size}}&thinsp;GiB</td>
- <td>{{type}}</td>
+ <tr id="part-{{hddidx}}-{{idx}}">
+ <td>{{index}}</td>
+ <td class="text-right text-nowrap">{{size_s}}</td>
+ <td class="text-nowrap">
+ <table class="slx-ellipsis"><tr><td>{{name}}</td></tr></table>
+ </td>
</tr>
{{/partitions}}
</table>
- <div class="slx-bold">{{lang_total}}: {{size}}&thinsp;GiB</div>
+ <div class="slx-bold">{{lang_total}}: {{size_s}}</div>
+ {{#unused_s}}
+ <div class="slx-bold">{{lang_unused}}: {{unused_s}}</div>
+ {{/unused_s}}
</div>
- <div class="col-sm-5">
- <canvas id="{{devid}}-chart" style="width:100%;height:250px"></canvas>
- <script type="text/javascript">
- document.addEventListener("DOMContentLoaded", function() {
- var data = {{{json}}};
- var sel = false;
- new Chart(document.getElementById('{{devid}}-chart').getContext('2d')).Pie(data, {
- animation: false,
- tooltipTemplate: "<%if (label){%><%=label%><%}%>",
- customTooltips: function(tooltip) {
- if (sel !== false) sel.removeClass('info');
- if (!tooltip) {
- sel = false;
- return;
- }
- sel = $('#' + tooltip.text);
- sel.addClass('info');
- }
- });
- }, false);
- </script>
+ <div class="col-sm-5 auto-chart" data-chart="{{json}}" data-chart-dest="#part-{{hddidx}}-">
</div>
</div>
</div>
</div>
</div>
{{/hdds}}
+ <script type="text/javascript">
+ document.addEventListener("DOMContentLoaded", function() {
+ $('.auto-chart').each(function() {
+ makePieChart($(this));
+ });
+ });
+ </script>
</div> \ No newline at end of file
diff --git a/modules-available/statistics/templates/machine-main.html b/modules-available/statistics/templates/machine-main.html
index 568099e0..be32f9c7 100644
--- a/modules-available/statistics/templates/machine-main.html
+++ b/modules-available/statistics/templates/machine-main.html
@@ -29,6 +29,12 @@
<td class="text-nowrap">{{lang_ip}}</td>
<td>{{clientip}}</td>
</tr>
+ {{#nic-speed}}
+ <tr>
+ <td class="text-nowrap">{{lang_nicSpeed}}</td>
+ <td>{{nic-speed}}&thinsp;MBit/s, {{lang_duplex}}: {{nic-duplex}}</td>
+ </tr>
+ {{/nic-speed}}
{{#hostname}}
<tr>
<td class="text-nowrap">{{lang_hostname}}</td>
@@ -41,7 +47,17 @@
</tr>
<tr>
<td class="text-nowrap">{{lang_lastBoot}}</td>
- <td>{{lastboot_s}}</td>
+ <td>
+ {{lastboot_s}}
+ {{#minilinux}}
+ <div>
+ {{lang_baseSystem}}: {{minilinux}}
+ {{#boottime_s}}
+ (<span title="{{lang_boottimeTooltip}}">{{boottime_s}}</span>)
+ {{/boottime_s}}
+ </div>
+ {{/minilinux}}
+ </td>
</tr>
<tr>
<td class="text-nowrap">{{lang_lastSeen}}</td>
@@ -88,19 +104,22 @@
</td>
</tr>
{{/modeid}}
- {{#hasroomplan}}
+ {{#roomsvg}}
<tr>
<td class="text-nowrap">
{{lang_roomplan}}
</td>
<td>
+ <div>
+ {{{roomsvg}}}
+ </div>
<a href="?do=roomplanner&amp;locationid={{locationid}}" target="_blank"
- onclick="window.open(this.href, '_blank', 'toolbar=0,scrollbars,resizable');return false">
- <img src="api.php?do=roomplanner&amp;show=svg&amp;locationid={{locationid}}&amp;machineuuid={{machineuuid}}&amp;fallback=1"/>
+ onclick="window.open(this.href, '_blank', 'toolbar=0,scrollbars,resizable');return false">
+ {{lang_edit}}
</a>
</td>
</tr>
- {{/hasroomplan}}
+ {{/roomsvg}}
{{#rebootcontrol}}
<tr>
<td class="text-nowrap">
@@ -219,12 +238,14 @@
<tr>
<td class="text-nowrap">{{lang_cpuModel}}</td>
<td>
- {{cpumodel}}
- {{#Sockets}}
+ <a href="?do=statistics&amp;show=list&amp;filter[cpumodel]=1&amp;op[cpumodel]=%3D&amp;arg[cpumodel]={{cpumodel}}">
+ {{cpumodel}}
+ </a>
+ {{#cpu-sockets}}
<div class="small">
- {{lang_sockets}}: {{Sockets}}, {{lang_cores}}: {{Realcores}}, {{lang_virtualCores}}: {{Virtualcores}}
+ {{lang_sockets}}: {{cpu-sockets}}, {{lang_cores}}: {{cpu-cores}}, {{lang_virtualCores}}: {{cpu-threads}}
</div>
- {{/Sockets}}
+ {{/cpu-sockets}}
{{#live_cpuload_s}}
<div class="meter">
<div class="text left">{{lang_cpuload}}</div>
@@ -243,13 +264,18 @@
</tr>
<tr>
<td class="text-nowrap">{{lang_pcmodel}}</td>
- <td>{{pcmodel}} ({{pcmanufacturer}})</td>
+ <td>
+ {{#system.Product Name}}
+ <a href="?do=statistics&amp;show=list&amp;filter[systemmodel]=1&amp;op[systemmodel]=%3D&amp;arg[systemmodel]={{system.Product Name}}+({{system.Manufacturer}})">
+ {{system.Product Name}} ({{system.Manufacturer}})
+ </a>
+ {{/system.Product Name}}
+ </td>
</tr>
<tr>
<td class="text-nowrap">{{lang_mobomodel}}</td>
- <td>{{mobomodel}} ({{mobomanufacturer}})</td>
+ <td>{{mainboard.Product Name}} ({{mainboard.Manufacturer}})</td>
</tr>
- {{#biosdate}}
<tr>
<td class="text-nowrap">
<div>{{lang_biosVersion}}</div>
@@ -257,19 +283,23 @@
</td>
<td class="text-nowrap">
<div id="bios-panel" class="pull-right"style="max-width:30%">{{{bioshtml}}}</div>
- <div>{{biosversion}} (<b>{{biosrevision}}</b>)</div>
- <div>{{biosdate}}</div>
+ <div>{{bios.Version}} (<b>{{bios.BIOS Revision}}</b>)</div>
+ <div>{{bios.Release Date}}</div>
</td>
</tr>
- {{/biosdate}}
<tr class="{{ramclass}}">
<td class="text-nowrap">{{lang_ram}}</td>
<td>
<div>
{{gbram}}&thinsp;GiB
- {{#maxram}}({{lang_maximumAbbrev}} {{maxram}}){{/maxram}}
- {{ramtype}}
+ {{#Memory Maximum Capacity}}
+ / {{lang_maximumAbbrev}} {{Memory Maximum Capacity}}
+ {{/Memory Maximum Capacity}}
+ {{#Memory Slot Count}}
+ ({{Memory Slot Count}} {{lang_slots}})
+ {{/Memory Slot Count}}
</div>
+ <div>{{ramtype}}</div>
{{#live_memsize}}
<div class="meter">
<div class="text left">{{lang_ram}}</div>
@@ -286,29 +316,64 @@
{{/live_swapsize}}
</td>
</tr>
- {{#extram}}
<tr>
- <td class="text-nowrap">{{lang_ramSlots}}</td>
- <td>
- {{ramslotcount}}:
- {{#ramslot}}
- [ <span title="{{manuf}}">{{size}}</span> ]
- {{/ramslot}}
+ <td colspan="2">
+ <table class="table-responsive slx-table text-nowrap">
+ <thead>
+ <tr class="small">
+ <td>{{lang_slot}}</td>
+ <td></td>
+ <td>{{lang_speed}}</td>
+ <td>{{lang_manufacturer}}</td>
+ <td>{{lang_serialNo}}</td>
+ </tr>
+ </thead>
+ {{#ram}}
+ {{#Speed}}
+ <tr>
+ <td>
+ {{Locator}},
+ {{Bank Locator}}
+ {{^Bank Locator}}{{#Set}}Set {{Set}}{{/Set}}{{/Bank Locator}}
+ </td>
+ <td class="slx-bold">{{Size}}</td>
+ <td>{{#Configured Memory Speed}}{{Configured Memory Speed}} / {{/Configured Memory Speed}}{{Speed}}</td>
+ <td>{{Manufacturer}}</td>
+ <td>{{Serial Number}}</td>
+ </tr>
+ {{/Speed}}
+ {{/ram}}
+ </table>
</td>
</tr>
- {{/extram}}
<tr class="{{hddclass}}">
- <td class="text-nowrap">{{lang_tempPart}}</td>
+ <td class="text-nowrap">{{lang_tempPartID}}
+ <div class="text-muted">
+ {{lang_tempPart}}
+ </div>
+ </td>
<td>
<div>
{{gbtmp}}&thinsp;GiB
</div>
{{#live_tmpsize}}
- <div class="meter">
- <div class="text right">{{live_tmpfree_s}} {{lang_free}}</div>
- <div class="bar" style="width:{{live_tmppercent}}%"></div>
- </div>
+ <div class="meter">
+ <div class="text right">{{live_tmpfree_s}} {{lang_free}}</div>
+ <div class="bar" style="width:{{live_tmppercent}}%"></div>
+ </div>
{{/live_tmpsize}}
+ </td>
+ </tr>
+ <tr>
+ <td class="text-nowrap">{{lang_persistentPartID}}
+ <div class="text-muted">
+ {{lang_persistentPart}}
+ </div>
+ </td>
+ <td>
+ <div>
+ {{gbid45}}&thinsp;GiB
+ </div>
{{#live_id45size}}
<div class="meter">
<div class="text right">{{live_id45free_s}} {{lang_free}}</div>
@@ -362,16 +427,28 @@
</table>
<h4>{{lang_devices}}</h4>
{{#lspci1}}
- <div><span class="{{lookupClass}}">{{class}}</span></div>
+ <div><span>{{class_s}}</span></div>
{{#entries}}
- <div class="small">&emsp;â”” <span class="{{lookupVen}}">{{ven}}</span> <span class="{{lookupDev}}">{{dev}}</span></div>
+ <div class="small">
+ &emsp;â””
+ <span class="badge">{{pt}}</span>
+ <span{{^vendor_s}} class="query-{{vendor}}"{{/vendor_s}}>{{vendor_s}}</span>
+ <span{{^device_s}} class="query-{{vendor}}-{{device}}"{{/device_s}}>{{device_s}}</span>
+ <a href="?do=passthrough&amp;show=hwlist#{{vendor}}-{{device}}">[{{vendor}}:{{device}}]</a>
+ </div>
{{/entries}}
{{/lspci1}}
<div id="lspci" class="collapse">
{{#lspci2}}
- <div><span class="{{lookupClass}}">{{class}}</span></div>
+ <div><span>{{class_s}}</span></div>
{{#entries}}
- <div class="small">&emsp;â”” <span class="{{lookupVen}}">{{ven}}</span> <span class="{{lookupDev}}">{{dev}}</span></div>
+ <div class="small">
+ &emsp;â””
+ <span class="badge">{{pt}}</span>
+ <span{{^vendor_s}} class="query-{{vendor}}"{{/vendor_s}}>{{vendor_s}}</span>
+ <span{{^device_s}} class="query-{{vendor}}-{{device}}"{{/device_s}}>{{device_s}}</span>
+ <a href="?do=passthrough&amp;show=hwlist#{{vendor}}-{{device}}">[{{vendor}}:{{device}}]</a>
+ </div>
{{/entries}}
{{/lspci2}}
</div>
@@ -380,13 +457,3 @@
</div>
</div>
</div>
-<script type="application/javascript"><!--
-document.addEventListener("DOMContentLoaded", function () {
- $('span.do-lookup').each(function () {
- $(this).load('?do=statistics&lookup=' + $(this).text());
- });
- {{#biosurl}}
- $('#bios-panel').load('{{{biosurl}}}');
- {{/biosurl}}
-}, false);
-// --></script>
diff --git a/modules-available/statistics/templates/memory.html b/modules-available/statistics/templates/memory.html
index f6f4c446..0ccbca98 100644
--- a/modules-available/statistics/templates/memory.html
+++ b/modules-available/statistics/templates/memory.html
@@ -15,9 +15,9 @@
</thead>
<tbody>
{{#rows}}
- <tr id="ramid{{gb}}" class="{{class}} {{collapse}}">
+ <tr id="ram-{{idx}}" class="{{class}} {{collapse}}">
<td class="text-left text-nowrap" data-sort-value="{{gb}}">
- <a class="filter-val" data-filter-val="{{gb}}" href="#">{{gb}}&thinsp;GiB</a>
+ <a class="filter-val" data-filter-val="{{gb}}" data-filter-op="~" href="#">{{gb}}&thinsp;GiB</a>
</td>
<td class="text-right">{{count}}</td>
</tr>
@@ -34,28 +34,7 @@
</tbody>
</table>
</div>
- <div class="col-sm-6">
- <canvas id="ramsizechart" style="width:100%;height:250px"></canvas>
- <script type="text/javascript">
- document.addEventListener("DOMContentLoaded", function() {
- var data = {{{json}}};
- var sel = false;
- new Chart(document.getElementById('ramsizechart').getContext('2d')).Pie(data, {
- animation: false,
- tooltipTemplate: "<%if (label){%><%=label%><%}%>",
- customTooltips: function(tooltip) {
- if (sel !== false) sel.removeClass('slx-bold');
- if (!tooltip) {
- sel = false;
- return;
- }
- sel = $('#ramid' + tooltip.text);
- sel.addClass('slx-bold');
- }
- });
- }, false);
- </script>
- </div>
+ <div class="col-sm-6 auto-chart" data-chart="{{json}}" data-chart-dest="#ram-"></div>
</div>
</div>
</div>
diff --git a/modules-available/statistics/templates/summary.html b/modules-available/statistics/templates/summary.html
index 3ede7bc5..751a9bed 100644
--- a/modules-available/statistics/templates/summary.html
+++ b/modules-available/statistics/templates/summary.html
@@ -23,17 +23,61 @@
</div>
</div>
<div>
+ {{#json}}
<canvas id="usagehist" style="width:100%;height:150px"></canvas>
<script type="text/javascript">
document.addEventListener("DOMContentLoaded", function() {
+
+ var markings = {{{markings}}};
+ var markMax = Math.max(...markings) * 3;
+ var showLegend = markMax > 0;
+ if (markMax < 8) markMax = 8;
+
+ var oldDraw = Chart.prototype._drawDatasets;
+
+ Chart.prototype._drawDatasets = function () {
+ if (this.chartArea) {
+ var ctx = this.ctx;
+ var chartArea = this.chartArea;
+
+ var meta = this.getDatasetMeta(0);
+
+ ctx.save();
+ var end = Math.min(meta.data.length, markings.length) - 1;
+ for (var i = 0; i < end; ++i) {
+ var start = meta.data[i].x;
+ var stop = meta.data[i+1].x;
+ ctx.fillStyle = 'rgba(16, 64, 255, ' + (!!markings[i] * .05 + markings[i] / markMax) + ')';
+ ctx.fillRect(start, chartArea.top, stop - start, chartArea.bottom - chartArea.top);
+ }
+ ctx.restore();
+ }
+
+ // Perform regular chart draw
+ oldDraw.call(this);
+ };
+
var data = {{{json}}};
var sel = false;
- new Chart(document.getElementById('usagehist').getContext('2d')).Line(data, {
+ new Chart(document.getElementById('usagehist').getContext('2d'), {type: 'line', data: data, options: {
+ responsive: true,
animation: false,
- pointHitDetectionRadius: 5
- });
+ pointRadius: 0,
+ pointHitRadius: 6,
+ interaction: { mode: 'index' },
+ plugins: {
+ subtitle: {
+ display: showLegend,
+ text: '{{lang_graphLectureTitle}}',
+ position: 'bottom',
+ },
+ legend: {position: 'left' },
+ }
+ }});
+
}, false);
</script>
+ {{/json}}
</div>
</div>
diff --git a/modules-available/statistics_reporting/hooks/cron.inc.php b/modules-available/statistics_reporting/hooks/cron.inc.php
index 14597f7d..7336b7d1 100644
--- a/modules-available/statistics_reporting/hooks/cron.inc.php
+++ b/modules-available/statistics_reporting/hooks/cron.inc.php
@@ -1,25 +1,23 @@
<?php
-if (RemoteReport::isReportingEnabled()) {
- $nextReporting = RemoteReport::getReportingTimestamp();
+$nextReporting = RemoteReport::getReportingTimestamp();
- // It's time to generate a new report
- while ($nextReporting <= time()) {
- RemoteReport::writeNextReportingTimestamp();
+// It's time to generate a new report
+while ($nextReporting <= time()) {
+ RemoteReport::writeNextReportingTimestamp();
- $to = $nextReporting;
+ $to = $nextReporting;
- $statisticsReport = json_encode(RemoteReport::generateReport($to));
+ $statisticsReport = json_encode(RemoteReport::generateReport($to));
- $params = array("action" => "statistics", "data" => $statisticsReport);
+ $params = array("action" => "statistics", "data" => $statisticsReport);
- $result = Download::asStringPost(CONFIG_REPORTING_URL, $params, 30, $code);
+ $result = Download::asStringPost(CONFIG_REPORTING_URL, $params, 30, $code);
- if ($code != 200) {
- EventLog::warning("Statistics Reporting failed: " . $code, $result);
- } else {
- EventLog::info('Statistics report sent to ' . CONFIG_REPORTING_URL);
- }
- $nextReporting = strtotime("+7 days", $nextReporting);
+ if ($code != 200) {
+ EventLog::warning("Statistics Reporting failed: " . $code, $result);
+ } else {
+ EventLog::info('Statistics report sent to ' . CONFIG_REPORTING_URL);
}
-} \ No newline at end of file
+ $nextReporting = strtotime("+7 days", $nextReporting);
+}
diff --git a/modules-available/statistics_reporting/inc/getdata.inc.php b/modules-available/statistics_reporting/inc/getdata.inc.php
index 13d39502..75db119d 100644
--- a/modules-available/statistics_reporting/inc/getdata.inc.php
+++ b/modules-available/statistics_reporting/inc/getdata.inc.php
@@ -24,7 +24,7 @@ class GetData
return $carry . sprintf("%04d", $item);
}) . sprintf("%04d", $entry['locationid']);
} else {
- $entry['locationname'] = Dictionary::translate('notAssigned', true);
+ $entry['locationname'] = Dictionary::translate('notAssigned');
}
if ($anonymize) {
unset($entry['locationid']);
@@ -56,7 +56,8 @@ class GetData
}
// total
- public static function total($flags = 0) {
+ public static function total(int $flags = 0): array
+ {
$printable = 0 !== ($flags & GETDATA_PRINTABLE);
// total time online, average time online, total number of logins
$data = Queries::getOverallStatistics(self::$from, self::$to, self::$lowerTimeBound, self::$upperTimeBound);
@@ -70,7 +71,8 @@ class GetData
}
// per location
- public static function perLocation($flags = 0) {
+ public static function perLocation(int $flags = 0): array
+ {
$anonymize = 0 !== ($flags & GETDATA_ANONYMOUS);
$printable = 0 !== ($flags & GETDATA_PRINTABLE);
$data = Queries::getLocationStatistics(self::$from, self::$to, self::$lowerTimeBound, self::$upperTimeBound);
@@ -85,7 +87,8 @@ class GetData
}
// per client
- public static function perClient($flags = 0, $new = false) {
+ public static function perClient(int $flags = 0): array
+ {
$anonymize = 0 !== ($flags & GETDATA_ANONYMOUS);
$printable = 0 !== ($flags & GETDATA_PRINTABLE);
$data = Queries::getClientStatistics(self::$from, self::$to, self::$lowerTimeBound, self::$upperTimeBound);
@@ -101,11 +104,12 @@ class GetData
}
// per user
- public static function perUser($flags = 0) {
+ public static function perUser(int $flags = 0): array
+ {
$anonymize = 0 !== ($flags & GETDATA_ANONYMOUS);
$res = Queries::getUserStatistics(self::$from, self::$to, self::$lowerTimeBound, self::$upperTimeBound);
$data = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if ($anonymize && $row['name'] !== 'anonymous') {
$row['name'] = md5($row['name'] . self::$salt);
}
@@ -116,11 +120,12 @@ class GetData
// per vm
- public static function perVM($flags = 0) {
+ public static function perVM(int $flags = 0): array
+ {
$anonymize = 0 !== ($flags & GETDATA_ANONYMOUS);
$res = Queries::getVMStatistics(self::$from, self::$to, self::$lowerTimeBound, self::$upperTimeBound);
$data = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
self::nullToZero($row);
if ($anonymize) {
$row['name'] = md5($row['name'] . self::$salt);
@@ -130,7 +135,7 @@ class GetData
return $data;
}
- private static function nullToZero(&$row)
+ private static function nullToZero(array &$row): void
{
foreach ($row as &$field) {
if (is_null($field)) {
@@ -140,7 +145,7 @@ class GetData
}
// Format $seconds into ".d .h .m .s" format (day, hour, minute, second)
- private static function formatSeconds($seconds)
+ private static function formatSeconds(int $seconds): string
{
return sprintf('%dd, %02d:%02d:%02d', $seconds / (3600*24), ($seconds % (3600*24)) / 3600, ($seconds%3600) / 60, $seconds%60);
}
diff --git a/modules-available/statistics_reporting/inc/queries.inc.php b/modules-available/statistics_reporting/inc/queries.inc.php
index 128d8e7d..bafe80bc 100644
--- a/modules-available/statistics_reporting/inc/queries.inc.php
+++ b/modules-available/statistics_reporting/inc/queries.inc.php
@@ -13,7 +13,7 @@ class Queries
}
}
- public static function getClientStatistics(int $from, int $to, $lowerTimeBound = 0, $upperTimeBound = 24)
+ public static function getClientStatistics(int $from, int $to, int $lowerTimeBound = 0, int $upperTimeBound = 24): array
{
$fromCutoff = $from - 86400 * 30;
$res = Database::simpleQuery("SELECT m.machineuuid, m.hostname, m.clientip,
@@ -23,12 +23,12 @@ class Queries
foreach ($machines as &$machine) {
$machine['medianSessionLength'] = self::calcMedian($machine['sessions']);
unset($machine['sessions']);
- $machine['clientName'] = $machine['hostname'] ? $machine['hostname'] : $machine['clientip'];
+ $machine['clientName'] = $machine['hostname'] ?: $machine['clientip'];
}
return $machines;
}
- public static function getLocationStatistics(int $from, int $to, $lowerTimeBound = 0, $upperTimeBound = 24)
+ public static function getLocationStatistics(int $from, int $to, int $lowerTimeBound = 0, int $upperTimeBound = 24): array
{
$fromCutoff = $from - 86400 * 30;
$res = Database::simpleQuery("SELECT m.machineuuid, m.hostname, m.clientip,
@@ -37,7 +37,7 @@ class Queries
$machines = self::getStats3($res, $from, $to, $lowerTimeBound, $upperTimeBound);
$locations = [];
$keys = ['locationid', 'totalTime', 'totalOffTime', 'totalSessionTime', 'totalStandbyTime', 'totalIdleTime', 'totalIdleTime', 'longSessions', 'shortSessions', 'sessions'];
- while ($machine = array_pop($machines)) {
+ while (($machine = array_pop($machines)) !== null) {
if (!isset($locations[$machine['locationid']])) {
self::keepKeys($machine, $keys);
$locations[$machine['locationid']] = $machine;
@@ -69,7 +69,7 @@ class Queries
$machines = self::getStats3($res, $from, $to, $lowerTimeBound, $upperTimeBound);
$total = false;
$keys = ['totalTime', 'totalOffTime', 'totalSessionTime', 'totalStandbyTime', 'totalIdleTime', 'totalIdleTime', 'longSessions', 'shortSessions', 'sessions'];
- while ($machine = array_pop($machines)) {
+ while (($machine = array_pop($machines)) !== null) {
if ($total === false) {
self::keepKeys($machine, $keys);
$total = $machine;
@@ -84,20 +84,12 @@ class Queries
$total['sessions'] = array_merge($total['sessions'], $machine['sessions']);
}
}
- $total['medianSessionLength'] = self::calcMedian($total['sessions']);
+ $total['medianSessionLength'] = $total ? self::calcMedian($total['sessions']) : 0;
unset($total['sessions']);
return $total;
}
- /**
- * @param \PDOStatement $res
- * @param int $from
- * @param int $to
- * @param int $lowerTimeBound
- * @param int $upperTimeBound
- * @return array
- */
- private static function getStats3($res, $from, $to, $lowerTimeBound, $upperTimeBound)
+ private static function getStats3(PDOStatement $res, int $from, int $to, int $lowerTimeBound, int $upperTimeBound): array
{
//$debug = false;
if ($lowerTimeBound === 0 && $upperTimeBound === 24 || $upperTimeBound <= $lowerTimeBound) {
@@ -106,7 +98,7 @@ class Queries
$bounds = [$lowerTimeBound, $upperTimeBound];
}
$machines = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$row['firstseen'] = max($row['firstseen'], $from);
$row += array(
'totalTime' => self::timeDiff($row['firstseen'], $to, $bounds),
@@ -132,7 +124,7 @@ class Queries
ORDER BY dateline ASC LIMIT 1000", compact('last', 'to'));
$last = false;
$count = 0;
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$count += 1; // Update count first, as we use it as a condition in outer loop. No continue before this
settype($row['logid'], 'int');
// Update for next query
@@ -228,8 +220,8 @@ class Queries
}
*/
$machine[$row['typeid']] += self::timeDiff($start, $end, $bounds);
- $sh = date('G', $start);
}
+ $sh = date('G', $start);
if ($row['typeid'] === 'totalSessionTime' && ($bounds === false || ($sh >= $bounds[0] && $sh < $bounds[1]))) {
if ($row['data'] >= 60) {
$machine['longSessions'] += 1;
@@ -307,17 +299,18 @@ class Queries
/**
* Get median of array.
- * @param int[] list of values
+ * @param int[] $array list of values
* @return int The median
*/
- private static function calcMedian($array) {
+ private static function calcMedian(array $array): int
+ {
if (empty($array))
return 0;
sort($array, SORT_NUMERIC);
$count = count($array); //total numbers in array
$middleval = (int)floor(($count-1) / 2); // find the middle value, or the lowest middle value
if($count % 2 === 1) { // odd number, middle is the median
- return (int)$array[$middleval];
+ return $array[$middleval];
}
// even number, calculate avg of 2 medians
$low = $array[$middleval];
@@ -327,25 +320,23 @@ class Queries
// User Data: Name, Name(anonymized), Number of Logins
public static function getUserStatistics($from, $to, $lowerTimeBound = 0, $upperTimeBound = 24) {
- $res = Database::simpleQuery("SELECT username AS name, COUNT(*) AS 'count'
+ return Database::simpleQuery("SELECT username AS name, COUNT(*) AS 'count'
FROM statistic
WHERE typeid='.vmchooser-session-name' AND dateline >= $from and dateline <= $to
AND FROM_UNIXTIME(dateline, '%H') >= $lowerTimeBound AND FROM_UNIXTIME(dateline, '%H') < $upperTimeBound
GROUP BY username");
- return $res;
}
// Virtual Machine Data: Name, Number of Usages
public static function getVMStatistics($from, $to, $lowerTimeBound = 0, $upperTimeBound = 24) {
- $res = Database::simpleQuery("SELECT data AS name, COUNT(*) AS 'count'
+ return Database::simpleQuery("SELECT data AS name, COUNT(*) AS 'count'
FROM statistic
WHERE typeid='.vmchooser-session-name' AND dateline >= $from and dateline <= $to
AND FROM_UNIXTIME(dateline, '%H') >= $lowerTimeBound AND FROM_UNIXTIME(dateline, '%H') < $upperTimeBound
GROUP BY data");
- return $res;
}
- public static function getDozmodStats(int $from, int $to)
+ public static function getDozmodStats(int $from, int $to): array
{
if (Module::get('dozmod') === false)
return ['disabled' => true];
@@ -366,7 +357,7 @@ class Queries
return $return;
}
- public static function getExamStats(int $from, int $to)
+ public static function getExamStats(int $from, int $to): array
{
if (Module::get('exams') === false)
return ['disabled' => true];
@@ -375,7 +366,7 @@ class Queries
LEFT JOIN exams_x_location exl USING (examid)
WHERE starttime < $to AND endtime > $from
GROUP BY examid");
- while ($row = $eres->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($eres as $row) {
// Get all boot events
$data = ['from' => $row['starttime'], 'to' => $row['endtime']];
if (empty($row['locs'])) {
@@ -397,7 +388,7 @@ class Queries
return $return;
}
- public static function getAggregatedMachineStats($from)
+ public static function getAggregatedMachineStats(int $from): array
{
$return = array();
$return['location'] = Database::queryAll("SELECT MD5(CONCAT(locationid, :salt)) AS `location`, Count(*) AS `count`
@@ -407,7 +398,7 @@ class Queries
array('salt' => GetData::$salt));
$prev = 0;
$str = ' ';
- foreach (array(0.5, 1, 1.5, 2, 3, 4, 6, 8, 10, 12, 16, 20, 24, 28, 32, 40, 48, 64, 72, 80, 88, 96, 128, 192, 256) as $val) {
+ foreach ([0.5, 1, 1.5, 2, 3, 4, 6, 8, 10, 12, 16, 20, 24, 28, 32, 40, 48, 64, 72, 80, 88, 96, 128, 192, 256, 512, 768, 1024, 1536, 2048, 3072, 4096] as $val) {
$str .= 'WHEN mbram < ' . round(($val + $prev) * 512) . " THEN '" . $prev . "' ";
$prev = $val;
}
@@ -421,6 +412,15 @@ class Queries
WHERE lastseen >= $from
GROUP BY $key");
}
+ // Legacy CPU
+ $f = Database::queryFirst("SELECT Count(*) AS `total`
+ FROM machine m
+ INNER JOIN machine_x_hw mxh USING (machineuuid)
+ INNER JOIN statistic_hw_prop hwp ON (hwp.hwid = mxh.hwid AND hwp.prop = 'vmx-legacy' AND hwp.value <> 0)
+ WHERE m.lastseen >= $from");
+ if (is_array($f)) {
+ $return['vmx-legacy'] = $f['total'];
+ }
return $return;
}
@@ -429,7 +429,7 @@ class Queries
* @param int $to end timestamp
* @return int count of user active in timespan
*/
- public static function getUniqueUserCount($from, $to)
+ public static function getUniqueUserCount(int $from, int $to): int
{
$res = Database::queryFirst("SELECT Count(DISTINCT username) as `total`
FROM statistic
diff --git a/modules-available/statistics_reporting/inc/remotereport.inc.php b/modules-available/statistics_reporting/inc/remotereport.inc.php
index 376691dd..ebd31046 100644
--- a/modules-available/statistics_reporting/inc/remotereport.inc.php
+++ b/modules-available/statistics_reporting/inc/remotereport.inc.php
@@ -23,19 +23,19 @@ class RemoteReport
*
* @return bool true if reporting is on, false if off
*/
- public static function isReportingEnabled()
+ public static function isReportingEnabled(): bool
{
return Property::get(self::ENABLED_ID, 'on') === 'on';
}
/**
- * Get the timestamp of the end of the next 7 day interval to
+ * Get the timestamp of the end of the next 7-day interval to
* report statistics for. Usually if this is < time() you want
* to generate the report.
*
* @return int timestamp of the end of the reporting time frame
*/
- public static function getReportingTimestamp()
+ public static function getReportingTimestamp(): int
{
$ts = Property::get(self::NEXT_SUBMIT_ID, 0);
if ($ts === 0) {
@@ -52,7 +52,7 @@ class RemoteReport
/**
* Update the timestamp of the next scheduled statistics report.
- * This sets the end of the next 7 day interval to the start of
+ * This sets the end of the next 7-day interval to the start of
* next monday (00:00).
*/
public static function writeNextReportingTimestamp()
@@ -68,43 +68,48 @@ class RemoteReport
* @param int[] $days list of days to generate aggregated stats for
* @return array wrapped up statistics, ready for reporting
*/
- public static function generateReport(int $to, $days = false) {
- if ($days === false) {
- $days = [7, 30, 90];
- }
- GetData::$salt = bin2hex(Util::randomBytes(20, false));
- GetData::$lowerTimeBound = 7;
- GetData::$upperTimeBound = 20;
+ public static function generateReport(int $to, $days = false): array
+ {
$result = array();
- foreach ($days as $day) {
- if (isset($result['days' . $day]))
- continue;
- $from = strtotime("-{$day} days", $to);
- GetData::$from = $from;
- GetData::$to = $to;
- $data = array('total' => GetData::total(GETDATA_ANONYMOUS));
- $data['perLocation'] = array_values(GetData::perLocation(GETDATA_ANONYMOUS));
- $data['perVM'] = GetData::perVM(GETDATA_ANONYMOUS);
- $data['tsFrom'] = $from;
- $data['tsTo'] = $to;
- $data['dozmod'] = Queries::getDozmodStats($from, $to);
- $data['machines'] = Queries::getAggregatedMachineStats($from);
- $data['exams'] = Queries::getExamStats($from, $to);
- $data['baseSystem'] = Queries::getBaseSystemStats($from, $to);
- $data['runmode'] = Queries::getRunmodeStats($from, $to);
- $result['days' . $day] = $data;
+ if (RemoteReport::isReportingEnabled()) {
+ if ($days === false) {
+ $days = [7, 30, 90];
+ }
+ GetData::$salt = bin2hex(Util::randomBytes(20, false));
+ GetData::$lowerTimeBound = 7;
+ GetData::$upperTimeBound = 20;
+ foreach ($days as $day) {
+ if (isset($result['days' . $day]))
+ continue;
+ $from = (int)strtotime("-{$day} days", $to);
+ GetData::$from = $from;
+ GetData::$to = $to;
+ $data = array('total' => GetData::total(GETDATA_ANONYMOUS));
+ $data['perLocation'] = array_values(GetData::perLocation(GETDATA_ANONYMOUS));
+ $data['perVM'] = GetData::perVM(GETDATA_ANONYMOUS);
+ $data['tsFrom'] = $from;
+ $data['tsTo'] = $to;
+ $data['dozmod'] = Queries::getDozmodStats($from, $to);
+ $data['machines'] = Queries::getAggregatedMachineStats($from);
+ $data['exams'] = Queries::getExamStats($from, $to);
+ $data['baseSystem'] = Queries::getBaseSystemStats($from, $to);
+ $data['runmode'] = Queries::getRunmodeStats($from, $to);
+ $data['gpus'] = self::getGpus($from, $to);
+ $result['days' . $day] = $data;
+ }
+ $result['server'] = self::getLocalHardware();
}
- $result['server'] = self::getLocalHardware();
$result['version'] = CONFIG_FOOTER;
return $result;
}
- private static function getLocalHardware()
+ private static function getLocalHardware(): array
{
$cpuInfo = file_get_contents('/proc/cpuinfo');
$uptime = file_get_contents('/proc/uptime');
$memInfo = file_get_contents('/proc/meminfo');
- preg_match_all('/\b(\w+):\s+(\d+)\s/s', $memInfo, $out, PREG_SET_ORDER);
+ $osInfo = parse_ini_file('/etc/os-release');
+ preg_match_all('/\b(\w+):\s+(\d+)\s/', $memInfo, $out, PREG_SET_ORDER);
$mem = array();
foreach ($out as $e) {
$mem[$e[1]] = $e[2];
@@ -124,7 +129,36 @@ class RemoteReport
$data['swapTotal'] = $mem['SwapTotal'];
$data['swapUsed'] = ($mem['SwapTotal'] - $mem['SwapFree']);
}
+
+ $data['ip'] = $_SERVER['SERVER_ADDR'] ?? Property::getServerIp();
+ $data['hostname'] = strtolower(gethostbyaddr($_SERVER['SERVER_ADDR'] ?? Property::getServerIp()));
+
+ $data['osID'] = $osInfo['ID'];
+ $data['osVersionID'] = $osInfo['VERSION_ID'];
+ $data['osVersionCodename'] = $osInfo['VERSION_CODENAME'];
+
return $data;
}
-} \ No newline at end of file
+ private static function getGpus(int $from, int $to): array
+ {
+
+ $q = new HardwareQuery(HardwareInfo::PCI_DEVICE, null, true);
+ $q->addGlobalColumn('vendor');
+ $q->addGlobalColumn('device');
+ $q->addGlobalColumn('class')->addCondition('=', '0300'); // VGA adapter
+ $q->addMachineWhere('lastseen', '>=', $from);
+ $q->addMachineWhere('lastseen', '<=', $to);
+ $res = $q->query(['vendor', 'device']);
+ $return = [];
+ foreach ($res as $row) {
+ $return[] = [
+ 'group_count' => $row['group_count'],
+ 'device' => $row['device'],
+ 'vendor' => $row['vendor'],
+ ];
+ }
+ return $return;
+ }
+
+}
diff --git a/modules-available/statistics_reporting/page.inc.php b/modules-available/statistics_reporting/page.inc.php
index c3e469fe..ec4bbf1a 100644
--- a/modules-available/statistics_reporting/page.inc.php
+++ b/modules-available/statistics_reporting/page.inc.php
@@ -125,14 +125,14 @@ class Page_Statistics_Reporting extends Page
foreach ($this->COLUMNS as $column) {
$data['columns'][] = array(
'id' => 'col_' . $column,
- 'name' => Dictionary::translateFile('template-tags', 'lang_' . $column, true),
+ 'name' => Dictionary::translateFile('template-tags', 'lang_' . $column),
'checked' => ($forceOn || Request::get('col_' . $column, 'off', 'string') !== 'off') ? 'checked' : '',
);
}
foreach ($this->TABLES as $table) {
$data['tables'][] = array(
- 'name' => Dictionary::translate('table_' . $table, true),
+ 'name' => Dictionary::translate('table_' . $table),
'value' => $table,
'allowed' => User::hasPermission("table.view.$table"),
'selected' => ($this->type === $table) ? 'selected' : '',
@@ -185,12 +185,10 @@ class Page_Statistics_Reporting extends Page
}
Header('Content-Type: application/json; charset=utf-8');
die(json_encode($data));
- } else {
- die('No permission.');
}
- } else {
- echo 'Invalid action.';
+ die('No permission.');
}
+ echo 'Invalid action.';
}
private function doExport()
@@ -257,7 +255,6 @@ class Page_Statistics_Reporting extends Page
}
fclose($fh);
exit();
- break;
case 'xml':
$xml_data = new SimpleXMLElement('<?xml version="1.0" encoding="utf-8" ?><data></data>');
$this->array_to_xml($res, $xml_data, 'row');
@@ -273,9 +270,9 @@ class Page_Statistics_Reporting extends Page
/**
* @param $data array Data to encode
- * @param $xml_data \SimpleXMLElement XML Object to append to
+ * @param $xml_data SimpleXMLElement XML Object to append to
*/
- private function array_to_xml($data, $xml_data, $parentName = 'row')
+ private function array_to_xml(array $data, SimpleXMLElement $xml_data, string $parentName = 'row'): void
{
foreach ($data as $key => $value) {
if (is_numeric($key)) {
@@ -290,7 +287,7 @@ class Page_Statistics_Reporting extends Page
}
}
- private function fetchData($flags)
+ private function fetchData(int $flags)
{
// TODO: Make all modes location-aware, filter while querying, not after
switch ($this->type) {
@@ -314,10 +311,9 @@ class Page_Statistics_Reporting extends Page
}
}
// correct indexing of array after deletions
- $data = array_values($data);
- return $data;
+ return array_values($data);
case 'client':
- $data = GetData::perClient($flags, Request::any('new', false, 'string'));
+ $data = GetData::perClient($flags);
// only show clients from locations which you have permission for
$filterLocs = User::getAllowedLocations("table.view.client");
foreach ($data as $key => $row) {
@@ -326,8 +322,7 @@ class Page_Statistics_Reporting extends Page
}
}
// correct indexing of array after deletions
- $data = array_values($data);
- return $data;
+ return array_values($data);
case 'user':
return GetData::perUser($flags);
case 'vm':
diff --git a/modules-available/sysconfig/addconfig.inc.php b/modules-available/sysconfig/addconfig.inc.php
index a22cdc46..27af31e8 100644
--- a/modules-available/sysconfig/addconfig.inc.php
+++ b/modules-available/sysconfig/addconfig.inc.php
@@ -9,21 +9,17 @@ abstract class AddConfig_Base
/**
* Holds the instance for the currently executing step
- * @var \AddConfig_Base
+ * @var AddConfig_Base
*/
- private static $instance = false;
+ private static $instance = null;
/**
* Config being edited (if any)
- * @var \ConfigTgz
+ * @var ?ConfigTgz
*/
- protected $edit = false;
+ protected $edit = null;
- /**
- *
- * @param string $step
- */
- public static function setStep($step)
+ public static function setStep(string $step)
{
if (empty($step) || !class_exists($step) || get_parent_class($step) !== 'AddConfig_Base') {
Message::addError('invalid-action', $step);
@@ -32,18 +28,12 @@ abstract class AddConfig_Base
self::$instance = new $step();
if (($editId = Request::any('edit', 0, 'int')) !== 0) {
self::$instance->edit = ConfigTgz::get($editId);
- if (self::$instance->edit === false)
- Util::traceError('Invalid config id for editing');
+ if (self::$instance->edit === null)
+ ErrorHandler::traceError('Invalid config id for editing');
Util::addRedirectParam('edit', self::$instance->edit->id());
}
}
- protected function tmError()
- {
- Message::addError('main.taskmanager-error');
- Util::redirect('?do=SysConfig');
- }
-
/**
* Called before any HTML rendering happens, so you can
* prepare stuff, validate input, and optionally redirect
@@ -73,25 +63,25 @@ abstract class AddConfig_Base
public static function preprocess()
{
- if (self::$instance === false) {
- Util::traceError('No step instance yet');
+ if (self::$instance === null) {
+ ErrorHandler::traceError('No step instance yet');
}
self::$instance->preprocessInternal();
}
public static function render()
{
- if (self::$instance === false)
- Util::traceError('No step instance yet');
- if (self::$instance->edit !== false)
+ if (self::$instance === null)
+ ErrorHandler::traceError('No step instance yet');
+ if (self::$instance->edit !== null)
Message::addInfo('replacing-config', self::$instance->edit->title());
self::$instance->renderInternal();
}
public static function ajax()
{
- if (self::$instance === false) {
- Util::traceError('No step instance yet');
+ if (self::$instance === null) {
+ ErrorHandler::traceError('No step instance yet');
}
self::$instance->ajaxInternal();
}
@@ -110,7 +100,7 @@ class AddConfig_Start extends AddConfig_Base
$mods = ConfigModule::getList();
$res = Database::simpleQuery("SELECT moduleid, title, moduletype, filepath FROM configtgz_module"
. " ORDER BY title ASC"); // Move to ConfigModule
- if ($this->edit === false) {
+ if ($this->edit === null) {
$active = array();
} else {
$active = $this->edit->getModuleIds();
@@ -122,7 +112,7 @@ class AddConfig_Start extends AddConfig_Base
$modGroups[$mod['group']] =& $mod;
}
unset($mod);
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if (!isset($mods[$row['moduletype']])) {
$mods[$row['moduletype']] = array(
'unique' => false,
@@ -140,7 +130,7 @@ class AddConfig_Start extends AddConfig_Base
$row['active'] = in_array($row['moduleid'], $active);
$group['modules'][] = $row;
}
- if ($this->edit !== false) {
+ if ($this->edit !== null) {
$title = $this->edit->title();
} else {
$title = Request::any('title', '', 'string');
@@ -162,7 +152,7 @@ class AddConfig_Start extends AddConfig_Base
'step' => 'AddConfig_Finish',
'groups' => array_values($modGroups),
'title' => $title,
- 'edit' => ($this->edit !== false ? $this->edit->id() : false)
+ 'edit' => $this->edit === null ? null : $this->edit->id(),
));
}
@@ -174,9 +164,9 @@ class AddConfig_Start extends AddConfig_Base
class AddConfig_Finish extends AddConfig_Base
{
/**
- * @var ConfigTgz
+ * @var ?ConfigTgz
*/
- private $config = false;
+ private $config = null;
protected function preprocessInternal()
{
@@ -186,13 +176,13 @@ class AddConfig_Finish extends AddConfig_Base
Message::addError('missing-file');
Util::redirect('?do=SysConfig&action=addconfig');
}
- if ($this->edit === false) {
+ if ($this->edit === null) {
$this->config = ConfigTgz::insert($title, $modules);
} else {
$this->edit->update($title, $modules);
$this->config = $this->edit;
}
- if ($this->config === false || $this->config->generate(true, 150) === false) {
+ if ($this->config->generate(true, 150) === false) {
Message::addError('unsuccessful-action');
Util::redirect('?do=SysConfig&action=addconfig');
}
diff --git a/modules-available/sysconfig/addmodule.inc.php b/modules-available/sysconfig/addmodule.inc.php
index 91fee45d..4564537e 100644
--- a/modules-available/sysconfig/addmodule.inc.php
+++ b/modules-available/sysconfig/addmodule.inc.php
@@ -9,34 +9,39 @@ abstract class AddModule_Base
/**
* Holds the instance for the currently executing step
- * @var \AddModule_Base
+ *
+ * @var AddModule_Base
*/
private static $instance = false;
/**
* Instance of ConfigModule we're editing. False if not editing but creating.
- * @var \ConfigModule
+ *
+ * @var ?ConfigModule
*/
- protected $edit = false;
+ protected $edit = null;
/**
*
* @param string $step name of class representing the current step
* @param int $editId (optional) overwrite for the request parameter 'edit'
*/
- public static function setStep($step, $editId = false)
+ public static function setStep(string $step, int $editId = null): void
{
if (empty($step) || !class_exists($step) || get_parent_class($step) !== 'AddModule_Base') {
Message::addError('invalid-action', $step);
Util::redirect('?do=SysConfig');
}
self::$instance = new $step();
- if ($editId = $editId ? $editId : Request::any('edit')) {
+ if ($editId === null) {
+ $editId = Request::any('edit', 0, 'int');
+ }
+ if ($editId !== 0) {
self::$instance->edit = ConfigModule::get($editId);
- if (self::$instance->edit === false)
- Util::traceError('Invalid module id for editing');
+ if (self::$instance->edit === null)
+ ErrorHandler::traceError('Invalid module id for editing');
if ($step !== 'AddModule_Assign' && !preg_match('/^' . self::$instance->edit->moduleType() . '_/', $step))
- Util::traceError('Module to edit is of different type!');
+ ErrorHandler::traceError('Module to edit is of different type!');
Util::addRedirectParam('edit', self::$instance->edit->id());
}
}
@@ -90,7 +95,7 @@ abstract class AddModule_Base
public static function preprocess()
{
if (self::$instance === false) {
- Util::traceError('No step instance yet');
+ ErrorHandler::traceError('No step instance yet');
}
self::$instance->preprocessInternal();
}
@@ -98,9 +103,9 @@ abstract class AddModule_Base
public static function render()
{
if (self::$instance === false) {
- Util::traceError('No step instance yet');
+ ErrorHandler::traceError('No step instance yet');
}
- if (get_class(self::$instance) !== 'AddModule_Assign' && self::$instance->edit !== false) {
+ if (get_class(self::$instance) !== 'AddModule_Assign' && self::$instance->edit !== null) {
Message::addInfo('replacing-module', self::$instance->edit->title());
}
self::$instance->renderInternal();
@@ -109,7 +114,7 @@ abstract class AddModule_Base
public static function ajax()
{
if (self::$instance === false) {
- Util::traceError('No step instance yet');
+ ErrorHandler::traceError('No step instance yet');
}
self::$instance->ajaxInternal();
}
@@ -155,7 +160,7 @@ class AddModule_Assign extends AddModule_Base
if (ConfigModule::getList()[$moduleType]['unique']) {
$moduleIds = [];
- foreach (ConfigModule::getAll($moduleType) as $module) {
+ foreach (ConfigModule::getAll($moduleType) ?? [] as $module) {
$moduleIds[] = $module->id();
}
@@ -209,41 +214,26 @@ class AddModule_Assign extends AddModule_Base
* Helper functions to set/get a batch of vars from/to post variables or a module
*/
-/**
- *
- * @param \ConfigModule $module
- * @param array $array
- * @param array $keys
- */
-function moduleToArray($module, &$array, $keys)
+function moduleToArray(ConfigModule $module, array &$array, array $keys): void
{
foreach ($keys as $key) {
$array[$key] = $module->getData($key);
}
}
-/**
- *
- * @param \ConfigModule $module
- * @param array $array
- * @param array $keys
- */
-function arrayToModule($module, $array, $keys)
+function arrayToModule(ConfigModule $module, array $array, array $keys): void
{
foreach ($keys as $key) {
$module->setData($key, $array[$key]);
}
}
-/**
- *
- * @param array $array
- * @param array $keys
- */
-function postToArray(&$array, $keys, $ignoreMissing = false)
+
+function postToArray(array &$array, array $keys, $ignoreMissing = false): void
{
foreach ($keys as $key) {
$val = Request::post($key, '--not-in-post');
- if ($ignoreMissing && $val === '--not-in-post') continue;
+ if ($ignoreMissing && $val === '--not-in-post')
+ continue;
$array[$key] = $val;
}
}
diff --git a/modules-available/sysconfig/addmodule_adauth.inc.php b/modules-available/sysconfig/addmodule_adauth.inc.php
index f2ac206e..42187171 100644
--- a/modules-available/sysconfig/addmodule_adauth.inc.php
+++ b/modules-available/sysconfig/addmodule_adauth.inc.php
@@ -15,7 +15,7 @@ class AdAuth_Start extends AddModule_Base
{
$ADAUTH_COMMON_FIELDS = array('title', 'server', 'searchbase', 'binddn', 'bindpw', 'home', 'homeattr', 'ssl', 'genuid', 'certificate', 'mapping', 'nohomewarn');
$data = array();
- if ($this->edit !== false) {
+ if ($this->edit !== null) {
moduleToArray($this->edit, $data, $ADAUTH_COMMON_FIELDS);
$data['title'] = $this->edit->title();
$data['edit'] = $this->edit->id();
@@ -35,7 +35,7 @@ class AdAuth_Start extends AddModule_Base
}
$data['step'] = 'AdAuth_CheckConnection';
$data['map_empty'] = true;
- $data['mapping'] = ConfigModuleBaseLdap::getMapping(isset($data['mapping']) ? $data['mapping'] : false, $data['map_empty']);
+ $data['mapping'] = ConfigModuleBaseLdap::getMapping($data['mapping'] ?? null, $data['map_empty']);
Render::addDialog(Dictionary::translateFile('config-module', 'adAuth_title'), false, 'ad-start', $data);
}
@@ -89,13 +89,12 @@ class AdAuth_CheckConnection extends AddModule_Base
));
if (!isset($this->scanTask['id'])) {
AddModule_Base::setStep('AdAuth_Start'); // Continues with AdAuth_Start for render()
- return;
}
}
protected function renderInternal()
{
- $mapping = Request::post('mapping', false, 'array');
+ $mapping = Request::post('mapping', null, 'array');
$data = array(
'edit' => Request::post('edit'),
'title' => Request::post('title'),
@@ -131,7 +130,7 @@ class AdAuth_SelfSearch extends AddModule_Base
protected function preprocessInternal()
{
- $server = $binddn = $port = null;
+ $server = $binddn = null;
$searchbase = Request::post('searchbase', '');
$bindpw = Request::post('bindpw');
$ssl = Request::post('ssl', 'off') === 'on';
@@ -140,14 +139,8 @@ class AdAuth_SelfSearch extends AddModule_Base
AddModule_Base::setStep('AdAuth_Start'); // Continues with AdAuth_Start for render()
return;
}
- foreach (['server', 'binddn', 'port'] as $var) {
- $$var = Request::post($var, null);
- if (empty($$var)) {
- Message::addError('main.parameter-empty', $var);
- AddModule_Base::setStep('AdAuth_Start'); // Continues with AdAuth_Start for render()
- return;
- }
- }
+ $server = Request::post('server', Request::REQUIRED, 'string');
+ $binddn = Request::post('binddn', Request::REQUIRED, 'string');
$this->originalBindDn = '';
// Fix bindDN if short name given
//
@@ -195,7 +188,7 @@ class AdAuth_SelfSearch extends AddModule_Base
protected function renderInternal()
{
- $mapping = Request::post('mapping', false, 'array');
+ $mapping = Request::post('mapping', null, 'array');
$data = array(
'edit' => Request::post('edit'),
'title' => Request::post('title'),
@@ -288,7 +281,7 @@ class AdAuth_HomeAttrCheck extends AddModule_Base
'certificate' => Request::post('certificate', ''),
'originalbinddn' => Request::post('originalbinddn'),
'tryHomeAttr' => true,
- 'mapping' => ConfigModuleBaseLdap::getMapping(Request::post('mapping', false, 'array')),
+ 'mapping' => ConfigModuleBaseLdap::getMapping(Request::post('mapping', null, 'array')),
'prev' => 'AdAuth_Start',
'next' => 'AdAuth_CheckCredentials'
))
@@ -359,7 +352,7 @@ class AdAuth_CheckCredentials extends AddModule_Base
'fingerprint' => Request::post('fingerprint'),
'certificate' => Request::post('certificate', ''),
'originalbinddn' => Request::post('originalbinddn'),
- 'mapping' => ConfigModuleBaseLdap::getMapping(Request::post('mapping', false, 'array')),
+ 'mapping' => ConfigModuleBaseLdap::getMapping(Request::post('mapping', null, 'array')),
'prev' => 'AdAuth_Start',
'next' => 'AdAuth_HomeDir'
))
@@ -423,11 +416,11 @@ class AdAuth_HomeDir extends AddModule_Base
'fingerprint' => Request::post('fingerprint'),
'certificate' => Request::post('certificate', ''),
'originalbinddn' => Request::post('originalbinddn'),
- 'mapping' => ConfigModuleBaseLdap::getMapping(Request::post('mapping', false, 'array')),
+ 'mapping' => ConfigModuleBaseLdap::getMapping(Request::post('mapping', null, 'array')),
'prev' => 'AdAuth_Start',
'next' => 'AdAuth_Finish'
);
- if ($this->edit !== false) {
+ if ($this->edit !== null) {
foreach (self::getAttributes() as $key) {
if ($this->edit->getData($key)) {
$data[$key . '_c'] = 'checked="checked"';
@@ -448,13 +441,13 @@ class AdAuth_HomeDir extends AddModule_Base
foreach (range('D', 'Z') as $l) {
$data['drives'][] = array(
'drive' => $l . ':',
- 'selected' => (strtoupper($letter{0}) === $l) ? 'selected="selected"' : ''
+ 'selected' => (strtoupper($letter[0]) === $l) ? 'selected="selected"' : ''
);
}
Render::addDialog(Dictionary::translateFile('config-module', 'adAuth_title'), false, 'ad_ldap-homedir', $data);
}
- public static function getAttributes()
+ public static function getAttributes(): array
{
return array('shareRemapMode', 'shareRemapCreate', 'shareDocuments', 'shareDownloads', 'shareDesktop',
'shareMedia', 'shareOther', 'shareHomeDrive', 'shareDomain', 'credentialPassthrough');
@@ -472,10 +465,11 @@ class AdAuth_Finish extends AddModule_Base
$title = Request::post('title');
if (empty($title))
$title = 'AD: ' . Request::post('server');
- if ($this->edit === false)
+ if ($this->edit === null) {
$module = ConfigModule::getInstance('AdAuth');
- else
+ } else {
$module = $this->edit;
+ }
$ssl = Request::post('ssl', 'off') === 'on';
foreach (['searchbase', 'binddn', 'server', 'bindpw', 'home', 'nohomewarn', 'homeattr', 'certificate', 'genuid',
'ldapAttrMountOpts', 'shareHomeMountOpts'] as $key) {
@@ -499,7 +493,7 @@ class AdAuth_Finish extends AddModule_Base
} else {
$module->setData('fingerprint', '');
}
- if ($this->edit !== false)
+ if ($this->edit !== null)
$ret = $module->update($title);
else
$ret = $module->insert($title);
@@ -507,7 +501,7 @@ class AdAuth_Finish extends AddModule_Base
Message::addError('main.value-invalid', 'any', 'any');
$tgz = false;
} else {
- $tgz = $module->generate($this->edit === false);
+ $tgz = $module->generate($this->edit === null);
}
if ($tgz === false) {
AddModule_Base::setStep('AdAuth_Start'); // Continues with AdAuth_Start for render()
@@ -517,9 +511,8 @@ class AdAuth_Finish extends AddModule_Base
'tm-config' => $tgz,
);
- if ($this->edit === false) {
+ if ($this->edit === null) {
AddModule_Base::setStep('AddModule_Assign', $module->id());
- return;
}
}
diff --git a/modules-available/sysconfig/addmodule_branding.inc.php b/modules-available/sysconfig/addmodule_branding.inc.php
index d941a7a7..54b2ad57 100644
--- a/modules-available/sysconfig/addmodule_branding.inc.php
+++ b/modules-available/sysconfig/addmodule_branding.inc.php
@@ -11,7 +11,7 @@ class Branding_Start extends AddModule_Base
{
Render::addDialog(Dictionary::translateFile('config-module', 'branding_title'), false, 'branding-start', array(
'step' => 'Branding_ProcessFile',
- 'edit' => $this->edit ? $this->edit->id() : false
+ 'edit' => $this->edit == null ? null : $this->edit->id(),
));
}
@@ -22,7 +22,6 @@ class Branding_ProcessFile extends AddModule_Base
private $task;
private $svgFile;
- private $tarFile;
protected function preprocessInternal()
{
@@ -48,14 +47,16 @@ class Branding_ProcessFile extends AddModule_Base
if (strpos($url, '://') === false)
$url = "http://$url";
$title = false;
- if (!$this->downloadSvg($this->svgFile, $url, $title))
+ if (!Branding_ProcessFile::downloadSvg($this->svgFile, $url, $title)) {
+ @unlink($this->svgFile);
Util::redirect('?do=SysConfig&action=addmodule&step=Branding_Start');
+ }
Session::set('logo_name', $title);
}
chmod($this->svgFile, 0644);
- $this->tarFile = '/tmp/bwlp-' . time() . '-' . mt_rand() . '.tgz';
+ $tarFile = '/tmp/bwlp-' . time() . '-' . mt_rand() . '.tgz';
$this->task = Taskmanager::submit('BrandingGenerator', array(
- 'tarFile' => $this->tarFile,
+ 'tarFile' => $tarFile,
'svgFile' => $this->svgFile
));
$this->task = Taskmanager::waitComplete($this->task, 5000);
@@ -64,8 +65,7 @@ class Branding_ProcessFile extends AddModule_Base
Taskmanager::addErrorMessage($this->task);
Util::redirect('?do=SysConfig&action=addmodule&step=Branding_Start');
}
- Session::set('logo_tgz', $this->tarFile);
- Session::save();
+ Session::set('logo_tgz', $tarFile);
}
protected function renderInternal()
@@ -75,13 +75,13 @@ class Branding_ProcessFile extends AddModule_Base
$png = base64_encode(file_get_contents($this->task['data']['pngFile']));
if (filesize($this->svgFile) < 1000000)
$svg = base64_encode(file_get_contents($this->svgFile));
- Render::addDialog(Dictionary::translate('config-module', 'branding_title'), false, 'branding-check', array(
+ Render::addDialog(Dictionary::translateFile('config-module', 'branding_title'), false, 'branding-check', array(
'png' => $png,
'svg' => $svg,
- 'error' => $this->task['data']['error'],
+ 'error' => $this->task['data']['error'] ?? $this->task['statusCode'],
'step' => 'Branding_Finish',
- 'edit' => $this->edit ? $this->edit->id() : false,
- 'title' => $this->edit ? $this->edit->title() : false
+ 'edit' => $this->edit === null ? null : $this->edit->id(),
+ 'title' => $this->edit === null ? null : $this->edit->title(),
)
);
@unlink($this->svgFile);
@@ -96,39 +96,56 @@ class Branding_ProcessFile extends AddModule_Base
* @return boolean true of download succeeded, false on download error (also returns true if downloaded file doesn't
* seem to be svg!)
*/
- private function downloadSvg($svgName, $url, &$title)
+ private static function downloadSvg(string $svgName, string $url, &$title): bool
{
$title = false;
- // [wikipedia] Did someone paste a link to a thumbnail of the svg? Let's fix that...
- if (preg_match('#^(.*)/thumb/(.*\.svg)/.*\.svg#', $url, $out)) {
- $url = $out[1] . '/' . $out[2];
- }
for ($i = 0; $i < 5; ++$i) {
+ // [wikipedia] Did someone paste a link to a thumbnail of the svg? Let's fix that...
+ if (preg_match('#^(.*)/thumb/(.*\.svg)/.*\.svg#', $url, $out)) {
+ $url = $out[1] . '/' . $out[2];
+ }
$code = 400;
if (!Download::toFile($svgName, $url, 3, $code) || $code < 200 || $code > 299) {
Message::addError('remote-timeout', $url, $code);
return false;
}
- $content = FileUtil::readFile($svgName, 25000);
+ $content = FileUtil::readFile($svgName, 250000);
// Is svg file?
if (strpos($content, '<svg') !== false)
return true; // Found an svg tag - don't try to find links to the actual image
// [wikipedia] Try to be nice and detect links that might give a hint where the svg can be found
- if (preg_match_all('#href="([^"]*upload.wikimedia.org/[^"]*/[^"]*/[^"]*\.svg|[^"]+/[^"]+:[^"]+\.svg[^"]*)"#', $content, $out, PREG_PATTERN_ORDER)) {
+ $out1 = $out2 = $out3 = null;
+ if (preg_match_all('#href="([^"]*upload.wikimedia.org/[^"]*/[^"]*/[^"]*\.svg)"#', $content, $out1, PREG_PATTERN_ORDER)
+ || preg_match_all('#src="([^"]*upload.wikimedia.org/[^"]*/thumb/[^"]*\.svg/[^"]+\.svg[^"]*)"#', $content, $out2, PREG_PATTERN_ORDER)
+ || preg_match_all('#href="([^"]+/[^"]+:[^"]+\.svg)"#', $content, $out3, PREG_PATTERN_ORDER)) {
if ($title === false && preg_match('#<title>([^<]*)</title>#i', $content, $tout)) {
$title = trim(preg_replace('/\W*Wikipedia.*/', '', $tout[1]));
}
$new = false;
- foreach ($out[1] as $res) {
+ $out = [];
+ if (isset($out1[1])) {
+ $out += $out1[1];
+ }
+ if (isset($out2[1])) {
+ $out += $out2[1];
+ }
+ if (isset($out3[1])) {
+ $out += $out3[1];
+ }
+ foreach ($out as $res) {
+ error_log("Match '$res'");
+ if (!preg_match('/hochschule|univers|logo|siegel/i', $res))
+ continue;
if (strpos($res, 'action=edit') !== false)
continue;
- $new = $this->internetCombineUrl($url, html_entity_decode($res, ENT_COMPAT, 'UTF-8'));
+ $new = Branding_ProcessFile::internetCombineUrl($url, html_entity_decode($res, ENT_COMPAT, 'UTF-8'));
if ($new !== $url)
break;
}
if ($new === $url || $new === false)
break;
+ error_log("New: '$new'");
$url = $new;
continue;
}
@@ -145,7 +162,7 @@ class Branding_ProcessFile extends AddModule_Base
* @param string $relative relative url that will be converted to an absolute url
* @return string combined absolute url
*/
- private function internetCombineUrl($absolute, $relative)
+ private static function internetCombineUrl(string $absolute, string $relative): string
{
$p = parse_url($relative);
if (!empty($p["scheme"]))
@@ -154,8 +171,8 @@ class Branding_ProcessFile extends AddModule_Base
$parsed = parse_url($absolute);
$path = dirname($parsed['path']);
- if ($relative{0} === '/') {
- if ($relative{1} === '/')
+ if ($relative[0] === '/') {
+ if ($relative[1] === '/')
return "{$parsed['scheme']}:$relative";
$cparts = array_filter(explode("/", $relative));
} else {
@@ -197,9 +214,9 @@ class Branding_Finish extends AddModule_Base
protected function preprocessInternal()
{
$title = Request::post('title');
- if ($title === false || empty($title))
+ if (empty($title))
$title = Session::get('logo_name');
- if ($title === false || empty($title)) {
+ if (empty($title)) {
Message::addError('missing-title'); // TODO: Ask for title again instead of starting over
Util::redirect('?do=SysConfig&action=addmodule&step=Branding_Start');
}
@@ -208,30 +225,26 @@ class Branding_Finish extends AddModule_Base
Message::addError('main.error-read', $tgz);
Util::redirect('?do=SysConfig&action=addmodule&step=Branding_Start');
}
- if ($this->edit === false)
+ if ($this->edit === null) {
$module = ConfigModule::getInstance('Branding');
- else
+ } else {
$module = $this->edit;
- if ($module === false) {
- Message::addError('main.error-read', 'branding.inc.php');
- Util::redirect('?do=SysConfig&action=addmodule&step=Branding_Start');
}
$module->setData('tmpFile', $tgz);
- if ($this->edit !== false)
+ if ($this->edit !== null)
$ret = $module->update($title);
else
$ret = $module->insert($title);
if (!$ret)
Util::redirect('?do=SysConfig&action=addmodule&step=Branding_Start');
- elseif ($module->generate($this->edit === false, NULL, 200) === false)
+ elseif ($module->generate($this->edit === null, NULL, 200) === false)
Util::redirect('?do=SysConfig&action=addmodule&step=Branding_Start');
Session::set('logo_tgz', false);
Session::set('logo_name', false);
- Session::save();
// Yay
- if ($this->edit !== false)
+ if ($this->edit !== null) {
Message::addSuccess('module-edited');
- else {
+ } else {
Message::addSuccess('module-added');
AddModule_Base::setStep('AddModule_Assign', $module->id());
return;
diff --git a/modules-available/sysconfig/addmodule_custommodule.inc.php b/modules-available/sysconfig/addmodule_custommodule.inc.php
index f7ab863e..3afdad0a 100644
--- a/modules-available/sysconfig/addmodule_custommodule.inc.php
+++ b/modules-available/sysconfig/addmodule_custommodule.inc.php
@@ -14,7 +14,7 @@ class CustomModule_Start extends AddModule_Base
Session::set('mod_temp', false);
Render::addDialog(Dictionary::translateFile('config-module', 'custom_title'), false, 'custom-upload', array(
'step' => 'CustomModule_ProcessUpload',
- 'edit' => $this->edit ? $this->edit->id() : false
+ 'edit' => $this->edit === null ? null : $this->edit->id(),
));
}
@@ -55,7 +55,7 @@ class CustomModule_ProcessUpload extends AddModule_Base
protected function renderInternal()
{
- $status = Taskmanager::waitComplete($this->taskId);
+ $status = Taskmanager::waitComplete($this->taskId, 7500);
Taskmanager::release($this->taskId);
$userGroupWarn = false;
$tempfile = Session::get('mod_temp');
@@ -69,21 +69,20 @@ class CustomModule_ProcessUpload extends AddModule_Base
}
$list = SysConfig::archiveContentsFromTask($status, $userGroupWarn);
- if ($this->edit !== false) {
+ if ($this->edit !== null) {
$title = $this->edit->title();
} else if (isset($_FILES['modulefile']['name'])) {
$title = basename($_FILES['modulefile']['name']);
} else {
$title = '';
}
- Render::addDialog(Dictionary::translate('config-module', 'custom_title'), false, 'custom-fileselect', array(
+ Render::addDialog(Dictionary::translateFile('config-module', 'custom_title'), false, 'custom-fileselect', array(
'step' => 'CustomModule_CompressModule',
'files' => $list,
- 'edit' => $this->edit ? $this->edit->id() : false,
+ 'edit' => $this->edit === null ? null : $this->edit->id(),
'title' => $title,
'userGroupWarn' => $userGroupWarn,
));
- Session::save();
}
}
@@ -108,7 +107,7 @@ class CustomModule_CompressModule extends AddModule_Base
'outputFile' => $destFile,
'forceRoot' => Request::post('force-owner', 0, 'int') !== 0,
), true);
- $status = Taskmanager::waitComplete($taskId, 5000);
+ $status = Taskmanager::waitComplete($taskId, 10000);
unlink($tempfile);
if (!isset($status['statusCode'])) {
$this->tmError();
@@ -117,29 +116,26 @@ class CustomModule_CompressModule extends AddModule_Base
$this->taskError($status);
}
// Seems ok, create entry
- if ($this->edit === false)
+ if ($this->edit === null) {
$module = ConfigModule::getInstance('CustomModule');
- else
+ } else {
$module = $this->edit;
- if ($module === false) {
- Message::addError('main.error-read', 'custommodule.inc.php');
- Util::redirect('?do=SysConfig&action=addmodule&step=CustomModule_Start');
}
$module->setData('tmpFile', $destFile);
- if ($this->edit !== false)
+ if ($this->edit !== null) {
$ret = $module->update($title);
- else
+ } else {
$ret = $module->insert($title);
+ }
if (!$ret)
Util::redirect('?do=SysConfig&action=addmodule&step=CustomModule_Start');
- elseif (!$module->generate($this->edit === false, NULL, 200))
+ elseif (!$module->generate($this->edit === null, NULL, 200))
Util::redirect('?do=SysConfig&action=addmodule&step=CustomModule_Start');
Session::set('mod_temp', false);
- Session::save();
// Yay
- if ($this->edit !== false)
+ if ($this->edit !== null) {
Message::addSuccess('module-edited');
- else {
+ } else {
Message::addSuccess('module-added');
AddModule_Base::setStep('AddModule_Assign', $module->id());
return;
diff --git a/modules-available/sysconfig/addmodule_ldapauth.inc.php b/modules-available/sysconfig/addmodule_ldapauth.inc.php
index 606ce381..6a385d9c 100644
--- a/modules-available/sysconfig/addmodule_ldapauth.inc.php
+++ b/modules-available/sysconfig/addmodule_ldapauth.inc.php
@@ -11,7 +11,7 @@ class LdapAuth_Start extends AddModule_Base
{
$LDAPAUTH_COMMON_FIELDS = array('title', 'server', 'searchbase', 'binddn', 'bindpw', 'home', 'homeattr', 'ssl', 'genuid', 'certificate', 'mapping', 'nohomewarn');
$data = array();
- if ($this->edit !== false) {
+ if ($this->edit !== null) {
moduleToArray($this->edit, $data, $LDAPAUTH_COMMON_FIELDS);
$data['title'] = $this->edit->title();
$data['edit'] = $this->edit->id();
@@ -27,7 +27,7 @@ class LdapAuth_Start extends AddModule_Base
}
$data['step'] = 'LdapAuth_CheckConnection';
$data['map_empty'] = true;
- $data['mapping'] = ConfigModuleBaseLdap::getMapping(isset($data['mapping']) ? $data['mapping'] : false, $data['map_empty']);
+ $data['mapping'] = ConfigModuleBaseLdap::getMapping($data['mapping'] ?? null, $data['map_empty']);
Render::addDialog(Dictionary::translateFile('config-module', 'ldapAuth_title'), false, 'ldap-start', $data);
}
@@ -64,7 +64,6 @@ class LdapAuth_CheckConnection extends AddModule_Base
));
if (!isset($this->scanTask['id'])) {
AddModule_Base::setStep('LdapAuth_Start'); // Continues with LdapAuth_Start for render()
- return;
}
}
@@ -82,7 +81,7 @@ class LdapAuth_CheckConnection extends AddModule_Base
'genuid' => Request::post('genuid'),
'certificate' => Request::post('certificate', ''),
'taskid' => $this->scanTask['id'],
- 'mapping' => ConfigModuleBaseLdap::getMapping(Request::post('mapping', false, 'array')),
+ 'mapping' => ConfigModuleBaseLdap::getMapping(Request::post('mapping', null, 'array')),
);
$data['prev'] = 'LdapAuth_Start';
$data['next'] = 'LdapAuth_CheckCredentials';
@@ -153,7 +152,7 @@ class LdapAuth_CheckCredentials extends AddModule_Base
'genuid' => Request::post('genuid'),
'fingerprint' => Request::post('fingerprint'),
'certificate' => Request::post('certificate', ''),
- 'mapping' => ConfigModuleBaseLdap::getMapping(Request::post('mapping', false, 'array')),
+ 'mapping' => ConfigModuleBaseLdap::getMapping(Request::post('mapping', null, 'array')),
'prev' => 'LdapAuth_Start',
'next' => 'LdapAuth_HomeDir',
))
@@ -194,11 +193,11 @@ class LdapAuth_HomeDir extends AddModule_Base
'fingerprint' => Request::post('fingerprint'),
'certificate' => Request::post('certificate', ''),
'originalbinddn' => Request::post('originalbinddn'),
- 'mapping' => ConfigModuleBaseLdap::getMapping(Request::post('mapping', false, 'array')),
+ 'mapping' => ConfigModuleBaseLdap::getMapping(Request::post('mapping', null, 'array')),
'prev' => 'LdapAuth_Start',
'next' => 'LdapAuth_Finish',
);
- if ($this->edit !== false) {
+ if ($this->edit !== null) {
foreach (self::getAttributes() as $key) {
if ($this->edit->getData($key)) {
$data[$key . '_c'] = 'checked="checked"';
@@ -219,13 +218,13 @@ class LdapAuth_HomeDir extends AddModule_Base
foreach (range('D', 'Z') as $l) {
$data['drives'][] = array(
'drive' => $l . ':',
- 'selected' => (strtoupper($letter{0}) === $l) ? 'selected="selected"' : ''
+ 'selected' => (strtoupper($letter[0]) === $l) ? 'selected="selected"' : ''
);
}
Render::addDialog(Dictionary::translateFile('config-module', 'ldapAuth_title'), false, 'ad_ldap-homedir', $data);
}
- public static function getAttributes()
+ public static function getAttributes(): array
{
return array('shareRemapMode', 'shareRemapCreate', 'shareDocuments', 'shareDownloads', 'shareDesktop',
'shareMedia', 'shareOther', 'shareHomeDrive', 'shareDomain', 'credentialPassthrough');
@@ -243,10 +242,11 @@ class LdapAuth_Finish extends AddModule_Base
$title = Request::post('title');
if (empty($title))
$title = 'LDAP: ' . Request::post('server');
- if ($this->edit === false)
+ if ($this->edit === null) {
$module = ConfigModule::getInstance('LdapAuth');
- else
+ } else {
$module = $this->edit;
+ }
$ssl = Request::post('ssl', 'off') === 'on';
foreach (['searchbase', 'binddn', 'server', 'bindpw', 'home', 'nohomewarn', 'certificate', 'genuid',
'ldapAttrMountOpts', 'shareHomeMountOpts'] as $key) {
@@ -270,15 +270,16 @@ class LdapAuth_Finish extends AddModule_Base
} else {
$module->setData('fingerprint', '');
}
- if ($this->edit !== false)
+ if ($this->edit !== null) {
$ret = $module->update($title);
- else
+ } else {
$ret = $module->insert($title);
+ }
if (!$ret) {
Message::addError('main.value-invalid', 'any', 'any');
$tgz = false;
} else {
- $tgz = $module->generate($this->edit === false);
+ $tgz = $module->generate($this->edit === null);
}
if ($tgz === false) {
AddModule_Base::setStep('LdapAuth_Start'); // Continues with LdapAuth_Start for render()
@@ -288,9 +289,8 @@ class LdapAuth_Finish extends AddModule_Base
'tm-config' => $tgz,
);
- if ($this->edit === false) {
+ if ($this->edit === null) {
AddModule_Base::setStep('AddModule_Assign', $module->id());
- return;
}
}
diff --git a/modules-available/sysconfig/addmodule_screensaver.inc.php b/modules-available/sysconfig/addmodule_screensaver.inc.php
index 8e5c5d28..7b6d0afb 100644
--- a/modules-available/sysconfig/addmodule_screensaver.inc.php
+++ b/modules-available/sysconfig/addmodule_screensaver.inc.php
@@ -14,7 +14,7 @@ class Screensaver_Start extends AddModule_Base
if (Request::get('back', 'false', 'string') !== 'false')
/* If coming via the back button, load the session data */
$this->session_data = Session::get('data');
- elseif ($this->edit !== false) {
+ elseif ($this->edit !== null) {
$this->session_data = array(
'title' => $this->edit->title(),
'qss' => $this->edit->getData('qss'),
@@ -24,22 +24,22 @@ class Screensaver_Start extends AddModule_Base
} else {
$this->session_data = array(
'title' => '',
- 'qss' => Dictionary::translate('saver_QssDefault', true),
+ 'qss' => Dictionary::translate('saver_QssDefault'),
'messages' => array(
'General' => array(
- 'shutdown' => Dictionary::translate('saver_MessageDefaultShutdown', true),
- 'shutdown-locked' => Dictionary::translate('saver_MessageDefaultShutdownLocked', true),
- 'idle-kill' => Dictionary::translate('saver_MessageDefaultIdleKill', true),
- 'idle-kill-locked' => Dictionary::translate('saver_MessageDefaultIdleKillLocked', true),
- 'no-timeout' => Dictionary::translate('saver_MessageDefaultNoTimeout', true),
- 'no-timeout-locked' => Dictionary::translate('saver_MessageDefaultNoTimeoutLocked', true),
+ 'shutdown' => Dictionary::translate('saver_MessageDefaultShutdown'),
+ 'shutdown-locked' => Dictionary::translate('saver_MessageDefaultShutdownLocked'),
+ 'idle-kill' => Dictionary::translate('saver_MessageDefaultIdleKill'),
+ 'idle-kill-locked' => Dictionary::translate('saver_MessageDefaultIdleKillLocked'),
+ 'no-timeout' => Dictionary::translate('saver_MessageDefaultNoTimeout'),
+ 'no-timeout-locked' => Dictionary::translate('saver_MessageDefaultNoTimeoutLocked'),
)
),
'texts' => array(
- 'text-shutdown' => Dictionary::translate('saver_TextDefaultShutdown', true),
+ 'text-shutdown' => Dictionary::translate('saver_TextDefaultShutdown'),
'text-shutdown-locked' => '',
- 'text-idle-kill' => Dictionary::translate('saver_TextDefaultIdleKill', true),
- 'text-idle-kill-locked' => Dictionary::translate('saver_TextDefaultIdleKillLocked', true),
+ 'text-idle-kill' => Dictionary::translate('saver_TextDefaultIdleKill'),
+ 'text-idle-kill-locked' => Dictionary::translate('saver_TextDefaultIdleKillLocked'),
'text-no-timeout' => '',
'text-no-timeout-locked' => '',
),
@@ -47,7 +47,6 @@ class Screensaver_Start extends AddModule_Base
}
$this->session_data['next'] = 'idle-kill';
Session::set('data', $this->session_data);
- Session::save();
}
protected function renderInternal()
@@ -57,7 +56,7 @@ class Screensaver_Start extends AddModule_Base
Render::addDialog(Dictionary::translateFile('config-module', 'screensaver_title'), false, 'screensaver-start', array(
'step' => 'Screensaver_Text',
'next' => 'idle-kill',
- 'edit' => $this->edit ? $this->edit->id() : 0,
+ 'edit' => $this->edit !== null ? $this->edit->id() : 0,
'id' => 'start',
'title' => $this->session_data['title'],
'qss' => $this->session_data['qss'],
@@ -68,24 +67,22 @@ class Screensaver_Start extends AddModule_Base
class Screensaver_Text extends AddModule_Base
{
private $session_data;
- private $id;
protected function preprocessInternal()
{
/* Load session data */
$this->session_data = Session::get('data');
- $this->id = Request::post('id', '', 'string');
+ $id = Request::post('id', '', 'string');
- if ($this->id === 'start') {
+ if ($id === 'start') {
Screensaver_Helper::processQssData($this->session_data);
- } elseif ($this->id !== '') {
- Screensaver_Helper::processScreensaverText($this->session_data, $this->id);
+ } elseif ($id !== '') {
+ Screensaver_Helper::processScreensaverText($this->session_data, $id);
}
$next = Request::post('next', $this->session_data['next'], 'string');
$this->session_data['next'] = $next;
Session::set('data', $this->session_data);
- Session::save();
if ($next === 'finish')
@@ -101,7 +98,7 @@ class Screensaver_Text extends AddModule_Base
$next = $this->session_data['next'];
$data = array(
- 'edit' => $this->edit ? $this->edit->id() : 0,
+ 'edit' => $this->edit !== null ? $this->edit->id() : 0,
);
/* Prepare and translate labels for the frontend */
@@ -117,8 +114,8 @@ class Screensaver_Text extends AddModule_Base
* Dictionary::translate('saver_TitleShutdown');
* Dictionary::translate('saver_DescriptionShutdown');
*/
- $data['title'] = Dictionary::translate('saver_Title' . $tag, true);
- $data['description'] = Dictionary::translate('saver_Description' . $tag, true);;
+ $data['title'] = Dictionary::translate('saver_Title' . $tag);
+ $data['description'] = Dictionary::translate('saver_Description' . $tag);
$data['msg_value'] = $this->session_data['messages']['General'][$next];
$data['msg_locked_value'] = $this->session_data['messages']['General'][$next . '-locked'];
$data['text_value'] = $this->session_data['texts']['text-' . $next];
@@ -153,14 +150,14 @@ class Screensaver_Finish extends AddModule_Base
if (empty($session_data['title'])) {
Message::addError('missing-title');
Util::redirect('?do=SysConfig');
- return;
}
/* Only create an instance, if it's a new one */
- if ($this->edit !== false)
+ if ($this->edit !== null) {
$module = $this->edit;
- else
+ } else {
$module = ConfigModule::getInstance('Screensaver');
+ }
/* Set all the data to the module instance */
$module->setData('qss', $session_data['qss']);
@@ -168,15 +165,16 @@ class Screensaver_Finish extends AddModule_Base
$module->setData('texts', $session_data['texts']);
/* Insert or update database entries */
- if ($this->edit !== false)
+ if ($this->edit !== null) {
$module->update($session_data['title']);
- else
+ } else {
$module->insert($session_data['title']);
+ }
- $task = $module->generate($this->edit === false);
+ $task = $module->generate($this->edit === null);
// Yay
- if ($task !== false && $this->edit !== false)
+ if ($task !== false && $this->edit !== null)
Message::addSuccess('module-edited');
elseif ($task !== false) {
Message::addSuccess('module-added');
@@ -195,7 +193,6 @@ class Screensaver_Helper
if (empty($session_data['title'])) {
Message::addError('missing-title');
Util::redirect('?do=SysConfig');
- return;
}
$session_data['qss'] = Request::post('qss', $session_data['qss'], 'string');
$helperMode = Request::post('helper_mode', 'false', 'string');
diff --git a/modules-available/sysconfig/addmodule_sshconfig.inc.php b/modules-available/sysconfig/addmodule_sshconfig.inc.php
index 4a75d77e..2447f9be 100644
--- a/modules-available/sysconfig/addmodule_sshconfig.inc.php
+++ b/modules-available/sysconfig/addmodule_sshconfig.inc.php
@@ -9,8 +9,8 @@ class SshConfig_Start extends AddModule_Base
protected function renderInternal()
{
- if ($this->edit !== false) {
- $data = $this->edit->getData(false) + array(
+ if ($this->edit !== null) {
+ $data = $this->edit->getData(null) + array(
'title' => $this->edit->title(),
'edit' => $this->edit->id(),
'PWD_' . strtoupper($this->edit->getData('allowPasswordLogin')) . '_selected' => 'selected',
@@ -40,10 +40,11 @@ class SshConfig_Finish extends AddModule_Base
return;
}
// Seems ok, create entry
- if ($this->edit === false)
+ if ($this->edit === null) {
$module = ConfigModule::getInstance('SshConfig');
- else
+ } else {
$module = $this->edit;
+ }
if ($module === false) {
Message::addError('main.error-read', 'sshconfig.inc.php');
Util::redirect('?do=SysConfig&action=addmodule&step=SshConfig_Start');
@@ -59,18 +60,20 @@ class SshConfig_Finish extends AddModule_Base
Util::redirect('?do=SysConfig&action=addmodule&step=SshConfig_Start');
}
$module->setData('publicKey', false);
- if ($this->edit !== false)
+ if ($this->edit !== null) {
$ret = $module->update($title);
- else
+ } else {
$ret = $module->insert($title);
- if (!$ret)
+ }
+ if (!$ret) {
Util::redirect('?do=SysConfig&action=addmodule&step=SshConfig_Start');
- elseif (!$module->generate($this->edit === false, NULL, 200))
+ } elseif (!$module->generate($this->edit === null, NULL, 200)) {
Util::redirect('?do=SysConfig&action=addmodule&step=SshConfig_Start');
+ }
// Yay
- if ($this->edit !== false)
+ if ($this->edit !== null) {
Message::addSuccess('module-edited');
- else {
+ } else {
Message::addSuccess('module-added');
AddModule_Base::setStep('AddModule_Assign', $module->id());
return;
diff --git a/modules-available/sysconfig/addmodule_sshkey.inc.php b/modules-available/sysconfig/addmodule_sshkey.inc.php
index b5ab4ad6..9f5bd1d3 100644
--- a/modules-available/sysconfig/addmodule_sshkey.inc.php
+++ b/modules-available/sysconfig/addmodule_sshkey.inc.php
@@ -9,8 +9,8 @@ class SshKey_Start extends AddModule_Base
protected function renderInternal()
{
- if ($this->edit !== false) {
- $data = $this->edit->getData(false) + array(
+ if ($this->edit !== null) {
+ $data = $this->edit->getData(null) + array(
'title' => $this->edit->title(),
'edit' => $this->edit->id(),
);
@@ -35,7 +35,7 @@ class SshKey_Finish extends AddModule_Base
return;
}
// Seems ok, create entry
- if ($this->edit === false) {
+ if ($this->edit === null) {
$module = ConfigModule::getInstance('SshKey');
} else {
$module = $this->edit;
@@ -48,18 +48,18 @@ class SshKey_Finish extends AddModule_Base
Message::addError('main.value-invalid', 'pubkey', Request::post('publicKey'));
Util::redirect('?do=SysConfig&action=addmodule&step=SshKey_Start');
}
- if ($this->edit !== false) {
+ if ($this->edit !== null) {
$ret = $module->update($title);
} else {
$ret = $module->insert($title);
}
if (!$ret) {
Util::redirect('?do=SysConfig&action=addmodule&step=SshKey_Start');
- } elseif (!$module->generate($this->edit === false, NULL, 200)) {
+ } elseif (!$module->generate($this->edit === null, NULL, 200)) {
Util::redirect('?do=SysConfig&action=addmodule&step=SshKey_Start');
}
// Yay
- if ($this->edit !== false) {
+ if ($this->edit !== null) {
Message::addSuccess('module-edited');
} else {
Message::addSuccess('module-added');
diff --git a/modules-available/sysconfig/api.inc.php b/modules-available/sysconfig/api.inc.php
index 983c6dcb..e7be3029 100644
--- a/modules-available/sysconfig/api.inc.php
+++ b/modules-available/sysconfig/api.inc.php
@@ -11,9 +11,9 @@ if (substr($ip, 0, 7) === '::ffff:') {
$ip = substr($ip, 7);
}
-$uuid = Request::any('uuid', false, 'string');
-if ($uuid !== false && strlen($uuid) !== 36) {
- $uuid = false;
+$uuid = Request::any('uuid', null, 'string');
+if ($uuid !== null && strlen($uuid) !== 36) {
+ $uuid = null;
}
// What we do if we can't supply the requested config
@@ -46,7 +46,7 @@ $res = Database::simpleQuery("SELECT c.title, c.filepath, c.status, cl.locationi
$best = 1000;
$row = false;
-while ($r = $res->fetch(PDO::FETCH_ASSOC)) {
+foreach ($res as $r) {
settype($r['locationid'], 'int');
$index = array_search($r['locationid'], $locationChain);
if ($index === false || $index > $best)
diff --git a/modules-available/sysconfig/hooks/bootup.inc.php b/modules-available/sysconfig/hooks/bootup.inc.php
new file mode 100644
index 00000000..8e445dc4
--- /dev/null
+++ b/modules-available/sysconfig/hooks/bootup.inc.php
@@ -0,0 +1,3 @@
+<?php
+
+ConfigModuleBaseLdap::ldadp(); \ No newline at end of file
diff --git a/modules-available/sysconfig/hooks/locations-column.inc.php b/modules-available/sysconfig/hooks/locations-column.inc.php
new file mode 100644
index 00000000..8042b51c
--- /dev/null
+++ b/modules-available/sysconfig/hooks/locations-column.inc.php
@@ -0,0 +1,57 @@
+<?php
+
+if (!User::hasPermission('.sysconfig.config.*') || !Module::isAvailable('sysconfig'))
+ return null;
+
+class SysconfigLocationColumn extends AbstractLocationColumn
+{
+
+ private $lookup = [];
+
+ public function __construct()
+ {
+ $confs = SysConfig::getAll();
+ foreach ($confs as $conf) {
+ if (!isset($conf['locs']) || strlen($conf['locs']) === 0)
+ continue;
+ $confLocs = explode(',', $conf['locs']);
+ foreach ($confLocs as $locId) {
+ $this->lookup[$locId] = $conf['title'];
+ }
+ }
+ }
+
+ public function getColumnHtml(int $locationId): string
+ {
+ return htmlspecialchars($this->lookup[$locationId] ?? '');
+ }
+
+ public function getEditUrl(int $locationId): string
+ {
+ if (!User::hasPermission('.sysconfig.config.assign', $locationId))
+ return '';
+ return '?do=sysconfig&locationid=' . $locationId;
+ }
+
+ public function header(): string
+ {
+ return Dictionary::translateFileModule('sysconfig', 'module', 'location-column-header');
+ }
+
+ public function priority(): int
+ {
+ return 2000;
+ }
+
+ public function propagateColumn(): bool
+ {
+ return true;
+ }
+
+ public function propagateDefaultHtml(): string
+ {
+ return htmlspecialchars($this->lookup[0] ?? '');
+ }
+}
+
+return new SysconfigLocationColumn(); \ No newline at end of file
diff --git a/modules-available/sysconfig/inc/configmodule.inc.php b/modules-available/sysconfig/inc/configmodule.inc.php
index 580c15a0..729cb959 100644
--- a/modules-available/sysconfig/inc/configmodule.inc.php
+++ b/modules-available/sysconfig/inc/configmodule.inc.php
@@ -7,18 +7,23 @@ abstract class ConfigModule
{
/**
- * @var array list of known module types
+ * @var ?array{'title': string,
+ * 'description': string,
+ * 'group': string,
+ * 'unique': bool,
+ * 'sortOrder': int,
+ * 'moduleClass': string,
+ * 'wizardClass': string}[] list of known module types
*/
- private static $moduleTypes = false;
+ private static $moduleTypes = null;
private $moduleId = 0;
- private $moduleArchive = false;
- private $moduleTitle = false;
- private $moduleStatus = false;
- /**
- * @var int
- */
+ private $moduleArchive = '';
+ private $moduleTitle = '';
+ private $moduleStatus = 'MISSING';
+ /** @var int */
private $dateline = 0;
+ /** @var int */
private $currentVersion = 0;
/**
* @var false|array Data of module, false if not initialized
@@ -33,9 +38,9 @@ abstract class ConfigModule
*/
public static function loadDb()
{
- if (self::$moduleTypes !== false)
+ if (self::$moduleTypes !== null)
return;
- self::$moduleTypes = array();
+ self::$moduleTypes = [];
Module::isAvailable('sysconfig');
foreach (glob(dirname(__FILE__) . '/configmodule/*.inc.php', GLOB_NOSORT) as $file) {
require_once $file;
@@ -44,10 +49,16 @@ abstract class ConfigModule
/**
* Get all known config module types.
- *
- * @return array list of modules
+ * @return array{'title': string,
+ * 'description': string,
+ * 'group': string,
+ * 'unique': bool,
+ * 'sortOrder': int,
+ * 'moduleClass': string,
+ * 'wizardClass': string}[] list of known module types
+ * /
*/
- public static function getList()
+ public static function getList(): array
{
self::loadDb();
return self::$moduleTypes;
@@ -63,20 +74,20 @@ abstract class ConfigModule
* @param string $description Description for this module type
* @param string $group Title for group this module type belongs to
* @param bool $unique Can only one such module be added to a config?
- * @param int $sortOrder Lower comes first, alphabetical ordering otherwiese
+ * @param int $sortOrder Lower comes first, alphabetical ordering otherwise
*/
- public static function registerModule($id, $title, $description, $group, $unique, $sortOrder = 0)
+ public static function registerModule(string $id, string $title, string $description, string $group, bool $unique, int $sortOrder = 0): void
{
if (isset(self::$moduleTypes[$id])) {
- Util::traceError("Config Module $id already registered!");
+ ErrorHandler::traceError("Config Module $id already registered!");
}
$moduleClass = 'ConfigModule_' . $id;
$wizardClass = $id . '_Start';
if (!class_exists($moduleClass)) {
- Util::traceError("Class $moduleClass does not exist!");
+ ErrorHandler::traceError("Class $moduleClass does not exist!");
}
if (!is_subclass_of($moduleClass, 'ConfigModule')) {
- Util::traceError("$moduleClass does not have ConfigModule as its parent!");
+ ErrorHandler::traceError("$moduleClass does not have ConfigModule as its parent!");
}
self::$moduleTypes[$id] = array(
'title' => $title,
@@ -93,21 +104,33 @@ abstract class ConfigModule
* Get fresh instance of ConfigModule subclass for given module type.
*
* @param string $moduleType name of module type
- * @return false|\ConfigModule module instance
+ * @return ConfigModule module instance
*/
- public static function getInstance($moduleType)
+ public static function getInstance(string $moduleType): ConfigModule
+ {
+ $ret = self::getInstanceOrNull($moduleType);
+ if ($ret === null) {
+ Message::addError('main.error-read', $moduleType . '.inc.php');
+ Util::redirect('?do=sysconfig');
+ }
+ return $ret;
+ }
+
+ public static function getInstanceOrNull(string $moduleType): ?ConfigModule
{
self::loadDb();
if (!isset(self::$moduleTypes[$moduleType])) {
error_log('Unknown module type: ' . $moduleType);
- return false;
+ return null;
}
return new self::$moduleTypes[$moduleType]['moduleClass'];
}
- public static function instanceFromDbRow($dbRow)
+ private static function instanceFromDbRow(array $dbRow): ?ConfigModule
{
- $instance = self::getInstance($dbRow['moduletype']);
+ $instance = self::getInstanceOrNull($dbRow['moduletype']);
+ if ($instance === null)
+ return null;
$instance->currentVersion = $dbRow['version'];
$instance->moduleArchive = $dbRow['filepath'];
$instance->moduleData = json_decode($dbRow['contents'], true);
@@ -125,37 +148,37 @@ abstract class ConfigModule
* Get module instance from id.
*
* @param int $moduleId module id to get
- * @return false|\ConfigModule The requested module from DB, or false on error
+ * @return ?ConfigModule The requested module from DB, or null on error
*/
- public static function get($moduleId)
+ public static function get(int $moduleId): ?ConfigModule
{
$ret = Database::queryFirst("SELECT moduleid, title, moduletype, filepath, contents, version, status, dateline FROM configtgz_module "
. " WHERE moduleid = :moduleid LIMIT 1", array('moduleid' => $moduleId));
if ($ret === false)
- return false;
+ return null;
return self::instanceFromDbRow($ret);
}
/**
* Get module instances from module type.
*
- * @param int $moduleType module type to get
- * @return \ConfigModule[]|false The requested modules from DB, or false on error
+ * @param string $moduleType module type to get
+ * @return ?ConfigModule[] The requested modules from DB, or null on error
*/
- public static function getAll($moduleType = false)
+ public static function getAll(string $moduleType = null): ?array
{
- if ($moduleType === false) {
+ if ($moduleType === null) {
$ret = Database::simpleQuery("SELECT moduleid, title, moduletype, filepath, contents, version, status, dateline FROM configtgz_module");
} else {
$ret = Database::simpleQuery("SELECT moduleid, title, moduletype, filepath, contents, version, status, dateline FROM configtgz_module "
. " WHERE moduletype = :moduletype", array('moduletype' => $moduleType));
}
if ($ret === false)
- return false;
+ return null;
$list = array();
- while ($row = $ret->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($ret as $row) {
$instance = self::instanceFromDbRow($row);
- if ($instance === false)
+ if ($instance === null)
continue;
$list[] = $instance;
}
@@ -167,37 +190,37 @@ abstract class ConfigModule
*
* @return int module version
*/
- protected abstract function moduleVersion();
+ protected abstract function moduleVersion(): int;
/**
* Validate the module's configuration.
*
- * @return boolean ok or not
+ * @return bool ok or not
*/
- protected abstract function validateConfig();
+ protected abstract function validateConfig(): bool;
/**
* Set module specific data.
*
* @param string $key key, name or id of data being set
* @param mixed $value Module specific data
- * @return boolean true if data was successfully set, false otherwise (i.e. invalid data being set)
+ * @return bool true if data was successfully set, false otherwise (i.e. invalid data being set)
*/
- public abstract function setData($key, $value);
+ public abstract function setData(string $key, $value): bool;
/**
* Get module specific data.
* Can be overridden by modules.
*
- * @param string $key key, name or id of data to get, or false to get the raw moduleData array
+ * @param ?string $key key, name or id of data to get, or null to get the raw moduleData array
* @return mixed Module specific data
*/
- public function getData($key)
+ public function getData(?string $key)
{
- if ($key === false)
+ if ($key === null)
return $this->moduleData;
if (!is_array($this->moduleData) || !isset($this->moduleData[$key]))
- return false;
+ return null;
return $this->moduleData[$key];
}
@@ -205,25 +228,25 @@ abstract class ConfigModule
* Module specific version of generate.
*
* @param string $tgz File name of tgz module to write final output to
- * @param string $parent Parent task of this task
+ * @param string|null $parent Parent task of this task
* @return array|boolean true if generation is completed immediately,
* a task struct if some task needs to be run for generation,
* false on error
*/
- protected abstract function generateInternal($tgz, $parent);
+ protected abstract function generateInternal(string $tgz, ?string $parent);
- private final function createFileName()
+ private function createFileName(): string
{
return CONFIG_TGZ_LIST_DIR . '/modules/'
. $this->moduleType() . '_id-' . $this->moduleId . '__' . mt_rand() . '-' . time() . '.tgz';
}
- public function allowDownload()
+ public function allowDownload(): bool
{
return false;
}
- public function needRebuild()
+ public function needRebuild(): bool
{
return $this->moduleStatus !== 'OK' || $this->currentVersion < $this->moduleVersion();
}
@@ -233,17 +256,15 @@ abstract class ConfigModule
*
* @return int id
*/
- public final function id()
+ public final function id(): int
{
return $this->moduleId;
}
/**
* Get module title.
- *
- * @return string
*/
- public final function title()
+ public final function title(): string
{
return $this->moduleTitle;
}
@@ -253,17 +274,17 @@ abstract class ConfigModule
*
* @return string tgz file absolute path
*/
- public final function archive()
+ public final function archive(): string
{
return $this->moduleArchive;
}
- public final function status()
+ public final function status(): string
{
return $this->moduleStatus;
}
- public final function currentVersion()
+ public final function currentVersion(): int
{
return $this->currentVersion;
}
@@ -273,14 +294,13 @@ abstract class ConfigModule
*
* @return string module type
*/
- public final function moduleType()
+ public final function moduleType(): string
{
+ // Yes, need to pass $this, otherwise we get ConfigModule, the base class this function is part of
$name = get_class($this);
- if ($name === false)
- Util::traceError('ConfigModule::moduleType: get_class($this) returned false!');
// ConfigModule_*
if (!preg_match('/^ConfigModule_(\w+)$/', $name, $out))
- Util::traceError('ConfigModule::moduleType: get_class($this) returned "' . $name . '"');
+ ErrorHandler::traceError('ConfigModule::moduleType: get_class($this) returned "' . $name . '"');
return $out[1];
}
@@ -292,10 +312,10 @@ abstract class ConfigModule
* @param string $title display name of the module
* @return boolean true if inserted successfully, false if module config is invalid
*/
- public final function insert($title)
+ public final function insert(string $title): bool
{
if ($this->moduleId !== 0)
- Util::traceError('ConfigModule::insert called when moduleId != 0');
+ ErrorHandler::traceError('ConfigModule::insert called when moduleId != 0');
if (!$this->validateConfig())
return false;
$this->moduleTitle = $title;
@@ -311,7 +331,7 @@ abstract class ConfigModule
));
$this->moduleId = Database::lastInsertId();
if (!is_numeric($this->moduleId))
- Util::traceError('Inserting new config module into DB did not yield a numeric insert id');
+ ErrorHandler::traceError('Inserting new config module into DB did not yield a numeric insert id');
$this->moduleArchive = $this->createFileName();
Database::exec("UPDATE configtgz_module SET filepath = :path WHERE moduleid = :moduleid LIMIT 1", array(
'path' => $this->moduleArchive,
@@ -326,10 +346,10 @@ abstract class ConfigModule
*
* @return boolean true on success, false otherwise
*/
- public final function update($title = '')
+ public final function update(string $title = ''): bool
{
if ($this->moduleId === 0)
- Util::traceError('ConfigModule::update called when moduleId == 0');
+ ErrorHandler::traceError('ConfigModule::update called when moduleId == 0');
if (!empty($title)) {
$this->moduleTitle = $title;
}
@@ -353,16 +373,16 @@ abstract class ConfigModule
* Updating the database etc. will happen later through a callback.
*
* @param boolean $deleteOnError if true, the db entry will be deleted if generation failed
- * @param string $parent Parent task of this task
+ * @param string|null $parent Parent task of this task
* @param int $timeoutMs maximum time in milliseconds we wait for completion
* @return string|boolean task id if deferred generation was started,
* true if generation succeeded (without using a task or within $timeoutMs)
* false on error
*/
- public final function generate($deleteOnError, $parent = NULL, $timeoutMs = 0)
+ public final function generate(bool $deleteOnError, string $parent = NULL, int $timeoutMs = 0)
{
- if ($this->moduleId === 0 || $this->moduleTitle === false)
- Util::traceError('ConfigModule::generateAsync called on uninitialized/uninserted module!');
+ if ($this->moduleId === 0 || empty($this->moduleTitle))
+ ErrorHandler::traceError('ConfigModule::generateAsync called on uninitialized/uninserted module!');
$tmpTgz = '/tmp/bwlp-id-' . $this->moduleId . '_' . mt_rand() . '_' . time() . '.tgz';
$ret = $this->generateInternal($tmpTgz, $parent);
// Wait for generation if requested
@@ -395,10 +415,10 @@ abstract class ConfigModule
/**
* Delete the module.
*/
- public final function delete()
+ public final function delete(): void
{
if ($this->moduleId === 0)
- Util::traceError('ConfigModule::delete called with invalid module id!');
+ ErrorHandler::traceError('ConfigModule::delete called with invalid module id!');
$ret = Database::exec("DELETE FROM configtgz_module WHERE moduleid = :moduleid LIMIT 1", array(
'moduleid' => $this->moduleId
), true) !== false;
@@ -410,17 +430,19 @@ abstract class ConfigModule
$this->moduleTitle = false;
$this->moduleArchive = false;
}
- return $ret;
}
- private final function markUpdated($tmpTgz)
+ /**
+ * @param ?string $tmpTgz new tar archive to use for this module, or null if the old one is still valid
+ */
+ private function markUpdated(?string $tmpTgz): bool
{
if ($this->moduleId === 0)
- Util::traceError('ConfigModule::markUpdated called with invalid module id!');
- if ($this->moduleArchive === false)
+ ErrorHandler::traceError('ConfigModule::markUpdated called with invalid module id!');
+ if ($this->moduleArchive === null)
$this->moduleArchive = $this->createFileName();
// Move file
- if ($tmpTgz === false) {
+ if ($tmpTgz === null) {
if (!file_exists($this->moduleArchive)) {
EventLog::failure('ConfigModule::markUpdated for "' . $this->moduleTitle . '" called with no tmpTgz and no existing tgz!');
$this->markFailed();
@@ -460,24 +482,26 @@ abstract class ConfigModule
return $retval;
}
- private final function markFailed()
+ private function markFailed(): void
{
if ($this->moduleId === 0)
- Util::traceError('ConfigModule::markFailed called with invalid module id!');
- if ($this->moduleArchive === false)
+ ErrorHandler::traceError('ConfigModule::markFailed called with invalid module id!');
+ if ($this->moduleArchive === '') {
$this->moduleArchive = $this->createFileName();
- if (!file_exists($this->moduleArchive))
+ }
+ if (!file_exists($this->moduleArchive)) {
$status = 'MISSING';
- else
+ } else {
$status = 'OUTDATED';
- return Database::exec("UPDATE configtgz_module SET filepath = :filename, status = :status WHERE moduleid = :id LIMIT 1", array(
+ }
+ Database::exec("UPDATE configtgz_module SET filepath = :filename, status = :status WHERE moduleid = :id LIMIT 1", array(
'id' => $this->moduleId,
'filename' => $this->moduleArchive,
'status' => $status
- )) !== false;
+ ));
}
- public function dateline_s()
+ public function dateline_s(): string
{
return Util::prettyTime($this->dateline);
}
@@ -489,7 +513,7 @@ abstract class ConfigModule
* Override this if you need to handle this, otherwise
* the base implementation does nothing.
*/
- public function event_serverIpChanged()
+ public function event_serverIpChanged(): void
{
// Do::Nothing()
}
@@ -500,11 +524,10 @@ abstract class ConfigModule
* Will be called if the server's IP address changes. The event will be propagated
* to all config module classes so action can be taken if appropriate.
*/
- public static function serverIpChanged()
+ public static function serverIpChanged(): void
{
self::loadDb();
- $list = self::getAll();
- foreach ($list as $mod) {
+ foreach (self::getAll() ?? [] as $mod) {
$mod->event_serverIpChanged();
}
}
@@ -513,53 +536,51 @@ abstract class ConfigModule
* Called when (re)generating a config module failed, so we can
* update the status in the DB and add a server log entry.
*
- * @param array $task
- * @param array $args contains 'moduleid' and optionally 'deleteOnError' and 'tmpTgz'
+ * @param array $args contains 'moduleid' and optionally 'deleteOnError'
*/
- public static function generateFailed($task, $args)
+ public static function generateFailed(array $task, array $args): void
{
if (!isset($args['moduleid']) || !is_numeric($args['moduleid'])) {
EventLog::warning('Ignoring generateFailed event as it has no moduleid assigned.');
return;
}
$module = self::get($args['moduleid']);
- if ($module === false) {
+ if ($module === null) {
EventLog::warning('generateFailed callback for module id ' . $args['moduleid'] . ', but no instance could be generated.');
return;
}
- if (isset($task['data']['error']))
+ if (isset($task['data']['error'])) {
$error = $task['data']['error'];
- elseif (isset($task['data']['messages']))
+ } elseif (isset($task['data']['messages'])) {
$error = $task['data']['messages'];
- else
+ } else {
$error = '';
+ }
EventLog::failure("Generating module '" . $module->moduleTitle . "' failed.", $error);
- if ($args['deleteOnError'])
+ if ($args['deleteOnError'] ?? false) {
$module->delete();
- else
+ } else {
$module->markFailed();
+ }
}
/**
* (Re)generating a config module succeeded. Update db entry.
*
- * @param array $args contains 'moduleid' and optionally 'deleteOnError' and 'tmpTgz'
+ * @param array $args contains 'moduleid' and optionally 'tmpTgz'
*/
- public static function generateSucceeded($args)
+ public static function generateSucceeded(array $args): void
{
if (!isset($args['moduleid']) || !is_numeric($args['moduleid'])) {
EventLog::warning('Ignoring generateSucceeded event as it has no moduleid assigned.');
return;
}
$module = self::get($args['moduleid']);
- if ($module === false) {
+ if ($module === null) {
EventLog::warning('generateSucceeded callback for module id ' . $args['moduleid'] . ', but no instance could be generated.');
return;
}
- if (isset($args['tmpTgz']))
- $module->markUpdated($args['tmpTgz']);
- else
- $module->markUpdated(false);
+ $module->markUpdated($args['tmpTgz'] ?? null);
}
}
diff --git a/modules-available/sysconfig/inc/configmodule/branding.inc.php b/modules-available/sysconfig/inc/configmodule/branding.inc.php
index 8990dbec..7013e3ae 100644
--- a/modules-available/sysconfig/inc/configmodule/branding.inc.php
+++ b/modules-available/sysconfig/inc/configmodule/branding.inc.php
@@ -14,34 +14,34 @@ class ConfigModule_Branding extends ConfigModule
const MODID = 'Branding';
const VERSION = 1;
-
+
+ /** @var false|string */
private $tmpFile = false;
- protected function generateInternal($tgz, $parent)
+ protected function generateInternal(string $tgz, ?string $parent)
{
if (!$this->validateConfig()) {
- return $this->archive() !== false && file_exists($this->archive()); // No new temp file given, old archive still exists, pretend it worked...
+ return !empty($this->archive()) && file_exists($this->archive()); // No new temp file given, old archive still exists, pretend it worked...
}
- $task = Taskmanager::submit('MoveFile', array(
+ return Taskmanager::submit('MoveFile', array(
'source' => $this->tmpFile,
'destination' => $tgz,
'parentTask' => $parent,
'failOnParentFail' => false
));
- return $task;
}
- protected function moduleVersion()
+ protected function moduleVersion(): int
{
return self::VERSION;
}
- protected function validateConfig()
+ protected function validateConfig(): bool
{
return $this->tmpFile !== false && file_exists($this->tmpFile);
}
- public function setData($key, $value)
+ public function setData(string $key, $value): bool
{
if ($key !== 'tmpFile' || !is_string($value) || !file_exists($value))
return false;
@@ -49,12 +49,12 @@ class ConfigModule_Branding extends ConfigModule
return true;
}
- public function getData($key)
+ public function getData(?string $key): bool
{
return false;
}
- public function allowDownload()
+ public function allowDownload(): bool
{
return true;
}
diff --git a/modules-available/sysconfig/inc/configmodule/customodule.inc.php b/modules-available/sysconfig/inc/configmodule/customodule.inc.php
index 8b968336..0b8e38d2 100644
--- a/modules-available/sysconfig/inc/configmodule/customodule.inc.php
+++ b/modules-available/sysconfig/inc/configmodule/customodule.inc.php
@@ -13,15 +13,16 @@ class ConfigModule_CustomModule extends ConfigModule
{
const MODID = 'CustomModule';
const VERSION = 2;
-
+
+ /** @var false|string */
private $tmpFile = false;
- protected function generateInternal($tgz, $parent)
+ protected function generateInternal(string $tgz, ?string $parent)
{
if (!$this->validateConfig()) {
// No temp file given from wizard
// Old archive still exists? pretend it worked...
- if ($this->archive() === false || !file_exists($this->archive()))
+ if ($this->archive() === '' || !file_exists($this->archive()))
return false;
if ($this->currentVersion() == 1) {
// Need an upgrade
@@ -34,26 +35,25 @@ class ConfigModule_CustomModule extends ConfigModule
// Nothing to do
return true;
}
- $task = Taskmanager::submit('MoveFile', array(
+ return Taskmanager::submit('MoveFile', array(
'source' => $this->tmpFile,
'destination' => $tgz,
'parentTask' => $parent,
'failOnParentFail' => false
));
- return $task;
}
- protected function moduleVersion()
+ protected function moduleVersion(): int
{
return self::VERSION;
}
- protected function validateConfig()
+ protected function validateConfig(): bool
{
return $this->tmpFile !== false && file_exists($this->tmpFile);
}
- public function setData($key, $value)
+ public function setData(string $key, $value): bool
{
// Sets the temp file from the wizard, where it stored the processed archive
if ($key !== 'tmpFile' || !file_exists($value))
@@ -62,12 +62,12 @@ class ConfigModule_CustomModule extends ConfigModule
return true;
}
- public function getData($key)
+ public function getData(?string $key): bool
{
return false;
}
- public function allowDownload()
+ public function allowDownload(): bool
{
return true;
}
diff --git a/modules-available/sysconfig/inc/configmodule/ldapauth.inc.php b/modules-available/sysconfig/inc/configmodule/ldapauth.inc.php
index 7af4671e..64af4c0e 100644
--- a/modules-available/sysconfig/inc/configmodule/ldapauth.inc.php
+++ b/modules-available/sysconfig/inc/configmodule/ldapauth.inc.php
@@ -5,7 +5,7 @@ class ConfigModule_LdapAuth extends ConfigModuleBaseLdap
const MODID = 'LdapAuth';
- protected function preTaskmanagerHook(&$config)
+ protected function preTaskmanagerHook(array &$config)
{
// Just set the flag so the taskmanager job knows we're dealing with a normal ldap server,
// not AD scheme
diff --git a/modules-available/sysconfig/inc/configmodule/screensaver.inc.php b/modules-available/sysconfig/inc/configmodule/screensaver.inc.php
index ed97941e..1797331c 100644
--- a/modules-available/sysconfig/inc/configmodule/screensaver.inc.php
+++ b/modules-available/sysconfig/inc/configmodule/screensaver.inc.php
@@ -14,7 +14,7 @@ class ConfigModule_Screensaver extends ConfigModule
const MODID = 'Screensaver';
const VERSION = 1;
- protected function generateInternal($tgz, $parent)
+ protected function generateInternal(string $tgz, ?string $parent)
{
/* Validate if all data are available */
if (!$this->validateConfig())
@@ -23,30 +23,27 @@ class ConfigModule_Screensaver extends ConfigModule
/* Give the Taskmanager the job and create the tgz */
$taskId = 'xscreensaver' . mt_rand() . '-' . microtime(true);
- $task = Taskmanager::submit('MakeTarball', array(
+ return Taskmanager::submit('MakeTarball', array(
'id' => $taskId,
'files' => $this->getFileArray(),
'destination' => $tgz,
), false);
-
- return $task;
}
- protected function moduleVersion()
+ protected function moduleVersion(): int
{
return self::VERSION;
}
- protected function validateConfig()
+ protected function validateConfig(): bool
{
- return isset($this->moduleData['qss']) &&
- isset($this->moduleData['texts']) &&
- isset($this->moduleData['texts']['text-idle-kill']) &&
- isset($this->moduleData['texts']['text-no-timeout']) &&
- isset($this->moduleData['texts']['text-shutdown']);
+ return isset($this->moduleData['texts']['text-no-timeout'])
+ && isset($this->moduleData['texts']['text-idle-kill'])
+ && isset($this->moduleData['texts']['text-shutdown'])
+ && isset($this->moduleData['qss']);
}
- public function setData($key, $value)
+ public function setData(string $key, $value): bool
{
switch ($key) {
case 'qss':
@@ -60,17 +57,15 @@ class ConfigModule_Screensaver extends ConfigModule
return true;
}
- public function allowDownload()
+ public function allowDownload(): bool
{
return false;
}
/**
* Creates a map with filepath => file content
- *
- * @return array in the form of Map<String, byte[]>
*/
- private function getFileArray()
+ private function getFileArray(): array
{
$files = array(
'/opt/openslx/xscreensaver/style.qss' => $this->moduleData['qss'],
@@ -100,7 +95,7 @@ class ConfigModule_Screensaver extends ConfigModule
return $files;
}
- private function wrapHtmlTags($text_name)
+ private function wrapHtmlTags(string $text_name): string
{
return '<html><body>' . $this->moduleData['texts'][$text_name] . '</body></html>';
}
diff --git a/modules-available/sysconfig/inc/configmodule/sshconfig.inc.php b/modules-available/sysconfig/inc/configmodule/sshconfig.inc.php
index b5ab20e4..a62d1035 100644
--- a/modules-available/sysconfig/inc/configmodule/sshconfig.inc.php
+++ b/modules-available/sysconfig/inc/configmodule/sshconfig.inc.php
@@ -14,7 +14,7 @@ class ConfigModule_SshConfig extends ConfigModule
const MODID = 'SshConfig';
const VERSION = 1;
- protected function generateInternal($tgz, $parent)
+ protected function generateInternal(string $tgz, ?string $parent)
{
if (!$this->validateConfig())
return false;
@@ -26,12 +26,12 @@ class ConfigModule_SshConfig extends ConfigModule
return Taskmanager::submit('SshdConfigGenerator', $config);
}
- protected function moduleVersion()
+ protected function moduleVersion(): int
{
return self::VERSION;
}
- protected function validateConfig()
+ protected function validateConfig(): bool
{
// UPGRADE
if (isset($this->moduleData['allowPasswordLogin']) && !isset($this->moduleData['allowedUsersLogin'])) {
@@ -45,7 +45,7 @@ class ConfigModule_SshConfig extends ConfigModule
&& isset($this->moduleData['listenPort']);
}
- public function setData($key, $value)
+ public function setData(string $key, $value): bool
{
switch ($key) {
case 'publicKey':
diff --git a/modules-available/sysconfig/inc/configmodule/sshkey.inc.php b/modules-available/sysconfig/inc/configmodule/sshkey.inc.php
index 2d212d25..e4a55ad7 100644
--- a/modules-available/sysconfig/inc/configmodule/sshkey.inc.php
+++ b/modules-available/sysconfig/inc/configmodule/sshkey.inc.php
@@ -14,7 +14,7 @@ class ConfigModule_SshKey extends ConfigModule
const MODID = 'SshKey';
const VERSION = 1;
- protected function generateInternal($tgz, $parent)
+ protected function generateInternal(string $tgz, ?string $parent)
{
if (!$this->validateConfig())
return false;
@@ -30,17 +30,17 @@ class ConfigModule_SshKey extends ConfigModule
return Taskmanager::submit('MakeTarball', $config);
}
- protected function moduleVersion()
+ protected function moduleVersion(): int
{
return self::VERSION;
}
- protected function validateConfig()
+ protected function validateConfig(): bool
{
return isset($this->moduleData['publicKey']);
}
- public function setData($key, $value)
+ public function setData(string $key, $value): bool
{
switch ($key) {
case 'publicKey':
diff --git a/modules-available/sysconfig/inc/configmodulebaseldap.inc.php b/modules-available/sysconfig/inc/configmodulebaseldap.inc.php
index b6498aed..770a40e6 100644
--- a/modules-available/sysconfig/inc/configmodulebaseldap.inc.php
+++ b/modules-available/sysconfig/inc/configmodulebaseldap.inc.php
@@ -11,7 +11,7 @@ abstract class ConfigModuleBaseLdap extends ConfigModule
'shareOther', 'shareHomeDrive', 'shareDomain', 'credentialPassthrough', 'mapping', 'genuid',
'ldapAttrMountOpts', 'shareHomeMountOpts', 'nohomewarn');
- public static function getMapping($config = false, &$empty = true)
+ public static function getMapping(array $config = null, ?bool &$empty = true): array
{
$list = array(
['name' => 'uid', 'field' => 'uid', 'ad' => 'sAMAccountName'],
@@ -46,10 +46,10 @@ abstract class ConfigModuleBaseLdap extends ConfigModule
*
* @param string $command start, restart, check
* @param bool|int|int[] $ids list of IDs to run command on, or false meaning "all"
- * @param string $parent if not NULL, this will be the parent task of the launch-task
+ * @param string|null $parent if not NULL, this will be the parent task of the launch-task
* @return boolean|string false on error, id of task otherwise
*/
- public static function ldadp($command = 'start', $ids = false, $parent = null)
+ public static function ldadp(string $command = 'start', $ids = false, string $parent = null)
{
if ($ids === false) {
$ids = self::getActiveModuleIds();
@@ -67,7 +67,7 @@ abstract class ConfigModuleBaseLdap extends ConfigModule
return $task['id'];
}
- protected function generateInternal($tgz, $parent)
+ protected function generateInternal(string $tgz, ?string $parent)
{
$config = $this->moduleData;
if (isset($config['certificate']) && !is_string($config['certificate'])) {
@@ -96,8 +96,8 @@ abstract class ConfigModuleBaseLdap extends ConfigModule
$config['shareHomeDrive'] = 'H:';
}
// This is now always on, as we mask it transparently in our lightdm greeter
- $config['fixnumeric'] = 'yes';
- $config['genuid'] = isset($config['genuid']) && !empty($config['genuid']);
+ $config['fixnumeric'] = 'true';
+ $config['genuid'] = !empty($config['genuid']);
$config['nohomewarn'] = isset($config['nohomewarn']) ? (int)$config['nohomewarn'] : 0;
$this->preTaskmanagerHook($config);
$task = Taskmanager::submit('CreateLdapConfig', $config);
@@ -111,25 +111,23 @@ abstract class ConfigModuleBaseLdap extends ConfigModule
* Hook called before running CreateLdapConfig task with the
* configuration to be passed to the task. Passed by reference
* so it can be modified.
- *
- * @param array $config
*/
- protected function preTaskmanagerHook(&$config)
+ protected function preTaskmanagerHook(array &$config)
{
}
- protected function moduleVersion()
+ protected function moduleVersion(): int
{
return self::VERSION;
}
- protected function validateConfig()
+ protected function validateConfig(): bool
{
// Check if required fields are filled
- return Util::hasAllKeys($this->moduleData, self::$REQUIRED_FIELDS);
+ return ArrayUtil::hasAllKeys($this->moduleData, self::$REQUIRED_FIELDS);
}
- public function setData($key, $value)
+ public function setData(string $key, $value): bool
{
if (!in_array($key, self::$REQUIRED_FIELDS) && !in_array($key, self::$OPTIONAL_FIELDS))
return false;
@@ -142,7 +140,7 @@ abstract class ConfigModuleBaseLdap extends ConfigModule
/**
* Server IP changed - rebuild all AD modules.
*/
- public function event_serverIpChanged()
+ public function event_serverIpChanged(): void
{
$this->generate(false);
}
diff --git a/modules-available/sysconfig/inc/configtgz.inc.php b/modules-available/sysconfig/inc/configtgz.inc.php
index 98f29753..8ac87908 100644
--- a/modules-available/sysconfig/inc/configtgz.inc.php
+++ b/modules-available/sysconfig/inc/configtgz.inc.php
@@ -4,29 +4,28 @@ class ConfigTgz
{
private $configId = 0;
- private $configTitle = false;
- private $file = false;
+ private $configTitle = '';
+ private $file = '';
private $modules = array();
private function __construct()
{
- ;
}
- public function id()
+ public function id(): int
{
return $this->configId;
}
- public function title()
+ public function title(): string
{
return $this->configTitle;
}
- public function areAllModulesUpToDate()
+ public function areAllModulesUpToDate(): bool
{
if (!$this->configId > 0)
- Util::traceError('ConfigTgz::areAllModulesUpToDate called on un-inserted config.tgz!');
+ ErrorHandler::traceError('ConfigTgz::areAllModulesUpToDate called on un-inserted config.tgz!');
foreach ($this->modules as $module) {
if (!empty($module['filepath']) && file_exists($module['filepath'])) {
if ($module['status'] !== 'OK')
@@ -38,24 +37,29 @@ class ConfigTgz
return true;
}
- public function isActive()
+ public function isActive(): bool
{
return readlink(CONFIG_HTTP_DIR . '/default/config.tgz') === $this->file;
}
-
- public function getModuleIds()
+
+ /**
+ * @return int[]
+ */
+ public function getModuleIds(): array
{
- $ret = array();
+ $ret = [];
foreach ($this->modules as $module) {
- $ret[] = $module['moduleid'];
+ $ret[] = (int)$module['moduleid'];
}
return $ret;
}
-
- public function update($title, $moduleIds)
+
+ /**
+ * @param string $title New title for module
+ * @param int[] $moduleIds List of modules to include in this config
+ */
+ public function update(string $title, array $moduleIds): void
{
- if (!is_array($moduleIds))
- return false;
if (!empty($title)) {
$this->configTitle = $title;
}
@@ -69,7 +73,7 @@ class ConfigTgz
// Delete old connections
Database::exec("DELETE FROM configtgz_x_module WHERE configid = :configid", array('configid' => $this->configId));
// Make connection
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
Database::exec("INSERT INTO configtgz_x_module (configid, moduleid) VALUES (:configid, :moduleid)", array(
'configid' => $this->configId,
'moduleid' => $row['moduleid']
@@ -83,20 +87,19 @@ class ConfigTgz
'status' => 'OUTDATED',
'now' => time(),
));
- return true;
}
/**
*
- * @param bool $deleteOnError
- * @param int $timeoutMs
- * @param string|null $parentTask parent task to order this rebuild after
+ * @param bool $deleteOnError Delete this config in case of error?
+ * @param int $timeoutMs max time to wait for completion
+ * @param string|null $parentTask parent task to order this (re)build after
* @return string|bool true=success, false=error, string=taskid, still running
*/
- public function generate($deleteOnError = false, $timeoutMs = 0, $parentTask = null)
+ public function generate(bool $deleteOnError = false, int $timeoutMs = 0, ?string $parentTask = null)
{
- if (!($this->configId > 0) || !is_array($this->modules) || $this->file === false)
- Util::traceError ('configId <= 0 or modules not array in ConfigTgz::rebuild()');
+ if (!($this->configId > 0) || empty($this->file))
+ ErrorHandler::traceError('configId <= 0 or no file in ConfigTgz::rebuild()');
$files = array();
// Get all config modules for system config
foreach ($this->modules as $module) {
@@ -134,33 +137,34 @@ class ConfigTgz
return $task['id'];
}
- public function delete()
+ public function delete(): bool
{
if ($this->configId === 0)
- Util::traceError('ConfigTgz::delete called with invalid config id!');
+ ErrorHandler::traceError('ConfigTgz::delete called with invalid config id!');
$ret = Database::exec("DELETE FROM configtgz WHERE configid = :configid LIMIT 1",
['configid' => $this->configId], true);
if ($ret !== false) {
- if ($this->file !== false)
+ if (!empty($this->file)) {
Taskmanager::submit('DeleteFile', array('file' => $this->file), true);
+ }
$this->configId = 0;
- $this->modules = false;
- $this->file = false;
+ $this->modules = [];
+ $this->file = '';
}
return $ret !== false;
}
- public function markOutdated()
+ public function markOutdated(): void
{
if ($this->configId === 0)
- Util::traceError('ConfigTgz::markOutdated called with invalid config id!');
- return $this->mark('OUTDATED');
+ ErrorHandler::traceError('ConfigTgz::markOutdated called with invalid config id!');
+ $this->mark('OUTDATED');
}
- private function markUpdated($task)
+ private function markUpdated(array $task): void
{
if ($this->configId === 0)
- Util::traceError('ConfigTgz::markUpdated called with invalid config id!');
+ ErrorHandler::traceError('ConfigTgz::markUpdated called with invalid config id!');
if ($this->areAllModulesUpToDate()) {
if (empty($task['data']['warnings'])) {
$warnings = '';
@@ -170,7 +174,7 @@ class ConfigTgz
// Get mapping of moduleid to module name for prettier log display
$res = Database::simpleQuery('SELECT moduleid, title FROM configtgz_module');
$mods = [];
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$mods[$row['moduleid']] = $row['title'];
}
// Now extract module id from filename and if applicable, replace filename by module name
@@ -186,27 +190,28 @@ class ConfigTgz
'status' => 'OK',
'warnings' => $warnings,
]);
- return 'OK';
+ return;
}
- return $this->mark('OUTDATED');
+ $this->mark('OUTDATED');
}
- private function markFailed()
+ private function markFailed(): void
{
if ($this->configId === 0)
- Util::traceError('ConfigTgz::markFailed called with invalid config id!');
- if ($this->file === false || !file_exists($this->file))
- return $this->mark('MISSING');
- return $this->mark('OUTDATED');
+ ErrorHandler::traceError('ConfigTgz::markFailed called with invalid config id!');
+ if (empty($this->file) || !file_exists($this->file)) {
+ $this->mark('MISSING');
+ } else {
+ $this->mark('OUTDATED');
+ }
}
- private function mark($status)
+ private function mark($status): void
{
Database::exec("UPDATE configtgz SET status = :status WHERE configid = :configid LIMIT 1", [
'configid' => $this->configId,
'status' => $status,
]);
- return $status;
}
/*
@@ -218,12 +223,12 @@ class ConfigTgz
* @param string $destFile where to store final result
* @return false|array taskmanager task
*/
- private static function recompress($files, $destFile, $parentTask = null)
+ private static function recompress(array $files, string $destFile, $parentTask = null)
{
// Get stuff other modules want to inject
$handler = function($hook) {
include $hook->file;
- return isset($file) ? $file : false;
+ return $file ?? false;
};
foreach (Hook::load('config-tgz') as $hook) {
$file = $handler($hook);
@@ -246,15 +251,15 @@ class ConfigTgz
* on each one. This mostly makes sense to call if a global module
* that is injected via a hook has changed.
*/
- public static function rebuildAllConfigs()
+ public static function rebuildAllConfigs(): void
{
Database::exec("UPDATE configtgz SET status = :status", array(
'status' => 'OUTDATED'
));
$res = Database::simpleQuery("SELECT configid FROM configtgz");
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$configTgz = self::get($row['configid']);
- if ($configTgz !== false) {
+ if ($configTgz !== null) {
$configTgz->generate();
}
}
@@ -263,12 +268,10 @@ class ConfigTgz
/**
* @param string $title Title of config
* @param int[] $moduleIds Modules to include in config
- * @return false|ConfigTgz The module instance, false on error
+ * @return ConfigTgz The module instance
*/
- public static function insert($title, $moduleIds)
+ public static function insert(string $title, array $moduleIds): ConfigTgz
{
- if (!is_array($moduleIds))
- return false;
$instance = new ConfigTgz;
$instance->configTitle = $title;
// Create output file name (config.tgz)
@@ -290,7 +293,7 @@ class ConfigTgz
}
$res = Database::simpleQuery("SELECT moduleid, moduletype, filepath, status FROM configtgz_module WHERE moduleid IN ($idstr)");
// Make connection
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
Database::exec("INSERT INTO configtgz_x_module (configid, moduleid) VALUES (:configid, :moduleid)", array(
'configid' => $instance->configId,
'moduleid' => $row['moduleid']
@@ -300,53 +303,53 @@ class ConfigTgz
return $instance;
}
- public static function get($configId)
+ /**
+ * @param array{configid: int, title: string, filepath: string} $row Input data, fields mandatory
+ */
+ private static function instanceFromRow(array $row): ConfigTgz
{
- $ret = Database::queryFirst("SELECT configid, title, filepath FROM configtgz WHERE configid = :configid", array(
- 'configid' => $configId
- ));
- if ($ret === false)
- return false;
$instance = new ConfigTgz;
- $instance->configId = $ret['configid'];
- $instance->configTitle = $ret['title'];
- $instance->file = $ret['filepath'];
- $ret = Database::simpleQuery("SELECT moduleid, moduletype, filepath, status FROM configtgz_x_module "
+ $instance->configId = $row['configid'];
+ $instance->configTitle = $row['title'];
+ $instance->file = $row['filepath'];
+ $innerRes = Database::simpleQuery("SELECT moduleid, moduletype, filepath, status FROM configtgz_x_module "
. " INNER JOIN configtgz_module USING (moduleid) "
. " WHERE configid = :configid", array('configid' => $instance->configId));
$instance->modules = array();
- while ($row = $ret->fetch(PDO::FETCH_ASSOC)) {
- $instance->modules[] = $row;
+ foreach ($innerRes as $innerRow) {
+ $instance->modules[] = $innerRow;
}
return $instance;
}
+ public static function get(int $configId): ?ConfigTgz
+ {
+ $ret = Database::queryFirst("SELECT configid, title, filepath FROM configtgz WHERE configid = :configid", array(
+ 'configid' => $configId
+ ));
+ if ($ret === false)
+ return null;
+ return self::instanceFromRow($ret);
+ }
+
/**
* @param int $moduleId ID of config module
- * @return ConfigTgz[]|false
+ * @return ConfigTgz[]
*/
- public static function getAllForModule($moduleId)
+ public static function getAllForModule(int $moduleId): array
{
$res = Database::simpleQuery("SELECT configid, title, filepath FROM configtgz_x_module "
. " INNER JOIN configtgz USING (configid) "
. " WHERE moduleid = :moduleid", array(
'moduleid' => $moduleId
));
- if ($res === false)
- return false;
+ if ($res === false) {
+ EventLog::warning('ConfigTgz::getAllForModule failed: ' . Database::lastError());
+ return [];
+ }
$list = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- $instance = new ConfigTgz;
- $instance->configId = $row['configid'];
- $instance->configTitle = $row['title'];
- $instance->file = $row['filepath'];
- $innerRes = Database::simpleQuery("SELECT moduleid, moduletype, filepath, status FROM configtgz_x_module "
- . " INNER JOIN configtgz_module USING (moduleid) "
- . " WHERE configid = :configid", array('configid' => $instance->configId));
- $instance->modules = array();
- while ($innerRow = $innerRes->fetch(PDO::FETCH_ASSOC)) {
- $instance->modules[] = $innerRow;
- }
+ foreach ($res as $row) {
+ $instance = self::instanceFromRow($row);
$list[] = $instance;
}
return $list;
@@ -356,31 +359,32 @@ class ConfigTgz
* Called when (re)generating a config tgz failed, so we can
* update the status in the DB and add a server log entry.
*
- * @param array $task
* @param array $args contains 'configid' and optionally 'deleteOnError'
*/
- public static function generateFailed($task, $args)
+ public static function generateFailed(array $task, array $args): void
{
if (!isset($args['configid']) || !is_numeric($args['configid'])) {
EventLog::warning('Ignoring generateFailed event as it has no configid assigned.');
return;
}
$config = self::get($args['configid']);
- if ($config === false) {
+ if ($config === null) {
EventLog::warning('generateFailed callback for config id ' . $args['configid'] . ', but no instance could be generated.');
return;
}
- if (isset($task['data']['error']))
+ if (isset($task['data']['error'])) {
$error = $task['data']['error'];
- elseif (isset($task['data']['messages']))
+ } elseif (isset($task['data']['messages'])) {
$error = $task['data']['messages'];
- else
+ } else {
$error = '';
+ }
EventLog::failure("Generating config.tgz '" . $config->configTitle . "' failed.", $error);
- if ($args['deleteOnError'])
+ if ($args['deleteOnError']) {
$config->delete();
- else
+ } else {
$config->markFailed();
+ }
}
/**
@@ -389,14 +393,14 @@ class ConfigTgz
* @param array $task the task object
* @param array $args contains 'configid' and optionally 'deleteOnError'
*/
- public static function generateSucceeded($task, $args)
+ public static function generateSucceeded(array $task, array $args): void
{
if (!isset($args['configid']) || !is_numeric($args['configid'])) {
EventLog::warning('Ignoring generateSucceeded event as it has no configid assigned.');
return;
}
$config = self::get($args['configid']);
- if ($config === false) {
+ if ($config === null) {
EventLog::warning('generateSucceeded callback for config id ' . $args['configid'] . ', but no instance could be generated.');
return;
}
diff --git a/modules-available/sysconfig/inc/ldap.inc.php b/modules-available/sysconfig/inc/ldap.inc.php
index 349a662e..e974a8a3 100644
--- a/modules-available/sysconfig/inc/ldap.inc.php
+++ b/modules-available/sysconfig/inc/ldap.inc.php
@@ -3,7 +3,7 @@
class Ldap
{
- public static function normalizeDn($dn)
+ public static function normalizeDn(string $dn): string
{
return trim(preg_replace('/[,;]\s*/', ',', $dn));
}
diff --git a/modules-available/sysconfig/inc/ppd.inc.php b/modules-available/sysconfig/inc/ppd.inc.php
index 9bd5d171..c28e0355 100644
--- a/modules-available/sysconfig/inc/ppd.inc.php
+++ b/modules-available/sysconfig/inc/ppd.inc.php
@@ -247,8 +247,8 @@ class Ppd
private $data;
private $dataLen;
- private $error;
- private $warnings;
+ private $error = null;
+ private $warnings = [];
private $knownKeywordMalformed;
@@ -301,16 +301,16 @@ class Ppd
$this->dataLen = strlen($this->data);
$this->encoder = false;
$this->sourceEncoding = false;
- $this->error = false;
+ $this->error = null;
$this->warnings = array();
$this->knownKeywordMalformed = false;
$this->settings = array();
$this->requiredKeywords = array();
// Parse
- /* @var $rawOption \PpdOption */
- /* @var $currentBlock \PpdBlockInternal */
- $currentBlock = false;
+ /* @var PpdOption $rawOption */
+ /* @var ?PpdBlockInternal $currentBlock */
+ $currentBlock = null;
$inRawBlock = false; // True if in a multi-line InvocationValue or QuotedValue (3.6: Parsing Summary for Values)
$wantsEnd = false;
// For now we ignore values mostly while parsing. The spec says that InvocationValues must only contain printable
@@ -318,9 +318,9 @@ class Ppd
$lStart = -1;
$lEnd = -1;
$no = 0;
- while ($lStart < $this->dataLen && $lEnd !== false) {
+ while ($lStart < $this->dataLen) {
unset($mainKeyword, $optionKeyword, $optionTranslation, $option, $value, $valueTranslation);
- if ($no !== 0 && $this->data{$lEnd} === "\r" && $this->data{$lEnd + 1} === "\n") {
+ if ($no !== 0 && $this->data[$lEnd] === "\r" && $this->data[$lEnd + 1] === "\n") {
$lEnd++;
}
if ($no === 1) {
@@ -332,6 +332,8 @@ class Ppd
}
$lStart = $lEnd + 1;
$lEnd = $this->nextLineEnd($lStart);
+ if ($lEnd === null)
+ break;
$no++;
// Validate
$len = $lEnd - $lStart;
@@ -343,7 +345,7 @@ class Ppd
$this->warn($no, 'Exceeds length of 255');
}
if (!$inRawBlock && preg_match_all('/[^\x09\x0A\x0D\x20-\xFF]/', $line, $out)) {
- $chars = $this->escapeBinaryArray($out[0]);
+ $chars = self::escapeBinaryArray($out[0]);
$this->warn($no, 'Contains invalid character(s) ' . $chars);
}
// Handle
@@ -373,13 +375,13 @@ class Ppd
}
}
// 3) Handle "key [option]: value"
- if ($line{0} === '*') {
- if ($line{1} === '%') {
+ if ($line[0] === '*') {
+ if ($line[1] === '%') {
// Skip comment
continue;
}
$parts = preg_split('/\s*:\s*/', $line, 2); // TODO: UIConstrains
- if (count($parts) !== 2) {
+ if (!is_array($parts) || count($parts) !== 2) {
$this->warn($no, 'No colon found; not in "key [option]: value" format, ignoring line');
continue;
}
@@ -390,11 +392,12 @@ class Ppd
continue;
}
$mainKeyword = $out[1];
- $optionKeyword = isset($out[3]) ? $out[3] : false;
+ $optionKeyword = $out[3] ?? null;
$optionTranslation = isset($out[4]) ? $this->unhexTranslation($no, substr($out[4], 1)) : $optionKeyword; // If no translation given, fallback to option
// 3b) Handle value
+ /** @var string $value */
$value = $parts[1];
- if ($value{0} === '"') {
+ if ($value[0] === '"') {
// Start of InvocationValue or QuotedValue
if (preg_match(',^"([^"]*)"(/.*)?$,', $value, $vMatch)) {
// Single line
@@ -419,28 +422,23 @@ class Ppd
// Key-value-pair parsed, now the fun part
// Special cases for opening closing certain groups
if ($mainKeyword === 'OpenGroup') {
- if ($currentBlock !== false) {
+ if ($currentBlock !== null) {
$this->error = 'Line ' . $no . ': OpenGroup while other block (type=' . $currentBlock->type
. ', id=' . $currentBlock->id . ') was not closed yet';
return;
}
// TODO: Check unique
- $nb = new PpdBlockInternal($value, $valueTranslation, 'Group', $currentBlock, $lStart);
- if ($currentBlock !== false) {
- $currentBlock->childBlocks[] = $nb;
- }
+ $nb = new PpdBlockInternal($value, $valueTranslation, 'Group', null, $lStart);
$currentBlock = $nb;
continue;
} elseif ($mainKeyword === 'OpenSubGroup') {
- if ($currentBlock === false || $currentBlock->type !== 'Group') {
+ if ($currentBlock === null || $currentBlock->type !== 'Group') {
$this->error = 'Line ' . $no . ': OpenSubGroup with no preceding OpenGroup';
return;
}
// TODO: Check unique
$nb = new PpdBlockInternal($value, $valueTranslation, 'SubGroup', $currentBlock, $lStart);
- if ($currentBlock !== false) {
- $currentBlock->childBlocks[] = $nb;
- }
+ $currentBlock->childBlocks[] = $nb;
$currentBlock = $nb;
continue;
} elseif ($mainKeyword === 'OpenUI' || $mainKeyword === 'JCLOpenUI') {
@@ -450,23 +448,24 @@ class Ppd
} else {
$type = substr($type, 4);
}
- if ($currentBlock !== false && $currentBlock->isUi()) {
+ if ($currentBlock !== null && $currentBlock->isUi()) {
$this->error = 'Line ' . $no . ': ' . $mainKeyword . ' while previous ' . $type . ' "'
. $currentBlock->id . '" was not closed yet';
return;
}
- if ($optionKeyword === false) {
+ if ($optionKeyword === null) {
$this->error = 'Line ' . $no . ': ' . $mainKeyword . ' with no option keyword';
return;
}
- if ($optionKeyword{0} !== '*') {
+ if ($optionKeyword[0] !== '*') {
$this->error = 'Line ' . $no . ': ' . $mainKeyword . " with option keyword that doesn't start with asterisk (*).";
return;
}
// TODO: Check unique
- $nb = new PpdBlockInternal($optionKeyword, $optionTranslation, $type, $currentBlock, $lStart);
+ $nb = new PpdBlockInternal($optionKeyword, $optionTranslation ?? $optionKeyword,
+ $type, $currentBlock, $lStart);
$nb->value = $value;
- if ($currentBlock !== false) {
+ if ($currentBlock !== null) {
$currentBlock->childBlocks[] = $nb;
}
$currentBlock = $nb;
@@ -481,7 +480,7 @@ class Ppd
} else {
$type = substr($type, 5);
}
- if ($currentBlock === false) {
+ if ($currentBlock === null) {
$this->error = 'Line ' . $no . ': ' . $mainKeyword . ' with no Open' . $type;
return;
}
@@ -498,7 +497,7 @@ class Ppd
$currentBlock = $currentBlock->parent;
continue;
} elseif ($mainKeyword === 'OrderDependency') {
- if ($currentBlock === false || $currentBlock->isUi()) {
+ if ($currentBlock === null || $currentBlock->isUi()) {
$this->warn($no, 'OrderDependency outside OpenUI/CloseUI block');
}
continue;
@@ -521,9 +520,10 @@ class Ppd
$this->warn($no, 'Required keyword ' . $mainKeyword . ' declared twice, ignoring');
continue;
}
+ } else {
+ $this->requiredKeywords[$mainKeyword] = array($value);
}
- $this->requiredKeywords[$mainKeyword] = array($value);
- if (($err = $this->validateLine($this->REQUIRED_KEYWORDS[$mainKeyword], $optionKeyword, $value)) !== true) {
+ if (($err = Ppd::validateLine($this->REQUIRED_KEYWORDS[$mainKeyword], $optionKeyword, $value)) !== true) {
$this->warn($no, 'Required main keyword ' . $mainKeyword . ': ' . $err);
$this->knownKeywordMalformed = true;
}
@@ -531,7 +531,7 @@ class Ppd
}
// Other well known keywords
if (isset($this->KNOWN_KEYWORDS[$mainKeyword])) {
- if (($err = $this->validateLine($this->KNOWN_KEYWORDS[$mainKeyword], $optionKeyword, $value)) !== true) {
+ if (($err = Ppd::validateLine($this->KNOWN_KEYWORDS[$mainKeyword], $optionKeyword, $value)) !== true) {
$this->warn($no, 'Known main keyword ' . $mainKeyword . ': ' . $err);
$this->knownKeywordMalformed = true;
}
@@ -541,17 +541,18 @@ class Ppd
$option = $this->getOption(substr($mainKeyword, 7), $currentBlock);
$option->default = new PpdOption($lStart, $len, $value, $valueTranslation);
continue;
- } elseif (substr($mainKeyword, 0, 17) === 'FoomaticRIPOption') {
- if ($optionKeyword === false) {
+ }
+ if (substr($mainKeyword, 0, 17) === 'FoomaticRIPOption') {
+ if ($optionKeyword === null) {
$this->warn($no, "$mainKeyword with no option keyword");
- } elseif ($currentBlock !== false && isset($this->settings[$optionKeyword])) {
+ } elseif ($currentBlock !== null && isset($this->settings[$optionKeyword])) {
$option = $this->getOption($optionKeyword, $currentBlock);
$option->foomatic[substr($mainKeyword, 11)] = new PpdOption($lStart, $len, $value, $valueTranslation);
} else {
$this->warn($no, 'TODO: ' . $line);
}
} elseif (substr($mainKeyword, 0, 6) === 'Custom') {
- if ($optionKeyword === false) {
+ if ($optionKeyword === null) {
$this->warn($no, "$mainKeyword with no option keyword");
} elseif ($optionKeyword !== 'True') {
$this->warn($no, "$mainKeyword with option keyword other than 'True'; ignored");
@@ -560,7 +561,7 @@ class Ppd
$option->custom = new PpdOption($lStart, $len, $value, $valueTranslation);
}
} elseif (substr($mainKeyword, 0, 11) === 'ParamCustom') {
- if ($optionKeyword === false) {
+ if ($optionKeyword === null) {
$this->warn($no, "$mainKeyword with no option keyword");
} elseif (substr($mainKeyword, 11) !== $optionKeyword) {
$this->warn($no, "Don't know how to handle $mainKeyword with option keyword $optionKeyword "
@@ -569,20 +570,20 @@ class Ppd
$option = $this->getOption($optionKeyword, $currentBlock);
$option->customParam = new PpdOption($lStart, $len, $value, $valueTranslation);
}
- } elseif ($mainKeyword{0} === '?') {
+ } elseif ($mainKeyword[0] === '?') {
// Ignoring option query for now
- } elseif ($optionKeyword === false && !isset($this->KNOWN_KEYWORDS[$mainKeyword])) {
+ } elseif ($optionKeyword === null && !isset($this->KNOWN_KEYWORDS[$mainKeyword])) {
// Must be a definition for an option
$this->warn($no, "Don't know how to handle line with main keyword '$mainKeyword', no option keyword found.");
} else {
// Some option for some option ;)
- if ($optionKeyword === false) {
+ if ($optionKeyword === null) {
// We know that this is a known main keyword otherwise we would have hit the previous elseif block
$optionKeyword = $value;
$optionTranslation = $valueTranslation;
}
$option = $this->getOption($mainKeyword, $currentBlock);
- $optionInstance = new PpdOption($lStart, $len, $optionKeyword, $optionTranslation);
+ $optionInstance = new PpdOption($lStart, $len, $optionKeyword, $optionTranslation ?? $optionKeyword);
if ($this->binary_in_array($mainKeyword, $this->REPEATED_KEYWORDS)) {
// This can occur multiple times, just pile them up
$option->values[] = $optionInstance;
@@ -603,9 +604,9 @@ class Ppd
} elseif (strlen(trim($line)) !== 0) {
$this->warn($no, 'Invalid format; not empty and not starting with asterisk (*)');
}
- }
+ } // end while loop over ppd contents
//
- if ($currentBlock !== false) {
+ if ($currentBlock !== null) {
$this->error = 'Block ' . $currentBlock->id . ' (' . $currentBlock->type . ') was never closed.';
return;
}
@@ -615,11 +616,11 @@ class Ppd
$this->error = 'One or more required keywords missing';
}
}
- if ($this->error !== false) {
+ if ($this->error !== null) {
return;
}
// All required keywords exist
- if (preg_match('/utf\-?8/i', $this->requiredKeywords['LanguageEncoding'][0])) {
+ if (preg_match('/utf-?8/i', $this->requiredKeywords['LanguageEncoding'][0])) {
$this->sourceEncoding = false; // Would be a NOOP
} elseif (isset($this->ENCODINGS[$this->requiredKeywords['LanguageEncoding'][0]])) {
$this->sourceEncoding = $this->ENCODINGS[$this->requiredKeywords['LanguageEncoding'][0]];
@@ -669,12 +670,12 @@ class Ppd
}
}
- private function nextLineEnd($start)
+ private function nextLineEnd(int $start): ?int
{
if ($start >= $this->dataLen)
- return false;
+ return null;
while ($start < $this->dataLen) {
- $char = $this->data{$start};
+ $char = $this->data[$start];
if ($char === "\r" || $char === "\n")
return $start;
++$start;
@@ -682,27 +683,26 @@ class Ppd
return $this->dataLen;
}
- private function warn($lineNo, $message)
+ private function warn(int $lineNo, string $message): void
{
$line = 'Line ' . $lineNo . ': ' . $message;
$this->warnings[] = $line;
}
- private function escapeBinaryArray($array)
+ private static function escapeBinaryArray(array $array): string
{
- $chars = array_reduce(array_unique($array), function ($carry, $item) {
+ return array_reduce(array_unique($array), function ($carry, $item) {
return $carry . '\x' . dechex(ord($item));
}, '');
- return $chars;
}
- private function unhexTranslation($lineNo, $translation)
+ private function unhexTranslation(int $lineNo, string $translation): string
{
if (strpos($translation, '<') === false)
return $translation;
return preg_replace_callback('/<[^>]*>/', function ($match) use ($lineNo) {
if (preg_match_all('/[^a-fA-F0-9\<\>\s]/', $match[0], $out)) {
- $this->warn($lineNo, 'Invalid character(s) in hex substring: ' . $this->escapeBinaryArray($out[0]));
+ $this->warn($lineNo, 'Invalid character(s) in hex substring: ' . self::escapeBinaryArray($out[0]));
}
$string = preg_replace('/[^a-fA-F0-9]/', '', $match[0]);
if (strlen($string) % 2 !== 0) {
@@ -713,7 +713,7 @@ class Ppd
}, $translation);
}
- private function hexTranslation($translation)
+ private function hexTranslation(string $translation): string
{
return preg_replace_callback('/[\x00-\x1f\x7b-\xff\:\<\>]+/', function ($match) {
return '<' . unpack('H*', $match[0])[1] . '>';
@@ -724,23 +724,23 @@ class Ppd
* Get option object
*
* @param string $name option name
- * @param \PpdBlockInternal $block which block this option is defined in
- * @return \PpdSettingInternal the option object
+ * @param ?PpdBlockInternal $block which block this option is defined in
+ * @return PpdSettingInternal the option object
*/
- private function getOption($name, $block = false)
+ private function getOption(string $name, ?PpdBlockInternal $block = null): PpdSettingInternal
{
if (!isset($this->settings[$name])) {
$this->settings[$name] = new PpdSettingInternal();
$this->settings[$name]->block = $block;
- } elseif ($block !== false) {
- if ($this->settings[$name]->block === false || $block->isChildOf($this->settings[$name]->block)) {
+ } elseif ($block !== null) {
+ if ($this->settings[$name]->block === null || $block->isChildOf($this->settings[$name]->block)) {
$this->settings[$name]->block = $block;
}
}
return $this->settings[$name];
}
- private function binary_in_array($elem, $array)
+ private function binary_in_array($elem, $array): bool
{
$top = sizeof($array) - 1;
$bot = 0;
@@ -755,7 +755,7 @@ class Ppd
return false;
}
- private function validateLine($validator, $option, $value)
+ private static function validateLine($validator, ?string $option, string $value)
{
if (is_array($validator)) {
$oExp = $validator[0];
@@ -780,7 +780,7 @@ class Ppd
return true;
}
- private function getEolChar()
+ private function getEolChar(): string
{
$rn = substr_count("\r\n", $this->data);
$r = substr_count("\r", $this->data) - $rn;
@@ -799,21 +799,21 @@ class Ppd
*
*/
- public function getError()
+ public function getError(): ?string
{
return $this->error;
}
- public function getWarnings()
+ public function getWarnings(): array
{
return $this->warnings;
}
- public function getUISettings()
+ public function getUISettings(): array
{
$result = array();
foreach ($this->settings as $mk => $option) {
- $isUi = ($option->block !== false && $option->block->isUi()) || isset($this->UI_KEYWORDS[$mk]);
+ $isUi = ($option->block !== null && $option->block->isUi()) || isset($this->UI_KEYWORDS[$mk]);
if ($isUi) {
$result[] = $mk;
}
@@ -821,30 +821,30 @@ class Ppd
return $result;
}
- public function getSetting($name)
+ public function getSetting(string $name): ?PpdSetting
{
if (!isset($this->settings[$name]))
- return false;
+ return null;
return new PpdSetting($this->settings[$name], isset($this->UI_KEYWORDS[$name]), $this->encoder);
}
- public function removeSetting($name)
+ public function removeSetting(string $name): bool
{
if (!isset($this->settings[$name]))
return false;
$setting = $this->settings[$name];
$ranges = array();
- $this->mergeRanges($ranges, $setting->default);
- $this->mergeRanges($ranges, $setting->custom);
- $this->mergeRanges($ranges, $setting->customParam);
+ Ppd::mergeRangesFromOption($ranges, $setting->default);
+ Ppd::mergeRangesFromOption($ranges, $setting->custom);
+ Ppd::mergeRangesFromOption($ranges, $setting->customParam);
foreach ($setting->foomatic as $obj) {
- $this->mergeRanges($ranges, $obj);
+ Ppd::mergeRangesFromOption($ranges, $obj);
}
foreach ($setting->values as $obj) {
- $this->mergeRanges($ranges, $obj);
+ Ppd::mergeRangesFromOption($ranges, $obj);
}
- if ($setting->block !== false && $setting->block->isUi()) {
- $this->mergeRanges($ranges, $setting->block->start, $setting->block->end);
+ if ($setting->block !== null && $setting->block->isUi()) {
+ Ppd::mergeRanges($ranges, $setting->block->start, $setting->block->end);
}
$tmp = array_map(function ($e) { return $e[0]; }, $ranges);
array_multisort($tmp, SORT_NUMERIC, $ranges);
@@ -853,20 +853,20 @@ class Ppd
foreach ($ranges as $range) {
$new .= substr($this->data, $last, $range[0] - $last);
$last = $range[1];
- if ($this->data{$last} === "\r") {
+ if ($this->data[$last] === "\r") {
$last++;
}
- if ($this->data{$last} === "\n") {
+ if ($this->data[$last] === "\n") {
$last++;
}
}
$new .= substr($this->data, $last);
$this->data = $new;
$this->parse();
- return $this->error === false;
+ return $this->error === null;
}
- public function addEmptyOption($settingName, $option, $translation = false, $prepend = true)
+ public function addEmptyOption(string $settingName, string $option, string $translation = null, bool $prepend = true): bool
{
if (!isset($this->settings[$settingName]))
return false;
@@ -874,15 +874,15 @@ class Ppd
$pos = false;
if (!empty($setting->values)) {
if ($prepend) {
- $pos = array_reduce($setting->values, function ($carry, $option) { return min($carry, $option->lineOffset); }, PHP_INT_MAX);
+ $pos = array_reduce($setting->values, function (int $carry, PpdOption $option) { return min($carry, $option->lineOffset); }, PHP_INT_MAX);
} else {
- $pos = array_reduce($setting->values, function ($carry, $option) { return max($carry, $option->lineOffset); }, 0);
+ $pos = array_reduce($setting->values, function (int $carry, PpdOption $option) { return max($carry, $option->lineOffset); }, 0);
}
- } elseif ($setting->default !== false) {
+ } elseif ($setting->default !== null) {
$pos = $setting->default->lineOffset;
- } elseif ($setting->block !== false && $setting->block->isUi()) {
+ } elseif ($setting->block !== null && $setting->block->isUi()) {
$pos = $this->nextLineEnd($setting->block->start);
- while ($pos !== false && $pos < $this->dataLen && ($this->data{$pos} === "\r" || $this->data{$pos} === "\n")) {
+ while ($pos !== null && $pos < $this->dataLen && ($this->data[$pos] === "\r" || $this->data[$pos] === "\n")) {
$pos++;
}
}
@@ -890,23 +890,23 @@ class Ppd
return false;
}
$line = '*' . $settingName . ' ' . $option;
- if ($translation !== false) {
+ if ($translation !== null) {
$line .= '/' . $this->hexTranslation(($this->encoder)($translation, true));
}
$eol = $this->getEolChar();
$line .= ': ""' . $eol;
$this->data = substr($this->data, 0, $pos) . $line . substr($this->data, $pos);
$this->parse();
- return $this->error === false;
+ return $this->error === null;
}
- public function setDefaultOption($settingName, $optionName)
+ public function setDefaultOption($settingName, $optionName): bool
{
if (!isset($this->settings[$settingName]))
return false;
$setting = $this->settings[$settingName];
$line = '*Default' . $settingName . ': ' . $optionName;
- if ($setting->default !== false) {
+ if ($setting->default !== null) {
$start = $setting->default->lineOffset;
$end = $start + $setting->default->lineLen;
} elseif (empty($setting->values)) {
@@ -918,22 +918,21 @@ class Ppd
}
$this->data = substr($this->data, 0, $start) . $line . substr($this->data, $end);
$this->parse();
- return $this->error === false;
+ return $this->error === null;
}
- public function write($file)
+ public function write(string $file)
{
return file_put_contents($file, $this->data);
}
- private function mergeRanges(&$ranges, $start, $end = false)
+ private static function mergeRangesFromOption(array &$ranges, PpdOption $option): void
+ {
+ self::mergeRanges($ranges, $option->lineOffset, $option->lineOffset + $option->lineLen);
+ }
+
+ private static function mergeRanges(array &$ranges, int $start, int $end): void
{
- if (is_object($start) && get_class($start) === 'PpdOption') {
- $end = $start->lineOffset + $start->lineLen;
- $start = $start->lineOffset;
- }
- if ($start === false || $end === false)
- return;
if ($start >= $end)
return; // Don't even bother
foreach (array_keys($ranges) as $key) {
@@ -956,7 +955,7 @@ class Ppd
// $start must lie before range start, otherwise we'd have hit the case above
$end = $ranges[$key][1];
unset($ranges[$key]);
- continue;
+ //continue;
}
}
$ranges[] = array($start, $end);
@@ -965,7 +964,7 @@ class Ppd
/**
* @return bool whether there was at least one known option with format restriction violated.
*/
- public function hasInvalidOption()
+ public function hasInvalidOption(): bool
{
return $this->knownKeywordMalformed;
}
@@ -1013,15 +1012,13 @@ class PpdSetting
/**
* PpdSetting constructor.
- *
- * @param \PpdSettingInternal $setting
*/
- public function __construct($setting, $isUi, $enc)
+ public function __construct(PpdSettingInternal $setting, bool $isUi, callable $enc)
{
- if ($setting->default !== false) {
+ if ($setting->default !== null) {
$this->default = $setting->default->option;
}
- if ($setting->block !== false && $setting->block->isUi()) {
+ if ($setting->block !== null && $setting->block->isUi()) {
$this->uiOptionType = $setting->block->value;
$this->uiOptionTranslation = $enc($setting->block->translation);
$this->isUi = true;
@@ -1055,9 +1052,9 @@ class PpdSetting
class PpdSettingInternal
{
/**
- * @var \PpdOption
+ * @var ?PpdOption
*/
- public $default = false;
+ public $default = null;
/**
* @var \PpdOption[]
*/
@@ -1067,17 +1064,17 @@ class PpdSettingInternal
*/
public $foomatic = array();
/**
- * @var \PpdOption
+ * @var ?PpdOption
*/
- public $custom = false;
+ public $custom = null;
/**
- * @var \PpdOption
+ * @var ?PpdOption
*/
- public $customParam = false;
+ public $customParam = null;
/**
- * @var \PpdBlockInternal the innermost block this option resides in
+ * @var ?PpdBlockInternal the innermost block this option resides in
*/
- public $block = false;
+ public $block = null;
}
class PpdOption
@@ -1088,7 +1085,7 @@ class PpdOption
public $lineLen;
public $multiLine = false;
- public function __construct($lineOffset, $lineLen, $option, $optionTranslation)
+ public function __construct(int $lineOffset, int $lineLen, string $option, string $optionTranslation)
{
$this->option = $option;
$this->optionTranslation = $optionTranslation;
@@ -1106,13 +1103,13 @@ class PpdBlockInternal
public $translation;
public $type;
/**
- * @var \PpdBlockInternal[]
+ * @var PpdBlockInternal[]
*/
public $childBlocks = array();
/**
- * @var \PpdBlockInternal
+ * @var ?PpdBlockInternal
*/
- public $parent;
+ public $parent = null;
/**
* @var int start byte in ppd
@@ -1120,16 +1117,16 @@ class PpdBlockInternal
public $start;
/**
- * @var int|bool end byte in ppd, false if block is not closed
+ * @var ?int end byte in ppd, null if block is not closed
*/
- public $end = false;
+ public $end = null;
/**
* @var string value of opening line for block, e.g. 'PickOne' for OpenUI
*/
- public $value = false;
+ public $value = '';
- public function __construct($id, $translation, $type, $parent, $start)
+ public function __construct(string $id, string $translation, string $type, ?PpdBlockInternal $parent, int $start)
{
$this->id = $id;
$this->translation = $translation;
@@ -1141,16 +1138,16 @@ class PpdBlockInternal
/**
* @return bool true if this is a UI block
*/
- public function isUi()
+ public function isUi(): bool
{
return $this->type == 'UI' || $this->type === 'JCLUI';
}
/**
- * @param \PpdBlockInternal $block some other PpdBlock instance
+ * @param PpdBlockInternal $block some other PpdBlock instance
* @return bool true if this is a child of $block
*/
- public function isChildOf($block)
+ public function isChildOf(PpdBlockInternal $block): bool
{
$parent = $this->parent;
while ($parent !== false) {
diff --git a/modules-available/sysconfig/inc/sysconfig.inc.php b/modules-available/sysconfig/inc/sysconfig.inc.php
index 9ad3a36f..09860c7d 100644
--- a/modules-available/sysconfig/inc/sysconfig.inc.php
+++ b/modules-available/sysconfig/inc/sysconfig.inc.php
@@ -3,12 +3,12 @@
class SysConfig
{
- public static function getAll()
+ public static function getAll(): array
{
$res = Database::simpleQuery("SELECT c.configid, c.title, c.filepath, c.status, Group_Concat(cl.locationid) AS locs FROM configtgz c"
. " LEFT JOIN configtgz_location cl USING (configid) GROUP BY c.configid");
$ret = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$ret[] = $row;
}
return $ret;
diff --git a/modules-available/sysconfig/install.inc.php b/modules-available/sysconfig/install.inc.php
index 3f80ffe6..53882882 100644
--- a/modules-available/sysconfig/install.inc.php
+++ b/modules-available/sysconfig/install.inc.php
@@ -87,7 +87,7 @@ if (!tableHasColumn('configtgz_module', 'dateline')) {
$update[] = UPDATE_DONE;
// Infer from module's filemtime
$res = Database::simpleQuery('SELECT moduleid, filepath FROM configtgz_module');
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
Database::exec('UPDATE configtgz_module SET dateline = :mtime WHERE moduleid = :moduleid',
['moduleid' => $row['moduleid'], 'mtime' => filemtime($row['filepath'])]);
}
@@ -102,7 +102,7 @@ if (!tableHasColumn('configtgz', 'dateline')) {
INNER JOIN configtgz_x_module cxm USING (configid)
INNER JOIN configtgz_module m USING (moduleid)
GROUP BY configid');
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
Database::exec('UPDATE configtgz SET dateline = :mtime WHERE configid = :configid',
['configid' => $row['configid'], 'mtime' => $row['dateline']]);
}
@@ -125,28 +125,28 @@ Module::isAvailable('sysconfig');
$list = ConfigModule::getAll();
$parentTask = null;
$configList = [];
-if ($list === false) {
+if ($list === null) {
EventLog::warning('Could not regenerate configs - please do so manually');
} else {
- foreach ($list as $ad) {
- if ($ad->moduleType() === 'SshConfig') {
+ foreach ($list as $confMod) {
+ if ($confMod->moduleType() === 'SshConfig') {
// 2020-11-12: Split SshConfig into SshConfig and SshKey
- $pubkey = $ad->getData('publicKey');
- if ($pubkey !== false && !empty($pubkey)) {
- error_log('Legacy module with pubkey ' . $ad->id());
- $key = ConfigModule::getInstance('SshKey');
- if ($key !== false) {
+ $pubkey = $confMod->getData('publicKey');
+ if (!empty($pubkey)) {
+ error_log('Legacy module with pubkey ' . $confMod->id());
+ $key = ConfigModule::getInstanceOrNull('SshKey');
+ if ($key !== null) {
$key->setData('publicKey', $pubkey);
- if ($key->insert($ad->title())) {
+ if ($key->insert($confMod->title())) {
// Insert worked, remove key from old module, add this module to the same configs
$task = $key->generate(false, $parentTask);
if ($task !== false) {
$parentTask = $task;
}
error_log('Inserted new module with id ' . $key->id());
- $ad->setData('publicKey', false);
- $ad->update();
- $configs = ConfigTgz::getAllForModule($ad->id());
+ $confMod->setData('publicKey', false);
+ $confMod->update();
+ $configs = ConfigTgz::getAllForModule($confMod->id());
foreach ($configs as $config) {
// Add newly created key-only module to all configs
$new = array_merge($config->getModuleIds(), [$key->id()]);
@@ -158,9 +158,9 @@ if ($list === false) {
}
}
}
- if ($ad->needRebuild()) {
+ if ($confMod->needRebuild()) {
$update[] = UPDATE_DONE;
- $task = $ad->generate(false, $parentTask);
+ $task = $confMod->generate(false, $parentTask);
if ($task !== false) {
$parentTask = $task;
}
@@ -171,5 +171,8 @@ if ($list === false) {
}
}
+// Start any changed services
+ConfigModuleBaseLdap::ldadp();
+
// Create response for browser
responseFromArray($update);
diff --git a/modules-available/sysconfig/lang/de/module.json b/modules-available/sysconfig/lang/de/module.json
index 1dbb268b..4bc1642f 100644
--- a/modules-available/sysconfig/lang/de/module.json
+++ b/modules-available/sysconfig/lang/de/module.json
@@ -6,8 +6,12 @@
"lang_moduleAssign": "Modul zu Systemkonfigurationen zuweisen",
"lang_noModuleFromThisGroup": "(Kein Modul dieser Gruppe)",
"lang_unknwonTaskManager": "Unbekannter Taskmanager-Fehler",
+ "location-column-header": "Lokalisierung",
"module_name": "Lokalisierung + Integration",
"page_title": "Lokalisierung + Integration",
+ "saver_DescriptionIdleKill": "Ein Bildschirmschoner mit Timeout, nach dessen Ablauf alle Anwendungen ohne weitere Nachfragen geschlossen werden und der Nutzer ausgeloggt wird.",
+ "saver_DescriptionNoTimeout": "Ein Bildschirmschoner ohne Timeout.",
+ "saver_DescriptionShutdown": "Ein Bildschirmschoner mit Timeout, nach dessen Ablauf alle Anwendungen ohne weitere Nachfragen geschlossen werden und der PC heruntergefahren oder neugestartet wird.",
"saver_MessageDefaultIdleKill": "Diese Sitzung wird bei Inaktivit\u00e4t in %1 beendet.",
"saver_MessageDefaultIdleKillLocked": "Diese Sitzung wird in %1 beendet, wenn sie nicht entsperrt wird.",
"saver_MessageDefaultNoTimeout": "Dieser Bildschirm wird gerade geschont.",
@@ -15,13 +19,10 @@
"saver_MessageDefaultShutdown": "Achtung: Rechner wird in %1 heruntergefahren!",
"saver_MessageDefaultShutdownLocked": "Achtung: Rechner wird in %1 heruntergefahren!",
"saver_QssDefault": "#Saver {\r\n background: qlineargradient(spread:pad, x1:0, y1:0, x2:0, y2:1, stop:0 #443, stop:1 #000)\r\n}\r\n\r\nQLabel {\r\n color: #f64;\r\n}\r\n\r\n#lblClock {\r\n color: #999;\r\n font-size: 20pt;\r\n}\r\n\r\n#lblHeader {\r\n font-size: 20pt;\r\n}\r\n",
- "saver_DescriptionIdleKill": "Ein Bildschirmschoner mit Timeout, nach dessen Ablauf alle Anwendungen ohne weitere Nachfragen geschlossen werden und der Nutzer ausgeloggt wird.",
- "saver_DescriptionNoTimeout": "Ein Bildschirmschoner ohne Timeout.",
- "saver_DescriptionShutdown": "Ein Bildschirmschoner mit Timeout, nach dessen Ablauf alle Anwendungen ohne weitere Nachfragen geschlossen werden und der PC heruntergefahren oder neugestartet wird.",
- "saver_TitleIdleKill": "Idle Kill",
- "saver_TitleNoTimeout": "Ohne Timeout",
- "saver_TitleShutdown": "Herunterfahren",
"saver_TextDefaultIdleKill": "<html><body>Keine Nutzeraktivit\u00e4t festgestellt. <br>Zum oben angegebenen Zeitpunkt wird die aktuell laufende Sitzung beendet, wenn der Rechner nicht mehr verwendet wird. <br>Alle noch laufenden Programme <br>werden ohne Nachfrage geschlossen. Stellen Sie daher sicher, bis zum angegebenen Zeitpunkt <br>s\u00e4mtliche sich in Bearbeitung befindlichen Daten abzuspeichern. <br><br>Dies dient dazu zu vermeiden, dass ein Rechner stundenlang gesperrt wird und somit <br>anderen Nutzern nicht zur Verf\u00fcgung steht.<\/body><\/html>",
"saver_TextDefaultIdleKillLocked": "<html><body><br>Zum oben angegebenen Zeitpunkt wird die aktuell laufende Sitzung beendet, wenn sie zuvor nicht wieder entsperrt wird. <br>Alle noch laufenden Programme werden ohne Nachfrage geschlossen. <br>Stellen Sie daher sicher, bis zum angegebenen Zeitpunkt <br>s\u00e4mtliche sich in Bearbeitung befindlichen Daten abzuspeichern, bzw. die Sitzung wieder zu entsperren. <br><br>Dies dient dazu zu vermeiden, dass ein Rechner stundenlang gesperrt wird und somit<br>anderen Nutzern nicht zur Verf\u00fcgung steht.<\/body><\/html>",
- "saver_TextDefaultShutdown": "<html><body>Achtung: Zum oben angegebenen Zeitpunkt wird der Computer heruntergefahren bzw. neugestartet. <br>Alle noch laufenden Programme werden ohne Nachfrage beendet. Stellen Sie daher sicher, bis <br>zum angegebenen Zeitpunkt s\u00e4mtliche Daten abzuspeichern und die Sitzung zu verlassen.<\/body><\/html>"
-}
+ "saver_TextDefaultShutdown": "<html><body>Achtung: Zum oben angegebenen Zeitpunkt wird der Computer heruntergefahren bzw. neugestartet. <br>Alle noch laufenden Programme werden ohne Nachfrage beendet. Stellen Sie daher sicher, bis <br>zum angegebenen Zeitpunkt s\u00e4mtliche Daten abzuspeichern und die Sitzung zu verlassen.<\/body><\/html>",
+ "saver_TitleIdleKill": "Idle Kill",
+ "saver_TitleNoTimeout": "Ohne Timeout",
+ "saver_TitleShutdown": "Herunterfahren"
+} \ No newline at end of file
diff --git a/modules-available/sysconfig/lang/en/module.json b/modules-available/sysconfig/lang/en/module.json
index b49cc1cf..dbfacdb8 100644
--- a/modules-available/sysconfig/lang/en/module.json
+++ b/modules-available/sysconfig/lang/en/module.json
@@ -6,8 +6,12 @@
"lang_moduleAssign": "Assign Module to System Configurations",
"lang_noModuleFromThisGroup": "(No module from this group)",
"lang_unknwonTaskManager": "Unknown Task Manager error",
+ "location-column-header": "SysConfig",
"module_name": "Localization",
"page_title": "Localize and integrate",
+ "saver_DescriptionIdleKill": "A screensaver with a timeout which on it's expiration will close all running applications without further requests and logout the user.",
+ "saver_DescriptionNoTimeout": "A screensaver without a timeout.",
+ "saver_DescriptionShutdown": "A screensaver with a timeout which on it's expiration the PC will shutdown or restart. All applications will be closed without further requests.",
"saver_MessageDefaultIdleKill": "This session will end in %1 when inactive.",
"saver_MessageDefaultIdleKillLocked": "This session will end in %1 if the session is not unlocked.",
"saver_MessageDefaultNoTimeout": "This screen is in saving mode.",
@@ -15,13 +19,10 @@
"saver_MessageDefaultShutdown": "Caution: Computer will shutdown in %1!",
"saver_MessageDefaultShutdownLocked": "Caution: Computer will shutdown in %1!",
"saver_QssDefault": "#Saver {\r\n background: qlineargradient(spread:pad, x1:0, y1:0, x2:0, y2:1, stop:0 #443, stop:1 #000)\r\n}\r\n\r\nQLabel {\r\n color: #f64;\r\n}\r\n\r\n#lblClock {\r\n color: #999;\r\n font-size: 20pt;\r\n}\r\n\r\n#lblHeader {\r\n font-size: 20pt;\r\n}\r\n",
- "saver_DescriptionIdleKill": "A screensaver with a timeout which on it's expiration will close all running applications without further requests and logout the user.",
- "saver_DescriptionNoTimeout": "A screensaver without a timeout.",
- "saver_DescriptionShutdown": "A screensaver with a timeout which on it's expiration the PC will shutdown or restart. All applications will be closed without further requests.",
- "saver_TitleIdleKill": "Idle Kill",
- "saver_TitleNoTimeout": "No Timeout",
- "saver_TitleShutdown": "Shutdown",
"saver_TextDefaultIdleKill": "<html><body>No user activity detected. <br>If the computer is not used until the time specified above, the session will end. <br> All running applications <br>will be closed without further requests. Make sure that all files and changes are saved <br> before the time runs out. <br><br>It prevents computers from beeing locked for hours and <br>not beeing available for other users.<\/body><\/html>",
"saver_TextDefaultIdleKillLocked": "<html><body><br>The current session will end by the time specified above if the computer isn't unlocked before. <br>All running applications will be closed without further requests. <br>Make sure that all files and changes are saved <br>or the session is unlocked before the time runs out. <br><br>It prevents computers from beeing locked for hours and <br>not beeing available for other users.<\/body><\/html>",
- "saver_TextDefaultShutdown": "<html><body>Caution: The computer will shutdown or restart respectively at the specified time above. <br>All running applications will be closed without further requests. Make sure to save all files and changes and leave the session<br>before the time runs out.<\/body><\/html>"
-}
+ "saver_TextDefaultShutdown": "<html><body>Caution: The computer will shutdown or restart respectively at the specified time above. <br>All running applications will be closed without further requests. Make sure to save all files and changes and leave the session<br>before the time runs out.<\/body><\/html>",
+ "saver_TitleIdleKill": "Idle Kill",
+ "saver_TitleNoTimeout": "No Timeout",
+ "saver_TitleShutdown": "Shutdown"
+} \ No newline at end of file
diff --git a/modules-available/sysconfig/page.inc.php b/modules-available/sysconfig/page.inc.php
index ff3983c1..b11f399e 100644
--- a/modules-available/sysconfig/page.inc.php
+++ b/modules-available/sysconfig/page.inc.php
@@ -152,11 +152,12 @@ class Page_SysConfig extends Page
$listid = Request::post('list', Request::REQUIRED, 'int');
$this->listConfigContents($listid);
return;
+ default:
}
Message::addError('invalid-action', $action, 'main');
}
- private function getLocationNames($locations, $ids)
+ private function getLocationNames(array $locations, array $ids): string
{
$ret = array();
foreach ($ids as $id) {
@@ -188,7 +189,7 @@ class Page_SysConfig extends Page
$locationName = false;
}
$hasDefault = false;
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
if (is_null($row['loclist'])) {
$locList = array();
} else {
@@ -236,7 +237,7 @@ class Page_SysConfig extends Page
private function listModules()
{
// Config modules
- $modules = ConfigModule::getAll();
+ $modules = ConfigModule::getAll() ?? [];
$types = array_map(function ($mod) { return $mod->moduleType(); }, $modules);
$titles = array_map(function ($mod) { return $mod->title(); }, $modules);
array_multisort($types, SORT_ASC, $titles, SORT_ASC, $modules);
@@ -291,7 +292,7 @@ class Page_SysConfig extends Page
. " ORDER BY module.title ASC", array('configid' => $configid));
$modules = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$modules[] = array(
'module' => $row['moduletitle'],
'moduleid' => $row['moduleid']
@@ -336,7 +337,7 @@ class Page_SysConfig extends Page
{
$configid = Request::post('rebuild', Request::REQUIRED, 'int');
$config = ConfigTgz::get($configid);
- if ($config === false) {
+ if ($config === null) {
Message::addError('config-invalid', $configid);
Util::redirect('?do=sysconfig&locationid=' . $this->currentLoc);
}
@@ -375,9 +376,9 @@ class Page_SysConfig extends Page
Message::addSuccess('module-deleted', $module['title']);
}
// Rebuild depending config.tgz
- while ($crow = $existing->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($existing as $crow) {
$config = ConfigTgz::get($crow['configid']);
- if ($config !== false) {
+ if ($config !== null) {
$config->generate();
}
}
@@ -401,11 +402,11 @@ class Page_SysConfig extends Page
{
$moduleid = Request::post('rebuild', Request::REQUIRED);
$module = ConfigModule::get($moduleid);
- if ($module === false) {
+ if ($module === null) {
Message::addError('config-invalid', $moduleid);
Util::redirect('?do=sysconfig');
}
- $ret = $module->generate(false, 250);
+ $ret = $module->generate(false, null, 500);
if ($ret === true)
Message::addSuccess('module-rebuilt', $module->title());
elseif ($ret === false)
@@ -419,7 +420,7 @@ class Page_SysConfig extends Page
{
$configid = Request::post('del', Request::REQUIRED);
$config = ConfigTgz::get($configid);
- if ($config === false) {
+ if ($config === null) {
Message::addError('config-invalid', $configid);
Util::redirect('?do=sysconfig&locationid=' . $this->currentLoc);
}
diff --git a/modules-available/sysconfig/templates/ad-start.html b/modules-available/sysconfig/templates/ad-start.html
index 3cca080f..6dd6208a 100644
--- a/modules-available/sysconfig/templates/ad-start.html
+++ b/modules-available/sysconfig/templates/ad-start.html
@@ -21,7 +21,7 @@
<input type="hidden" name="edit" value="{{edit}}">
<div class="input-group">
<span class="input-group-addon slx-ga2">{{lang_moduleTitle}}</span>
- <input tabindex="1" name="title" value="{{title}}" type="text" class="form-control" autofocus>
+ <input tabindex="1" name="title" value="{{title}}" type="text" class="form-control" autofocus required>
</div>
<div class="input-group">
<span class="input-group-addon slx-ga2">Server *</span>
diff --git a/modules-available/sysconfig/templates/branding-check.html b/modules-available/sysconfig/templates/branding-check.html
index d48f9631..80eb4d48 100644
--- a/modules-available/sysconfig/templates/branding-check.html
+++ b/modules-available/sysconfig/templates/branding-check.html
@@ -20,7 +20,7 @@
<input type="hidden" name="edit" value="{{edit}}">
<div class="form-group">
<label for="title-id">{{lang_title}}</label>
- <input type="text" name="title" value="{{title}}" id ="title-id" class="form-control" placeholder="Name des Moduls">
+ <input type="text" name="title" value="{{title}}" id ="title-id" class="form-control" placeholder="Name des Moduls" required>
</div>
<div class="btn-group">
<a class="btn btn-default" href="?do=SysConfig&action=addmodule&step=Branding_Start">{{lang_cancel}}</a>
diff --git a/modules-available/sysconfig/templates/branding-start.html b/modules-available/sysconfig/templates/branding-start.html
index 0db085d9..a6346552 100644
--- a/modules-available/sysconfig/templates/branding-start.html
+++ b/modules-available/sysconfig/templates/branding-start.html
@@ -14,7 +14,7 @@
<input type="text" class="form-control" readonly placeholder="{{lang_selectFile}}">
<span class="input-group-btn">
<span class="btn btn-default btn-file">
- {{lang_browseForFile}}&hellip; <input type="file" name="file" id="input-file">
+ {{lang_browseForFile}}&hellip; <input type="file" accept="image/svg+xml" name="file" id="input-file">
</span>
</span>
</div>
diff --git a/modules-available/sysconfig/templates/cfg-start.html b/modules-available/sysconfig/templates/cfg-start.html
index b4628cba..018cf89f 100644
--- a/modules-available/sysconfig/templates/cfg-start.html
+++ b/modules-available/sysconfig/templates/cfg-start.html
@@ -3,7 +3,7 @@
<input type="hidden" name="edit" value="{{edit}}">
<div class="input-group">
<span class="input-group-addon">{{lang_name}} *</span>
- <input type="text" name="title" value="{{title}}" class="form-control" placeholder="{{lang_configuration}}" autofocus="autofocus">
+ <input type="text" name="title" value="{{title}}" class="form-control" placeholder="{{lang_configuration}}" autofocus="autofocus" required>
</div>
<hr>
<p>{{lang_configurationChoose}}</p>
diff --git a/modules-available/sysconfig/templates/custom-fileselect.html b/modules-available/sysconfig/templates/custom-fileselect.html
index 3e7dd3d6..5f190f08 100644
--- a/modules-available/sysconfig/templates/custom-fileselect.html
+++ b/modules-available/sysconfig/templates/custom-fileselect.html
@@ -5,7 +5,7 @@
<div class="input-group">
<span class="input-group-addon">{{lang_moduleName}}</span>
<input type="text" name="title" value="{{title}}" class="form-control" placeholder="Mein Konfigurationsmodul"
- autofocus="autofocus">
+ autofocus="autofocus" required>
</div>
<div class="pull-right">
<button type="submit" class="btn btn-primary">{{lang_next}} &raquo;</button>
@@ -51,4 +51,5 @@
<div class="pull-right">
<button type="submit" class="btn btn-primary">{{lang_next}} &raquo;</button>
</div>
+ <div class="clearfix"></div>
</form>
diff --git a/modules-available/sysconfig/templates/ldap-start.html b/modules-available/sysconfig/templates/ldap-start.html
index 059d54f5..e6c98680 100644
--- a/modules-available/sysconfig/templates/ldap-start.html
+++ b/modules-available/sysconfig/templates/ldap-start.html
@@ -11,7 +11,7 @@
<input type="hidden" name="edit" value="{{edit}}">
<div class="input-group">
<span class="input-group-addon slx-ga2">{{lang_moduleTitle}}</span>
- <input tabindex="1" name="title" value="{{title}}" type="text" class="form-control">
+ <input tabindex="1" name="title" value="{{title}}" type="text" class="form-control" required>
</div>
<div class="input-group">
<span class="input-group-addon slx-ga2">Server *</span>
diff --git a/modules-available/sysconfig/templates/sshconfig-start.html b/modules-available/sysconfig/templates/sshconfig-start.html
index 9920683d..b56be415 100644
--- a/modules-available/sysconfig/templates/sshconfig-start.html
+++ b/modules-available/sysconfig/templates/sshconfig-start.html
@@ -3,7 +3,7 @@
<input type="hidden" name="edit" value="{{edit}}">
<div class="input-group">
<span class="input-group-addon">{{lang_moduleName}}</span>
- <input type="text" name="title" value="{{title}}" class="form-control" autofocus="autofocus">
+ <input type="text" name="title" value="{{title}}" class="form-control" autofocus="autofocus" required>
</div>
<br>
<div class="form-group">
diff --git a/modules-available/sysconfig/templates/sshkey-start.html b/modules-available/sysconfig/templates/sshkey-start.html
index 52709984..8033740c 100644
--- a/modules-available/sysconfig/templates/sshkey-start.html
+++ b/modules-available/sysconfig/templates/sshkey-start.html
@@ -3,13 +3,16 @@
<input type="hidden" name="edit" value="{{edit}}">
<div class="input-group">
<span class="input-group-addon">{{lang_moduleName}}</span>
- <input type="text" name="title" value="{{title}}" class="form-control" autofocus="autofocus">
+ <input type="text" name="title" value="{{title}}" class="form-control" autofocus="autofocus" required>
</div>
<div class="form-group">
<label for="root-key">{{lang_rootKey}}</label>
<input class="form-control" type="text" name="publicKey" value="{{publicKey}}" id="root-key" required pattern="[a-z0-9\-]+ +[a-zA-Z0-9=/\+]+ +.*">
<i>{{lang_rootKeyInfo}}</i>
</div>
+ <div class="btn-group">
+ <a class="btn btn-default" href="?do=SysConfig&action=addmodule">{{lang_back}}</a>
+ </div>
<div class="btn-group pull-right">
<button type="submit" class="btn btn-primary"><span class="glyphicon glyphicon-floppy-disk"></span> {{lang_save}}</button>
</div>
diff --git a/modules-available/syslog/api.inc.php b/modules-available/syslog/api.inc.php
index a8a8b0da..cc64b31c 100644
--- a/modules-available/syslog/api.inc.php
+++ b/modules-available/syslog/api.inc.php
@@ -25,7 +25,7 @@ if (($user = Request::post('export-user', false, 'string')) !== false) {
unset($best);
foreach ($srcs as &$src) {
if (!isset($src['row'])) {
- $src['row'] = $src['res']->fetch(PDO::FETCH_ASSOC);
+ $src['row'] = $src['res']->fetch();
}
if ($src['row'] !== false && (!isset($best) || $src['row']['dateline'] < $best['dateline'])) {
$best =& $src['row'];
@@ -64,25 +64,17 @@ $longdesc = '';
if (isset($_POST['longdesc'])) $longdesc = $_POST['longdesc'];
$longdesc = Request::post('longdesc', '', 'string');
-if ($type{0} !== '.' && $type{0} !== '~') {
+if (preg_match('/^[a-z0-9\-]+$/', $type)) {
- // Spam from IP
- $row = Database::queryFirst('SELECT Count(*) AS cnt FROM clientlog WHERE clientip = :client AND dateline + 1800 > UNIX_TIMESTAMP()', array(':client' => $ip));
+ // Spam from IP?
+ $row = Database::queryFirst('SELECT Count(*) AS cnt FROM clientlog
+ WHERE clientip = :client AND dateline + 1800 > UNIX_TIMESTAMP()',
+ [':client' => $ip]);
if ($row !== false && $row['cnt'] > 250) {
exit(0);
}
- $ret = Database::exec('INSERT INTO clientlog (dateline, logtypeid, clientip, machineuuid, description, extra) VALUES (UNIX_TIMESTAMP(), :type, :client, :uuid, :description, :longdesc)', array(
- 'type' => $type,
- 'client' => $ip,
- 'description' => $description,
- 'longdesc' => $longdesc,
- 'uuid' => $uuid,
- ), true);
- if ($ret === false) {
- error_log("Constraint failed for client log from $uuid for $type : $description");
- die("NOPE.\n");
- }
+ ClientLog::write(['machineuuid' => $uuid, 'clientip' => $ip], $type, $description, $longdesc);
}
diff --git a/modules-available/syslog/inc/clientlog.inc.php b/modules-available/syslog/inc/clientlog.inc.php
new file mode 100644
index 00000000..b38c29fe
--- /dev/null
+++ b/modules-available/syslog/inc/clientlog.inc.php
@@ -0,0 +1,47 @@
+<?php
+
+class ClientLog
+{
+
+ public static function write(array $client, string $type, string $description, string $longDesc = ''): bool
+ {
+ if (!isset($client['machineuuid']) && !isset($client['clientip'])) {
+ error_log("Bad clientlog write call: " . json_encode($client));
+ return false;
+ }
+ if (!isset($client['machineuuid'])) {
+ $res = Database::queryFirst("SELECT machineuuid FROM machine WHERE clientip = :ip
+ ORDER BY lastseen DESC LIMIT 1", ['ip' => $client['clientip']]);
+ if ($res === false) {
+ error_log("Invalid client IP for client log: " . $client['clientip']);
+ return false;
+ }
+ $client['machineuuid'] = $res['machineuuid'];
+ }
+ if (!isset($client['clientip'])) {
+ $res = Database::queryFirst("SELECT clientip FROM machine WHERE machineuuid = :uuid",
+ ['uuid' => $client['machineuuid']]);
+ if ($res === false) {
+ error_log("Invalid machine uuid for client log: " . $client['machineuuid']);
+ return false;
+ }
+ $client['clientip'] = $res['clientip'];
+ }
+ $data = [
+ 'type' => $type,
+ 'clientip' => $client['clientip'],
+ 'description' => $description,
+ 'extra' => $longDesc,
+ 'machineuuid' => $client['machineuuid'],
+ ];
+ $res = Database::exec('INSERT INTO clientlog (dateline, logtypeid, clientip, machineuuid, description, extra)
+ VALUES (UNIX_TIMESTAMP(), :type, :clientip, :machineuuid, :description, :extra)', $data, true);
+ if ($res === false) {
+ error_log("Constraint failed for client log from {$client['machineuuid']} for $type : $description");
+ return false;
+ }
+ EventLog::applyFilterRules($type, $data + $client);
+ return true;
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/syslog/lang/de/template-tags.json b/modules-available/syslog/lang/de/template-tags.json
index c00d619a..90d0a294 100644
--- a/modules-available/syslog/lang/de/template-tags.json
+++ b/modules-available/syslog/lang/de/template-tags.json
@@ -9,6 +9,7 @@
"lang_exportUserDesc": "Mit dieser Funktion k\u00f6nnen Sie alle in der Datenbank vorhandenen Datens\u00e4tze zu einem bestimmten Benutzer exportieren. Bitte geben Sie den Benutzernamen genau so ein, wie ihn der Nutzer beim Login am Client angeben muss.",
"lang_filter": "Filter",
"lang_not": "not",
+ "lang_searchString": "Suchbegriff",
"lang_settings": "Einstellungen",
"lang_userExport": "Nutzer-Export",
"lang_userLogin": "Benutzer-Login",
diff --git a/modules-available/syslog/lang/en/template-tags.json b/modules-available/syslog/lang/en/template-tags.json
index 24e9aaa1..725f7e94 100644
--- a/modules-available/syslog/lang/en/template-tags.json
+++ b/modules-available/syslog/lang/en/template-tags.json
@@ -9,6 +9,7 @@
"lang_exportUserDesc": "This exports all data from the database relating to the given user login. Please specify the user name exactly the way they would provide it when logging in on a client.",
"lang_filter": "Filter",
"lang_not": "not",
+ "lang_searchString": "Search term",
"lang_settings": "Settings",
"lang_userExport": "User export",
"lang_userLogin": "User login",
diff --git a/modules-available/syslog/page.inc.php b/modules-available/syslog/page.inc.php
index 6c1a0a16..401d9dd8 100644
--- a/modules-available/syslog/page.inc.php
+++ b/modules-available/syslog/page.inc.php
@@ -25,6 +25,20 @@ class Page_SysLog extends Page
}
Util::redirect('?do=syslog');
}
+ if (Request::isPost()) {
+ $pairs = [];
+ foreach (['search', 'filter', 'not', 'machineuuid'] as $key) {
+ $val = Request::any($key, false, 'string');
+ if (!empty($val)) {
+ if ($key === 'not') {
+ $val = (bool)$val;
+ }
+ $pairs[$key] = $val;
+ }
+ Session::set('log_' . $key, $pairs[$key] ?? false, false);
+ }
+ Util::redirect('?do=syslog&' . http_build_query($pairs));
+ }
User::assertPermission('*');
}
@@ -40,64 +54,63 @@ class Page_SysLog extends Page
}
$cutoff = strtotime('-1 month');
- $res = Database::simpleQuery("SELECT logtypeid, Count(*) AS counter FROM clientlog WHERE dateline > $cutoff GROUP BY logtypeid ORDER BY counter ASC");
+ $res = Database::simpleQuery("SELECT logtypeid, Count(*) AS counter
+ FROM clientlog
+ WHERE dateline > $cutoff
+ GROUP BY logtypeid ORDER BY counter ASC");
$types = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$types[$row['logtypeid']] = $row;
}
- if (Request::get('filter') !== false) {
+ if (Request::get('filter') !== false || Request::get('search') !== false) {
+ $search = Request::get('search');
$filter = Request::get('filter');
- $not = Request::get('not') ? 'NOT' : '';
- } elseif (Request::post('filter') !== false) {
- $filter = Request::post('filter');
- $not = Request::post('not') ? 'NOT' : '';
-
- Session::set('log_filter', $filter);
- Session::set('log_not', $not);
- Session::save();
+ $not = Request::get('not', false, 'bool');
} else {
+ $search = Session::get('log_search');
$filter = Session::get('log_filter');
- $not = Session::get('log_not') ? 'NOT' : '';
+ $not = (bool)Session::get('log_not');
}
+ $qArgs = [];
+ $whereClause = '1';
if (!empty($filter)) {
- $filterList = explode(',', $filter);
- $whereClause = array();
+ $whereClause .= ' AND ( ';
+ if ($not) {
+ $whereClause .= 'NOT ';
+ }
+ $filterList = array_unique(explode(',', $filter));
foreach ($filterList as $filterItem) {
- $filterItem = preg_replace('/[^a-z0-9_\-]/', '', trim($filterItem));
- if (empty($filterItem) || in_array($filterItem, $whereClause)) continue;
- $whereClause[] = "'$filterItem'";
if (!isset($types[$filterItem])) {
$types[$filterItem] = ['logtypeid' => $filterItem, 'counter' => ''];
}
}
- if (!empty($whereClause)) $whereClause = ' WHERE logtypeid ' . $not . ' IN (' . implode(', ', $whereClause) . ')';
+ $whereClause .= "logtypeid IN (:typeids) )";
+ $qArgs['typeids'] = $filterList;
+ }
+ if (!empty($search)) {
+ $qArgs['search'] = '%' . str_replace(array('=', '_', '%', '*', '?'), array('==', '=_', '=%', '%', '_'), $search) . '%';
+ $whereClause .= " AND description LIKE :search ESCAPE '='";
}
- if (!isset($whereClause) || empty($whereClause)) $whereClause = '';
if (Request::get('machineuuid')) {
- if (empty($whereClause))
- $whereClause .= ' WHERE ';
- else
- $whereClause .= ' AND ';
-
- $whereClause .= "machineuuid='" . preg_replace('/[^0-9a-zA-Z\-]/', '', Request::get('machineuuid', '', 'string')) . "'";
+ $whereClause .= " AND machineuuid = :uuid";
+ $qArgs['uuid'] = Request::get('machineuuid', '', 'string');
}
$allowedLocations = User::getAllowedLocations("view");
$joinClause = "";
if (!in_array(0, $allowedLocations)) {
$joinClause = "INNER JOIN machine USING (machineuuid)";
- if (empty($whereClause))
- $whereClause .= ' WHERE ';
- else
- $whereClause .= ' AND ';
-
- $whereClause .= 'locationid IN (:allowedLocations)';
+ $whereClause .= ' AND locationid IN (:allowedLocations)';
+ $qArgs['allowedLocations'] = $allowedLocations;
}
$lines = array();
- $paginate = new Paginate("SELECT logid, dateline, logtypeid, clientlog.clientip, clientlog.machineuuid, description, extra FROM clientlog $joinClause $whereClause ORDER BY logid DESC", 50);
- $res = $paginate->exec(array("allowedLocations" => $allowedLocations));
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ $paginate = new Paginate("SELECT logid, dateline, logtypeid, clientlog.clientip, clientlog.machineuuid, description, extra
+ FROM clientlog $joinClause
+ WHERE $whereClause
+ ORDER BY logid DESC", 50);
+ $res = $paginate->exec($qArgs);
+ foreach ($res as $row) {
$row['date'] = Util::prettyTime($row['dateline']);
$row['icon'] = $this->eventToIconName($row['logtypeid']);
$lines[] = $row;
@@ -105,6 +118,7 @@ class Page_SysLog extends Page
$paginate->render('page-syslog', array(
'filter' => $filter,
+ 'search' => $search,
'not' => $not,
'list' => $lines,
'types' => json_encode(array_values($types)),
@@ -112,7 +126,7 @@ class Page_SysLog extends Page
));
}
- private function eventToIconName($event)
+ private function eventToIconName(string $event): string
{
switch ($event) {
case 'session-open':
diff --git a/modules-available/syslog/templates/page-syslog.html b/modules-available/syslog/templates/page-syslog.html
index 9d05d434..2b7c1439 100644
--- a/modules-available/syslog/templates/page-syslog.html
+++ b/modules-available/syslog/templates/page-syslog.html
@@ -3,8 +3,9 @@
max-width: 500px;
}
</style>
-<form method="post" action="?do=SysLog{{#machineuuid}}&machineuuid={{machineuuid}}{{/machineuuid}}">
+<form method="post" action="?do=syslog">
<input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="machineuuid" value="{{machineuuid}}">
<div class="pull-left">
<label for="filterstring">{{lang_filter}}</label>
</div>
@@ -12,6 +13,11 @@
<div class="row">
+ <div class="col-sm-11">
+ <div>
+ <input class="form-control" id="filterstring" placeholder="id" value="{{filter}}" name="filter">
+ </div>
+ </div>
<div class="col-sm-1">
<div class="checkbox">
<input id="notbox" type="checkbox" name="not" {{#not}}checked="checked"{{/not}}>
@@ -19,12 +25,10 @@
</div>
</div>
<div class="col-sm-11">
- <div class="input-group">
- <input id="filterstring" placeholder="id" value="{{filter}}" name="filter">
- <span style="padding-bottom: 5px;" class="input-group-btn">
- <button class="btn btn-primary" type="submit">{{lang_applyFilter}}</button>
- </span>
- </div>
+ <input class="form-control" placeholder="{{lang_searchString}}" value="{{search}}" name="search">
+ </div>
+ <div class="col-sm-1">
+ <button class="btn btn-primary" type="submit">{{lang_applyFilter}}</button>
</div>
</div>
</form>
diff --git a/modules-available/systemstatus/hooks/cron.inc.php b/modules-available/systemstatus/hooks/cron.inc.php
new file mode 100644
index 00000000..91e069d4
--- /dev/null
+++ b/modules-available/systemstatus/hooks/cron.inc.php
@@ -0,0 +1,6 @@
+<?php
+
+if ((int)gmdate('i') < 5) {
+ // Don't care about task, this will register a callback
+ SystemStatus::getUpgradableTask();
+} \ No newline at end of file
diff --git a/modules-available/systemstatus/hooks/main-warning.inc.php b/modules-available/systemstatus/hooks/main-warning.inc.php
index 5707c7d2..6b0ac981 100644
--- a/modules-available/systemstatus/hooks/main-warning.inc.php
+++ b/modules-available/systemstatus/hooks/main-warning.inc.php
@@ -1,9 +1,19 @@
<?php
-if (file_exists('/run/reboot-required.pkgs')) {
- $lines = file('/run/reboot-required.pkgs', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
- $lines = array_unique($lines);
- Message::addInfo('systemstatus.update-reboot-required', true, implode(', ', $lines));
+$cnt = SystemStatus::getUpgradableSecurityCount();
+if ($cnt > 0) {
+ Message::addWarning('systemstatus.security-updates-available', true, $cnt);
+}
+
+$pkgs = SystemStatus::getPackagesRequiringReboot();
+if (!empty($pkgs)) {
+ Message::addInfo('systemstatus.update-reboot-required', true, implode(', ', $pkgs));
+}
+
+$aptTs = SystemStatus::getAptLastDbUpdateTime();
+if ($aptTs + 864000 < time()) {
+ // No update for 10 days
+ Message::addWarning('systemstatus.apt-db-out-of-date', true, Util::prettyTime($aptTs));
}
if (SystemStatus::diskStat($systemUsage, $storeUsage, $current, $wanted)) {
diff --git a/modules-available/systemstatus/inc/systemstatus.inc.php b/modules-available/systemstatus/inc/systemstatus.inc.php
index 4413af5f..c50e0ef6 100644
--- a/modules-available/systemstatus/inc/systemstatus.inc.php
+++ b/modules-available/systemstatus/inc/systemstatus.inc.php
@@ -3,6 +3,8 @@
class SystemStatus
{
+ const PROP_UPGRADABLE_COUNT = 'systemstatus.upgradable-count';
+
/**
* Collect status about the disk and vmstore.
* The *Usage vars are filled with arrays with keys mountPoint, fileSystem,
@@ -13,27 +15,28 @@ class SystemStatus
* @param string|false $wantedSource What should be mounted as vmstore (false if nothing configured)
* @return bool false if querying fs data from taskmanager failed
*/
- public static function diskStat(&$systemUsage, &$storeUsage, &$currentSource = false, &$wantedSource = false)
+ public static function diskStat(&$systemUsage, &$storeUsage, &$currentSource = false, &$wantedSource = false): bool
{
$task = Taskmanager::submit('DiskStat');
if ($task === false)
return false;
$task = Taskmanager::waitComplete($task, 3000);
- if (!isset($task['data']['list']) || empty($task['data']['list']))
+ if (empty($task['data']['list']))
return false;
$wantedSource = Property::getVmStoreUrl();
- $currentSource = false;
$storeUsage = false;
$systemUsage = false;
if ($wantedSource === '<local>') {
$storePoint = '/';
+ $currentSource = $wantedSource;
} else {
$storePoint = CONFIG_VMSTORE_DIR;
+ $currentSource = false;
}
// Collect free space information
foreach ($task['data']['list'] as $entry) {
- // StorePoint is either the atual directory of the vmstore, or / if we use internal storage
+ // StorePoint is either the actual directory of the vmstore, or / if we use internal storage
if ($entry['mountPoint'] === $storePoint) {
$storeUsage = $entry;
}
@@ -42,12 +45,116 @@ class SystemStatus
$systemUsage = $entry;
}
// Record what's mounted at destination, regardless of config, to indicate something is wrong
- if (($currentSource === false && $wantedSource === '<local>' && $entry['mountPoint'] === '/')
- || $entry['mountPoint'] === CONFIG_VMSTORE_DIR) {
+ if ($entry['mountPoint'] === CONFIG_VMSTORE_DIR) {
$currentSource = $entry['fileSystem'];
+
+ // If internal/local storage is used but there is a mount on CONFIG_VMSTORE_DIR,
+ // we assume it's on purpose like a second hdd and use that
+ if ($wantedSource === '<local>') {
+ $wantedSource = $currentSource;
+ $storeUsage = $entry;
+ }
}
}
return true;
}
+ /**
+ * Get timestamp of the available updates. This is an estimate; the downloaded apt data usually
+ * preserves the Last-Modified timestamp from the HTTP download of the according data. Note
+ * that this list gets updated whenever any available package changes, so it does not necessarily
+ * mean that any currently installed package can be updated when the list changes.
+ */
+ public static function getAptLastDbUpdateTime(): int
+ {
+ $osRelease = parse_ini_file('/etc/os-release');
+ $updateDbTime = 0;
+ foreach (glob('/var/lib/apt/lists/*_dists_' . ($osRelease['VERSION_CODENAME'] ?? '') . '*_InRelease', GLOB_NOSORT) as $f) {
+ $b = basename($f);
+ if (preg_match('/dists_[a-z]+(?:[\-_](?:updates|security))?_InRelease$/', $b)) {
+ $updateDbTime = max($updateDbTime, filemtime($f));
+ }
+ }
+ return $updateDbTime;
+ }
+
+ /**
+ * Get timestamp when the apt database was last attempted to be updated. This does not
+ * imply that the operation was successful.
+ */
+ public static function getAptLastUpdateAttemptTime(): int
+ {
+ return (int)filemtime('/var/lib/apt/lists/partial');
+ }
+
+ /**
+ * Get when the dpkg database was last changed, i.e. when a package was last installed, updated or removed.
+ * This is an estimate as it just looks at the modification time of relevant files. It is possible these
+ * files get modified for other reasons.
+ */
+ public static function getDpkgLastPackageChanges(): int
+ {
+ return (int)filemtime(file_exists('/var/log/dpkg.log') ? '/var/log/dpkg.log' : '/var/lib/dpkg/status');
+ }
+
+ /**
+ * Get list of packages that have been updated, but require a reboot of the system
+ * to fully take effect.
+ * @return string[]
+ */
+ public static function getPackagesRequiringReboot(): array
+ {
+ if (!file_exists('/run/reboot-required.pkgs'))
+ return [];
+ $lines = file('/run/reboot-required.pkgs', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
+ return array_unique($lines);
+ }
+
+ /**
+ * Get a task struct querying upgradable packages. This adds
+ * a hook to the task to cache the number of upgradable packages
+ * that are security upgrades, so it is preferred to call this
+ * wrapper instead of running the task directly.
+ */
+ public static function getUpgradableTask()
+ {
+ $task = Taskmanager::submit('AptGetUpgradable');
+ if (Taskmanager::isTask($task)) {
+ TaskmanagerCallback::addCallback($task, 'ssUpgradable');
+ }
+ return $task;
+ }
+
+ /**
+ * Called from Taskmanager callback after invoking getUpgradableTask().
+ */
+ public static function setUpgradableData(array $task)
+ {
+ if (Taskmanager::isFailed($task) || !Taskmanager::isFinished($task) || !isset($task['data']['packages']))
+ return;
+ $count = 0;
+ foreach ($task['data']['packages'] as $package) {
+ if (substr($package['source'], -9) === '-security') {
+ $count++;
+ }
+ }
+ error_log("Upgradable security count: $count, total count: " . count($task['data']['packages']));
+ Property::set(self::PROP_UPGRADABLE_COUNT, $count . '|' . count($task['data']['packages']), 61);
+ }
+
+ /**
+ * Get number of packages that can be upgraded and are security updates.
+ * This is a cached value and should be updated at least once an hour.
+ */
+ public static function getUpgradableSecurityCount(): int
+ {
+ $str = Property::get(self::PROP_UPGRADABLE_COUNT);
+ if ($str === false)
+ return 0;
+ $p = explode('|', $str);
+ if (empty($p) || !is_numeric($p[0]))
+ return 0;
+ return (int)$p[0];
+ }
+
} \ No newline at end of file
diff --git a/modules-available/systemstatus/lang/de/messages.json b/modules-available/systemstatus/lang/de/messages.json
index 0aaefd23..551e937a 100644
--- a/modules-available/systemstatus/lang/de/messages.json
+++ b/modules-available/systemstatus/lang/de/messages.json
@@ -1,4 +1,6 @@
{
+ "apt-db-out-of-date": "Die Systemupdate-Datenbank ist veraltet ({{0}}). Erw\u00e4gen Sie, nach neuen Updates zu suchen, und diese zu installieren.",
+ "security-updates-available": "Ausstehende Sicherheitsupdates: {{0}}",
"storage-low-system": "Dem Betriebssystem geht der freie Speicher aus. Nur noch {{0}} frei. Versuchen Sie, alte \"Netboot Grundsystem\"-Versionen zu l\u00f6schen.",
"storage-low-vmstore": "Auf dem VMstore sind nur noch {{0}} frei",
"update-reboot-required": "Das Update der folgenden Pakete erfordert einen Reboot des Servers: {{0}}"
diff --git a/modules-available/systemstatus/lang/de/module.json b/modules-available/systemstatus/lang/de/module.json
index 22be8a1d..3b310b1d 100644
--- a/modules-available/systemstatus/lang/de/module.json
+++ b/modules-available/systemstatus/lang/de/module.json
@@ -5,6 +5,7 @@
"tab_Dnbd3Log": "DNBD3 Server Log",
"tab_LdadpLog": "LDAP\/AD",
"tab_LighttpdLog": "lighttpd Log",
+ "tab_ListUpgradable": "System-Updates",
"tab_Netstat": "netstat -tulpn",
"tab_PsList": "ps auxf"
} \ No newline at end of file
diff --git a/modules-available/systemstatus/lang/de/permissions.json b/modules-available/systemstatus/lang/de/permissions.json
index 9e7c5be9..f2dd9a21 100644
--- a/modules-available/systemstatus/lang/de/permissions.json
+++ b/modules-available/systemstatus/lang/de/permissions.json
@@ -1,4 +1,8 @@
{
+ "apt.autoremove": "Nicht mehr ben\u00f6tigte Pakete deinstallieren.",
+ "apt.fix": "Installalations- und Abh\u00e4ngigkeitsprobleme beheben lassen.",
+ "apt.update": "Das System updaten.",
+ "apt.upgrade": "Das System mittels \"full-upgrade\" updaten.",
"restart.dmsd": "bwLehrpool-Suite neustarten.",
"restart.dnbd3-server": "DNBD3-Server neustarten.",
"restart.ldadp": "LDADP neustarten.",
@@ -12,6 +16,7 @@
"tab.dnbd3log": "Zugriff auf DNBD3 Log.",
"tab.ldadplog": "Zugriff auf LDAP\/AD-Proxy Logs.",
"tab.lighttpdlog": "Zugriff auf Webserver-Logs.",
+ "tab.listupgradable": "Zugriff auf System-Update-\u00dcbersicht.",
"tab.netstat": "Zeige Ausgabe von netstat.",
"tab.pslist": "Zeige Prozessliste."
} \ No newline at end of file
diff --git a/modules-available/systemstatus/lang/de/template-tags.json b/modules-available/systemstatus/lang/de/template-tags.json
index 6641b9e1..c94720a6 100644
--- a/modules-available/systemstatus/lang/de/template-tags.json
+++ b/modules-available/systemstatus/lang/de/template-tags.json
@@ -1,17 +1,28 @@
{
"lang_OK": "OK",
"lang_addressConfiguration": "Adresskonfiguration",
+ "lang_aptOutput": "apt-get Ausgabe",
"lang_areYouSureReboot": "Server jetzt neustarten?",
"lang_attention": "Achtung!",
"lang_average": "Durchschnitt",
+ "lang_backToPackagelist": "Zur\u00fcck zur Update-\u00dcbersicht",
"lang_capacity": "Kapazit\u00e4t",
+ "lang_checkForUpdates": "Auf neue Updates pr\u00fcfen",
"lang_confirmRestart": "Diesen Dienst wirklich neustarten? Dies kann Auswirkungen auf den Betrieb haben.",
"lang_cpuLoad": "CPU-Last",
+ "lang_distribution": "Distribution",
"lang_dmsdUnreachable": "dmsd nicht erreichbar",
+ "lang_everythingUpToDate": "Das System ist auf dem aktuellen Stand",
"lang_failure": "Fehler",
+ "lang_fixDependencies": "Installationsprobleme beheben",
"lang_foundStore": "Vorgefunden:",
"lang_free": "Frei",
"lang_goToStoreConf": "Zur VM-Store-Konfiguration wechseln",
+ "lang_kernel": "Kernel",
+ "lang_lastPackageInstall": "Updates installiert",
+ "lang_lastUpdateCheck": "Letzter Update-Check",
+ "lang_lastestUpdate": "Neuste Updates von",
+ "lang_listOldWarning": "Die Update-Datenbank scheint veraltet. Es wird empfohlen, zun\u00e4chst nach Updates zu suchen.",
"lang_logicCPUs": "Logische CPUs",
"lang_maintenance": "Maintenance",
"lang_moduleHeading": "System-Status",
@@ -19,12 +30,18 @@
"lang_occupied": "Belegt",
"lang_onlyOS": "Nur OS",
"lang_overview": "\u00dcbersicht",
+ "lang_package": "Paket",
+ "lang_packagesNeedingReboot": "Pakete, die einen Systemneustart erfordern",
"lang_ramUsage": "RAM-Nutzung",
+ "lang_removeUnusedPackages": "Nicht mehr ben\u00f6tigte Pakete entfernen",
"lang_restart": "Neustarten",
+ "lang_runFullUpdate": "\"Full-Upgrade\" durchf\u00fchren",
+ "lang_runUpdate": "Update durchf\u00fchren",
"lang_runningDownloads": "Aktive Downloads",
"lang_runningUploads": "Aktive Uploads",
"lang_serverReboot": "Server neustarten",
"lang_services": "Dienste",
+ "lang_showPackageList": "Zur Update- und Paket-\u00dcbersicht",
"lang_space": "Speicherplatz",
"lang_storeMissingExpected": "VM-Store nicht eingebunden. Erwartet:",
"lang_storeNotConfigured": "Kein VM-Store konfiguriert!",
@@ -35,7 +52,9 @@
"lang_systemStoreError": "Fehler beim Ermitteln des verf\u00fcgbaren Systemspeichers",
"lang_total": "Gesamt",
"lang_updatedPackages": "Ausstehende Updates",
+ "lang_updatesWikiLink": "Mehr Informationen zu Updates im bwLehrpool-Wiki",
"lang_uptimeOS": "OS Uptime",
+ "lang_versionFromTo": "Update-Details",
"lang_vmStore": "VM-Speicher",
"lang_vmStoreError": "Fehler beim Ermitteln des verf\u00fcgbaren Speicherplatzes am VM-Speicherort. Bitte \u00fcberpr\u00fcfen Sie die Konfiguration."
} \ No newline at end of file
diff --git a/modules-available/systemstatus/lang/en/messages.json b/modules-available/systemstatus/lang/en/messages.json
index dc9d3dd0..2afc3431 100644
--- a/modules-available/systemstatus/lang/en/messages.json
+++ b/modules-available/systemstatus/lang/en/messages.json
@@ -1,4 +1,6 @@
{
+ "apt-db-out-of-date": "The system update database is out of date ({{0}}). Consider checking for new updates and installing them.",
+ "security-updates-available": "Pending security updates: {{0}}",
"storage-low-system": "System storage running out. Only {{0}} free. You could try deleting old Net-boot OS versions.",
"storage-low-vmstore": "VMstore space is running out. Only {{0}} left.",
"update-reboot-required": "Updating the following system packages requires reboot: {{0}}"
diff --git a/modules-available/systemstatus/lang/en/module.json b/modules-available/systemstatus/lang/en/module.json
index f5fec515..cc2b5283 100644
--- a/modules-available/systemstatus/lang/en/module.json
+++ b/modules-available/systemstatus/lang/en/module.json
@@ -5,6 +5,7 @@
"tab_Dnbd3Log": "DNBD3 server log",
"tab_LdadpLog": "LDAP\/AD",
"tab_LighttpdLog": "lighttpd log",
+ "tab_ListUpgradable": "System updates",
"tab_Netstat": "netstat -tulpn",
"tab_PsList": "ps auxf"
} \ No newline at end of file
diff --git a/modules-available/systemstatus/lang/en/permissions.json b/modules-available/systemstatus/lang/en/permissions.json
index d510e257..21e7538f 100644
--- a/modules-available/systemstatus/lang/en/permissions.json
+++ b/modules-available/systemstatus/lang/en/permissions.json
@@ -1,4 +1,8 @@
{
+ "apt.autoremove": "Remove packages that are no longer required.",
+ "apt.fix": "Try to fix installation and dependency problems.",
+ "apt.update": "Update system.",
+ "apt.upgrade": "Update system via \"full-upgrade\".",
"restart.dmsd": "Restart bwLehrpool-Suite.",
"restart.dnbd3-server": "Restart DNBD3 server.",
"restart.ldadp": "Restart LDADP.",
@@ -12,6 +16,7 @@
"tab.dnbd3log": "Show DNBD3 log.",
"tab.ldadplog": "Show LDAP\/AD proxy logs.",
"tab.lighttpdlog": "Show web server logs.",
+ "tab.listupgradable": "Show system update status.",
"tab.netstat": "Show output of netstat.",
"tab.pslist": "Show process list."
} \ No newline at end of file
diff --git a/modules-available/systemstatus/lang/en/template-tags.json b/modules-available/systemstatus/lang/en/template-tags.json
index b7f9867a..7758c71c 100644
--- a/modules-available/systemstatus/lang/en/template-tags.json
+++ b/modules-available/systemstatus/lang/en/template-tags.json
@@ -1,17 +1,28 @@
{
"lang_OK": "OK",
"lang_addressConfiguration": "Address Configuration",
+ "lang_aptOutput": "apt-get output",
"lang_areYouSureReboot": "Reboot server now?",
"lang_attention": "Attention!",
"lang_average": "Average",
+ "lang_backToPackagelist": "Back up update page",
"lang_capacity": "Capacity",
+ "lang_checkForUpdates": "Check for updates",
"lang_confirmRestart": "Are you sure you want to restart this service? This can lead to interruptions.",
"lang_cpuLoad": "CPU Load",
+ "lang_distribution": "Distribution",
"lang_dmsdUnreachable": "dmsd not reachable",
+ "lang_everythingUpToDate": "Everything is up to date",
"lang_failure": "Failure",
+ "lang_fixDependencies": "Fix installation\/dependency problems",
"lang_foundStore": "Found:",
"lang_free": "Free",
"lang_goToStoreConf": "Go to VM store configuration",
+ "lang_kernel": "Kernel",
+ "lang_lastPackageInstall": "Updates installed",
+ "lang_lastUpdateCheck": "Last update check",
+ "lang_lastestUpdate": "Latest updates from",
+ "lang_listOldWarning": "The package database is out of date. Consider checking for updates before updating.",
"lang_logicCPUs": "Logical CPUs",
"lang_maintenance": "Maintenance",
"lang_moduleHeading": "System Status",
@@ -19,12 +30,18 @@
"lang_occupied": "In use",
"lang_onlyOS": "OS only",
"lang_overview": "Overview",
+ "lang_package": "Package",
+ "lang_packagesNeedingReboot": "Packages that require a system reboot",
"lang_ramUsage": "RAM usage",
+ "lang_removeUnusedPackages": "Remove unused packages",
"lang_restart": "Restart",
+ "lang_runFullUpdate": "Run \"full-upgrade\"",
+ "lang_runUpdate": "Run update",
"lang_runningDownloads": "Running downloads",
"lang_runningUploads": "Running uploads",
"lang_serverReboot": "Reboot Server",
"lang_services": "Services",
+ "lang_showPackageList": "Show updates and packages",
"lang_space": "Space",
"lang_storeMissingExpected": "VM store not mounted. Expected:",
"lang_storeNotConfigured": "No VM store configured!",
@@ -35,7 +52,9 @@
"lang_systemStoreError": "Error querying available system storage",
"lang_total": "Total",
"lang_updatedPackages": "Pending updates",
+ "lang_updatesWikiLink": "See bwLehrpool Wiki for more information regarding updates",
"lang_uptimeOS": "OS uptime",
+ "lang_versionFromTo": "Update details",
"lang_vmStore": "VM store",
"lang_vmStoreError": "Error determining available space of the VM storage. Please check the configuration."
} \ No newline at end of file
diff --git a/modules-available/systemstatus/page.inc.php b/modules-available/systemstatus/page.inc.php
index 238537d8..f774c4e0 100644
--- a/modules-available/systemstatus/page.inc.php
+++ b/modules-available/systemstatus/page.inc.php
@@ -3,6 +3,8 @@
class Page_SystemStatus extends Page
{
+ const TM_UPDATE_UUID = '345-45763457-24356-234324556';
+
protected function doPreprocess()
{
User::load();
@@ -13,14 +15,47 @@ class Page_SystemStatus extends Page
}
$action = Request::post('action', false, 'string');
- if ($action === 'reboot') {
+ $aptAction = null;
+ switch ($action) {
+ case 'reboot':
User::assertPermission("serverreboot");
$task = Taskmanager::submit('Reboot');
if (Taskmanager::isTask($task)) {
Util::redirect('?do=systemstatus&taskid=' . $task['id']);
}
- } elseif ($action === 'service-start' || $action === 'service-restart') {
+ break;
+ case 'service-start':
+ case 'service-restart':
$this->handleServiceAction(substr($action, 8));
+ break;
+ case 'apt-update':
+ User::assertPermission('apt.update');
+ $aptAction = 'UPDATE';
+ break;
+ case 'apt-upgrade':
+ User::assertPermission('apt.upgrade');
+ $aptAction = 'UPGRADE';
+ break;
+ case 'apt-full-upgrade':
+ User::assertPermission('apt.upgrade');
+ $aptAction = 'FULL_UPGRADE';
+ break;
+ case 'apt-autoremove':
+ User::assertPermission('apt.autoremove');
+ $aptAction = 'AUTOREMOVE';
+ break;
+ case 'apt-fix':
+ User::assertPermission('apt.fix');
+ $aptAction = 'FIX';
+ break;
+ default:
+ }
+ if ($aptAction !== null) {
+ if (!Taskmanager::isRunning(Taskmanager::status(self::TM_UPDATE_UUID))) {
+ $task = Taskmanager::submit('AptUpgrade', ['mode' => $aptAction, 'id' => self::TM_UPDATE_UUID]);
+ Taskmanager::release($task);
+ }
+ Util::redirect('?do=systemstatus#id-ListUpgradable_pane');
}
if (Request::isPost()) {
Util::redirect('?do=systemstatus');
@@ -50,22 +85,24 @@ class Page_SystemStatus extends Page
$data = array();
$data['taskid'] = Request::get('taskid', '', 'string');
$data['taskname'] = Request::get('taskname', 'Reboot', 'string');
- $tabs = array('DmsdLog', 'Netstat', 'PsList', 'LdadpLog', 'LighttpdLog', 'Dnbd3Log');
+ $tabs = ['DmsdLog', 'Netstat', 'PsList', 'LdadpLog', 'LighttpdLog', 'Dnbd3Log', 'ListUpgradable'];
$data['tabs'] = array();
// Dictionary::translate('tab_DmsdLog') Dictionary::translate('tab_LdadpLog') Dictionary::translate('tab_Netstat')
// Dictionary::translate('tab_LighttpdLog') Dictionary::translate('tab_PsList') Dictionary::translate('tab_Dnbd3Log')
+ // Dictionary::translate('tab_ListUpgradable')
foreach ($tabs as $tab) {
$data['tabs'][] = array(
'type' => $tab,
- 'name' => Dictionary::translate('tab_' . $tab, true),
+ 'name' => Dictionary::translate('tab_' . $tab),
'enabled' => User::hasPermission('tab.' . $tab),
+ 'important' => $tab === 'ListUpgradable'
+ && (SystemStatus::getAptLastDbUpdateTime() + 864000 < time() || SystemStatus::getUpgradableSecurityCount() > 0),
);
}
Permission::addGlobalTags($data['perms'], null, ['serverreboot']);
- if (file_exists('/run/reboot-required.pkgs')) {
- $lines = file('/run/reboot-required.pkgs', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
- $lines = array_unique($lines);
- $data['packages'] = implode(', ', $lines);
+ $pkgs = SystemStatus::getPackagesRequiringReboot();
+ if (!empty($pkgs)) {
+ $data['packages'] = implode(', ', $pkgs);
}
Render::addTemplate('_page', $data);
}
@@ -82,20 +119,78 @@ class Page_SystemStatus extends Page
$this->$action();
Message::renderList();
} else {
- echo "Action $action not known in " . get_class();
+ // get_class() !== get_class($this)
+ echo "Action $action not known in " . get_class($this);
}
}
+ protected function ajaxListUpgradable()
+ {
+ User::assertPermission("tab.listupgradable");
+
+ if (User::hasPermission('apt.update')
+ && Taskmanager::isRunning(Taskmanager::status(self::TM_UPDATE_UUID))) {
+ echo Render::parse('sys-update-update', [
+ 'taskid' => self::TM_UPDATE_UUID,
+ 'rnd' => mt_rand(),
+ ]);
+ return;
+ }
+
+ $task = SystemStatus::getUpgradableTask();
+
+ // Estimate last time package list was updated
+ $lastPackageInstalled = SystemStatus::getDpkgLastPackageChanges();
+ $lastListDownloadAttempt = SystemStatus::getAptLastUpdateAttemptTime();
+ $updateDbTime = SystemStatus::getAptLastDbUpdateTime();
+
+ $perms = [];
+ Permission::addGlobalTags($perms, 0, ['apt.update', 'apt.upgrade', 'apt.autoremove', 'apt.fix']);
+
+ if ($task !== false) {
+ $task = Taskmanager::waitComplete($task, 30000);
+
+ if (Taskmanager::isFailed($task) || !Taskmanager::isFinished($task)) {
+ Taskmanager::addErrorMessage($task);
+ return;
+ }
+ if (!Taskmanager::isFailed($task) && empty($task['data']['packages'])) {
+ $task['data']['error'] = '';
+ }
+ } else {
+ $task['data']['error'] = 'ECONNREFUSED';
+ }
+
+ foreach ($task['data']['packages'] as &$pkg) {
+ if (substr($pkg['source'], -9) === '-security') {
+ $pkg['row_class'] = 'bg-danger';
+ } else {
+ $pkg['row_class'] = '';
+ }
+ }
+ unset($pkg);
+
+ echo Render::parse('sys-update-main', [
+ 'task' => $task['data'],
+ 'lastDownload' => Util::prettyTime($lastListDownloadAttempt),
+ 'lastChanged' => Util::prettyTime($updateDbTime),
+ 'lastInstalled' => Util::prettyTime($lastPackageInstalled),
+ 'perm' => $perms,
+ 'list_old' => $lastListDownloadAttempt + 86400 < time(),
+ 'needReboot' => implode(', ', SystemStatus::getPackagesRequiringReboot()),
+ ]);
+ }
+
protected function ajaxDiskStat()
{
User::assertPermission("show.overview.diskstat");
if (!SystemStatus::diskStat($systemUsage, $storeUsage, $currentSource, $wantedSource))
return;
- $data = ['system' => $this->convertDiskStat($systemUsage)];
+ $data = ['system' => $this->convertDiskStat($systemUsage, 3000)];
if ($wantedSource === false) { // Not configured yet, nothing to display
$data['notConfigured'] = true;
} elseif ($wantedSource === $currentSource) { // Fine and dandy
- $data['store'] = $this->convertDiskStat($storeUsage);
+ $data['store'] = $this->convertDiskStat($storeUsage, 250000);
} elseif ($currentSource === false) { // No current source, nothing mounted
$data['storeMissing'] = true;
} else { // Something else mounted
@@ -112,7 +207,7 @@ class Page_SystemStatus extends Page
return;
$task = Taskmanager::waitComplete($task, 3000);
- if (!isset($task['data']['addresses']) || empty($task['data']['addresses'])) {
+ if (empty($task['data']['addresses'])) {
Taskmanager::addErrorMessage($task);
return;
}
@@ -129,16 +224,17 @@ class Page_SystemStatus extends Page
'addresses' => $task['data']['addresses']
));
}
-
- private function sysInfo()
+
+ private function sysInfo(): array
{
$data = array();
$memInfo = file_get_contents('/proc/meminfo');
$stat = file_get_contents('/proc/stat');
- preg_match_all('/\b(\w+):\s+(\d+)\s/s', $memInfo, $out, PREG_SET_ORDER);
+ preg_match_all('/\b(\w+):\s+(\d+)\s/', $memInfo, $out, PREG_SET_ORDER);
foreach ($out as $e) {
$data[$e[1]] = $e[2];
}
+ /** @var array{user: numeric, nice: numeric, system: numeric, idle: numeric, iowait: numeric, irq: numeric, softirq: numeric} $out */
if (preg_match('/\bcpu\s+(?<user>\d+)\s+(?<nice>\d+)\s+(?<system>\d+)\s+(?<idle>\d+)\s+(?<iowait>\d+)\s+(?<irq>\d+)\s+(?<softirq>\d+)(\s|$)/', $stat, $out)) {
$data['CpuTotal'] = $out['user'] + $out['nice'] + $out['system'] + $out['idle'] + $out['iowait'] + $out['irq'] + $out['softirq'];
$data['CpuIdle'] = $out['idle'] + $out['iowait'];
@@ -153,27 +249,32 @@ class Page_SystemStatus extends Page
$cpuInfo = file_get_contents('/proc/cpuinfo');
$uptime = file_get_contents('/proc/uptime');
$cpuCount = preg_match_all('/\bprocessor\s/', $cpuInfo, $out);
- //$cpuCount = count($out);
+ $out = parse_ini_file('/etc/os-release');
$data = array(
'cpuCount' => $cpuCount,
'memTotal' => '???',
'memFree' => '???',
'swapTotal' => '???',
'swapUsed' => '???',
- 'uptime' => '???'
+ 'uptime' => '???',
+ 'kernel' => php_uname('r'),
+ 'distribution' => $out['PRETTY_NAME'] ?? (($out['NAME'] ?? '???') . ' ' . ($out['VERSION'] ?? '???')),
);
if (preg_match('/^(\d+)\D/', $uptime, $out)) {
$data['uptime'] = floor($out[1] / 86400) . ' ' . Dictionary::translate('lang_days') . ', ' . floor(($out[1] % 86400) / 3600) . ' ' . Dictionary::translate('lang_hours');
}
$info = $this->sysInfo();
if (isset($info['MemTotal']) && isset($info['MemFree']) && isset($info['SwapTotal'])) {
+ $avail = $info['MemAvailable'] ?? ($info['MemFree'] + $info['Buffers'] + $info['Cached']);
$data['memTotal'] = Util::readableFileSize($info['MemTotal'] * 1024);
- $data['memFree'] = Util::readableFileSize(($info['MemFree'] + $info['Buffers'] + $info['Cached']) * 1024);
- $data['memPercent'] = 100 - round((($info['MemFree'] + $info['Buffers'] + $info['Cached']) / $info['MemTotal']) * 100);
+ $data['memFree'] = Util::readableFileSize($avail * 1024);
+ $data['memPercent'] = 100 - round(($avail / $info['MemTotal']) * 100);
$data['swapTotal'] = Util::readableFileSize($info['SwapTotal'] * 1024);
$data['swapUsed'] = Util::readableFileSize(($info['SwapTotal'] - $info['SwapFree']) * 1024);
$data['swapPercent'] = 100 - round(($info['SwapFree'] / $info['SwapTotal']) * 100);
- $data['swapWarning'] = ($data['swapPercent'] > 50 || $info['SwapFree'] < 400000);
+ if ($data['swapTotal'] > 0 && $data['memPercent'] > 75) {
+ $data['swapWarning'] = ($data['swapPercent'] > 80 || $info['SwapFree'] < 400000);
+ }
}
if (isset($info['CpuIdle']) && isset($info['CpuSystem']) && isset($info['CpuTotal'])) {
$data['cpuLoad'] = 100 - round(($info['CpuIdle'] / $info['CpuTotal']) * 100);
@@ -305,37 +406,38 @@ class Page_SystemStatus extends Page
echo Render::parse('ajax-journal', ['modules' => [$output]]);
}
- protected function ajaxLighttpdLog()
+ private function grepLighttpdLog(string $file, int $num): array
{
- User::assertPermission("tab.lighttpdlog");
- $fh = @fopen('/var/log/lighttpd/error.log', 'r');
- if ($fh === false) {
- echo 'Error opening log file';
- return;
- }
- fseek($fh, -6000, SEEK_END);
- $data = fread($fh, 6000);
- @fclose($fh);
- if ($data === false) {
- echo 'Error reading from log file';
- return;
+ $fh = @fopen($file, 'r');
+ if ($fh === false)
+ return ['Error opening ' . $file];
+ $ret = [];
+ fseek($fh, -($num * 2000), SEEK_END);
+ if (ftell($fh) > 0) {
+ // Throw away first line, as it's most likely incomplete
+ fgets($fh, 1000);
}
- // If we could read less, try the .1 file too
- $amount = 6000 - strlen($data);
- if ($amount > 100) {
- $fh = @fopen('/var/log/lighttpd/error.log.1', 'r');
- if ($fh !== false) {
- fseek($fh, -$amount, SEEK_END);
- $data = fread($fh, $amount) . $data;
- @fclose($fh);
+ while (($line = fgets($fh, 1000))) {
+ if (strpos($line, ':SSL routines:') === false
+ && strpos($line, ' SSL: -1 5 104 Connection reset by peer') === false
+ && strpos($line, 'GET/HEAD with content-length') === false
+ && strpos($line, 'POST-request, but content-length missing') === false) {
+ $ret[] = $line;
}
}
- if (strlen($data) < 5990) {
- $start = 0;
- } else {
- $start = strpos($data, "\n") + 1;
+ fclose($fh);
+ return array_slice($ret, -$num);
+ }
+
+ protected function ajaxLighttpdLog()
+ {
+ User::assertPermission("tab.lighttpdlog");
+ $lines = $this->grepLighttpdLog('/var/log/lighttpd/error.log', 60);
+ if (count($lines) < 50) {
+ $lines = array_merge(
+ $this->grepLighttpdLog('/var/log/lighttpd/error.log.1', 60 - count($lines)), $lines);
}
- echo '<pre>', htmlspecialchars(substr($data, $start), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), '</pre>';
+ echo '<pre>', htmlspecialchars(implode('', $lines), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), '</pre>';
}
protected function ajaxLdadpLog()
@@ -349,7 +451,7 @@ class Page_SystemStatus extends Page
$output = [];
foreach ($ids as $id) {
$module = ConfigModule::get($id);
- if ($module === false) {
+ if ($module === null) {
$name = "#$id";
} else {
$name = $module->title();
@@ -373,10 +475,7 @@ class Page_SystemStatus extends Page
return;
$status = Taskmanager::waitComplete($taskId, 3500);
- if (isset($status['data']['messages']))
- $data = $status['data']['messages'];
- else
- $data = 'Taskmanager error';
+ $data = $status['data']['messages'] ?? 'Taskmanager error';
echo '<pre>', htmlspecialchars($data, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), '</pre>';
}
@@ -389,43 +488,52 @@ class Page_SystemStatus extends Page
return;
$status = Taskmanager::waitComplete($taskId, 3500);
- if (isset($status['data']['messages']))
- $data = $status['data']['messages'];
- else
- $data = 'Taskmanager error';
+ $data = $status['data']['messages'] ?? 'Taskmanager error';
echo '<pre>', htmlspecialchars($data, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), '</pre>';
}
- private function convertDiskStat($stat)
+ /**
+ * @return array{percent: numeric, size: string, free: string, color: string, filesystem: string}
+ */
+ private function convertDiskStat(array $stat, int $minFreeMb): array
{
- if (!is_array($stat))
- return false;
return [
'percent' => $stat['usedPercent'],
'size' => Util::readableFileSize($stat['sizeKb'] * 1024),
'free' => Util::readableFileSize($stat['freeKb'] * 1024),
- 'color' => $this->usageColor($stat['usedPercent']),
+ 'color' => $this->usageColor($stat, $minFreeMb),
+ 'filesystem' => $stat['fileSystem'],
];
}
- private function usageColor($percent)
+ private function usageColor(array $stat, int $minFreeMb): string
{
- if ($percent <= 50) {
- $r = $b = $percent / 3;
- $g = (100 - $percent * (50 / 80));
- } elseif ($percent <= 70) {
- $r = 55 + ($percent - 50) * (30 / 20);
+ $freeMb = round($stat['freeKb'] / 1024);
+ // All good is half space free, or 4x the min free amount, whatever is more
+ $okFreeMb = max($minFreeMb * 4, round($stat['sizeKb']) / (1024 * 2));
+ if ($freeMb > $okFreeMb) {
+ $usedPercent = 0;
+ } elseif ($freeMb < $minFreeMb) {
+ $usedPercent = 100;
+ } else {
+ $usedPercent = 100 - round(($freeMb - $minFreeMb) / ($okFreeMb - $minFreeMb) * 100);
+ }
+ if ($usedPercent <= 50) {
+ $r = $b = $usedPercent / 3;
+ $g = (100 - $usedPercent * (50 / 80));
+ } elseif ($usedPercent <= 70) {
+ $r = 55 + ($usedPercent - 50) * (30 / 20);
$g = 60;
$b = 0;
} else {
- $r = ($percent - 70) / 3 + 90;
- $g = (100 - $percent) * (60 / 30);
+ $r = ($usedPercent - 70) / 3 + 90;
+ $g = (100 - $usedPercent) * (60 / 30);
$b = 0;
}
- $r = dechex(round($r * 2.55));
- $g = dechex(round($g * 2.55));
- $b = dechex(round($b * 2.55));
+ $r = dechex((int)round($r * 2.55));
+ $g = dechex((int)round($g * 2.55));
+ $b = dechex((int)round($b * 2.55));
return sprintf("%02s%02s%02s", $r, $g, $b);
}
diff --git a/modules-available/systemstatus/permissions/permissions.json b/modules-available/systemstatus/permissions/permissions.json
index a116fd56..0be0a8c5 100644
--- a/modules-available/systemstatus/permissions/permissions.json
+++ b/modules-available/systemstatus/permissions/permissions.json
@@ -20,6 +20,9 @@
"tab.lighttpdlog": {
"location-aware": false
},
+ "tab.listupgradable": {
+ "location-aware": false
+ },
"show.overview.addresses": {
"location-aware": false
},
@@ -43,5 +46,17 @@
},
"restart.dmsd": {
"location-aware": false
+ },
+ "apt.update": {
+ "location-aware": false
+ },
+ "apt.upgrade": {
+ "location-aware": false
+ },
+ "apt.autoremove": {
+ "location-aware": false
+ },
+ "apt.fix": {
+ "location-aware": false
}
} \ No newline at end of file
diff --git a/modules-available/systemstatus/templates/_page.html b/modules-available/systemstatus/templates/_page.html
index 4b62104c..dedcf01a 100644
--- a/modules-available/systemstatus/templates/_page.html
+++ b/modules-available/systemstatus/templates/_page.html
@@ -14,6 +14,9 @@
{{^enabled}}
<li class="disabled">
<a>
+ {{#important}}
+ <span class="glyphicon glyphicon-warning-sign text-danger"></span>
+ {{/important}}
{{name}}
</a>
</li>
@@ -21,6 +24,9 @@
{{#enabled}}
<li>
<a href="#id-{{type}}_pane" class="ajax-tab" id="id-{{type}}" data-toggle="tab" role="tab">
+ {{#important}}
+ <span class="glyphicon glyphicon-warning-sign text-danger"></span>
+ {{/important}}
{{name}}
</a>
</li>
diff --git a/modules-available/systemstatus/templates/diskstat.html b/modules-available/systemstatus/templates/diskstat.html
index 528d9792..1cb8a3ef 100644
--- a/modules-available/systemstatus/templates/diskstat.html
+++ b/modules-available/systemstatus/templates/diskstat.html
@@ -1,6 +1,7 @@
<div class="slx-storechart">
{{#system}}
<b>{{lang_systemPartition}}</b>
+ <span class="glyphicon glyphicon-info-sign" title={{filesystem}}/>
<div id="circles-system"></div>
<div>{{lang_capacity}}: {{size}}</div>
<div>{{lang_free}}: {{free}}</div>
@@ -12,6 +13,7 @@
<div class="slx-storechart">
{{#store}}
<b>{{lang_vmStore}}</b>
+ <span class="glyphicon glyphicon-info-sign" title={{filesystem}}/>
<div id="circles-store"></div>
<div>{{lang_capacity}}: {{size}}</div>
<div>{{lang_free}}: {{free}}</div>
diff --git a/modules-available/systemstatus/templates/sys-update-main.html b/modules-available/systemstatus/templates/sys-update-main.html
new file mode 100644
index 00000000..ca502ddb
--- /dev/null
+++ b/modules-available/systemstatus/templates/sys-update-main.html
@@ -0,0 +1,118 @@
+<div class="panel panel-default">
+ <div class="panel-body">
+ <div class="pull-right">
+ <form action="?do=systemstatus" method="post">
+ <input type="hidden" name="token" value="{{token}}">
+ <div>
+ <button type="submit" class="btn btn-success" name="action"
+ value="apt-update" {{perm.apt.update.disabled}}>
+ <span class="glyphicon glyphicon-refresh"></span>
+ {{lang_checkForUpdates}}
+ </button>
+ </div>
+ <div class="slx-smallspace"></div>
+ <div class="btn-group dropdown">
+ <button type="submit" class="btn btn-primary" name="action"
+ value="apt-upgrade" {{perm.apt.upgrade.disabled}}>
+ <span class="glyphicon glyphicon-play"></span>
+ {{lang_runUpdate}}
+ </button>
+ <button type="button" class="btn btn-primary dropdown-toggle"
+ data-toggle="dropdown" aria-haspopup="true">
+ <span class="caret"></span>
+ <span class="sr-only">Toggle Dropdown</span>
+ </button>
+ <div class="dropdown-menu" style="padding:0">
+ <div class="btn-group-vertical">
+ <button type="submit" name="action" value="apt-full-upgrade" class="btn btn-primary"
+ style="text-align: left !important" {{perm.apt.upgrade.disabled}}>
+ <span class="glyphicon glyphicon-play"></span>
+ {{lang_runFullUpdate}}
+ </button>
+ <button type="submit" name="action" value="apt-autoremove" class="btn btn-primary"
+ style="text-align: left !important" {{perm.apt.autoremove.disabled}}>
+ <span class="glyphicon glyphicon-trash"></span>
+ {{lang_removeUnusedPackages}}
+ </button>
+ <button type="submit" name="action" value="apt-fix" class="btn btn-primary"
+ style="text-align: left !important" {{perm.apt.fix.disabled}}>
+ <span class="glyphicon glyphicon-wrench"></span>
+ {{lang_fixDependencies}}
+ </button>
+ </div>
+ </div>
+ </div>
+ </form>
+ </div>
+ <table>
+ <tr>
+ <td>{{lang_lastestUpdate}}:&nbsp;</td>
+ <td>{{lastChanged}}</td>
+ <tr>
+ <tr>
+ <td>{{lang_lastUpdateCheck}}:&nbsp;</td>
+ <td>{{lastDownload}}</td>
+ </tr>
+ <tr>
+ <td>{{lang_lastPackageInstall}}:&nbsp;</td>
+ <td>{{lastInstalled}}</td>
+ <tr>
+ </table>
+ {{#list_old}}
+ <div class="slx-smallspace"></div>
+ <div class="text-danger">
+ {{lang_listOldWarning}}
+ </div>
+ {{/list_old}}
+ </div>
+</div>
+
+{{#task.packages.0}}
+ <table class="table table-condensed">
+ <thead>
+ <tr>
+ <th>{{lang_package}}</th>
+ <th colspan="3">{{lang_versionFromTo}}</th>
+ </tr>
+ </thead>
+ {{#task.packages}}
+ <tr class="{{row_class}}">
+ <td><strong>{{name}}</strong><span class="text-muted">/{{source}}</span></td>
+ <td class="slx-smallcol">{{oldVersion}}</td>
+ <td class="slx-smallcol">&rarr;</td>
+ <td class="slx-smallcol">{{newVersion}}</td>
+ </tr>
+ {{/task.packages}}
+ </table>
+{{/task.packages.0}}
+
+{{^task.packages}}
+ {{^task.error}}
+ <div class="alert alert-success">
+ <span class="glyphicon glyphicon-ok"></span>
+ {{lang_everythingUpToDate}}
+ </div>
+ {{/task.error}}
+{{/task.packages}}
+
+{{#needReboot}}
+ <div class="alert alert-warning">
+ <span class="glyphicon glyphicon-exclamation-sign"></span>
+ {{lang_packagesNeedingReboot}}:
+ <a href="#id-default_pane">
+ {{.}}
+ </a>
+ </div>
+{{/needReboot}}
+
+{{#task.error}}
+ <div class="alert alert-warning">{{task.error}}</div>
+{{/task.error}}
+
+<div>
+ <a href="https://www.bwlehrpool.de/wiki/doku.php/satellite/system_updates" target="_blank">
+ {{lang_updatesWikiLink}}
+ <span class="glyphicon glyphicon-new-window"></span>
+ </a>
+</div>
+<div class="clearfix"></div> \ No newline at end of file
diff --git a/modules-available/systemstatus/templates/sys-update-update.html b/modules-available/systemstatus/templates/sys-update-update.html
new file mode 100644
index 00000000..7be4e648
--- /dev/null
+++ b/modules-available/systemstatus/templates/sys-update-update.html
@@ -0,0 +1,35 @@
+<div id="apt-output" data-tm-id="{{taskid}}" data-tm-log="output"
+ data-tm-callback="aptUpdate">{{lang_aptOutput}}</div>
+
+<div class="buttonbar pull-right" id="btn-bar">
+ <a id="fail-button" href="?do=systemstatus&amp;rnd={{rnd}}#id-ListUpgradable_pane" class="btn btn-warning collapse">
+ <span class="glyphicon glyphicon-arrow-left"></span>
+ {{lang_backToPackagelist}}
+ </a>
+ <a id="success-button" href="?do=systemstatus&amp;rnd={{rnd}}#id-ListUpgradable_pane" class="btn btn-success
+ collapse">
+ <span class="glyphicon glyphicon-arrow-right"></span>
+ {{lang_showPackageList}}
+ </a>
+</div>
+<div class="clearfix"></div>
+
+<script>
+ function aptUpdate(task) {
+ if (task.statusCode === 'TASK_ERROR') {
+ if (task.data && task.data.error) {
+ $('#apt-output').append($('<pre class="text-danger slx-bold">').text(task.data.error));
+ }
+ $('#fail-button').show();
+ }
+ if (task.statusCode === 'TASK_FINISHED') {
+ $('#success-button').show();
+ }
+ }
+
+ tmInit();
+ $('#btn-bar a').click(function () {
+ window.location.reload();
+ return false;
+ });
+</script> \ No newline at end of file
diff --git a/modules-available/systemstatus/templates/systeminfo.html b/modules-available/systemstatus/templates/systeminfo.html
index cf3f0cc2..2489bcaa 100644
--- a/modules-available/systemstatus/templates/systeminfo.html
+++ b/modules-available/systemstatus/templates/systeminfo.html
@@ -1,6 +1,6 @@
-<div>
- {{lang_uptimeOS}}: {{uptime}}
-</div>
+<div>{{lang_uptimeOS}}: <b>{{uptime}}</b></div>
+<div>{{lang_distribution}}: <b>{{distribution}}</b></div>
+<div>{{lang_kernel}}: <b>{{kernel}}</b></div>
<div class="slx-storechart">
<b>{{lang_cpuLoad}}</b>
@@ -48,73 +48,94 @@
{{/swapWarning}}
<script type="text/javascript">
- {{#cpuLoadOk}}
- var cpuCircle = Circles.create({
- id: 'circles-cpuload',
- radius: 60,
- value: {{{cpuLoad}}},
- maxValue: 100,
- width: 10,
- text: function(value){return value + '%'; },
- colors: ['#dbc', '#33f'],
- duration: 400,
- wrpClass: 'circles-wrp',
- textClass: 'circles-text'
- });
- var $cpu = $(cpuCircle._el);
- var lastCpuTotal = {{CpuTotal}};
- var lastCpuIdle = {{CpuIdle}};
- var lastCpuPercent = {{cpuLoad}};
- {{/cpuLoadOk}}
- {{#memTotal}}
- var memCircle = Circles.create({
- id: 'circles-mem',
- radius: 60,
- value: {{{memPercent}}},
- maxValue: 100,
- width: 10,
- text: function(value){return value + '%'; },
- colors: ['#dbc', '#33f'],
- duration: 400,
- wrpClass: 'circles-wrp',
- textClass: 'circles-text'
- });
- var swapCircle = Circles.create({
- id: 'circles-swap',
- radius: 60,
- value: {{{swapPercent}}},
- maxValue: 100,
- width: 10,
- text: function(value){return value + '%'; },
- colors: ['#dbc', '#f33'],
- duration: 400,
- wrpClass: 'circles-wrp',
- textClass: 'circles-text'
- });
- {{/memTotal}}
- function updateSystem() {
- if (!cpuCircle && !memCircle) return;
- if (!$cpu.is(':visible')) {
- setTimeout(updateSystem, 1200);
- return;
+ (function () {
+ var hiddenProp;
+ if (typeof document.hidden !== "undefined") { // Opera 12.10 and Firefox 18 and later support
+ hiddenProp = "hidden";
+ } else if (typeof document.msHidden !== "undefined") {
+ hiddenProp = "msHidden";
+ } else if (typeof document.webkitHidden !== "undefined") {
+ hiddenProp = "webkitHidden";
+ } else {
+ hiddenProp = null;
}
- $.post('?do=SystemStatus&action=SysPoll', { token: TOKEN }, function(data) {
- if (memCircle && data.MemPercent) memCircle.update(data.MemPercent);
- if (swapCircle && data.SwapPercent) swapCircle.update(data.SwapPercent);
- if (cpuCircle && data.CpuIdle) {
- var total = data.CpuTotal - lastCpuTotal;
- var load = total - (data.CpuIdle - lastCpuIdle);
- var percent = Math.round(100 * load / total);
- cpuCircle.update(percent, Math.abs(percent - lastCpuPercent) < 5 ? 0 : 250);
- lastCpuTotal = data.CpuTotal;
- lastCpuIdle = data.CpuIdle;
- lastCpuPercent = percent;
+
+ {{#cpuLoadOk}}
+ var cpuCircle = Circles.create({
+ id: 'circles-cpuload',
+ radius: 60,
+ value: {{{cpuLoad}}},
+ maxValue: 100,
+ width: 10,
+ text: function (value) {
+ return value + '%';
+ },
+ colors: ['#dbc', '#33f'],
+ duration: 400,
+ wrpClass: 'circles-wrp',
+ textClass: 'circles-text'
+ });
+ var $cpu = $(cpuCircle._el);
+ var lastCpuTotal = {{CpuTotal}};
+ var lastCpuIdle = {{CpuIdle}};
+ var lastCpuPercent = {{cpuLoad}};
+ {{/cpuLoadOk}}
+ {{#memTotal}}
+ var memCircle = Circles.create({
+ id: 'circles-mem',
+ radius: 60,
+ value: {{{memPercent}}},
+ maxValue: 100,
+ width: 10,
+ text: function (value) {
+ return value + '%';
+ },
+ colors: ['#dbc', '#33f'],
+ duration: 400,
+ wrpClass: 'circles-wrp',
+ textClass: 'circles-text'
+ });
+ var swapCircle = Circles.create({
+ id: 'circles-swap',
+ radius: 60,
+ value: {{{swapPercent}}},
+ maxValue: 100,
+ width: 10,
+ text: function (value) {
+ return value + '%';
+ },
+ colors: ['#dbc', '#f33'],
+ duration: 400,
+ wrpClass: 'circles-wrp',
+ textClass: 'circles-text'
+ });
+ {{/memTotal}}
+
+ function updateSystem() {
+ if (!cpuCircle && !memCircle) return;
+ if (!$cpu.is(':visible') || (hiddenProp && document[hiddenProp])) {
+ setTimeout(updateSystem, 2500);
+ return;
}
- }, 'json').fail(function(data) {
- console.log(data);
- }).always(function() {
- setTimeout(updateSystem, 1200);
- });
- }
- setTimeout(updateSystem, 1000);
+ $.post('?do=SystemStatus&action=SysPoll', {token: TOKEN}, function (data) {
+ if (memCircle && data.MemPercent) memCircle.update(data.MemPercent);
+ if (swapCircle && data.SwapPercent) swapCircle.update(data.SwapPercent);
+ if (cpuCircle && data.CpuIdle) {
+ var total = data.CpuTotal - lastCpuTotal;
+ var load = total - (data.CpuIdle - lastCpuIdle);
+ var percent = Math.round(100 * load / total);
+ cpuCircle.update(percent, Math.abs(percent - lastCpuPercent) < 5 ? 0 : 250);
+ lastCpuTotal = data.CpuTotal;
+ lastCpuIdle = data.CpuIdle;
+ lastCpuPercent = percent;
+ }
+ }, 'json').fail(function (data) {
+ console.log(data);
+ }).always(function () {
+ setTimeout(updateSystem, 1200);
+ });
+ }
+
+ setTimeout(updateSystem, 1000);
+ })();
</script>
diff --git a/modules-available/translation/page.inc.php b/modules-available/translation/page.inc.php
index 34389b75..4563b8e5 100644
--- a/modules-available/translation/page.inc.php
+++ b/modules-available/translation/page.inc.php
@@ -1,5 +1,7 @@
<?php
+use JetBrains\PhpStorm\NoReturn;
+
/**
* The pages where you can administrate the website translations
*/
@@ -45,12 +47,12 @@ class Page_Translation extends Page
$this->builtInSections = array('template', 'messages', 'module', 'menucategory', 'custom');
}
- private function isValidSection($section)
+ private function isValidSection(string $section): bool
{
return in_array($section, $this->builtInSections);
}
- private function loadCustomHandler($moduleName)
+ private function loadCustomHandler(string $moduleName): ?array
{
$path = 'modules/' . $moduleName . '/hooks/translation.inc.php';
$HANDLER = array();
@@ -75,7 +77,7 @@ class Page_Translation extends Page
}
}
if (empty($backup))
- return false;
+ return null;
return $backup;
}
@@ -83,7 +85,8 @@ class Page_Translation extends Page
* Redirect to the closest matching page, as extracted from
* get/post parameters.
*/
- private function redirect($level = 99)
+ #[NoReturn]
+ private function redirect(int $level = 99): void
{
$params = array('do' => 'translation');
if ($level > 0 && $this->module !== false) {
@@ -329,9 +332,9 @@ class Page_Translation extends Page
Render::addTemplate('menu-category-list', $data);
}
- private function showModuleCustom()
+ private function showModuleCustom(): void
{
- if ($this->customHandler === false)
+ if ($this->customHandler === null)
return;
foreach ($this->customHandler['subsections'] as $subsection) {
$this->showModuleCustomSubsection($subsection);
@@ -420,13 +423,13 @@ class Page_Translation extends Page
/**
* Get all tags used by templates of the given module.
- * @param \Module $module module in question, false to use the one being edited.
+ * @param \Module $module module in question, null to use the one being edited.
*
* @return array of array(tag => array of templates using that tag)
*/
- private function loadUsedTemplateTags($module = false)
+ private function loadUsedTemplateTags(Module $module = null): array
{
- if ($module === false) {
+ if ($module === null) {
$module = $this->module;
}
$tags = array();
@@ -451,12 +454,12 @@ class Page_Translation extends Page
* 'files' => array(filename => occurencecount, ...)
* )
*
- * @param \Module $module module in question, false to use the one being edited
+ * @param \Module $module module in question, null to use the one being edited
* @return array see above
*/
- private function loadUsedMessageTags($module = false)
+ private function loadUsedMessageTags(Module $module = null): array
{
- if ($module === false) {
+ if ($module === null) {
$module = $this->module;
}
$allFiles = $this->getAllFiles('modules', '.php');
@@ -477,20 +480,18 @@ class Page_Translation extends Page
$tags[$p[1]] = $tag;
}
}
- $tags = $this->loadTagsFromPhp('/Message\s*::\s*add\w+\s*\(\s*[\'"](?<tag>[^\'"\.]*)[\'"]\s*(?<data>\)|\,.*)/i',
+ return $this->loadTagsFromPhp('/Message\s*::\s*add\w+\s*\(\s*[\'"](?<tag>[^\'"\.]*)[\'"]\s*(?<data>\)|\,.*)/i',
$this->getModulePhpFiles($module), $tags);
- return $tags;
}
/**
* Get all module tags used/required.
*
- * @param string $module
- * @return array of array(tagname => (bool)required)
+ * @return array (<tagname> => (bool)required)
*/
- private function loadUsedModuleTags($module = false)
+ private function loadUsedModuleTags(Module $module = null): array
{
- if ($module === false) {
+ if ($module === null) {
$module = $this->module;
}
$tags = $this->loadTagsFromPhp('/Dictionary\s*::\s*translate\s*\(\s*[\'"](?<tag>[^\'"\.]*)[\'"]\s*[\),]/i',
@@ -500,7 +501,7 @@ class Page_Translation extends Page
}
unset($tag);
// Fixup special tags
- if ($module->getCategory() === false) {
+ if ($module->getCategory() === null) {
unset($tags['module_name']);
unset($tags['page_title']);
} else {
@@ -510,17 +511,15 @@ class Page_Translation extends Page
return $tags;
}
- private function loadUsedMenuCategories($module = false)
+ private function loadUsedMenuCategories(): array
{
- if ($module === false) {
- $module = $this->module;
- }
+ $module = $this->module;
$skip = strlen($module->getIdentifier()) + 1;
$match = $module->getIdentifier() . '.';
$want = array();
foreach (Module::getAll() as $module) {
$cat = $module->getCategory();
- if (is_string($cat) && substr($cat, 0, $skip) === $match) {
+ if ($cat !== null && substr($cat, 0, $skip) === $match) {
$want[substr($cat, $skip)] = true;
}
}
@@ -536,14 +535,14 @@ class Page_Translation extends Page
* @param string $subsection Name of subsection
* @return array|false List of tags as KEYS of array
*/
- private function loadUsedCustomTags($subsection)
+ private function loadUsedCustomTags(string $subsection)
{
if (!isset($this->customHandler['grep_'.$subsection]))
return false;
return $this->customHandler['grep_'.$subsection]($this->module);
}
- private function getTagsFromTemplate($templateFile)
+ private function getTagsFromTemplate(string $templateFile)
{
//checks if the template is valid
if (!file_exists($templateFile)) {
@@ -564,10 +563,10 @@ class Page_Translation extends Page
*
* @param string $lang lang to use
* @param array $tags Array of tags, where the tag names are the keys
- * @param \Module|false $module the module to work with, defaults to the currently edited module
+ * @param Module $module the module to work with, defaults to the currently edited module
* @return array [missingCount, unusedCount]
*/
- private function getModuleTemplateStatus($lang, $tags = false, $module = false)
+ private function getModuleTemplateStatus(string $lang, array $tags, Module $module = null): array
{
return $this->getModuleTranslationStatus($lang, 'template-tags', true, $tags, $module);
}
@@ -579,14 +578,14 @@ class Page_Translation extends Page
*
* @param string $lang lang cc to use
* @param string $file the name of the translation file to load for checking
- * @param boolean $fallback whether to check the global-tags of the main module as fallback
+ * @param bool $fallback whether to check the global-tags of the main module as fallback
* @param array $tags list of tags that are expected to exist. Tags are the array keys!
- * @param \Module|false $module the module to work with, defaults to the currently edited module
+ * @param Module $module the module to work with, defaults to the currently edited module
* @return array [missingCount, unusedCount]
*/
- private function getModuleTranslationStatus($lang, $file, $fallback, $tags, $module = false)
+ private function getModuleTranslationStatus(string $lang, string $file, bool $fallback, array $tags, Module $module = null): array
{
- if ($module === false) {
+ if ($module === null) {
$module = $this->module;
}
if ($fallback) {
@@ -619,7 +618,7 @@ class Page_Translation extends Page
return array($missing, $unused);
}
- private function checkModuleTranslation($module)
+ private function checkModuleTranslation(Module $module): string
{
$templateTags = $this->loadUsedTemplateTags($module);
$messageTags = $this->loadUsedMessageTags($module);
@@ -654,7 +653,7 @@ class Page_Translation extends Page
*
* @return array of all php file names
*/
- private function getAllFiles($dir, $extension)
+ private function getAllFiles(string $dir, string $extension): array
{
$php = array();
$extLen = -strlen($extension);
@@ -674,10 +673,10 @@ class Page_Translation extends Page
/**
* Finds and returns all PHP files of current module.
*
- * @param \Module $module Module to get the php files of
+ * @param Module $module Module to get the php files of
* @return array of php file names
*/
- private function getModulePhpFiles($module)
+ private function getModulePhpFiles(Module $module): array
{
return $this->getAllFiles('modules/' . $module->getIdentifier(), '.php');
}
@@ -685,10 +684,9 @@ class Page_Translation extends Page
/**
* Get array to pass to edit page with all the tags and translations.
*
- * @param string $path the template's path
* @return array structure to pass to the tags list in the edit template
*/
- private function loadTemplateEditArray()
+ private function loadTemplateEditArray(): array
{
$tags = $this->loadUsedTemplateTags();
$table = $this->buildTranslationTable('template-tags', array_keys($tags), true);
@@ -707,10 +705,9 @@ class Page_Translation extends Page
/**
* Get array to pass to edit page with all the message ids.
*
- * @param string $path the template's path
* @return array structure to pass to the tags list in the edit template
*/
- private function loadMessagesEditArray()
+ private function loadMessagesEditArray(): array
{
$tags = $this->loadUsedMessageTags();
$table = $this->buildTranslationTable('messages', array_keys($tags), true);
@@ -734,34 +731,29 @@ class Page_Translation extends Page
/**
* Get array to pass to edit page with all the message ids.
*
- * @param string $path the template's path
* @return array structure to pass to the tags list in the edit template
*/
- private function loadModuleEditArray()
+ private function loadModuleEditArray(): array
{
$tags = $this->loadUsedModuleTags();
- $table = $this->buildTranslationTable('module', array_keys($tags), true);
- return $table;
+ return $this->buildTranslationTable('module', array_keys($tags), true);
}
- private function loadMenuCategoryEditArray()
+ private function loadMenuCategoryEditArray(): array
{
$tags = $this->loadUsedMenuCategories();
- $table = $this->buildTranslationTable('categories', array_keys($tags), true);
- return $table;
+ return $this->buildTranslationTable('categories', array_keys($tags), true);
}
/**
* Get array to pass to edit page with all the message ids.
*
- * @param string $path the template's path
* @return array structure to pass to the tags list in the edit template
*/
- private function loadCustomEditArray()
+ private function loadCustomEditArray(): array
{
$tags = $this->loadUsedCustomTags($this->subsection);
- $table = $this->buildTranslationTable($this->subsection, array_keys($tags), true);
- return $table;
+ return $this->buildTranslationTable($this->subsection, array_keys($tags), true);
}
/**
@@ -773,7 +765,7 @@ class Page_Translation extends Page
* @param string $str the partial method call
* @return int number of arguments to the method, minus the message id
*/
- private function countMessageParams($str)
+ private function countMessageParams(string $str): int
{
$quote = false;
$escape = false;
@@ -781,7 +773,7 @@ class Page_Translation extends Page
$len = strlen($str);
$depth = 0;
for ($i = 0; $i < $len; ++$i) {
- $char = $str{$i};
+ $char = $str[$i];
// Last char was backslash? Ignore this char
if ($escape) {
$escape = false;
@@ -832,11 +824,11 @@ class Page_Translation extends Page
* )).
*
* @param string $regexp regular expression
- * @param array $files list of files to scan
- * @param array $tags existing tag array to append to
+ * @param string[] $files list of files to scan
+ * @param string[] $tags existing tag array to append to
* @return array of all tags found, where the tag is the key, and the value is as described above
*/
- private function loadTagsFromPhp($regexp, $files, $tags = [])
+ private function loadTagsFromPhp(string $regexp, array $files, array $tags = []): array
{
// Get all php files, so we can find all strings that need to be translated
// Now find all tags in all php files. Only works for literal usage, not something like $foo = 'bar'; Dictionary::translate($foo);
@@ -861,32 +853,28 @@ class Page_Translation extends Page
/**
* @param string $file Source dictionary
- * @param string[]|false $requiredTags Tags that are considered required
+ * @param string[] $requiredTags Tags that are considered required
* @param bool $findAlreadyTranslated If true, try to find a translation for this string in another language
* @return array numeric array suitable for passing to mustache
*/
- private function buildTranslationTable($file, $requiredTags = false, $findAlreadyTranslated = false)
+ private function buildTranslationTable(string $file, array $requiredTags, bool $findAlreadyTranslated = false): array
{
$tags = array();
- if (is_array($requiredTags)) {
- foreach ($requiredTags as $tagName) {
- $tags[$tagName] = array('tag' => $tagName, 'required' => true);
- }
+ foreach ($requiredTags as $tagName) {
+ $tags[$tagName] = array('tag' => $tagName, 'required' => true);
}
// Sort here, so all tags known to be used are in alphabetical order
ksort($tags);
// Finds every tag within the JSON language file
$jsonTags = Dictionary::getArray($this->module->getIdentifier(), $file, $this->destLang);
- if (is_array($jsonTags)) {
- // Sort these separately so unused tags will be at the bottom of the list, but still ordered alphabetically
- ksort($jsonTags);
- foreach ($jsonTags as $tag => $translation) {
- $tags[$tag]['translation'] = $translation;
- if (strpos($translation, "\n") !== false) {
- $tags[$tag]['big'] = true;
- }
- $tags[$tag]['tag'] = $tag;
+ // Sort these separately so unused tags will be at the bottom of the list, but still ordered alphabetically
+ ksort($jsonTags);
+ foreach ($jsonTags as $tag => $translation) {
+ $tags[$tag]['translation'] = $translation;
+ if (strpos($translation, "\n") !== false) {
+ $tags[$tag]['big'] = true;
}
+ $tags[$tag]['tag'] = $tag;
}
if ($findAlreadyTranslated) {
// For each tag, include a translated string from another language as reference
@@ -900,17 +888,15 @@ class Page_Translation extends Page
$tagid = 0;
foreach ($tags as &$tag) {
$tag['tagid'] = $tagid++;
- if ($requiredTags !== false) {
- // We have a list of required tags, so mark those that are missing or unused
- if (!isset($tag['required'])) {
- $tag['unused'] = true;
- } elseif (!isset($tag['translation']) && !isset($globals[$tag['tag']])) {
- $tag['missing'] = true;
- }
- if (isset($globals[$tag['tag']])) {
- $tag['isglobal'] = true;
- $tag['placeholder'] = $globals[$tag['tag']];
- }
+ // We have a list of required tags, so mark those that are missing or unused
+ if (!isset($tag['required'])) {
+ $tag['unused'] = true;
+ } elseif (!isset($tag['translation']) && !isset($globals[$tag['tag']])) {
+ $tag['missing'] = true;
+ }
+ if (isset($globals[$tag['tag']])) {
+ $tag['isglobal'] = true;
+ $tag['placeholder'] = $globals[$tag['tag']];
}
}
// Finally remove tagname from the keys so mustache will iterate over them via {{#..}}
@@ -925,7 +911,7 @@ class Page_Translation extends Page
* @param string $file translation unit
* @param array $tags list of tags, formatted as used in buildTranslationTable()
*/
- private function findTranslationSamples($file, &$tags)
+ private function findTranslationSamples(string $file, array &$tags): void
{
$srcLangs = array_unique(array_merge(array(LANG), array('en'), Dictionary::getLanguages()));
if (($key = array_search($this->destLang, $srcLangs)) !== false) {
@@ -933,8 +919,6 @@ class Page_Translation extends Page
}
foreach ($srcLangs as $lang) {
$otherLang = Dictionary::getArray($this->module->getIdentifier(), $file, $lang);
- if (!is_array($otherLang))
- continue;
$missing = false;
foreach (array_keys($tags) as $tag) {
if (isset($tags[$tag]['samplelang']))
@@ -951,7 +935,7 @@ class Page_Translation extends Page
}
}
- private function getJsonFile()
+ private function getJsonFile(): string
{
$prefix = 'modules/' . $this->module->getIdentifier() . '/lang/' . $this->destLang;
// File
@@ -969,7 +953,7 @@ class Page_Translation extends Page
}
// Custom submodule
if ($this->section === 'custom') {
- if ($this->customHandler === false || !isset($this->customHandler['subsections'])) {
+ if ($this->customHandler === null || !isset($this->customHandler['subsections'])) {
Message::addError('no-custom-handlers');
$this->redirect(1);
}
@@ -981,13 +965,12 @@ class Page_Translation extends Page
}
Message::addError('invalid-section', $this->section);
$this->redirect(1);
- return false;
}
/**
* Updates a JSON file with it's new tags or/and tags values
*/
- private function updateJson()
+ private function updateJson(): void
{
$this->ensureValidDestLanguage();
if ($this->module === false) {
diff --git a/modules-available/vmstore/baseconfig/getconfig.inc.php b/modules-available/vmstore/baseconfig/getconfig.inc.php
index 3bad16e1..d239a3d7 100644
--- a/modules-available/vmstore/baseconfig/getconfig.inc.php
+++ b/modules-available/vmstore/baseconfig/getconfig.inc.php
@@ -1,5 +1,8 @@
<?php
+/** @var ?string $uuid */
+/** @var ?string $ip */
+
// VMStore path and type
$vmstore = Property::getVmStoreConfig();
if (is_array($vmstore) && isset($vmstore['storetype'])) {
@@ -21,6 +24,7 @@ if (is_array($vmstore) && isset($vmstore['storetype'])) {
ConfigHolder::add("SLX_VM_NFS_OPTS", $vmstore['cifsopts']);
}
break;
+ default:
}
}
diff --git a/modules-available/vmstore/hooks/main-warning.inc.php b/modules-available/vmstore/hooks/main-warning.inc.php
index ca2d1382..50d81ac8 100644
--- a/modules-available/vmstore/hooks/main-warning.inc.php
+++ b/modules-available/vmstore/hooks/main-warning.inc.php
@@ -4,7 +4,7 @@
* Hook for main page: Show warning if vmstore not configured yet; set "warning" flag if so
*/
-if (!is_array(Property::getVmStoreConfig())) {
+if (empty(Property::getVmStoreConfig())) {
Message::addError('vmstore.vmstore-not-configured', true); // Always specify module prefix since this is running in main
$needSetup = true; // Set $needSetup to true if you want a warning badge to appear in the menu
}
diff --git a/modules-available/vmstore/inc/vmstorebenchmark.inc.php b/modules-available/vmstore/inc/vmstorebenchmark.inc.php
new file mode 100644
index 00000000..b819ef8a
--- /dev/null
+++ b/modules-available/vmstore/inc/vmstorebenchmark.inc.php
@@ -0,0 +1,84 @@
+<?php
+
+class VmStoreBenchmark
+{
+
+ const PROP_LIST_KEY = 'vmstore.benchmark';
+
+ /**
+ * @param string[] $machineUuids List of UUIDs
+ * @return void
+ */
+ public static function prepareSelectDialog(array $uuids)
+ {
+ Module::isAvailable('rebootcontrol');
+ User::assertPermission('.vmstore.benchmark');
+ $uuids = array_values(Request::post('uuid', Request::REQUIRED, 'array'));
+ $machines = RebootUtils::getFilteredMachineList($uuids, '.vmstore.benchmark');
+ if ($machines === false)
+ return;
+ $machines = array_column($machines, 'machineuuid');
+ $id = Property::addToList(self::PROP_LIST_KEY,
+ json_encode(['machines' => $machines]), 60);
+ Util::redirect('?do=vmstore&show=benchmark&action=select&id=' . $id);
+ }
+
+ /**
+ * @param string $image relative path/name of image
+ * @param string $serverOrMode IP address of DNBD3 server, OR 'auto' for all servers known to client, or 'nfs' for NFS
+ * @param int $start timestamp when the clients should start
+ * @return ?string taskId, or null on error
+ */
+ public static function start(string $id, array $machineUuids, string $image, string $serverOrMode, int &$start): ?string
+ {
+ Module::isAvailable('rebootcontrol');
+ $clients = Database::queryAll('SELECT machineuuid, clientip FROM machine WHERE machineuuid IN (:uuids)',
+ ['uuids' => $machineUuids]);
+ if (empty($clients)) {
+ ErrorHandler::traceError('Cannot start benchmark: No matching clients');
+ }
+ // The more clients we have, the longer it takes to SSH into all of them.
+ // As of 2022, RemoteExec processes 4 clients in parallel
+ $start = ceil(count($clients) / 4 + 5 + time());
+ if ($serverOrMode === 'nfs') {
+ $modeOption = '--nfs';
+ } elseif ($serverOrMode === 'auto') {
+ $modeOption = '';
+ } else {
+ $modeOption = "--servers '$serverOrMode'";
+ }
+ // We fork off the benchmark into the background, and collect the results with another RemoteExec job
+ // when we're done. This is because RemoteExec only does four concurrent SSH connections, so if we wanted to
+ // do this the easy, synchronous way, we never could run more than four tests at the same time.
+ $command = <<<COMMAND
+(
+ exec &> /dev/null < /dev/null
+ setsid
+ while true; do
+ echo 3 > /proc/sys/vm/drop_caches
+ sleep 1
+ done &
+ flush=\$!
+ image_speedcheck --start $start --console $modeOption --file "$image" > "/tmp/speedcheck-$id"
+ kill \$flush
+) &
+COMMAND;
+ $task = RebootControl::runScript($clients, $command);
+ return $task['id'] ?? null;
+ }
+
+ /**
+ * @return array{cpu: array, net: array}
+ */
+ public static function parseBenchLine(string $line): array
+ {
+ $out = ['cpu' => [], 'net' => []];
+ foreach (explode(',', $line) as $elem) {
+ $elem = explode('+', $elem);
+ $out['net'][] = ['x' => (int)$elem[0], 'y' => (int)$elem[1]];
+ //$out['cpu'][] = ['x' => $elem[0], 'y' => $elem[2]];
+ }
+ return $out;
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/vmstore/lang/de/messages.json b/modules-available/vmstore/lang/de/messages.json
index 993d355d..86aa780e 100644
--- a/modules-available/vmstore/lang/de/messages.json
+++ b/modules-available/vmstore/lang/de/messages.json
@@ -1,3 +1,9 @@
{
+ "benchmark-already-started": "Benchmark bereits gestartet",
+ "benchmark-failed": "Benchmark fehlgeschlagen",
+ "dnbd3-failed": "DNBD3-Verbindung fehlgeschlagen",
+ "invalid-benchmark-job": "Ung\u00fcltige Benchmark-ID: {{0}}",
+ "invalid-dnbd3-server-id": "Ung\u00fcltige DNBD3 Server-ID: {{0}}",
+ "select-image-first": "Bitte zuerst ein Image ausw\u00e4hlen",
"vmstore-not-configured": "Es ist noch kein Speicherort f\u00fcr die Virtuellen Maschinen festgelegt."
} \ No newline at end of file
diff --git a/modules-available/vmstore/lang/de/module.json b/modules-available/vmstore/lang/de/module.json
index 87be6cae..80241434 100644
--- a/modules-available/vmstore/lang/de/module.json
+++ b/modules-available/vmstore/lang/de/module.json
@@ -1,4 +1,8 @@
{
+ "dnbd3-all-loadbalance": "Alle DNBD3-Server nutzen (load balancing)",
+ "menu_benchmark": "Benchmark",
+ "menu_edit": "Bearbeiten",
"module_name": "VM Speicherort",
+ "page-title-benchmark": "Netzwerk Benchmark",
"page_title": "Speicherort f\u00fcr VMs festlegen"
} \ No newline at end of file
diff --git a/modules-available/vmstore/lang/de/permissions.json b/modules-available/vmstore/lang/de/permissions.json
index 1f8d18d7..ffc3be39 100644
--- a/modules-available/vmstore/lang/de/permissions.json
+++ b/modules-available/vmstore/lang/de/permissions.json
@@ -1,3 +1,4 @@
{
+ "benchmark": "Darf Benchmarks starten.",
"edit": "Den verwendeten VM-Speicher konfigurieren."
} \ No newline at end of file
diff --git a/modules-available/vmstore/lang/de/template-tags.json b/modules-available/vmstore/lang/de/template-tags.json
index 8b6661c2..4b848e79 100644
--- a/modules-available/vmstore/lang/de/template-tags.json
+++ b/modules-available/vmstore/lang/de/template-tags.json
@@ -1,8 +1,13 @@
{
+ "lang_benchmark": "Benchmark",
+ "lang_benchmarkMainPageText": "Um ein Benchmark mit einem oder mehreren Rechnern zu starten, w\u00e4hlen Sie die entsprechenden Ger\u00e4te in der Listenansicht der Client-Statistiken aus.",
+ "lang_benchmarkResult": "Ergebnis",
+ "lang_benchmarkSecondsReminaing": "Sekunden, die f\u00fcr den Test verbleiben",
"lang_cifsHelp1": "Ben\u00f6tigt wird ein CIFS-Share, z.B. von einem Windows Server, der f\u00fcr\r\nden Satellitenserver schreibbar, und f\u00fcr die Arbeitsstationen lesbar\r\nist.",
"lang_cifsHelp2": "Geben Sie f\u00fcr den Satellitenserver einen User mit Lese- und\r\nSchreibberechtigungen an. F\u00fcr die Clients sollte ein User angegeben\r\nwerden, der nur Leseberechtigungen auf dem Share besitzt. Am einfachsten\r\nerreichen Sie dies, indem Sie passwortlosen Gastzugriff mit Leserechten\r\nauf die Freigabe erlauben.",
"lang_cifsHelp3": "Wenn exklusiv DNBD3 verwendet wird, k\u00f6nnen Sie den passwortlosen\r\nGastzugriff deaktivieren und die Zeile \"Nur-Lese-Zugangsdaten\" leer\r\nlassen. Dies erh\u00f6ht die Sicherheit.",
"lang_configure": "Konfigurieren",
+ "lang_image": "Image",
"lang_internal": "Intern",
"lang_nfsHelp1": "Ben\u00f6tigt wird ein NFSv4\/3-Share, der f\u00fcr den Satellitenserver schreibbar, und f\u00fcr die Arbeitsstationen lesbar ist. Beispielkonfiguration auf dem NFS-Server, wenn der Satellitenserver die Adresse 1.2.3.4 hat:",
"lang_nfsHelp2": "Alternative Konfiguration mittels all_squash. In diesem Fall muss das Verzeichnis auf dem Server dem Benutzer mit der uid 1234 geh\u00f6ren:",
@@ -12,6 +17,11 @@
"lang_optionalMountOptions": "Zu verwendende Mount-Optionen (optional):",
"lang_readOnly": "Nur-Lese-Zugangsdaten",
"lang_readWrite": "Lese\/Schreib-Zugangsdaten",
+ "lang_selectImage": "Image f\u00fcr den Test ausw\u00e4hlen",
+ "lang_selectServerOrNfs": "Quelle f\u00fcr Lesetest ausw\u00e4hlen",
+ "lang_size": "Gr\u00f6\u00dfe",
+ "lang_start": "Start",
+ "lang_users": "Aktuelle Verbindungen",
"lang_vmLocation": "VM Speicherort",
"lang_vmLocationChoose": "Bitte w\u00e4hlen Sie, wo die Images der Virtuellen Maschinen gespeichert werden sollen.",
"lang_vmLocationConfiguration": "VM Speicherort wird konfiguriert",
diff --git a/modules-available/vmstore/lang/en/messages.json b/modules-available/vmstore/lang/en/messages.json
index 9ac360eb..0b935c94 100644
--- a/modules-available/vmstore/lang/en/messages.json
+++ b/modules-available/vmstore/lang/en/messages.json
@@ -1,3 +1,9 @@
{
+ "benchmark-already-started": "Benchmark already started",
+ "benchmark-failed": "Benchmark failed",
+ "dnbd3-failed": "DNBD3 connection failed",
+ "invalid-benchmark-job": "Invalid benchmark ID: {{0}}",
+ "invalid-dnbd3-server-id": "Invalid DNBD3 server ID: {{0}}",
+ "select-image-first": "Please select an image first",
"vmstore-not-configured": "A location for the virtual machine is not set yet."
} \ No newline at end of file
diff --git a/modules-available/vmstore/lang/en/module.json b/modules-available/vmstore/lang/en/module.json
index a424640e..12e5167b 100644
--- a/modules-available/vmstore/lang/en/module.json
+++ b/modules-available/vmstore/lang/en/module.json
@@ -1,4 +1,8 @@
{
+ "dnbd3-all-loadbalance": "Use all DNBD3 servers (load balancing)",
+ "menu_benchmark": "Benchmark",
+ "menu_edit": "Edit",
"module_name": "VM Storage Location",
+ "page-title-benchmark": "Network benchmark",
"page_title": "Setting VM Storage Location"
} \ No newline at end of file
diff --git a/modules-available/vmstore/lang/en/permissions.json b/modules-available/vmstore/lang/en/permissions.json
index 6d34014a..fb5d56a5 100644
--- a/modules-available/vmstore/lang/en/permissions.json
+++ b/modules-available/vmstore/lang/en/permissions.json
@@ -1,3 +1,4 @@
{
+ "benchmark": "May start benchmarks.",
"edit": "Configure VM storage to use."
} \ No newline at end of file
diff --git a/modules-available/vmstore/lang/en/template-tags.json b/modules-available/vmstore/lang/en/template-tags.json
index b1d53db1..348c59fc 100644
--- a/modules-available/vmstore/lang/en/template-tags.json
+++ b/modules-available/vmstore/lang/en/template-tags.json
@@ -1,8 +1,13 @@
{
+ "lang_benchmark": "Benchmark",
+ "lang_benchmarkMainPageText": "To start a benchmark, select one or more clients in the list view of the Client Statistics.",
+ "lang_benchmarkResult": "Results",
+ "lang_benchmarkSecondsReminaing": "Seconds remaining",
"lang_cifsHelp1": "Requires a CIFS\/SMB share that's writable for the satellite server and read-only for the clients (if not using DNBD3).",
"lang_cifsHelp2": "Please provide user credentials with read\/write permissions which will be used by the server. For the clients, user credentials that allow read-only access is required. You could also enable passwordless guest login for read-only access.",
"lang_cifsHelp3": "If you want to use DNBD3 in exclusive mode, you can leave the read only credentials empty, to prevent people from browsing the share.",
"lang_configure": "Configure",
+ "lang_image": "Image",
"lang_internal": "Internal",
"lang_nfsHelp1": "An NFSv4\/3-Share is required. It should be readable by all the workstations, and writable for the satellite server. An example, assuming the satellite server has IP address 1.2.3.4:",
"lang_nfsHelp2": "Alternate configuration using all_squash. The exported directory should be owned (and be writable) by the user with uid 1234.",
@@ -12,6 +17,11 @@
"lang_optionalMountOptions": "Mount options to use (optional):",
"lang_readOnly": "Read-only Access",
"lang_readWrite": "Read\/Write Access",
+ "lang_selectImage": "Select image for testing",
+ "lang_selectServerOrNfs": "Select source for reading",
+ "lang_size": "Size",
+ "lang_start": "Start",
+ "lang_users": "Current connections",
"lang_vmLocation": "VM Storage Location",
"lang_vmLocationChoose": "Please choose where the images of virtual machines will be stored.",
"lang_vmLocationConfiguration": "VM location is configured",
diff --git a/modules-available/vmstore/page.inc.php b/modules-available/vmstore/page.inc.php
index 1e0cc619..9d7f16c2 100644
--- a/modules-available/vmstore/page.inc.php
+++ b/modules-available/vmstore/page.inc.php
@@ -2,12 +2,28 @@
class Page_VmStore extends Page
{
- private $mountTask = false;
+ /**
+ * @var ?string
+ */
+ private $mountTask = null;
protected function doPreprocess()
{
User::load();
+ if (User::hasPermission('edit')) {
+ Dashboard::addSubmenu('?do=vmstore', Dictionary::translate('menu_edit'));
+ }
+ if (User::hasPermission('benchmark')) {
+ Dashboard::addSubmenu('?do=vmstore&show=benchmark', Dictionary::translate('menu_benchmark'));
+ }
+
+ if (Request::any('show') === 'benchmark') {
+ User::assertPermission('benchmark');
+ $this->benchmarkDoPreprocess();
+ return;
+ }
+
User::assertPermission('edit');
$action = Request::post('action');
@@ -19,10 +35,15 @@ class Page_VmStore extends Page
protected function doRender()
{
+ if (Request::any('show') === 'benchmark') {
+ $this->benchmarkDoRender();
+ return;
+ }
+
$action = Request::post('action');
- if ($action === 'setstore' && !Taskmanager::isFailed($this->mountTask)) {
+ if ($action === 'setstore' && !Taskmanager::isFailed(Taskmanager::status($this->mountTask))) {
Render::addTemplate('mount', array(
- 'task' => $this->mountTask['id']
+ 'task' => $this->mountTask
));
return;
}
@@ -50,19 +71,256 @@ class Page_VmStore extends Page
Util::redirect('?do=VmStore');
}
// Validate syntax of nfs/cifs
- if ($storetype === 'nfs' && !preg_match('#^\S+:\S+$#is', $vmstore['nfsaddr'])) {
+ if ($storetype === 'nfs' && !preg_match('#^\S+:\S+$#i', $vmstore['nfsaddr'])) {
Message::addError('main.value-invalid', 'nfsaddr', $vmstore['nfsaddr']);
Util::redirect('?do=VmStore');
}
$vmstore['cifsaddr'] = str_replace('\\', '/', $vmstore['cifsaddr']);
- if ($storetype === 'cifs' && !preg_match('#^//\S+/.+$#is', $vmstore['cifsaddr'])) {
+ if ($storetype === 'cifs' && !preg_match('#^//\S+/.+$#i', $vmstore['cifsaddr'])) {
Message::addError('main.value-invalid', 'nfsaddr', $vmstore['nfsaddr']);
Util::redirect('?do=VmStore');
}
$this->mountTask = Trigger::mount($vmstore);
- if ($this->mountTask !== false) {
+ if ($this->mountTask !== null) {
TaskmanagerCallback::addCallback($this->mountTask, 'manualMount', $vmstore);
}
}
+ private function benchmarkDoPreprocess()
+ {
+ if (!Module::isAvailable('rebootcontrol')) {
+ ErrorHandler::traceError('rebootcontrol module not enabled');
+ }
+ Render::setTitle(Dictionary::translate('page-title-benchmark'));
+ if (Request::post('action') === 'start') {
+ $this->benchmarkActionStart();
+ }
+ }
+
+ private function benchmarkDoRender()
+ {
+ switch (Request::get('action')) {
+ case 'select':
+ $this->benchmarkShowImageSelect();
+ break;
+ case 'result':
+ $this->benchmarkShowResult();
+ break;
+ default:
+ Render::addTemplate('benchmark-nothing');
+ }
+ }
+
+ private function getJobFromId(int $id): ?array
+ {
+ $data = Property::getListEntry(VmStoreBenchmark::PROP_LIST_KEY, $id);
+ if ($data !== null) {
+ $data = json_decode($data, true);
+ }
+ if (!is_array($data) || !isset($data['machines'])) {
+ Message::addError('invalid-benchmark-job', $id);
+ return null;
+ }
+ return $data;
+ }
+
+ private function benchmarkActionStart()
+ {
+ Module::isAvailable('dnbd3');
+ $id = Request::post('id', Request::REQUIRED, 'int');
+ $data = $this->getJobFromId($id);
+ if ($data === null)
+ return;
+ if (isset($data['task'])) {
+ if ($data['task'] === 'inprogress') {
+ // Let's hope the proper ID gets written in a short while
+ sleep(1);
+ }
+ Util::redirect('?do=vmstore&show=benchmark&action=result&id=' . $id);
+ }
+ $selectedServer = Request::post('server', 'auto', 'string');
+ if ($selectedServer === 'nfs' || !Dnbd3::isEnabled()) {
+ $selectedServer = 'nfs';
+ } elseif ($selectedServer !== 'auto') {
+ $ip = Dnbd3::getServer($selectedServer);
+ if ($ip === false) {
+ Message::addError('invalid-dnbd3-server-id', $selectedServer);
+ return;
+ }
+ $selectedServer = $ip['clientip'];
+ }
+ $data['image'] = Request::post('image', Request::REQUIRED, 'string');
+ // Save once first to minimize race window
+ $data['task'] = 'inprogress';
+ Property::updateListEntry(VmStoreBenchmark::PROP_LIST_KEY, $id, json_encode($data), 30);
+ $start = 0;
+ $data['task'] = VmStoreBenchmark::start($id, $data['machines'], $data['image'], $selectedServer, $start);
+ if ($data['task'] === null) {
+ $data['task'] = 'failed';
+ } else {
+ // Test is 2x 30 seconds
+ $data['expected'] = $start + 64;
+ }
+ error_log('Saving: ' . json_encode($data));
+ Property::updateListEntry(VmStoreBenchmark::PROP_LIST_KEY, $id, json_encode($data), 30);
+ Util::redirect('?do=vmstore&show=benchmark&action=result&id=' . $id);
+ }
+
+ private function benchmarkShowImageSelect()
+ {
+ $id = Request::get('id', Request::REQUIRED, 'int');
+ $data = $this->getJobFromId($id);
+ if ($data === null)
+ return;
+ if (isset($data['task'])) {
+ Message::addWarning('benchmark-already-started');
+ Util::redirect('?do=vmstore&show=benchmark&action=result&id=' . $id);
+ }
+ Module::isAvailable('dnbd3');
+ $lookup = Dnbd3::getActiveServers();
+ $list = Dnbd3Rpc::getStatsMulti(array_keys($lookup), [Dnbd3Rpc::QUERY_IMAGES]);
+ if (empty($list)) {
+ Message::addError('dnbd3-failed');
+ Util::redirect('?do=vmstore');
+ }
+ $images = [];
+ foreach ($list as $json) {
+ foreach ($json['images'] as $img) {
+ $name = $img['name'] . ':' . $img['rid'];
+ if (!isset($images[$name])) {
+ $images[$name] = [
+ 'users' => 0,
+ 'size' => $img['size'],
+ 'size_s' => Util::readableFileSize($img['size'], 1),
+ 'name' => $name,
+ 'id' => count($images)
+ ];
+ }
+ $images[$name]['users'] += $img['users'];
+ }
+ }
+ $servers = [];
+ if (Dnbd3::isEnabled()) {
+ $servers[] = ['idx' => 'auto',
+ 'server' => Dictionary::translate('dnbd3-all-loadbalance')];
+ foreach ($lookup as $ip => $idx) {
+ $servers[] = ['idx' => $idx, 'server' => $ip];
+ }
+ }
+ if (!Dnbd3::isEnabled() || Dnbd3::hasNfsFallback()) {
+ $servers[] = ['idx' => 'nfs', 'server' => 'NFS'];
+ }
+ $servers[0]['checked'] = 'checked';
+ ArrayUtil::sortByColumn($images, 'users', SORT_DESC, SORT_NUMERIC);
+ Module::isAvailable('js_stupidtable');
+ Render::addTemplate('benchmark-imgselect', [
+ 'id' => $id,
+ 'list' => array_values($images),
+ 'servers' => $servers,
+ ]);
+ }
+
+ private function benchmarkShowResult()
+ {
+ $id = Request::get('id', Request::REQUIRED, 'int');
+ $data = $this->getJobFromId($id);
+ if ($data === null)
+ return;
+ if (!isset($data['task'])) {
+ Message::addWarning('select-image-first');
+ Util::redirect('?do=vmstore&show=benchmark&action=select&id=' . $id);
+ }
+ if ($data['task'] === 'failed') {
+ Message::addError('benchmark-failed');
+ return;
+ }
+ $remaining = 0;
+ if ($data['task'] !== 'done') {
+ $remaining = ($data['expected'] ?? 0) - time();
+ if ($remaining < 0) {
+ $remaining = 0;
+ }
+ $this->processRunningBenchmark($id, $data, $remaining === 0);
+ $refresh = $remaining;
+ Util::clamp($refresh, 2, 64);
+ }
+ $args = [
+ 'id' => $id,
+ 'result' => json_encode($data['result'] ?? []),
+ 'wanted' => json_encode($data['machines']),
+ ];
+ if ($remaining > 0) {
+ $args['remaining'] = $remaining;
+ $args['refresh'] = $refresh ?? 60;
+ }
+ Module::isAvailable('js_chart');
+ Render::addTemplate('benchmark-result', $args);
+ }
+
+ private function processRunningBenchmark(int $id, array &$data, bool $timeout)
+ {
+ Module::isAvailable('rebootcontrol');
+ $changed = false;
+
+ $active = array_filter($data['machines'], function ($e) use ($data) { return !isset($data['result'][$e]); });
+ if (empty($active)) {
+ $timeout = true;
+ } else {
+ if ($timeout) {
+ // cat everything for easier troubleshooting
+ $command = <<<EOF
+cat "/tmp/speedcheck-$id"
+EOF;
+ } else {
+ $command = <<<EOF
+grep -q '^Seq:' "/tmp/speedcheck-$id" && cat "/tmp/speedcheck-$id"
+EOF;
+ }
+ $task = RebootControl::runScript($active, $command);
+ $task = Taskmanager::waitComplete($task, 4000);
+ if ($task === false) {
+ $data['task'] = 'failed';
+ return;
+ }
+ if (!isset($data['result'])) {
+ $data['result'] = [];
+ }
+ $res =& $task['data'];
+ foreach ($res['result'] as $uuid => $out) {
+ if (isset($data['result'][$uuid]))
+ continue;
+ error_log(json_encode($out));
+ // Not finished, ignore
+ if (($out['state'] !== 'DONE' || $out['exitCode'] !== 0) && !$timeout)
+ continue;
+ $changed = true;
+ unset($client);
+ $client = ['machineuuid' => $uuid];
+ $data['result'][$uuid] =& $client;
+ if (preg_match_all("/^\+(\w{3}):(\d+),(.*)$/m", $out['stdout'], $modes, PREG_SET_ORDER)) {
+ foreach ($modes as $mode) {
+ $client[$mode[1]] = [
+ 'start' => $mode[2],
+ 'values' => VmStoreBenchmark::parseBenchLine($mode[3]),
+ ];
+ }
+ } else {
+ $client['stderr'] = substr($out['stderr'], 0, 4000)
+ . "\nStatus: {$out['state']}, ExitCode: {$out['exitCode']}";
+ $client['stdout'] = substr($out['stdout'], 0, 4000);
+ }
+ $m = Database::queryFirst('SELECT clientip, hostname FROM machine WHERE machineuuid = :uuid',
+ ['uuid' => $uuid]);
+ $client['name'] = empty($m['hostname']) ? $m['clientip'] : $m['hostname'];
+ }
+ }
+ if (count($data['result']) === count($data['machines']) || $timeout) {
+ $data['task'] = 'done';
+ $changed = true;
+ }
+ if ($changed) {
+ Property::updateListEntry(VmStoreBenchmark::PROP_LIST_KEY, $id, json_encode($data), 30);
+ }
+ }
+
} \ No newline at end of file
diff --git a/modules-available/vmstore/permissions/permissions.json b/modules-available/vmstore/permissions/permissions.json
index 8303fd02..0617c673 100644
--- a/modules-available/vmstore/permissions/permissions.json
+++ b/modules-available/vmstore/permissions/permissions.json
@@ -1,5 +1,8 @@
{
"edit": {
"location-aware": false
+ },
+ "benchmark": {
+ "location-aware": true
}
} \ No newline at end of file
diff --git a/modules-available/vmstore/templates/benchmark-imgselect.html b/modules-available/vmstore/templates/benchmark-imgselect.html
new file mode 100644
index 00000000..be81aa3e
--- /dev/null
+++ b/modules-available/vmstore/templates/benchmark-imgselect.html
@@ -0,0 +1,59 @@
+<div class="page-header">
+ <h1>{{lang_benchmark}}</h1>
+</div>
+
+<form role="form" method="post" action="?do=vmstore">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="show" value="benchmark">
+ <input type="hidden" name="id" value="{{id}}">
+
+ <h4>{{lang_selectServerOrNfs}}</h4>
+ {{#servers}}
+ <div class="radio">
+ <input type="radio" id="s-{{idx}}" name="server" value="{{idx}}" {{checked}}>
+ <label for="s-{{idx}}">{{server}}</label>
+ </div>
+ {{/servers}}
+
+ <div class="slx-space"></div>
+
+ <h4>{{lang_selectImage}}</h4>
+ <div>
+ <table class="table table-condensed stupidtable">
+ <thead>
+ <tr>
+ <th data-sort="string">{{lang_image}}</th>
+ <th class="slx-smallcol" data-sort="int" data-sort-default="desc">{{lang_users}}</th>
+ <th class="slx-smallcol" data-sort="int" data-sort-default="desc">{{lang_size}}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{#list}}
+ <tr>
+ <td>
+ <div class="radio radio-inline">
+ <input type="radio" id="r-{{id}}" name="image" value="{{name}}">
+ <label for="r-{{id}}">{{name}}</label>
+ </div>
+ </td>
+ <td class="text-right">{{users}}</td>
+ <td class="text-right" data-sort-value="{{size}}">{{size_s}}</td>
+ </tr>
+ {{/list}}
+ </tbody>
+ </table>
+ </div>
+
+ <div class="slx-space"></div>
+
+ <div style="position:fixed;bottom:0;right:0;padding:8px;background:#fff;width:100%;border-top:1px solid #ddd">
+ <div class="buttonbar text-right">
+ <button type="submit" name="action" value="start" class="btn btn-primary">
+ <span class="glyphicon glyphicon-play"></span>
+ {{lang_start}}
+ </button>
+ </div>
+ </div>
+
+
+</form> \ No newline at end of file
diff --git a/modules-available/vmstore/templates/benchmark-nothing.html b/modules-available/vmstore/templates/benchmark-nothing.html
new file mode 100644
index 00000000..aeef9187
--- /dev/null
+++ b/modules-available/vmstore/templates/benchmark-nothing.html
@@ -0,0 +1,7 @@
+<div class="page-header">
+ <h1>{{lang_benchmark}}</h1>
+</div>
+
+<div class="alert alert-info">
+ {{lang_benchmarkMainPageText}}
+</div> \ No newline at end of file
diff --git a/modules-available/vmstore/templates/benchmark-result.html b/modules-available/vmstore/templates/benchmark-result.html
new file mode 100644
index 00000000..28f31f12
--- /dev/null
+++ b/modules-available/vmstore/templates/benchmark-result.html
@@ -0,0 +1,141 @@
+<h1>{{lang_benchmark}}</h1>
+
+<h2>{{lang_benchmarkResult}}</h2>
+
+{{#remaining}}
+<div class="alert alert-info">
+ {{lang_benchmarkSecondsReminaing}}: <span id="remaining-seconds">{{remaining}}</span>
+</div>
+{{/remaining}}
+
+<div id="graphs"></div>
+
+<div id="errors"></div>
+
+<script>
+ document.addEventListener('DOMContentLoaded', function() {
+ var result = {{{result}}};
+ var clients = {{{wanted}}};
+ var graphs = {};
+ function formatBytes(val) {
+ return Math.floor(val / 1024 / 1024) + "\u2009MiB/s";
+ }
+ function renderX(val, index) {
+ return Math.floor(val / 1000) + '\u2009s';
+ }
+ function makeGraph(typeKey, resourceKey, caption) {
+ var uuid;
+ var ds = [];
+ var gmin = 0, rmax = 0;
+ var colors = [];
+ var cnt = 0;
+ for (uuid in result) {
+ if (!result[uuid][typeKey]) {
+ delete result[uuid];
+ continue;
+ }
+ if (gmin === 0 || result[uuid][typeKey].start < gmin) {
+ gmin = result[uuid][typeKey].start;
+ }
+ cnt++;
+ }
+ if (cnt === 1) {
+ colors.push('rgb(0, 128, 0)');
+ } else {
+ for (i = 0; i < cnt; ++i) {
+ colors.push('rgb(0, 128, ' + (i / (cnt - 1)) * 255 + ')');
+ }
+ }
+ var v, i, o, idx;
+ var sums = [];
+ for (uuid in result) {
+ o = result[uuid][typeKey].start - gmin; // Adjust according to earliest client
+ v = result[uuid][typeKey].values[resourceKey];
+ for (i = 0; i < v.length; ++i) {
+ v[i].x += o;
+ if (cnt > 1) {
+ idx = Math.round(v[i].x / 250);
+ if (sums[idx]) {
+ sums[idx] += v[i].y | 0;
+ } else {
+ sums[idx] = v[i].y | 0;
+ }
+ }
+ }
+ if (v[v.length-1].x > rmax) rmax = v[v.length-1].x; // Get max value
+ ds.push({data: v, label: result[uuid].name, borderColor: colors[ds.length], fill: false});
+ }
+ if (cnt > 1) {
+ ds.push({data: sums, label: 'Sum', borderColor: '#c00'});
+ }
+ if (!graphs[typeKey]) {
+ var $e = $('#graphs');
+ var $c = $('<canvas style="width:100%;height:250px">');
+ $e.append($('<h3>').text(caption));
+ $e.append($c);
+ var ls = [];
+ for (i = 0; i <= rmax; i += 250) ls.push(i); // Generate steps for graph
+ graphs[typeKey] = new Chart($c[0].getContext('2d'), {data: {datasets: ds, labels: ls}, type: 'scatter', options: {
+ animation: false,
+ responsive: true,
+ borderWidth: 2,
+ pointBorderWidth: 0,
+ showLine: true,
+ scales: { y: { ticks: { callback: formatBytes }}, x: { ticks: { callback: renderX }, max: rmax } },
+ plugins: {
+ tooltip: { callbacks: { label: function(context) {
+ if (context.parsed.y !== null) {
+ return context.dataset.label + ": " + formatBytes(context.parsed.y);
+ }
+ return context.dataset.label;
+ }
+ }},
+ legend: { position: 'left'}
+ }
+ }});
+ } else {
+ graphs[typeKey].data.datasets = ds;
+ graphs[typeKey].update();
+ }
+ }
+
+ var $err = $('#errors');
+ for (var uuid in result) {
+ if (result[uuid].stdout || result[uuid].stderr) {
+ var $frame = $('<div class="panel panel-body">');
+ $frame.append($('<h5>').text(result[uuid].name));
+ if (result[uuid].stdout) {
+ $frame.append($('<label>').text('stdout'));
+ $frame.append($('<pre>').text(result[uuid].stdout));
+ }
+ if (result[uuid].stderr) {
+ $frame.append($('<label>').text('stderr'));
+ $frame.append($('<pre>').text(result[uuid].stderr));
+ }
+ $err.append($frame);
+ }
+ }
+
+ makeGraph('SEQ', 'net', 'Sequential Reads');
+ makeGraph('RND', 'net', 'Random 1M');
+
+ {{#refresh}}
+ setTimeout(function() {
+ window.location.reload();
+ }, {{refresh}} * 1000);
+ {{#remaining}}
+ var remaining = {{remaining}};
+ function updateRemainingCounter() {
+ if (remaining > 0) {
+ setTimeout(updateRemainingCounter, 1000);
+ } else {
+ window.location.reload();
+ }
+ $('#remaining-seconds').text(remaining--);
+ }
+ updateRemainingCounter();
+ {{/remaining}}
+ {{/refresh}}
+
+ });
+</script> \ No newline at end of file
diff --git a/modules-available/vmstore/templates/page-vmstore.html b/modules-available/vmstore/templates/page-vmstore.html
index 0e1ad601..fa222631 100644
--- a/modules-available/vmstore/templates/page-vmstore.html
+++ b/modules-available/vmstore/templates/page-vmstore.html
@@ -1,10 +1,11 @@
+<h1>{{lang_vmLocation}}</h1>
+
<form role="form" method="post" action="?do=VmStore">
<input type="text" name="prevent_autofill" id="prevent_autofill" value="" style="position:absolute;top:-2000px" tabindex="-1">
<input type="password" name="password_fake" id="password_fake" value="" style="position:absolute;top:-2000px" tabindex="-1">
<input type="hidden" name="token" value="{{token}}">
<input type="hidden" name="action" value="setstore">
- <h1>{{lang_vmLocation}}</h1>
<p>{{lang_vmLocationChoose}} <a class="btn btn-default" data-toggle="modal" data-target="#help-store"><span class="glyphicon glyphicon-question-sign"></span></a></p>
diff --git a/modules-available/webinterface/baseconfig/getconfig.inc.php b/modules-available/webinterface/baseconfig/getconfig.inc.php
index 1bc5c1e3..53a5993c 100644
--- a/modules-available/webinterface/baseconfig/getconfig.inc.php
+++ b/modules-available/webinterface/baseconfig/getconfig.inc.php
@@ -1,5 +1,8 @@
<?php
+/** @var ?string $uuid */
+/** @var ?string $ip */
+
$file = '/opt/openslx/configs/modules/self-signed-ca.tar';
if (is_file($file) && is_readable($file)) {
ConfigHolder::add('SLX_REMOTE_SSL', $_SERVER['SERVER_ADDR']);
diff --git a/modules-available/webinterface/lang/en/template-tags.json b/modules-available/webinterface/lang/en/template-tags.json
index 28129e64..2378261b 100644
--- a/modules-available/webinterface/lang/en/template-tags.json
+++ b/modules-available/webinterface/lang/en/template-tags.json
@@ -22,7 +22,7 @@
"lang_randomCert": "Generate new self-signed certificate",
"lang_showPasswords": "Show passwords",
"lang_suppliedSelected": "The server is currently using a certificate supplied using the \"Supply own certificate\" option.",
- "lang_unknownSelected": "Unknown or invalid certificate in use. The server war probably updated from an old version while HTTPS was already enabled. Redo the HTTPS configuration steps to get rid of this message.",
+ "lang_unknownSelected": "Unknown or invalid certificate in use. The server was probably updated from an old version while HTTPS was already enabled. Redo the HTTPS configuration steps to get rid of this message.",
"lang_useHsts": "Use HSTS (increases security but might lead to problems accessing the site if you disable HTTPS later)",
"lang_youreNotUsingHttps": "You're not using HTTPS to visit this website (or the HTTPS termination is done by a reverse proxy).",
"lang_youreUsingHttps": "You're visiting this server through an HTTPS connection (from the server's point of view)."
diff --git a/pack.sh b/pack.sh
index 49ef3bef..e9ed8b45 100755
--- a/pack.sh
+++ b/pack.sh
@@ -2,5 +2,9 @@
tar ckzf slx-admin.tar.gz api.php index.php apis fonts inc lang modules-available Mustache script style install.php install-all || exit 1
-[ "$1" = "--deploy" ] && scp slx-admin.tar.gz root@132.230.4.17:install/slx-admin.tar.gz
+if [ "$1" = "--deploy" ]; then
+ scp slx-admin.tar.gz root@132.230.4.17:install/slx-admin.tar.gz
+else
+ echo "Not deploying"
+fi
diff --git a/style/default.css b/style/default.css
index a8d1dc22..19e1ba08 100644
--- a/style/default.css
+++ b/style/default.css
@@ -623,4 +623,11 @@ div.disabled-hack {
label.disabled {
color: #888;
+}
+
+/* Override for selectize.js which renders disabled inputs not like bootstrap */
+.selectize-input.disabled, .selectize-input.disabled * {
+ cursor: not-allowed !important;
+ background-color: #eee !important;
+ opacity: 1 !important;
} \ No newline at end of file
diff --git a/tools/convert-modules.php b/tools/convert-modules.php
index f8c7a1a5..1b92490d 100644
--- a/tools/convert-modules.php
+++ b/tools/convert-modules.php
@@ -78,7 +78,7 @@ foreach (
$exFile = "modules/$module/lang/$lang/template-tags.json";
$existing = @json_decode(@file_get_contents($exFile), true);
if (!is_array($existing)) $existing = array();
- $existing = $existing + $old;
+ $existing += $old;
ksort($existing);
if (file_put_contents($exFile, json_encode($existing, JSON_PRETTY_PRINT)) > 0) {
unlink($path);
@@ -181,7 +181,6 @@ echo "Processing\n";
foreach ($messages as $id => $modules) {
asort($modules, SORT_NUMERIC);
$modules = array_reverse($modules, true);
- reset($modules);
$topModule = key($modules);
$topCount = $modules[$topModule];
$sum = 0;
diff --git a/tools/global-candidates.php b/tools/global-candidates.php
index 12e900a5..c42a43aa 100644
--- a/tools/global-candidates.php
+++ b/tools/global-candidates.php
@@ -58,6 +58,7 @@ foreach ($tags as $k => &$tag) {
}
}
}
+unset($tag);
echo "\n\nDUPLICATE STRINGS WITH DIFFERENT NAMES:\n";
foreach ($strings as $text => $data) {
diff --git a/tools/jedec.php b/tools/jedec.php
index a4df9667..1883abb9 100644
--- a/tools/jedec.php
+++ b/tools/jedec.php
@@ -13,11 +13,11 @@
$last = 0;
$index = 1;
$line = file_get_contents('jedec');
-preg_match_all("/^\s*([1-9][0-9]?|1[01][0-9]|12[0-6])\s+([^\r\n]{2,9}(?:[a-z][^\r\n]{0,10}){1,3}[\r\n]?[^\r\n0]{0,31})(?:\s*[\r\n]\s|\s+)((?:[10]\s+){8})([0-9a-f]{2})\s*\$/sim", $line, $oout, PREG_SET_ORDER);
+preg_match_all("/^\s*([1-9][0-9]?|1[01][0-9]|12[0-6])\s+([^\r\n]{2,9}(?:[a-z][^\r\n]{0,10}){1,3}[\r\n]?[^\r\n0]{0,31})(?:\s*[\r\n]\s|\s+)((?:[10]\s+){8})([0-9a-f]{2})\s*\$/im", $line, $oout, PREG_SET_ORDER);
$output = [];
foreach ($oout as $out) {
$id = (int)$out[1];
- $name = preg_replace("/[\s\r\n]+/ms", ' ', $out[2]);
+ $name = preg_replace("/[\s\r\n]+/m", ' ', $out[2]);
$bin = $out[3];
$hex = $out[4];
if ($id < $last) {