summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.idea/codeStyles/Project.xml5
-rw-r--r--.idea/inspectionProfiles/Project_Default.xml549
-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.php33
-rw-r--r--apis/getconfig.inc.php2
-rw-r--r--config.php.example18
-rw-r--r--doc/design_guidelines2
-rw-r--r--doc/locationinfo4
-rw-r--r--inc/arrayutil.inc.php83
-rw-r--r--inc/crypto.inc.php18
-rw-r--r--inc/dashboard.inc.php35
-rw-r--r--inc/database.inc.php163
-rw-r--r--inc/dictionary.inc.php106
-rw-r--r--inc/download.inc.php76
-rw-r--r--inc/errorhandler.inc.php153
-rw-r--r--inc/event.inc.php40
-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.php78
-rw-r--r--inc/mailer.inc.php186
-rw-r--r--inc/message.inc.php47
-rw-r--r--inc/module.inc.php74
-rw-r--r--inc/paginate.inc.php39
-rw-r--r--inc/permission.inc.php34
-rw-r--r--inc/property.inc.php159
-rw-r--r--inc/render.inc.php70
-rw-r--r--inc/request.inc.php51
-rw-r--r--inc/session.inc.php186
-rw-r--r--inc/taskmanager.inc.php170
-rw-r--r--inc/taskmanagercallback.inc.php78
-rw-r--r--inc/trigger.inc.php110
-rw-r--r--inc/up_json_encode.php180
-rw-r--r--inc/user.inc.php68
-rw-r--r--inc/util.inc.php365
-rw-r--r--index.php17
-rw-r--r--install.php183
-rw-r--r--modules-available/adduser/lang/de/template-tags.json2
-rw-r--r--modules-available/adduser/lang/en/template-tags.json4
-rw-r--r--modules-available/adduser/page.inc.php11
-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/module.json2
-rw-r--r--modules-available/backup/lang/de/permissions.json5
-rw-r--r--modules-available/backup/lang/de/template-tags.json27
-rw-r--r--modules-available/backup/lang/en/messages.json1
-rw-r--r--modules-available/backup/lang/en/module.json2
-rw-r--r--modules-available/backup/lang/en/permissions.json5
-rw-r--r--modules-available/backup/lang/en/template-tags.json27
-rw-r--r--modules-available/backup/page.inc.php94
-rw-r--r--modules-available/backup/permissions/permissions.json3
-rw-r--r--modules-available/backup/templates/_page.html89
-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/api.inc.php197
-rw-r--r--modules-available/baseconfig/hooks/locations-column.inc.php76
-rw-r--r--modules-available/baseconfig/inc/baseconfig.inc.php151
-rw-r--r--modules-available/baseconfig/inc/baseconfigutil.inc.php14
-rw-r--r--modules-available/baseconfig/inc/configholder.inc.php134
-rw-r--r--modules-available/baseconfig/inc/validator.inc.php20
-rw-r--r--modules-available/baseconfig/lang/de/module.json7
-rw-r--r--modules-available/baseconfig/lang/de/template-tags.json3
-rw-r--r--modules-available/baseconfig/lang/en/module.json7
-rw-r--r--modules-available/baseconfig/lang/en/template-tags.json3
-rw-r--r--modules-available/baseconfig/page.inc.php140
-rw-r--r--modules-available/baseconfig/templates/_page.html116
-rw-r--r--modules-available/baseconfig_bwidm/hooks/translation.inc.php8
-rw-r--r--modules-available/baseconfig_bwlp/baseconfig/settings.json46
-rw-r--r--modules-available/baseconfig_bwlp/hooks/translation.inc.php8
-rw-r--r--modules-available/baseconfig_bwlp/lang/de/config-variables.json11
-rw-r--r--modules-available/baseconfig_bwlp/lang/en/config-variables.json17
-rw-r--r--modules-available/dnbd3/baseconfig/getconfig.inc.php28
-rw-r--r--modules-available/dnbd3/hooks/main-warning.inc.php4
-rw-r--r--modules-available/dnbd3/hooks/runmode/config.json1
-rw-r--r--modules-available/dnbd3/hooks/translation.inc.php4
-rw-r--r--modules-available/dnbd3/inc/dnbd3.inc.php44
-rw-r--r--modules-available/dnbd3/inc/dnbd3rpc.inc.php147
-rw-r--r--modules-available/dnbd3/inc/dnbd3util.inc.php62
-rw-r--r--modules-available/dnbd3/lang/de/config-variables.json4
-rw-r--r--modules-available/dnbd3/lang/de/template-tags.json15
-rw-r--r--modules-available/dnbd3/lang/en/config-variables.json4
-rw-r--r--modules-available/dnbd3/lang/en/template-tags.json15
-rw-r--r--modules-available/dnbd3/page.inc.php140
-rw-r--r--modules-available/dnbd3/templates/fragment-server-settings.html56
-rw-r--r--modules-available/dnbd3/templates/page-proxy-clients.html27
-rw-r--r--modules-available/dnbd3/templates/page-proxy-images.html30
-rw-r--r--modules-available/dnbd3/templates/page-proxy-stats.html7
-rw-r--r--modules-available/dnbd3/templates/page-serverlist.html160
-rw-r--r--modules-available/dozmod/api.inc.php107
-rw-r--r--modules-available/dozmod/lang/de/messages.json13
-rw-r--r--modules-available/dozmod/lang/de/permissions.json2
-rw-r--r--modules-available/dozmod/lang/de/template-tags.json20
-rw-r--r--modules-available/dozmod/lang/en/messages.json1
-rw-r--r--modules-available/dozmod/lang/en/module.json10
-rw-r--r--modules-available/dozmod/lang/en/permissions.json4
-rw-r--r--modules-available/dozmod/lang/en/template-tags.json20
-rw-r--r--modules-available/dozmod/page.inc.php67
-rw-r--r--modules-available/dozmod/pages/actionlog.inc.php124
-rw-r--r--modules-available/dozmod/pages/expiredimages.inc.php134
-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.php85
-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/permissions/permissions.json6
-rw-r--r--modules-available/dozmod/templates/actionlog-lecture.html8
-rw-r--r--modules-available/dozmod/templates/blockstats.html5
-rw-r--r--modules-available/dozmod/templates/images-delete.html172
-rw-r--r--modules-available/dozmod/templates/images-orphaned.html25
-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.php20
-rw-r--r--modules-available/exams/inc/exams.inc.php9
-rw-r--r--modules-available/exams/lang/de/template-tags.json12
-rw-r--r--modules-available/exams/lang/en/template-tags.json12
-rw-r--r--modules-available/exams/page.inc.php72
-rw-r--r--modules-available/exams/templates/page-add-edit-exam.html223
-rw-r--r--modules-available/exams/templates/page-exams.html12
-rw-r--r--modules-available/js_chart/clientscript.js20
-rw-r--r--modules-available/js_ip/clientscript.js67
-rw-r--r--modules-available/js_ip/config.json7
-rwxr-xr-xmodules-available/js_jqueryui/style.css2
-rw-r--r--modules-available/js_stupidtable/clientscript.js2
-rwxr-xr-xmodules-available/js_weekcalendar/clientscript.js42
-rw-r--r--modules-available/locationinfo/api.inc.php87
-rw-r--r--modules-available/locationinfo/clientscript.js153
-rw-r--r--modules-available/locationinfo/config.json2
-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/PhpEws/Type/WellKnownResponseObjectType.php2
-rw-r--r--modules-available/locationinfo/exchange-includes/jamesiarmes/PhpEws/assets/types.xsd2
-rw-r--r--modules-available/locationinfo/exchange-includes/jamesiarmes/PhpNtlm/SoapClient.php5
-rw-r--r--modules-available/locationinfo/frontend/frontendscript.js3
-rw-r--r--modules-available/locationinfo/hooks/runmode/config.json5
-rw-r--r--modules-available/locationinfo/inc/coursebackend.inc.php98
-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.php55
-rw-r--r--modules-available/locationinfo/inc/coursebackend/coursebackend_hisinone.inc.php383
-rw-r--r--modules-available/locationinfo/inc/coursebackend/coursebackend_ical.inc.php61
-rw-r--r--modules-available/locationinfo/inc/icalcoursebackend.inc.php148
-rw-r--r--modules-available/locationinfo/inc/icalevent.inc.php254
-rw-r--r--modules-available/locationinfo/inc/icalparser.inc.php2052
-rw-r--r--modules-available/locationinfo/inc/infopanel.inc.php64
-rw-r--r--modules-available/locationinfo/inc/locationinfo.inc.php153
-rw-r--r--modules-available/locationinfo/inc/locationinfohooks.inc.php99
-rw-r--r--modules-available/locationinfo/install.inc.php3
-rw-r--r--modules-available/locationinfo/lang/de/backend-hisinone.json10
-rw-r--r--modules-available/locationinfo/lang/de/backend-ical.json16
-rw-r--r--modules-available/locationinfo/lang/de/messages.json4
-rw-r--r--modules-available/locationinfo/lang/de/module.json10
-rw-r--r--modules-available/locationinfo/lang/de/template-tags.json62
-rw-r--r--modules-available/locationinfo/lang/en/backend-hisinone.json12
-rw-r--r--modules-available/locationinfo/lang/en/backend-ical.json16
-rw-r--r--modules-available/locationinfo/lang/en/messages.json4
-rw-r--r--modules-available/locationinfo/lang/en/module.json12
-rw-r--r--modules-available/locationinfo/lang/en/template-tags.json54
-rw-r--r--modules-available/locationinfo/page.inc.php423
-rw-r--r--modules-available/locationinfo/style.css12
-rw-r--r--modules-available/locationinfo/templates/ajax-config-location.html191
-rw-r--r--modules-available/locationinfo/templates/ajax-config-server.html77
-rwxr-xr-xmodules-available/locationinfo/templates/frontend-default.html316
-rw-r--r--modules-available/locationinfo/templates/frontend-summary.html22
-rw-r--r--modules-available/locationinfo/templates/page-config-panel-default.html520
-rw-r--r--modules-available/locationinfo/templates/page-config-panel-summary.html172
-rw-r--r--modules-available/locationinfo/templates/page-config-panel-url.html310
-rw-r--r--modules-available/locationinfo/templates/page-locations.html40
-rw-r--r--modules-available/locationinfo/templates/page-servers.html5
-rw-r--r--modules-available/locationinfo/templates/server-prop-bool.html12
-rw-r--r--modules-available/locationinfo/templates/server-prop-dropdown.html12
-rw-r--r--modules-available/locationinfo/templates/server-prop-generic.html12
-rw-r--r--modules-available/locations/baseconfig/getconfig.inc.php30
-rw-r--r--modules-available/locations/baseconfig/hook.json3
-rw-r--r--modules-available/locations/clientscript.js205
-rw-r--r--modules-available/locations/config.json8
-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.php119
-rw-r--r--modules-available/locations/inc/locationhooks.inc.php29
-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.php28
-rw-r--r--modules-available/locations/lang/de/messages.json6
-rw-r--r--modules-available/locations/lang/de/module.json2
-rw-r--r--modules-available/locations/lang/de/permissions.json1
-rw-r--r--modules-available/locations/lang/de/template-tags.json34
-rw-r--r--modules-available/locations/lang/en/messages.json6
-rw-r--r--modules-available/locations/lang/en/module.json2
-rw-r--r--modules-available/locations/lang/en/permissions.json1
-rw-r--r--modules-available/locations/lang/en/template-tags.json36
-rw-r--r--modules-available/locations/pages/cleanup.inc.php9
-rw-r--r--modules-available/locations/pages/details.inc.php259
-rw-r--r--modules-available/locations/pages/locations.inc.php171
-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.css15
-rw-r--r--modules-available/locations/templates/ajax-opening-location.html263
-rw-r--r--modules-available/locations/templates/location-subnets.html49
-rw-r--r--modules-available/locations/templates/locations.html122
-rw-r--r--modules-available/locations/templates/mismatch-cleanup.html24
-rw-r--r--modules-available/main/hooks/cron.inc.php6
-rw-r--r--modules-available/main/hooks/translation.inc.php5
-rw-r--r--modules-available/main/install.inc.php76
-rw-r--r--modules-available/main/lang/de/categories.json1
-rw-r--r--modules-available/main/lang/de/messages.json1
-rw-r--r--modules-available/main/lang/de/template-tags.json1
-rw-r--r--modules-available/main/lang/en/messages.json1
-rw-r--r--modules-available/main/lang/en/template-tags.json7
-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/hooks/bootup.inc.php3
-rw-r--r--modules-available/minilinux/hooks/ipxe-bootentry.inc.php142
-rw-r--r--modules-available/minilinux/hooks/main-warning.inc.php2
-rw-r--r--modules-available/minilinux/inc/linuxbootentryhook.inc.php193
-rw-r--r--modules-available/minilinux/inc/minilinux.inc.php331
-rw-r--r--modules-available/minilinux/install.inc.php34
-rw-r--r--modules-available/minilinux/lang/de/messages.json12
-rw-r--r--modules-available/minilinux/lang/de/module.json11
-rw-r--r--modules-available/minilinux/lang/de/permissions.json5
-rw-r--r--modules-available/minilinux/lang/de/template-tags.json14
-rw-r--r--modules-available/minilinux/lang/en/messages.json6
-rw-r--r--modules-available/minilinux/lang/en/module.json11
-rw-r--r--modules-available/minilinux/lang/en/permissions.json5
-rw-r--r--modules-available/minilinux/lang/en/template-tags.json42
-rw-r--r--modules-available/minilinux/page.inc.php148
-rw-r--r--modules-available/minilinux/templates/branches.html38
-rw-r--r--modules-available/minilinux/templates/filelist.html17
-rw-r--r--modules-available/minilinux/templates/page-minilinux.html17
-rw-r--r--modules-available/minilinux/templates/sources.html4
-rw-r--r--modules-available/minilinux/templates/versionlist.html56
-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.php39
-rw-r--r--modules-available/permissionmanager/inc/permissiondbupdate.inc.php21
-rw-r--r--modules-available/permissionmanager/inc/permissionutil.inc.php65
-rw-r--r--modules-available/permissionmanager/install.inc.php70
-rw-r--r--modules-available/permissionmanager/lang/de/messages.json4
-rw-r--r--modules-available/permissionmanager/lang/de/template-tags.json1
-rw-r--r--modules-available/permissionmanager/lang/en/messages.json4
-rw-r--r--modules-available/permissionmanager/lang/en/template-tags.json1
-rw-r--r--modules-available/permissionmanager/page.inc.php59
-rw-r--r--modules-available/permissionmanager/templates/roleeditor.html7
-rw-r--r--modules-available/permissionmanager/templates/rolestable.html16
-rw-r--r--modules-available/rebootcontrol/api.inc.php36
-rw-r--r--modules-available/rebootcontrol/clientscript.js27
-rw-r--r--modules-available/rebootcontrol/config.json3
-rw-r--r--modules-available/rebootcontrol/hooks/client-update.inc.php25
-rw-r--r--modules-available/rebootcontrol/hooks/config-tgz.inc.php6
-rw-r--r--modules-available/rebootcontrol/hooks/cron.inc.php253
-rw-r--r--modules-available/rebootcontrol/inc/rebootcontrol.inc.php519
-rw-r--r--modules-available/rebootcontrol/inc/rebootqueries.inc.php58
-rw-r--r--modules-available/rebootcontrol/inc/rebootutils.inc.php74
-rw-r--r--modules-available/rebootcontrol/inc/scheduler.inc.php330
-rw-r--r--modules-available/rebootcontrol/inc/sshkey.inc.php27
-rw-r--r--modules-available/rebootcontrol/install.inc.php77
-rw-r--r--modules-available/rebootcontrol/lang/de/messages.json18
-rw-r--r--modules-available/rebootcontrol/lang/de/module.json6
-rw-r--r--modules-available/rebootcontrol/lang/de/permissions.json16
-rw-r--r--modules-available/rebootcontrol/lang/de/template-tags.json84
-rw-r--r--modules-available/rebootcontrol/lang/en/messages.json18
-rw-r--r--modules-available/rebootcontrol/lang/en/module.json6
-rw-r--r--modules-available/rebootcontrol/lang/en/permissions.json16
-rw-r--r--modules-available/rebootcontrol/lang/en/template-tags.json88
-rw-r--r--modules-available/rebootcontrol/page.inc.php241
-rw-r--r--modules-available/rebootcontrol/pages/exec.inc.php57
-rw-r--r--modules-available/rebootcontrol/pages/jumphost.inc.php222
-rw-r--r--modules-available/rebootcontrol/pages/subnet.inc.php179
-rw-r--r--modules-available/rebootcontrol/pages/task.inc.php147
-rw-r--r--modules-available/rebootcontrol/permissions/permissions.json30
-rw-r--r--modules-available/rebootcontrol/style.css30
-rw-r--r--modules-available/rebootcontrol/templates/_page.html184
-rw-r--r--modules-available/rebootcontrol/templates/exec-enter-command.html43
-rw-r--r--modules-available/rebootcontrol/templates/header.html125
-rw-r--r--modules-available/rebootcontrol/templates/jumphost-edit.html42
-rw-r--r--modules-available/rebootcontrol/templates/jumphost-list.html63
-rw-r--r--modules-available/rebootcontrol/templates/jumphost-subnets.html28
-rw-r--r--modules-available/rebootcontrol/templates/status-checkconnection.html47
-rw-r--r--modules-available/rebootcontrol/templates/status-exec.html76
-rw-r--r--modules-available/rebootcontrol/templates/status-reboot.html (renamed from modules-available/rebootcontrol/templates/status.html)64
-rw-r--r--modules-available/rebootcontrol/templates/status-wol.html82
-rw-r--r--modules-available/rebootcontrol/templates/subnet-edit.html78
-rw-r--r--modules-available/rebootcontrol/templates/subnet-list.html78
-rw-r--r--modules-available/rebootcontrol/templates/task-header.html4
-rw-r--r--modules-available/rebootcontrol/templates/task-list.html24
-rw-r--r--modules-available/remoteaccess/api.inc.php98
-rw-r--r--modules-available/remoteaccess/baseconfig/getconfig.inc.php50
-rw-r--r--modules-available/remoteaccess/config.json7
-rw-r--r--modules-available/remoteaccess/hooks/client-update.inc.php9
-rw-r--r--modules-available/remoteaccess/hooks/cron.inc.php3
-rw-r--r--modules-available/remoteaccess/inc/remoteaccess.inc.php117
-rw-r--r--modules-available/remoteaccess/install.inc.php80
-rw-r--r--modules-available/remoteaccess/lang/de/messages.json8
-rw-r--r--modules-available/remoteaccess/lang/de/module.json4
-rw-r--r--modules-available/remoteaccess/lang/de/permissions.json7
-rw-r--r--modules-available/remoteaccess/lang/de/template-tags.json27
-rw-r--r--modules-available/remoteaccess/lang/en/messages.json8
-rw-r--r--modules-available/remoteaccess/lang/en/module.json4
-rw-r--r--modules-available/remoteaccess/lang/en/permissions.json7
-rw-r--r--modules-available/remoteaccess/lang/en/template-tags.json27
-rw-r--r--modules-available/remoteaccess/page.inc.php196
-rw-r--r--modules-available/remoteaccess/permissions/permissions.json17
-rw-r--r--modules-available/remoteaccess/templates/edit-group.html56
-rw-r--r--modules-available/remoteaccess/templates/edit-settings.html142
-rw-r--r--modules-available/roomplanner/api.inc.php6
-rw-r--r--modules-available/roomplanner/clientscript.js4
-rw-r--r--modules-available/roomplanner/hooks/client-update.inc.php19
-rw-r--r--modules-available/roomplanner/images/electricalDevices_wIP/copier-east.pngbin0 -> 39293 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_wIP/copier-north.pngbin0 -> 36298 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_wIP/copier-south.png (renamed from modules-available/roomplanner/images/electricalDevices_wIP/copier.png)bin35255 -> 35255 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_wIP/copier-west.pngbin0 -> 39294 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_wIP/pc.pngbin45209 -> 0 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_wIP/printer-east.pngbin0 -> 9589 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_wIP/printer-north.pngbin0 -> 10211 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_wIP/printer-south.png (renamed from modules-available/roomplanner/images/electricalDevices_wIP/printer.png)bin9472 -> 9472 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_wIP/printer-west.pngbin0 -> 9644 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_wIP/telephone-east.pngbin0 -> 33545 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_wIP/telephone-north.pngbin0 -> 33815 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_wIP/telephone-south.png (renamed from modules-available/roomplanner/images/electricalDevices_wIP/telephone.png)bin31658 -> 31658 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_wIP/telephone-west.pngbin0 -> 33511 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_woIP/flatscreen-east.png (renamed from modules-available/roomplanner/images/electricalDevices_woIP/flatscreen.png)bin23508 -> 23508 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_woIP/flatscreen-north.pngbin0 -> 23804 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_woIP/flatscreen-south.pngbin0 -> 23454 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_woIP/flatscreen-west.pngbin0 -> 24322 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_woIP/lamp-east.png (renamed from modules-available/roomplanner/images/electricalDevices_woIP/lamp.png)bin59715 -> 59715 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_woIP/lamp-north.pngbin0 -> 63087 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_woIP/lamp-south.pngbin0 -> 63086 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_woIP/lamp-west.pngbin0 -> 60989 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_woIP/tvcamera-east.png (renamed from modules-available/roomplanner/images/electricalDevices_woIP/tvcamera.png)bin6066 -> 6066 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_woIP/tvcamera-north.pngbin0 -> 7330 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_woIP/tvcamera-south.pngbin0 -> 7376 bytes
-rw-r--r--modules-available/roomplanner/images/electricalDevices_woIP/tvcamera-west.pngbin0 -> 6475 bytes
-rw-r--r--modules-available/roomplanner/inc/composedroom.inc.php21
-rw-r--r--modules-available/roomplanner/inc/pvsgenerator.inc.php22
-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/js/grid.js16
-rw-r--r--modules-available/roomplanner/js/init.js18
-rw-r--r--modules-available/roomplanner/js/lib/jquery-ui-draggable-collision.js4
-rw-r--r--modules-available/roomplanner/lang/de/messages.json2
-rw-r--r--modules-available/roomplanner/lang/de/template-tags.json7
-rw-r--r--modules-available/roomplanner/lang/en/messages.json2
-rw-r--r--modules-available/roomplanner/lang/en/template-tags.json7
-rw-r--r--modules-available/roomplanner/page.inc.php119
-rw-r--r--modules-available/roomplanner/style.css137
-rw-r--r--modules-available/roomplanner/templates/item-selector.html16
-rw-r--r--modules-available/roomplanner/templates/svg-plan.html26
-rw-r--r--modules-available/runmode/baseconfig/getconfig.inc.php15
-rw-r--r--modules-available/runmode/inc/runmode.inc.php89
-rw-r--r--modules-available/runmode/page.inc.php52
-rw-r--r--modules-available/serversetup-bwlp-ipxe/api.inc.php319
-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.php244
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/bootentryhook.inc.php67
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/execdata.inc.php9
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/ipxe.inc.php211
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/ipxebuilder.inc.php80
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/ipxemenu.inc.php189
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/localboot.inc.php37
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/menuentry.inc.php119
-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.php102
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderbash.inc.php97
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/scriptbuildergrub.inc.php330
-rw-r--r--modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderipxe.inc.php478
-rw-r--r--modules-available/serversetup-bwlp-ipxe/install.inc.php6
-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.json13
-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.json20
-rw-r--r--modules-available/serversetup-bwlp-ipxe/page.inc.php241
-rw-r--r--modules-available/serversetup-bwlp-ipxe/templates/bootentry-list.html10
-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-new-boot-entry.html19
-rw-r--r--modules-available/serversetup-bwlp-ipxe/templates/ipxe_update.html28
-rw-r--r--modules-available/serversetup-bwlp-ipxe/templates/menu-assign-location.html9
-rw-r--r--modules-available/serversetup-bwlp-ipxe/templates/menu-edit.html25
-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.php232
-rw-r--r--modules-available/statistics/baseconfig/getconfig.inc.php41
-rw-r--r--modules-available/statistics/baseconfig/hook.json7
-rw-r--r--modules-available/statistics/clientscript.js76
-rw-r--r--modules-available/statistics/config.json2
-rw-r--r--modules-available/statistics/hooks/config-tgz.inc.php4
-rw-r--r--modules-available/statistics/hooks/cron.inc.php60
-rw-r--r--modules-available/statistics/hooks/locations-column.inc.php150
-rw-r--r--modules-available/statistics/hooks/translation.inc.php28
-rw-r--r--modules-available/statistics/inc/devicetype.inc.php6
-rw-r--r--modules-available/statistics/inc/filter.inc.php308
-rw-r--r--modules-available/statistics/inc/filterset.inc.php143
-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.php398
-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.php855
-rw-r--r--modules-available/statistics/inc/statisticsfilterset.inc.php139
-rw-r--r--modules-available/statistics/inc/statisticshooks.inc.php55
-rw-r--r--modules-available/statistics/inc/statisticsstyling.inc.php62
-rw-r--r--modules-available/statistics/install.inc.php150
-rw-r--r--modules-available/statistics/lang/de/filters.json29
-rw-r--r--modules-available/statistics/lang/de/messages.json7
-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.json69
-rw-r--r--modules-available/statistics/lang/en/filters.json29
-rw-r--r--modules-available/statistics/lang/en/messages.json7
-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.json69
-rw-r--r--modules-available/statistics/page.inc.php1383
-rw-r--r--modules-available/statistics/pages/hints.inc.php221
-rw-r--r--modules-available/statistics/pages/list.inc.php220
-rw-r--r--modules-available/statistics/pages/machine.inc.php753
-rw-r--r--modules-available/statistics/pages/projectors.inc.php6
-rw-r--r--modules-available/statistics/pages/replace.inc.php33
-rw-r--r--modules-available/statistics/pages/summary.inc.php364
-rw-r--r--modules-available/statistics/permissions/permissions.json6
-rw-r--r--modules-available/statistics/style.css59
-rw-r--r--modules-available/statistics/templates/clientlist.html303
-rw-r--r--modules-available/statistics/templates/cpumodels.html35
-rw-r--r--modules-available/statistics/templates/filterbox.html322
-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.html27
-rw-r--r--modules-available/statistics/templates/machine-hdds.html76
-rw-r--r--modules-available/statistics/templates/machine-main.html232
-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/config.json3
-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.php125
-rw-r--r--modules-available/statistics_reporting/inc/remotereport.inc.php97
-rw-r--r--modules-available/statistics_reporting/page.inc.php53
-rw-r--r--modules-available/statistics_reporting/templates/columnChooser.html38
-rw-r--r--modules-available/sysconfig/addconfig.inc.php86
-rw-r--r--modules-available/sysconfig/addmodule.inc.php131
-rw-r--r--modules-available/sysconfig/addmodule_adauth.inc.php61
-rw-r--r--modules-available/sysconfig/addmodule_branding.inc.php88
-rw-r--r--modules-available/sysconfig/addmodule_custommodule.inc.php78
-rw-r--r--modules-available/sysconfig/addmodule_ldapauth.inc.php45
-rw-r--r--modules-available/sysconfig/addmodule_screensaver.inc.php246
-rw-r--r--modules-available/sysconfig/addmodule_sshconfig.inc.php42
-rw-r--r--modules-available/sysconfig/addmodule_sshkey.inc.php72
-rw-r--r--modules-available/sysconfig/api.inc.php86
-rw-r--r--modules-available/sysconfig/clientscript.js195
-rw-r--r--modules-available/sysconfig/hooks/bootup.inc.php3
-rw-r--r--modules-available/sysconfig/hooks/cron.inc.php2
-rw-r--r--modules-available/sysconfig/hooks/locations-column.inc.php57
-rw-r--r--modules-available/sysconfig/inc/configmodule.inc.php236
-rw-r--r--modules-available/sysconfig/inc/configmodule/adauth.inc.php3
-rw-r--r--modules-available/sysconfig/inc/configmodule/branding.inc.php23
-rw-r--r--modules-available/sysconfig/inc/configmodule/customodule.inc.php38
-rw-r--r--modules-available/sysconfig/inc/configmodule/ldapauth.inc.php5
-rw-r--r--modules-available/sysconfig/inc/configmodule/screensaver.inc.php102
-rw-r--r--modules-available/sysconfig/inc/configmodule/sshconfig.inc.php41
-rw-r--r--modules-available/sysconfig/inc/configmodule/sshkey.inc.php55
-rw-r--r--modules-available/sysconfig/inc/configmodulebaseldap.inc.php68
-rw-r--r--modules-available/sysconfig/inc/configtgz.inc.php292
-rw-r--r--modules-available/sysconfig/inc/ldap.inc.php2
-rw-r--r--modules-available/sysconfig/inc/ppd.inc.php255
-rw-r--r--modules-available/sysconfig/inc/sysconfig.inc.php33
-rw-r--r--modules-available/sysconfig/install.inc.php87
-rw-r--r--modules-available/sysconfig/lang/de/config-module.json12
-rw-r--r--modules-available/sysconfig/lang/de/messages.json3
-rw-r--r--modules-available/sysconfig/lang/de/module.json21
-rw-r--r--modules-available/sysconfig/lang/de/template-tags.json40
-rw-r--r--modules-available/sysconfig/lang/en/config-module.json12
-rw-r--r--modules-available/sysconfig/lang/en/messages.json3
-rw-r--r--modules-available/sysconfig/lang/en/module.json21
-rw-r--r--modules-available/sysconfig/lang/en/template-tags.json42
-rw-r--r--modules-available/sysconfig/page.inc.php165
-rw-r--r--modules-available/sysconfig/templates/ad-selfsearch.html2
-rw-r--r--modules-available/sysconfig/templates/ad-start.html18
-rw-r--r--modules-available/sysconfig/templates/ad_ldap-checkconnection.html2
-rw-r--r--modules-available/sysconfig/templates/ad_ldap-checkcredentials.html3
-rw-r--r--modules-available/sysconfig/templates/ad_ldap-homedir.html1
-rw-r--r--modules-available/sysconfig/templates/assign.html31
-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-filelist.html18
-rw-r--r--modules-available/sysconfig/templates/custom-fileselect.html36
-rw-r--r--modules-available/sysconfig/templates/js.html9
-rw-r--r--modules-available/sysconfig/templates/ldap-finish.html1
-rw-r--r--modules-available/sysconfig/templates/ldap-start.html16
-rw-r--r--modules-available/sysconfig/templates/list-configs.html18
-rw-r--r--modules-available/sysconfig/templates/list-legend.html4
-rw-r--r--modules-available/sysconfig/templates/list-modules.html15
-rw-r--r--modules-available/sysconfig/templates/screensaver-start.html123
-rw-r--r--modules-available/sysconfig/templates/screensaver-text.html121
-rw-r--r--modules-available/sysconfig/templates/sshconfig-start.html38
-rw-r--r--modules-available/sysconfig/templates/sshkey-start.html21
-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.php27
-rw-r--r--modules-available/systemstatus/inc/systemstatus.inc.php160
-rw-r--r--modules-available/systemstatus/lang/de/messages.json5
-rw-r--r--modules-available/systemstatus/lang/de/module.json3
-rw-r--r--modules-available/systemstatus/lang/de/permissions.json9
-rw-r--r--modules-available/systemstatus/lang/de/template-tags.json26
-rw-r--r--modules-available/systemstatus/lang/en/messages.json5
-rw-r--r--modules-available/systemstatus/lang/en/module.json3
-rw-r--r--modules-available/systemstatus/lang/en/permissions.json9
-rw-r--r--modules-available/systemstatus/lang/en/template-tags.json42
-rw-r--r--modules-available/systemstatus/page.inc.php493
-rw-r--r--modules-available/systemstatus/permissions/permissions.json27
-rw-r--r--modules-available/systemstatus/templates/_page.html67
-rw-r--r--modules-available/systemstatus/templates/ajax-journal.html20
-rw-r--r--modules-available/systemstatus/templates/ajax-reboot.html14
-rw-r--r--modules-available/systemstatus/templates/diskstat.html2
-rw-r--r--modules-available/systemstatus/templates/services.html32
-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.html158
-rw-r--r--modules-available/translation/lang/en/template-tags.json2
-rw-r--r--modules-available/translation/page.inc.php165
-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.json12
-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.php9
-rw-r--r--modules-available/webinterface/hooks/config-tgz.inc.php6
-rw-r--r--modules-available/webinterface/lang/en/template-tags.json2
-rwxr-xr-xpack.sh10
-rw-r--r--script/slx-fixes.js3
-rw-r--r--script/taskmanager.js8
-rw-r--r--style/default.css16
-rw-r--r--tools/convert-modules.php3
-rw-r--r--tools/global-candidates.php75
-rw-r--r--tools/jedec.php4
682 files changed, 35777 insertions, 14163 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 b86f71cc..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,14 +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="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> \ 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 75d7f132..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,9 +62,22 @@ function getJobStatus($id)
}
// Hooks by other modules
-function handleModule($file)
+function handleModule(Hook $hook): void
{
- include_once $file;
+ global $cron_log_text;
+ $cron_log_text = '';
+ include_once $hook->file;
+ if (!empty($cron_log_text)) {
+ EventLog::info('CronJob ' . $hook->moduleId . ' finished.', $cron_log_text);
+ }
+}
+
+$cron_log_text = '';
+function cron_log($text)
+{
+ // XXX: Enable this code for debugging -- make this configurable some day
+ //global $cron_log_text;
+ //$cron_log_text .= $text . "\n";
}
$blocked = Property::getList(CRON_KEY_BLOCKED);
@@ -75,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;
}
@@ -93,10 +106,10 @@ foreach (Hook::load('cron') as $hook) {
$value = $hook->moduleId . '|' . time();
Property::addToList(CRON_KEY_STATUS, $value, 30);
try {
- handleModule($hook->file);
+ handleModule($hook);
} catch (Exception $e) {
// 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 b78ace80..6b291cd1 100644
--- a/config.php.example
+++ b/config.php.example
@@ -7,15 +7,13 @@ 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_TGZ_LIST_DIR', '/opt/openslx/configs');
+define('CONFIG_TM_PASSWORD', '%TM_OPENSLX_PASS%');
-define('CONFIG_REMOTE_ML', 'https://bwlp-masterserver.ruf.uni-freiburg.de/minilinux/sat_03');
+define('CONFIG_TGZ_LIST_DIR', '/opt/openslx/configs');
define('CONFIG_TFTP_DIR', '/srv/openslx/tftp');
define('CONFIG_HTTP_DIR', '/srv/openslx/www/boot');
@@ -24,9 +22,7 @@ define('CONFIG_IPXE_DIR', '/opt/openslx/ipxe');
define('CONFIG_VMSTORE_DIR', '/srv/openslx/nfs');
-define('CONFIG_PROXY_CONF', '/opt/openslx/proxy/config');
-
-/* for the dozmod API proxy cache */
+/* for the dozmod API proxy cache */
define('CONFIG_DOZMOD_URL', 'http://127.0.0.1:9080');
define('CONFIG_DOZMOD_EXPIRE', 60);
@@ -37,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,
@@ -49,12 +47,12 @@ $MENU_CAT_OVERRIDE = array(
'sysconfig', 'baseconfig', 'minilinux'
),
'main.settings-server' => array(
- 'serversetup', 'vmstore', 'webinterface', 'backup', 'dnbd3'
+ 'serversetup', 'vmstore', 'webinterface', 'backup', 'dnbd3', 'rebootcontrol'
),
'main.status' => array(
'systemstatus', 'eventlog', 'syslog', 'statistics', 'statistics_reporting'
),
'main.etc' => array(
- 'rebootcontrol', 'runmode', 'translation'
+ 'runmode', 'translation'
)
); \ No newline at end of file
diff --git a/doc/design_guidelines b/doc/design_guidelines
index defc626c..ba94e39e 100644
--- a/doc/design_guidelines
+++ b/doc/design_guidelines
@@ -15,7 +15,7 @@ Button colors:
speciel actions (e.g. send test mail) = yellow (btn-warning)
Secondary action (e.g. cancel) = white (btn-default)
-Prefered glyphicons:
+Preferred glyphicons:
add (create) = glyphicon-plus
add (assign) = glyphicon-share-alt
delete = glyphicon-trash
diff --git a/doc/locationinfo b/doc/locationinfo
index e8f4ea50..a4397480 100644
--- a/doc/locationinfo
+++ b/doc/locationinfo
@@ -64,7 +64,7 @@ optional:
daystoshow:[1,2,3,4,5,6,7] sets how many days the calendar shows
scale:[10-90] scales the calendar and Roomplan in mode 1
switchtime:[1-120] sets the time between switchen in mode 4 (in seconds)
- calupdate: Time the calender querys for updates,in minutes.
+ calupdate: Time the calendar queries for updates,in minutes.
roomupdate: Time the PCs in the room gets updated,in seconds.
rotation:[0-3] rotation of the roomplan
vertical:[true] only mode 1, sets the calendar above the roomplan
@@ -80,7 +80,7 @@ overwrite it for all rooms.
First you need an Image(svg,png,jpg), add it to ./locationinfo/frontend/img/overlay.
You can add your own css class if you want. To do so create an css calss named .overlay-YOUR_IMAGE_NAME in the doorsign.html.
You can find an example in the doorsign.html called ".overlay-rollstuhl".
-The backend functionaltiy is right now not implemented since it relays on the roominfo module.
+The backend functionality is right now not implemented since it relays on the roominfo module.
But you can add it manually.
You need to add the image name (without ending) in the machine database on the position column with the key overlays in an array.
diff --git a/inc/arrayutil.inc.php b/inc/arrayutil.inc.php
new file mode 100644
index 00000000..3d93d7d5
--- /dev/null
+++ b/inc/arrayutil.inc.php
@@ -0,0 +1,83 @@
+<?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.
+ */
+ public static function flattenByKey(array $list, string $key): array
+ {
+ 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.
+ */
+ public static function mergeByKey(array $arrays): array
+ {
+ $empty = array_combine(array_keys($arrays), array_fill(0, count($arrays), false));
+ $out = [];
+ foreach ($arrays as $subkey => $array) {
+ foreach ($array as $key => $item) {
+ if (!isset($out[$key])) {
+ $out[$key] = $empty;
+ }
+ $out[$key][$subkey] = $item;
+ }
+ }
+ 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 3b2414b5..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,36 +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 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();
});
@@ -53,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();
}
/**
@@ -68,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();
}
/**
@@ -81,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)
@@ -90,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)
@@ -107,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;
}
@@ -132,27 +184,32 @@ 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
$oquery = $query;
self::handleArrayArgument($query, $args);
- try {
- if (!isset(self::$statements[$query])) {
- self::$statements[$query] = self::$dbh->prepare($query);
- } else {
- //self::$statements[$query]->closeCursor();
+ // Now turn any bools into 0 or 1, since PDO unfortunately only does (string)<bool>, which
+ // results in an empty string for false
+ foreach ($args as &$arg) {
+ if ($arg === false) {
+ $arg = '0';
+ } elseif ($arg === true) {
+ $arg = '1';
}
+ }
+ try {
+ $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;
@@ -165,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()
@@ -182,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;
@@ -202,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;
@@ -215,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;
}
@@ -236,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 . "|");
}
@@ -254,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) {
@@ -269,7 +326,7 @@ class Database
continue;
}
$newkey = $key;
- if ($newkey{0} !== ':') {
+ if ($newkey[0] !== ':') {
$newkey = ":$newkey";
}
$new = array();
@@ -296,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
@@ -324,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';
@@ -392,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 935d1f4e..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;
@@ -28,7 +29,8 @@ class Dictionary
//Changes the language in case there is a request to
$lang = Request::get('lang');
if ($lang !== false && in_array($lang, self::$languages)) {
- setcookie('lang', $lang, time() + 60 * 60 * 24 * 30 * 12);
+ Util::clearCookie('lang');
+ setcookie('lang', $lang, time() + 86400 * 30 * 12);
$url = Request::get('url');
if ($url === false && isset($_SERVER['HTTP_REFERER'])) {
$url = $_SERVER['HTTP_REFERER'];
@@ -63,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;
}
@@ -100,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])) {
@@ -120,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
@@ -134,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);
@@ -150,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) {
@@ -164,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;
@@ -189,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");
@@ -204,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;
@@ -216,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");
@@ -238,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
- * @retrun string html code of img tag for 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 16e5323a..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;
@@ -41,42 +43,30 @@ class Event
// Tasks: fire away
$mountStatus = false;
$mountId = Trigger::mount();
- $ldadpId = Trigger::ldadp();
$ipxeId = Trigger::ipxe();
// Check status of all tasks
// Mount vm store
- if ($mountId === false) {
+ if ($mountId === null) {
EventLog::info('No VM store type defined.');
$everythingFine = false;
} else {
$mountStatus = Taskmanager::waitComplete($mountId, 5000);
}
- // LDAP AD Proxy
- if ($ldadpId === false) {
- EventLog::failure('Cannot start LDAP-AD-Proxy: Taskmanager unreachable!');
- $everythingFine = false;
- } else {
- $res = Taskmanager::waitComplete($ldadpId, 5000);
- if (Taskmanager::isFailed($res)) {
- EventLog::failure('Starting LDAP-AD-Proxy failed', $res['data']['messages']);
- $everythingFine = false;
- }
- }
// Primary IP address
if (!$autoIp) {
EventLog::failure("The server's IP address could not be determined automatically, and there is no valid address configured.");
$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']);
+ EventLog::failure('Update PXE Menu failed', $res['data']['error'] ?? $res['statusCode'] ?? '');
$everythingFine = false;
}
}
@@ -90,10 +80,10 @@ class Event
$mountId = Trigger::mount();
$mountStatus = Taskmanager::waitComplete($mountId, 10000);
}
- if ($mountId !== false && Taskmanager::isFailed($mountStatus)) {
- EventLog::failure('Mounting VM store failed', $mountStatus['data']['messages']);
+ 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
}
@@ -108,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
@@ -116,15 +106,5 @@ class Event
}
}
- /**
- * The activated configuration changed.
- */
- public static function activeConfigChanged()
- {
- $task = Trigger::ldadp();
- if ($task === false)
- return;
- TaskmanagerCallback::addCallback($task, 'ldadpStartup');
- }
}
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
new file mode 100644
index 00000000..a50f22eb
--- /dev/null
+++ b/inc/iputil.inc.php
@@ -0,0 +1,78 @@
+<?php
+
+declare(strict_types=1);
+
+class IpUtil
+{
+
+ public static function rangeToCidr(int $start, int $end): string
+ {
+ $value = $start ^ $end;
+ if (!self::isAllOnes($value))
+ return 'NOT SUBNET: ' . long2ip($start) . '-' . long2ip($end);
+ $ones = self::bitLength($value);
+ return long2ip($start) . '/' . (32 - $ones);
+ }
+
+ public static function isValidSubnetRange(int $start, int $end): bool
+ {
+ return self::isAllOnes($start ^ $end);
+ }
+
+ /**
+ * Return number of bits required to represent
+ * this number.
+ * !! Assumes given number is 2^n - 1 !!
+ */
+ 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 (int)round(log($value) / 0.69314718055995);
+ }
+
+ /**
+ * Is the given number just ones if converted to
+ * binary (ignoring leading zeros)?
+ */
+ private static function isAllOnes(int $value): bool
+ {
+ return ($value & ($value + 1)) === 0;
+ }
+
+ /**
+ * Parse network range in CIDR notion, return
+ * ['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{start: int, end: int}|null start and end address, false on error
+ */
+ public static function parseCidr(string $cidr): ?array
+ {
+ $parts = explode('/', $cidr);
+ if (count($parts) !== 2) {
+ $ip = ip2long($cidr);
+ if ($ip === false)
+ return null;
+ return ['start' => $ip, 'end' => $ip];
+ }
+ $ip = $parts[0];
+ $bits = $parts[1];
+ if (!is_numeric($bits) || $bits < 0 || $bits > 32)
+ return null;
+ $dots = substr_count($ip, '.');
+ if ($dots < 3) {
+ $ip .= str_repeat('.0', 3 - $dots);
+ }
+ $ip = ip2long($ip);
+ if ($ip === false)
+ 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 5525c0a4..b072c4a2 100644
--- a/inc/module.inc.php
+++ b/inc/module.inc.php
@@ -1,5 +1,7 @@
<?php
+declare(strict_types=1);
+
class Module
{
/*
@@ -7,11 +9,16 @@ class Module
*/
/**
- * @var \Module[]
+ * @var ?Module[]
*/
- private static $modules = false;
-
- public static function get($name, $ignoreDepFail = false)
+ private static $modules = null;
+
+ /**
+ * @param string $name ID/Internal name of module
+ * @param bool $ignoreDepFail whether to return the module even if some of its dependencies failed
+ * @return false|Module
+ */
+ public static function get(string $name, bool $ignoreDepFail = false)
{
if (!isset(self::$modules[$name]))
return false;
@@ -23,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)
@@ -40,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;
@@ -52,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;
@@ -70,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();
@@ -91,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);
@@ -102,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;
@@ -115,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)
@@ -138,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;
@@ -156,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);
@@ -168,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;
@@ -223,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;
@@ -245,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')) {
@@ -298,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 3a7bdc36..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)) {
@@ -37,7 +42,7 @@ class Permission
continue;
$temp =& $temp[$sub];
}
- $temp = ['disabled' => 'disabled', 'readonly' => 'readonly'];
+ $temp = ['disabled' => 'disabled', 'readonly' => 'readonly', 'hidden' => 'hidden'];
}
if (!$one && !is_null($noneAvailDisabled)) {
$array[$noneAvailDisabled] = [
@@ -47,12 +52,31 @@ class Permission
}
}
- public static function moduleHasPermissions($moduleId)
+ public static function moduleHasPermissions(string $moduleId): bool
{
if (Module::get('permissionmanager') === false)
return true;
return file_exists('modules/' . $moduleId . '/permissions/permissions.json');
}
+ /**
+ * 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.
+ */
+ public static function mergeWithDisallowed(array $passedLocations, string $permission, string $query, array $params): array
+ {
+ $allowed = User::getAllowedLocations($permission);
+ if (in_array(0, $allowed))
+ return $passedLocations;
+ $passedLocations = array_intersect($passedLocations, $allowed);
+ $oldSet = Database::queryColumnArray($query, $params);
+ $oldSet = array_diff($oldSet, $allowed);
+ if (!empty($oldSet)) {
+ $passedLocations = array_unique(array_merge($passedLocations, $oldSet));
+ }
+ return $passedLocations;
+ }
+
}
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 f8f9e56b..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');
@@ -230,6 +229,11 @@ class Render
$params['password_type'] = Property::getPasswordFieldType();
// Branding
$params['product_name'] = defined('CONFIG_PRODUCT_NAME') ? CONFIG_PRODUCT_NAME : 'OpenSLX';
+ // Query string
+ if (strpos($_SERVER['QUERY_STRING'], 'message[]=') !== false) {
+ $_SERVER['QUERY_STRING'] = preg_replace('/message\[\]=[^&]+(&|$)/', '', $_SERVER['QUERY_STRING']);
+ }
+ $params['qstr_urlencode'] = rawurlencode('?' . $_SERVER['QUERY_STRING']);
// Return rendered html
return self::$mustache->render($html, $params);
}
@@ -239,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 {
@@ -252,22 +256,23 @@ class Render
}
/**
- * Close the given tag. Will check if it maches the tag last opened
+ * Close the given tag. Will check if it matches the tag last opened
*/
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])) {
@@ -282,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 515f723d..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, ...)
*/
@@ -7,17 +9,25 @@ class Request
{
/**
+ * Required and not empty
+ */
+ const REQUIRED = "\0\1\2REQ\0\1\2";
+
+ /**
+ * Required, but might be empty
+ */
+ const REQUIRED_EMPTY = "\0\3\4REQ\0\3\4";
+
+ /**
*
* @param string $key Key of field to get from $_GET
* @param string $default Value to return if $_GET does not contain $key
* @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)
{
- if (!isset($_GET[$key])) return $default;
- if ($type !== false) settype($_GET[$key], $type);
- return $_GET[$key];
+ return self::handle($_GET, $key, $default, $type);
}
/**
@@ -26,11 +36,9 @@ 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)
{
- if (!isset($_POST[$key])) return $default;
- if ($type !== false) settype($_POST[$key], $type);
- return $_POST[$key];
+ return self::handle($_POST, $key, $default, $type);
}
/**
@@ -39,17 +47,15 @@ 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)
{
- if (!isset($_REQUEST[$key])) return $default;
- if ($type !== false) settype($_REQUEST[$key], $type);
- return $_REQUEST[$key];
+ return self::handle($_REQUEST, $key, $default, $type);
}
/**
* @return true iff the request is a POST request
*/
- public static function isPost()
+ public static function isPost(): bool
{
return $_SERVER['REQUEST_METHOD'] === 'POST';
}
@@ -57,9 +63,26 @@ 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 (!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 && $array[$key] === '') {
+ Message::addError('main.parameter-empty', $key);
+ Util::redirect('?do=' . $_REQUEST['do']);
+ }
+ if ($type !== false) settype($array[$key], $type);
+ return $array[$key];
+ }
+
}
diff --git a/inc/session.inc.php b/inc/session.inc.php
index 24bf6ac0..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,90 +29,176 @@ 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;
- // Succeded, now try to load session data. If successful, job is done
- if (self::readSessionData()) return true;
+ if (!self::loadSessionId())
+ return false;
+ // Succeeded, now try to load session data. If successful, job is done
+ 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])) return false;
- return self::$data[$key];
+ 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];
}
- public static function set($key, $value)
+ /**
+ * @param string $key key of entry
+ * @param mixed $value data to store for key, false = delete
+ * @param int|false $validMinutes validity in minutes, or false = forever
+ */
+ 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;
+ 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
{
- setcookie('sid', '', time() - 8640000, null, null, !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off', true);
+ 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);
+ 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();
+ if ($row === false || $row['dateline'] < $now) {
+ self::delete();
return false;
- }
- self::$data = @unserialize(@file_get_contents($sessionfile));
- if (self::$data === false) 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]);
+ self::$dataChanged = true;
+ }
+ }
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.');
- $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 547a75d4..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,14 +21,27 @@ class Taskmanager
*/
private static $sock = false;
- private static function init()
+ private static function init(): void
{
if (self::$sock !== false)
return;
- self::$sock = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
- socket_set_option(self::$sock, SOL_SOCKET, SO_RCVTIMEO, array('sec' => 0, 'usec' => 300000));
- socket_set_option(self::$sock, SOL_SOCKET, SO_SNDTIMEO, array('sec' => 0, 'usec' => 200000));
- socket_connect(self::$sock, '127.0.0.1', 9215);
+ self::$sock = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
+ if (self::$sock === false)
+ return;
+ socket_set_option(self::$sock, SOL_SOCKET, SO_SNDTIMEO, array('sec' => 1, 'usec' => 0));
+ if (socket_connect(self::$sock, '127.0.0.1', 9215) === false)
+ return;
+ self::send(CONFIG_TM_PASSWORD);
+ }
+
+ private static function send(string $message): bool
+ {
+ $len = strlen($message);
+ $sent = socket_send(self::$sock, pack('N', $len) . $message, $len + 4, 0);
+ if ($sent === $len + 4)
+ return true;
+ self::reset();
+ return false;
}
/**
@@ -35,10 +50,10 @@ class Taskmanager
* @param string $task name of task to start
* @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 acutally be started)
- * @return array|false struct representing the task status (as a result of submit); false on communication error
+ * the return value is just true (and you won't know if the task could actually be started)
+ * @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();
@@ -48,8 +63,7 @@ class Taskmanager
$data = json_encode($data);
}
$message = "$seq, $task, $data";
- $sent = socket_send(self::$sock, $message, strlen($message), 0);
- if ($sent != strlen($message)) {
+ if (!self::send($message)) {
self::addErrorMessage(false);
return false;
}
@@ -79,7 +93,8 @@ class Taskmanager
self::init();
$seq = (string) mt_rand();
$message = "$seq, status, $task";
- socket_send(self::$sock, $message, strlen($message), 0);
+ if (!self::send($message))
+ return false;
$reply = self::readReply($seq);
if (!is_array($reply))
return false;
@@ -96,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;
@@ -114,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) {
@@ -127,7 +142,9 @@ class Taskmanager
return false;
$done = false;
$deadline = microtime(true) + $timeout / 1000;
- do {
+ $status = false;
+ while (($remaining = $deadline - microtime(true)) > 0) {
+ usleep((int)min(100000, $remaining * 100000));
$status = self::status($task);
if (!isset($status['statusCode']))
break;
@@ -135,8 +152,7 @@ class Taskmanager
$done = true;
break;
}
- usleep(100000);
- } while (microtime(true) < $deadline);
+ }
if ($done) { // For now we do this unconditionally, but maybe we want to keep them longer some time?
self::release($task);
}
@@ -150,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;
@@ -163,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;
@@ -179,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;
@@ -191,7 +207,7 @@ class Taskmanager
return false;
}
- public static function addErrorMessage($task)
+ public static function addErrorMessage($task): void
{
static $failure = false;
if ($task === false) {
@@ -218,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'];
@@ -228,43 +244,109 @@ class Taskmanager
self::init();
$seq = (string) mt_rand();
$message = "$seq, release, $task";
- socket_send(self::$sock, $message, strlen($message), 0);
+ self::send($message);
}
/**
* Read reply from socket for given sequence number.
*
- * @param string $seq
- * @return mixed the decoded json data for that message as an array, or null on error
+ * @return mixed the decoded json data for that message as an array, or false on error
*/
- private static function readReply($seq)
+ private static function readReply(string $seq)
{
$tries = 0;
- while (($bytes = @socket_recvfrom(self::$sock, $buf, 90000, 0, $bla1, $bla2)) !== false || socket_last_error() === 11) {
- $parts = explode(',', $buf, 2);
- // Do we have compressed data?
- if (substr($parts[0], 0, 3) === '+z:') {
- $parts[0] = substr($parts[0], 3);
- $gz = true;
- } else {
- $gz = false;
+ $deadline = microtime(true) + 2;
+ self::updateRecvTimeout($deadline);
+ while (($bytes = socket_recv(self::$sock, $buf, 4, MSG_WAITALL)) !== false) {
+ if ($bytes !== 4) {
+ error_log('TM: Short read');
+ self::reset();
+ return false;
+ }
+ $len = unpack('Nx', $buf)['x'];
+ if ($len < 0 || $len > 1024 * 1024) {
+ error_log('TM: Invalid payload length: ' . $len);
+ self::reset();
+ return false;
+ }
+ $message = '';
+ while ($len > 0) {
+ self::updateRecvTimeout($deadline);
+ $ret = socket_recv(self::$sock, $buf, $len, 0);
+ if ($ret === false) {
+ error_log('TM: Error reading payload');
+ self::reset();
+ return false;
+ }
+ if ($ret <= 0) {
+ error_log('TM: Taskmanager closed connection');
+ self::reset();
+ return false;
+ }
+ $message .= $buf;
+ $len -= $ret;
}
- // See if it's our message
- if (count($parts) === 2 && $parts[0] === $seq) {
- if ($gz) {
- $parts[1] = gzinflate($parts[1]);
- if ($parts[1] === false) {
- error_log('Taskmanager: Invalid deflate data received');
- continue;
+ $parts = explode(',', $message, 2);
+ if (count($parts) !== 2) {
+ error_log('TM: Invalid reply, no "," in payload');
+ } elseif ($parts[0] === 'ERROR') {
+ ErrorHandler::traceError('Taskmanager remote error: ' . $parts[1]);
+ } elseif ($parts[0] === 'WARNING') {
+ Message::addWarning('main.taskmanager-warning', $parts[1]);
+ } else {
+ // Do we have compressed data?
+ if (substr($parts[0], 0, 3) === '+z:') {
+ $parts[0] = substr($parts[0], 3);
+ $gz = true;
+ } else {
+ $gz = false;
+ }
+ // See if it's our message
+ if ($parts[0] === $seq) {
+ if ($gz) {
+ $parts[1] = gzinflate($parts[1]);
+ if ($parts[1] === false) {
+ error_log('TM: Invalid deflate data received');
+ continue;
+ }
}
+ return json_decode($parts[1], true);
}
- return json_decode($parts[1], true);
}
if (++$tries > 10)
return false;
}
- error_log('Reading taskmanager reply failed, socket error ' . socket_last_error());
+ error_log('TM: Reading reply failed, socket error ' . socket_last_error());
return false;
}
+ /**
+ * Closes connection and resets the variable.
+ * Should be called if something goes wrong when
+ * sending or receiving and the send or receive
+ * buffer might be in an undefined state.
+ */
+ private static function reset(): void
+ {
+ if (self::$sock === false)
+ return;
+ socket_close(self::$sock);
+ self::$sock = false;
+ }
+
+ /**
+ * @param float $deadline end time
+ */
+ private static function updateRecvTimeout(float $deadline): void
+ {
+ $to = $deadline - microtime(true);
+ if ($to <= 0) {
+ $to = ['sec' => 0, 'usec' => 1];
+ } else {
+ $s = (int)$to;
+ $to = ['sec' => $s, 'usec' => (int)(($to - $s) * 1000000)];
+ }
+ socket_set_option(self::$sock, SOL_SOCKET, SO_RCVTIMEO, $to);
+ }
+
}
diff --git a/inc/taskmanagercallback.inc.php b/inc/taskmanagercallback.inc.php
index 0c4b116a..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)
@@ -159,11 +162,11 @@ class TaskmanagerCallback
if (Taskmanager::isFailed($task)) {
ConfigTgz::generateFailed($task, $args);
} else {
- ConfigTgz::generateSucceeded($args);
+ ConfigTgz::generateSucceeded($task, $args);
}
}
- 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,20 +204,40 @@ class TaskmanagerCallback
MiniLinux::linuxDownloadCallback($task, $args);
}
- public static function uploadimg($task)
+ public static function rbcConnCheck(array $task, $args)
{
- //$string=var_export($task, true);
- //file_put_contents('teste.txt',$string);
- if (Taskmanager::isFailed($task)){
- EventLog::warning("ApiUpload failed: ", $task['data']['messages']);
- }
- $ret = Database::exec("UPDATE upload SET is_ready = :ready," .
- " path = :path WHERE token = :token", array(
- "ready"=>true,
- "path"=>$task['data']['path'],
- "token"=>$task['data']['token']
- ));
+ $mod = Module::get('rebootcontrol');
+ if ($mod === false)
+ return;
+ $mod->activate(1, false);
+ 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 988b31c6..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();
@@ -94,54 +97,20 @@ class Trigger
}
/**
- * Launch all ldadp instances that need to be running.
- *
- * @param int $exclude if not NULL, id of config module NOT to start
- * @param string $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($exclude = NULL, $parent = NULL)
- {
- // TODO: Fetch list from ConfigModule_AdAuth (call loadDb first)
- $res = Database::simpleQuery("SELECT DISTINCT moduleid FROM configtgz_module"
- . " INNER JOIN configtgz_x_module USING (moduleid)"
- . " INNER JOIN configtgz USING (configid)"
- . " INNER JOIN configtgz_location USING (configid)"
- . " WHERE moduletype IN ('AdAuth', 'LdapAuth')");
- $id = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- if (!is_null($exclude) && (int)$row['moduleid'] === (int)$exclude)
- continue;
- $id[] = (int)$row['moduleid'];
- }
- $task = Taskmanager::submit('LdadpLauncher', array(
- 'ids' => $id,
- 'parentTask' => $parent,
- 'failOnParentFail' => false
- ));
- if (!isset($task['id']))
- return false;
- return $task['id'];
- }
-
- /**
* Mount the VM store into the server.
*
- * @param array $vmstore VM Store configuration to use. If false, read from properties
- * @return array|false task status of mount procedure, or false on error
+ * @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 task id of mount procedure, or false on error
*/
- public static function mount($vmstore = 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';
@@ -152,18 +121,24 @@ class Trigger
$opts = null;
$addr = 'null';
}
- if (isset($vmstore[$opts])) {
- $opts = $vmstore[$opts];
- }else {
- $opts = null;
- }
- return Taskmanager::submit('MountVmStore', array(
+ // Bail out if storage is not local, and we only want to run it in that case
+ if ($ifLocalOnly && $addr !== 'null')
+ return null;
+ $opts = $vmstore[$opts] ?? null;
+ $status = Taskmanager::submit('MountVmStore', array(
'address' => $addr,
'type' => 'images',
'opts' => $opts,
+ 'localNfs' => !Module::isAvailable('dnbd3') || !Dnbd3::isEnabled() || Dnbd3::hasNfsFallback(),
'username' => $vmstore['cifsuser'],
'password' => $vmstore['cifspasswd']
));
+ if (!Taskmanager::isFailed($status)) {
+ // In case we have a concurrent active task, this should be enough
+ // for the taskmanager to give us the existing id
+ $status = Taskmanager::waitComplete($status, 100);
+ }
+ return $status['data']['existingTask'] ?? $status['id'] ?? null;
}
/**
@@ -171,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();
@@ -182,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,
@@ -215,7 +191,10 @@ class Trigger
return $parent;
}
- public static function stopDaemons($parent, &$taskids)
+ /**
+ * Stop any daemons that might be sitting on the VMstore, or database.
+ */
+ public static function stopDaemons(?string $parent, array &$taskids): ?string
{
$parent = self::triggerDaemons('stop', $parent, $taskids);
$task = Taskmanager::submit('LdadpLauncher', array(
@@ -230,15 +209,4 @@ class Trigger
return $parent;
}
- public static function startDaemons($parent, &$taskids)
- {
- $parent = self::triggerDaemons('start', $parent, $taskids);
- $taskid = self::ldadp($parent);
- if ($taskid !== false) {
- $taskids['ldadpid'] = $taskid;
- $parent = $taskid;
- }
- return $parent;
- }
-
}
diff --git a/inc/up_json_encode.php b/inc/up_json_encode.php
deleted file mode 100644
index ac47ef51..00000000
--- a/inc/up_json_encode.php
+++ /dev/null
@@ -1,180 +0,0 @@
-<?php
-
-if (defined('JSON_PRETTY_PRINT'))
- define('JSON_NATIVE', true);
-else
- define('JSON_NATIVE', false);
-
-/**
- * api: php
- * title: upgrade.php
- * description: Emulates functions from new PHP versions on older interpreters.
- * version: 19
- * license: Public Domain
- * url: http://freshmeat.net/projects/upgradephp
- * type: functions
- * category: library
- * priority: auto
- * load_if: (PHP_VERSION<5.2)
- * sort: -255
- * provides: upgrade-php, api:php5, json
- *
- *
- * By loading this library you get PHP version independence. It provides
- * downwards compatibility to older PHP interpreters by emulating missing
- * functions or constants using IDENTICAL NAMES. So this doesn't slow down
- * script execution on setups where the native functions already exist. It
- * is meant as quick drop-in solution. It spares you from rewriting code or
- * using cumbersome workarounds instead of the more powerful v5 functions.
- *
- * It cannot mirror PHP5s extended OO-semantics and functionality into PHP4
- * however. A few features are added here that weren't part of PHP yet. And
- * some other function collections are separated out into the ext/ directory.
- * It doesn't produce many custom error messages (YAGNI), and instead leaves
- * reporting to invoked functions or for native PHP execution.
- *
- * And further this is PUBLIC DOMAIN (no copyright, no license, no warranty)
- * so therefore compatible to ALL open source licenses. You could rip this
- * paragraph out to republish this instead only under more restrictive terms
- * or your favorite license (GNU LGPL/GPL, BSDL, MPL/CDDL, Artistic/PHPL, ..)
- *
- * Any contribution is appreciated. <milky*users#sf#net>
- *
- */
-/**
- * -------------------------- FUTURE ---
- * @group SVN
- * @since future
- *
- * Following functions aren't implemented in current PHP versions, but
- * might already be in CVS/SVN.
- *
- * @removed
- * setcookie2
- *
- */
-/**
- * Converts PHP variable or array into a "JSON" (JavaScript value expression
- * or "object notation") string.
- *
- * @compat
- * Output seems identical to PECL versions. "Only" 20x slower than PECL version.
- * @bugs
- * Doesn't take care with unicode too much - leaves UTF-8 sequences alone.
- *
- * @param $var mixed PHP variable/array/object
- * @return string transformed into JSON equivalent
- */
-if (!defined("JSON_HEX_TAG")) {
- define("JSON_HEX_TAG", 1);
- define("JSON_HEX_AMP", 2);
- define("JSON_HEX_APOS", 4);
- define("JSON_HEX_QUOT", 8);
- define("JSON_FORCE_OBJECT", 16);
-}
-if (!defined("JSON_NUMERIC_CHECK")) {
- define("JSON_NUMERIC_CHECK", 32); // 5.3.3
-}
-if (!defined("JSON_UNESCAPED_SLASHES")) {
- define("JSON_UNESCAPED_SLASHES", 64); // 5.4.0
- define("JSON_PRETTY_PRINT", 128); // 5.4.0
- define("JSON_UNESCAPED_UNICODE", 256); // 5.4.0
-}
-
-function up_json_encode($var, $options = 0, $_indent = "")
-{
- if (defined('JSON_NATIVE') && JSON_NATIVE)
- return json_encode($var, $options);
- global ${'.json_last_error'};
- ${'.json_last_error'} = JSON_ERROR_NONE;
-
- #-- prepare JSON string
- list($_space, $_tab, $_nl) = ($options & JSON_PRETTY_PRINT) ? array(" ", " $_indent", "\n") : array("", "", "");
-
- if (($options & JSON_NUMERIC_CHECK) and is_string($var) and is_numeric($var)) {
- $var = (strpos($var, ".") || strpos($var, "e")) ? floatval($var) : intval($var);
- }
-
- #-- add array entries
- if (is_array($var) || ($obj = is_object($var))) {
- $obj = is_object($var);
- #-- check if array is associative
- if (!$obj && !($options & JSON_FORCE_OBJECT)) {
- $keys = array_keys($var);
- sort($keys);
- for ($i = 0; $i < count($keys); ++$i) {
- if (!is_numeric($keys[$i]) || (int)$keys[$i] !== $i)
- $obj = true;
- }
- } else {
- $obj = true;
- }
-
- #-- concat individual entries
- $empty = 0;
- $json = "";
- foreach ((array) $var as $i => $v) {
- $json .= ($empty++ ? ",$_nl" : "") // comma separators
- . $_tab . ($obj ? (up_json_encode((string)$i, $options & ~JSON_NUMERIC_CHECK, $_tab) . ":$_space") : "") // assoc prefix
- . (up_json_encode($v, $options, $_tab)); // value
- }
-
- #-- enclose into braces or brackets
- $json = $obj ? "{" . "$_nl$json$_nl$_indent}" : "[$_nl$json$_nl$_indent]";
- }
-
- #-- strings need some care
- elseif (is_string($var)) {
-
- if (!empty($var) && mb_detect_encoding($var, 'UTF-8', true) === false) {
- trigger_error("up_json_encode: invalid UTF-8 encoding in string '$var', cannot proceed.", E_USER_WARNING);
- $var = NULL;
- }
- $rewrite = array(
- "\\" => "\\\\",
- "\"" => "\\\"",
- "\010" => "\\b",
- "\f" => "\\f",
- "\n" => "\\n",
- "\r" => "\\r",
- "\t" => "\\t",
- "/" => $options & JSON_UNESCAPED_SLASHES ? "/" : "\\/",
- "<" => $options & JSON_HEX_TAG ? "\\u003C" : "<",
- ">" => $options & JSON_HEX_TAG ? "\\u003E" : ">",
- "'" => $options & JSON_HEX_APOS ? "\\u0027" : "'",
- "&" => $options & JSON_HEX_AMP ? "\\u0026" : "&",
- );
- $var = strtr($var, $rewrite);
- //@COMPAT control chars should probably be stripped beforehand, not escaped as here
- if (function_exists("iconv") && ($options & JSON_UNESCAPED_UNICODE) == 0) {
- $var = preg_replace("/[^\\x{0020}-\\x{007F}]/ue", "'\\u'.current(unpack('H*', iconv('UTF-8', 'UCS-2BE', '$0')))", $var);
- }
- $json = '"' . $var . '"';
- }
-
- #-- basic types
- elseif (is_bool($var)) {
- $json = $var ? "true" : "false";
- } elseif ($var === NULL) {
- $json = "null";
- } elseif (is_int($var)) {
- $json = "$var";
- } elseif (is_float($var)) {
- if (is_nan($var) || is_infinite($var)) {
- ${'.json_last_error'} = JSON_ERROR_INF_OR_NAN;
- return false;
- } else {
- $json = "$var";
- }
- }
-
- #-- something went wrong
- else {
- trigger_error("up_json_encode: don't know what a '" . gettype($var) . "' is.", E_USER_WARNING);
- ${'.json_last_error'} = JSON_ERROR_UNSUPPORTED_TYPE;
- return false;
- }
-
- #-- done
- return($json);
-}
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 3d912f80..267a3971 100644
--- a/inc/util.inc.php
+++ b/inc/util.inc.php
@@ -1,168 +1,31 @@
<?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 sensistive 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
- && !preg_match(',^(\w+\:|//),', $redirect) /* no uri scheme, no server */) {
+ && !preg_match(',^([0-9a-zA-Z_+\-]+:|//),', $redirect) /* no uri scheme, no server */) {
$location = $redirect;
}
if (!empty($messages)) {
@@ -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,62 +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
{
- $bytes = round($bytes);
+ // round doesn't reliably work for large floats, pick workaround depending on OS
+ $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;
}
@@ -284,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:
@@ -322,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;
@@ -344,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
@@ -370,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();
@@ -381,19 +236,8 @@ SADFACE;
}
Header('Content-Type: application/octet-stream', true);
Header('Content-Disposition: attachment; filename=' . str_replace(array(' ', '=', ',', '/', '\\', ':', '?'), '_', iconv('UTF-8', 'ASCII//TRANSLIT', $name)));
- Header('Content-Length: ' . @filesize($file));
- while (!feof($fh)) {
- $data = fread($fh, 16000);
- if ($data === false) {
- echo "\r\n\nDOWNLOAD INTERRUPTED!\n";
- if ($delete)
- @unlink($file);
- return true;
- }
- echo $data;
- @ob_flush();
- @flush();
- }
+ Header('Content-Length: ' . filesize($file));
+ fpassthru($fh);
fclose($fh);
if ($delete) {
unlink($file);
@@ -409,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 {
@@ -448,7 +292,7 @@ SADFACE;
}
}
if ($secure) {
- return false;
+ return null;
}
$bytes = '';
while ($length > 0) {
@@ -460,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"
@@ -488,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)
@@ -514,35 +359,115 @@ 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;
foreach ($UNITS as $k => $v) {
if ($seconds < $v)
continue;
$n = floor($seconds / $v);
$seconds -= $n * $v;
- $parts[] = $n. $k;
+ if ($prev) {
+ $parts[] = sprintf('%02d', $n) . $k;
+ } else {
+ $parts[] = $n . $k;
+ $prev = true;
+ }
+ }
+ $parts[] = gmdate($showSecs ? 'H:i:s' : 'H:i', (int)$seconds);
+ return implode(' ', $parts);
+ }
+
+ /**
+ * Properly clear a cookie from the user's browser.
+ * This recursively wipes it from the current script's path. There
+ * 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(string $name): void
+ {
+ $parts = explode('/', $_SERVER['SCRIPT_NAME']);
+ $path = '';
+ foreach ($parts as $part) {
+ $path .= $part;
+ setcookie($name, '', 0, $path);
+ $path .= '/';
+ setcookie($name, '', 0, $path);
+ }
+ }
+
+ /**
+ * Remove any non-utf8 sequences from string.
+ */
+ public static function cleanUtf8(string $string): string
+ {
+ // https://stackoverflow.com/a/1401716/2043481
+ $regex = '/
+ (
+ (?: [\x00-\x7F] # single-byte sequences 0xxxxxxx
+ | [\xC0-\xDF][\x80-\xBF] # double-byte sequences 110xxxxx 10xxxxxx
+ | [\xE0-\xEF][\x80-\xBF]{2} # triple-byte sequences 1110xxxx 10xxxxxx * 2
+ | [\xF0-\xF7][\x80-\xBF]{3} # quadruple-byte sequence 11110xxx 10xxxxxx * 3
+ ){1,100} # ...one or more times
+ )
+ | . # anything else
+ /x';
+ return preg_replace($regex, '$1', $string);
+ }
+
+ /**
+ * Remove non-printable < 0x20 chars from ANSI string, then convert to UTF-8
+ */
+ public static function ansiToUtf8(string $string): string
+ {
+ $regex = '/
+ (
+ [\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');
}
- return implode(' ', $parts) . ' ' . gmdate($showSecs ? 'H:i:s' : 'H:i', $seconds);
}
}
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 38cc3a1e..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));
@@ -41,65 +49,103 @@ function finalResponse($status, $message = '')
define('UPDATE_DONE', 'UPDATE_DONE'); // Process completed successfully. This is a success return code.
define('UPDATE_NOOP', 'UPDATE_NOOP'); // Nothing had to be done, everything is up to date. This is also a success code.
define('UPDATE_RETRY', 'UPDATE_RETRY'); // Install/update process failed, but should be retried later.
-define('UPDATE_FAILED', 'UPDATE_FAILED'); // Fatal error occured, retry will not resolve the issue.
+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')) {
@@ -137,7 +183,10 @@ function tableGetConstraints($table, $column, $refTable, $refColumn)
}
return Database::queryFirst('SELECT b.CONSTRAINT_NAME, b.UPDATE_RULE, b.DELETE_RULE
FROM information_schema.KEY_COLUMN_USAGE a
- INNER JOIN information_schema.REFERENTIAL_CONSTRAINTS b USING (CONSTRAINT_CATALOG, CONSTRAINT_SCHEMA, CONSTRAINT_NAME)
+ INNER JOIN information_schema.REFERENTIAL_CONSTRAINTS b ON (
+ a.CONSTRAINT_CATALOG = b.CONSTRAINT_CATALOG
+ AND a.CONSTRAINT_SCHEMA = b.CONSTRAINT_SCHEMA
+ AND a.CONSTRAINT_NAME = b.CONSTRAINT_NAME)
WHERE a.TABLE_SCHEMA = :db AND a.TABLE_NAME = :table AND a.COLUMN_NAME = :column
AND a.REFERENCED_TABLE_NAME = :refTable AND a.REFERENCED_COLUMN_NAME = :refColumn',
compact('db', 'table', 'column', 'refTable', 'refColumn'));
@@ -154,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) {
@@ -177,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
@@ -203,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;
}
@@ -217,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;
}
@@ -237,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!');
@@ -250,7 +302,6 @@ function responseFromArray($array)
}
finalResponse(UPDATE_NOOP, 'Everything already up to date');
-
}
/*
@@ -293,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();
@@ -376,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;
@@ -393,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 dc68d7a6..cf202381 100644
--- a/modules-available/adduser/lang/en/template-tags.json
+++ b/modules-available/adduser/lang/en/template-tags.json
@@ -1,8 +1,8 @@
{
"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 acounts 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_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 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 c8acb554..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');
@@ -48,7 +46,7 @@ class Page_AddUser extends Page
'email' => $email,
);
$ret = Database::exec('INSERT INTO user
- SET login = :login, passwd = :pass, fullname = :fullname, phone = :phone, email = :email', $data, true);
+ SET login = :login, passwd = :pass, fullname = :fullname, phone = :phone, email = :email, permissions = 1', $data, true);
if ($ret === false) {
Message::addError('user-already-exists', $login);
return;
@@ -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/module.json b/modules-available/backup/lang/de/module.json
index 8b32a289..0e38aee2 100644
--- a/modules-available/backup/lang/de/module.json
+++ b/modules-available/backup/lang/de/module.json
@@ -1,4 +1,4 @@
{
- "module_name": "Sichern\/Wiederherstellen",
+ "module_name": "Sichern \/ Wiederherstellen",
"page_title": "Sichern und Wiederherstellen"
} \ No newline at end of file
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 1e41abbc..170c3d96 100644
--- a/modules-available/backup/lang/de/template-tags.json
+++ b/modules-available/backup/lang/de/template-tags.json
@@ -1,18 +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 des Dozentenmoduls 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_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_restoreDozmodConfig": "Dozentenmodul-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 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": "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/module.json b/modules-available/backup/lang/en/module.json
index cd6c87d0..e6e093b6 100644
--- a/modules-available/backup/lang/en/module.json
+++ b/modules-available/backup/lang/en/module.json
@@ -1,3 +1,3 @@
{
- "module_name": "Backup"
+ "module_name": "Backup \/ Restore"
} \ No newline at end of file
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 8cb131f7..da737b6c 100644
--- a/modules-available/backup/lang/en/template-tags.json
+++ b/modules-available/backup/lang/en/template-tags.json
@@ -1,18 +1,37 @@
{
+ "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 \"Dozentenmodul\". 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 pathes on the share are still the same, otherwise the virtual machines won't be usable.",
+ "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 Dozentenmodul config",
+ "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",
"lang_selectFile": "Please select a backup archive",
"lang_stopping": "Stopping",
"lang_systemExplanation": "Restore basic configuration like authentication method, passwords, vm storage location, proxy config, etc. WARNING: If you restore a configuration backup that was made before WS15\/16 (backup format version <10), the system configuration will be restored regardless of this check mark.",
diff --git a/modules-available/backup/page.inc.php b/modules-available/backup/page.inc.php
index 985f39ee..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_v16_' . 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'];
@@ -126,7 +168,7 @@ class Page_Backup extends Page
}
// Wait a bit
$task = Taskmanager::submit('SleepTask', array(
- 'seconds' => 3,
+ 'seconds' => 6,
'parentTask' => $parent,
'failOnParentFail' => false
));
@@ -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 4c6cade4..818f42cd 100644
--- a/modules-available/backup/templates/_page.html
+++ b/modules-available/backup/templates/_page.html
@@ -1,18 +1,27 @@
<h1>{{lang_backupRestore}}</h1>
-<form action="?do=Backup" method="post">
+<form action="?do=Backup" method="post" onsubmit="$('#b-btn').prop('disabled', true).find('span').removeClass('glyphicon-save').addClass('glyphicon-refresh slx-rotation')">
<input type="hidden" name="token" value="{{token}}">
<input type="hidden" name="action" value="backup">
<div class="panel panel-default">
<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 {{perms.create.disabled}} class="btn btn-primary pull-right" type="submit"><span class="glyphicon glyphicon-save"></span> {{lang_download}}</button>
+ <button id="b-btn" {{perms.create.disabled}} class="btn btn-primary pull-right" type="submit">
+ <span class="glyphicon glyphicon-save"></span>
+ {{lang_download}}
+ </button>
</div>
</div>
</form>
@@ -21,32 +30,94 @@
<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>
</div>
<p><i>{{lang_dozmodExplanation}}</i></p>
</div>
- <button {{perms.restore.disabled}} class="btn btn-primary pull-right" type="submit"><span class="glyphicon glyphicon-open"></span> {{lang_restore}}</button>
+ <p>{{lang_automaticRebootHint}}</p>
+ <div id="dmsd-users">
+ {{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>
+ {{id_prefix}}
+ {{lang_restoreButton}}
+ </button>
</div>
</div>
-</form> \ No newline at end of file
+</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>
+
+<script>
+ document.addEventListener('DOMContentLoaded', function () {
+ var $dmsd = $('#dmsd-users');
+ $.ajax({
+ url: '?do=dozmod&section=special&action=dmsd-status',
+ timeout: 3000,
+ dataType: 'json'
+ }).done(function (data) {
+ if (data.downloads !== null) $dmsd.find('.downloads').text(data.downloads);
+ if (data.uploads !== null) $dmsd.find('.uploads').text(data.uploads);
+ });
+ });
+</script> \ No newline at end of file
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/api.inc.php b/modules-available/baseconfig/api.inc.php
index 013640e7..350c6173 100644
--- a/modules-available/baseconfig/api.inc.php
+++ b/modules-available/baseconfig/api.inc.php
@@ -1,124 +1,5 @@
<?php
-$ip = $_SERVER['REMOTE_ADDR'];
-if (substr($ip, 0, 7) === '::ffff:') {
- $ip = substr($ip, 7);
-}
-
-$uuid = Request::any('uuid', false, 'string');
-if ($uuid !== false && strlen($uuid) !== 36) {
- $uuid = false;
-}
-
-class ConfigHolder
-{
- private static $config = [];
-
- private static $context = '';
-
- private static $postHooks = [];
-
- public static function setContext($name)
- {
- self::$context = $name;
- }
-
- public static function addArray($array, $prio = 0)
- {
- foreach ($array as $key => $value) {
- self::add($key, $value, $prio);
- }
- }
-
- public static function add($key, $value, $prio = 0)
- {
- if (!isset(self::$config[$key])) {
- self::$config[$key] = [];
- }
- $new = [
- 'prio' => $prio,
- 'value' => $value,
- 'context' => self::$context,
- ];
- if (empty(self::$config[$key]) || self::$config[$key][0]['prio'] > $prio) {
- // Existing is higher, append new one
- array_push(self::$config[$key], $new);
- } else {
- // New one has highest prio or matches existing, put in front
- array_unshift(self::$config[$key], $new);
- }
- }
-
- public static function get($key)
- {
- if (!isset(self::$config[$key]))
- return false;
- return self::$config[$key][0]['value'];
- }
-
- /**
- * @param callable $func
- */
- public static function addPostHook($func)
- {
- self::$postHooks[] = array('context' => self::$context, 'function' => $func);
- }
-
- public static function applyPostHooks()
- {
- foreach (self::$postHooks as $hook) {
- self::$context = $hook['context'] . ':post';
- $hook['function']();
- }
- self::$postHooks = [];
- }
-
- public static function getConfig()
- {
- self::applyPostHooks();
- $ret = [];
- foreach (self::$config as $key => $list) {
- if ($list[0]['value'] === false)
- continue;
- $ret[$key] = $list[0]['value'];
- }
- return $ret;
- }
-
- public static function outputConfig()
- {
- self::applyPostHooks();
- foreach (self::$config as $key => $list) {
- echo '##', $key, "\n";
- foreach ($list as $pos => $item) {
- echo '# (', $item['context'], ':', $item['prio'], ')';
- if ($pos != 0 || $item['value'] === false) {
- if ($pos == 0) {
- echo " <disabled>\n";
- } else {
- echo " <overridden>\n";
- }
- continue;
- }
- echo "⤵\n", $key, "='", escape($item['value']), "'\n";
- }
- }
- }
-
-}
-
-/**
- * Escape given string so it is a valid string in sh that can be surrounded
- * by single quotes ('). This basically turns _'_ into _'"'"'_
- *
- * @param string $string input
- * @return string escaped sh string
- */
-function escape($string)
-{
- return str_replace("'", "'\"'\"'", $string);
-}
-
/*
* We gather all config variables here. First, let other modules generate
* their desired config vars. Afterwards, add the global config vars from
@@ -126,79 +7,19 @@ function escape($string)
* global setting.
*/
-function handleModule($file, $ip, $uuid) // Pass ip and uuid instead of global to make them read only
-{
- $configVars = [];
- include_once $file;
- ConfigHolder::addArray($configVars, 0);
-}
-
-// Handle any hooks by other modules first
-// other modules should generally only populate $configVars
-foreach (glob('modules/*/baseconfig/getconfig.inc.php') as $file) {
- preg_match('#^modules/([^/]+)/#', $file, $out);
- $mod = Module::get($out[1]);
- if ($mod === false)
- continue;
- $mod->activate(1, false);
- foreach ($mod->getDependencies() as $dep) {
- $depFile = 'modules/' . $dep . '/baseconfig/getconfig.inc.php';
- if (file_exists($depFile) && Module::isAvailable($dep)) {
- ConfigHolder::setContext($dep);
- handleModule($depFile, $ip, $uuid);
- }
- }
- ConfigHolder::setContext($out[1]);
- handleModule($file, $ip, $uuid);
-}
-
-// Rest is handled by module
-$defaults = BaseConfigUtil::getVariables();
-// Dump global config from DB
-ConfigHolder::setContext('<global>');
-$res = Database::simpleQuery('SELECT setting, value FROM setting_global');
-while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- if (!isset($defaults[$row['setting']]))
- continue; // Setting is not defined in any <module>/baseconfig/settings.json
- ConfigHolder::add($row['setting'], $row['value'], -1);
-}
+// Prepare ConfigHolder from request data
+BaseConfig::prepareFromRequest();
-// Fallback to default values from json files
-ConfigHolder::setContext('<default>');
-foreach ($defaults as $setting => $value) {
- ConfigHolder::add($setting, $value['defaultvalue'], -1000);
-}
+ConfigHolder::add('SLX_NOW', time(), PHP_INT_MAX);
// All done, now output
-
-if (Request::any('save') === 'true') {
- // output AND save to disk: Generate contents
- $lines = '';
- foreach (ConfigHolder::getConfig() as $setting => $value) {
- $lines .= $setting . "='" . escape($value) . "'\n";
- }
- // Save to all the locations
- $data = Property::getVersionCheckInformation();
- if (is_array($data) && isset($data['systems'])) {
- foreach ($data['systems'] as $system) {
- $path = CONFIG_HTTP_DIR . '/' . $system['id'] . '/config';
- if (file_put_contents($path, $lines) > 0) {
- echo "# Saved config to $path\n";
- } else {
- echo "# Error saving config to $path\n";
- }
- echo "SLX_NOW='", time(), "'\n";
- }
- }
- // Output to browser
- echo $lines;
-} else {
- // Only output to client
- ConfigHolder::add('SLX_NOW', time(), PHP_INT_MAX);
- ConfigHolder::outputConfig();
-}
+ConfigHolder::applyPostHooks();
+ConfigHolder::outputConfig();
// For quick testing or custom extensions: Include external file that should do nothing
// more than outputting more key-value-pairs. It's expected in the webroot of slxadmin
-if (file_exists('client_config_additional.php')) @include('client_config_additional.php');
+if (file_exists('client_config_additional.php')) {
+ echo "########## client_config_additional.php:\n";
+ @include('client_config_additional.php');
+}
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
new file mode 100644
index 00000000..36622dce
--- /dev/null
+++ b/modules-available/baseconfig/inc/baseconfig.inc.php
@@ -0,0 +1,151 @@
+<?php
+
+class BaseConfig
+{
+
+ private static $modulesDone = [];
+
+ /**
+ * @var array key-value-pairs of override vars, can be accessed by hooks
+ */
+ private static $overrides = [];
+
+ /**
+ * Fill the ConfigHolder with values from various hooks, while taking
+ * into account UUID and IP-address of the client making the current
+ * HTTP request.
+ */
+ public static function prepareFromRequest()
+ {
+ $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', null, 'string');
+ if ($uuid !== null && strlen($uuid) !== 36) {
+ $uuid = null;
+ }
+ // Handle any hooks by other modules first
+ // other modules should generally only populate $configVars
+ foreach (glob('modules/*/baseconfig/getconfig.inc.php') as $file) {
+ preg_match('#^modules/([^/]+)/#', $file, $out);
+ ConfigHolder::setContext($out[1]);
+ self::handleModule($out[1], $ip, $uuid, false);
+ }
+ self::commonBase();
+ }
+
+ /**
+ * Fill the ConfigHolder with data from various hooks that supply
+ * static overrides for config variables. The overrides can be used
+ * to make the hooks behave in certain ways, by setting specific values like
+ * 'locationid'
+ * @param array $overrides key value pairs of overrides
+ */
+ public static function prepareWithOverrides(array $overrides): void
+ {
+ self::$overrides = $overrides;
+ $ip = $uuid = null;
+ if (self::hasOverride('ip')) {
+ $ip = self::getOverride('ip');
+ }
+ if (self::hasOverride('uuid')) {
+ $uuid = self::getOverride('uuid');
+ }
+ // Handle only static hooks that don't dynamically generate output
+ foreach (glob('modules/*/baseconfig/hook.json') as $file) {
+ preg_match('#^modules/([^/]+)/#', $file, $out);
+ ConfigHolder::setContext($out[1]);
+ self::handleModule($out[1], $ip, $uuid, true);
+ }
+ self::commonBase();
+ }
+
+ /**
+ * Just fill the ConfigHolder with the defaults from all the json files
+ * that define config variables.
+ */
+ public static function prepareDefaults()
+ {
+ $defaults = BaseConfigUtil::getVariables();
+ self::addDefaults($defaults);
+ }
+
+ private static function commonBase()
+ {
+ $defaults = BaseConfigUtil::getVariables();
+
+ // Dump global config from DB
+ ConfigHolder::setContext('<global>', function($id) {
+ return [
+ 'name' => Dictionary::translate('source-global'),
+ 'locationid' => 0,
+ ];
+ });
+ $res = Database::simpleQuery('SELECT setting, value, displayvalue FROM setting_global');
+ 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);
+ }
+
+ // Fallback to default values from json files
+ self::addDefaults($defaults);
+
+ }
+
+ private static function addDefaults($defaults)
+ {
+ ConfigHolder::setContext('<default>', function($id) {
+ return [
+ 'name' => Dictionary::translate('source-default'),
+ 'locationid' => 0,
+ ];
+ });
+ foreach ($defaults as $setting => $value) {
+ ConfigHolder::add($setting, $value['defaultvalue'], -1000);
+ }
+ }
+
+ 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;
+ // Module has getconfig hook
+ $file = 'modules/' . $name . '/baseconfig/getconfig.inc.php';
+ if (!is_file($file))
+ return;
+ // We want only static hooks that have a json config (currently used for displaying inheritance tree)
+ if ($needJsonHook && !is_file('modules/' . $name . '/baseconfig/hook.json'))
+ return;
+ // Properly registered and can be activated
+ $mod = Module::get($name);
+ if ($mod === false)
+ return;
+ if (!$mod->activate(1, false))
+ return;
+ // Process dependencies first
+ foreach ($mod->getDependencies() as $dep) {
+ self::handleModule($dep, $ip, $uuid, $needJsonHook);
+ }
+ ConfigHolder::setContext($name);
+ (function (string $file, ?string $ip, ?string $uuid) {
+ include_once($file);
+ })($file, $ip, $uuid);
+ }
+
+ public static function hasOverride($key): bool
+ {
+ return array_key_exists($key, self::$overrides);
+ }
+
+ public static function getOverride($key)
+ {
+ return self::$overrides[$key];
+ }
+
+} \ No newline at end of file
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
new file mode 100644
index 00000000..75b43460
--- /dev/null
+++ b/modules-available/baseconfig/inc/configholder.inc.php
@@ -0,0 +1,134 @@
+<?php
+
+class ConfigHolder
+{
+ private static $config = [];
+
+ private static $context = '';
+
+ private static $postHooks = [];
+
+ public static function setContext($id, $resolver = false)
+ {
+ $tmp = ['id' => $id, 'resolver' => $resolver];
+ self::$context =& $tmp;
+ }
+
+ /**
+ * @param string $key config variable name
+ * @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(string $key, $value, int $prio = 0): void
+ {
+ if (!isset(self::$config[$key])) {
+ self::$config[$key] = [];
+ }
+ $new = [
+ 'prio' => $prio,
+ 'context' => &self::$context,
+ ];
+ if (is_array($value)) {
+ $new['value'] = $value['value'];
+ $new['displayvalue'] = $value['displayvalue'];
+ } else {
+ $new['value'] = $value;
+ }
+ if (empty(self::$config[$key]) || self::$config[$key][0]['prio'] > $prio) {
+ // Existing is higher, append new one
+ array_push(self::$config[$key], $new);
+ } else {
+ // New one has highest prio or matches existing, put in front
+ array_unshift(self::$config[$key], $new);
+ }
+ }
+
+ public static function get(string $key): ?string
+ {
+ if (!isset(self::$config[$key]))
+ return null;
+ return self::$config[$key][0]['value'];
+ }
+
+ public static function addPostHook(callable $func): void
+ {
+ self::$postHooks[] = array('context' => &self::$context, 'function' => $func);
+ }
+
+ public static function applyPostHooks(): void
+ {
+ foreach (self::$postHooks as $hook) {
+ $newContext = $hook['context'];
+ $newContext['post'] = true;
+ self::$context =& $newContext;
+ $hook['function']();
+ }
+ self::$postHooks = [];
+ }
+
+ public static function getRecursiveConfig(bool $prettyPrint = true): array
+ {
+ $ret = [];
+ foreach (self::$config as $key => $list) {
+ $last = false;
+ foreach ($list as $entry) {
+ if ($last !== false && $last['context']['id'] === '<global>'
+ && $entry['context']['id'] === '<default>' && $last['value'] === $entry['value'])
+ continue;
+ $cb =& $entry['context']['resolver'];
+ $valueKey = 'value';
+ if ($prettyPrint && is_callable($cb)) {
+ $data = $cb($entry['context']['id']);
+ $name = $data['name'];
+ if (isset($data['locationid']) && isset($entry['displayvalue'])
+ && User::hasPermission('.baseconfig.view', $data['locationid'])) {
+ $valueKey = 'displayvalue';
+ }
+ } else {
+ $name = $entry['context']['id'];
+ }
+ $ret[$key][] = ['name' => $name, 'value' => $entry[$valueKey]];
+ $last = $entry;
+ }
+ }
+ return $ret;
+ }
+
+ public static function outputConfig(): void
+ {
+ foreach (self::$config as $key => $list) {
+ echo str_pad('# ' . $key . ' ', 35, '#', STR_PAD_BOTH), "\n";
+ foreach ($list as $pos => $item) {
+ $ctx = $item['context']['id'];
+ if (isset($item['context']['post'])) {
+ $ctx .= '[post-hook]';
+ }
+ $ctx .= ' :: ' . $item['prio'];
+ if ($pos != 0 || $item['value'] === false) {
+ echo '# (', $ctx, ')';
+ if ($pos == 0) {
+ echo " <disabled this setting>\n";
+ } else {
+ echo " <overridden>\n";
+ }
+ } else {
+ echo $key, "='", self::escape($item['value']), "'\n";
+ echo '# (', $ctx, ") <active>\n";
+ }
+ }
+ }
+ }
+
+ /**
+ * Escape given string so it is a valid string in sh that can be surrounded
+ * by single quotes ('). This basically turns _'_ into _'"'"'_
+ *
+ * @param string $string input
+ * @return string escaped sh 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 2dfeed7c..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,10 +45,10 @@ 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)
+ private static function linuxPassword($displayValue)
{
if (empty($displayValue))
return '';
@@ -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 44b51ec3..a9c2c6bf 100644
--- a/modules-available/baseconfig/lang/de/module.json
+++ b/modules-available/baseconfig/lang/de/module.json
@@ -1,3 +1,8 @@
{
- "module_name": "Konfigurationsvariablen"
+ "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/de/template-tags.json b/modules-available/baseconfig/lang/de/template-tags.json
index dfaecc96..1ce62c87 100644
--- a/modules-available/baseconfig/lang/de/template-tags.json
+++ b/modules-available/baseconfig/lang/de/template-tags.json
@@ -1,8 +1,7 @@
{
"lang_basicConfiguration": "Basiskonfiguration",
"lang_clientRelatedConfig": "Die Optionen auf dieser Seite beziehen sich auf das Verhalten der bwLehrpool-Clients.",
- "lang_defaultValue": "Standard",
"lang_editOverrideNotice": "Sie bearbeiten die Einstellungen f\u00fcr einen Unterbereich",
"lang_enableOverride": "\u00dcberschreiben",
- "lang_inheritSource": "Geerbt von"
+ "lang_showParents": "Geerbte Werte"
} \ 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 9ad9d10f..5a25bcff 100644
--- a/modules-available/baseconfig/lang/en/module.json
+++ b/modules-available/baseconfig/lang/en/module.json
@@ -1,3 +1,8 @@
{
- "module_name": "Config Variables"
+ "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/lang/en/template-tags.json b/modules-available/baseconfig/lang/en/template-tags.json
index 471fef35..9ac578e7 100644
--- a/modules-available/baseconfig/lang/en/template-tags.json
+++ b/modules-available/baseconfig/lang/en/template-tags.json
@@ -1,8 +1,7 @@
{
"lang_basicConfiguration": "Basic Configuration",
"lang_clientRelatedConfig": "The options on this page are related to the bwLehrpool client machines.",
- "lang_defaultValue": "Default",
"lang_editOverrideNotice": "You're editing the settings of a sub-section",
"lang_enableOverride": "Override",
- "lang_inheritSource": "Inherited from"
+ "lang_showParents": "Inherited values"
} \ No newline at end of file
diff --git a/modules-available/baseconfig/page.inc.php b/modules-available/baseconfig/page.inc.php
index 837a3b67..5d684a8e 100644
--- a/modules-available/baseconfig/page.inc.php
+++ b/modules-available/baseconfig/page.inc.php
@@ -23,11 +23,7 @@ class Page_BaseConfig extends Page
$newValues = Request::post('setting');
if (is_array($newValues)) {
- if ($this->targetModule === 'locations') {
- User::assertPermission('edit', $this->qry_extra['field_value']);
- } else {
- User::assertPermission('edit', 0);
- }
+ User::assertPermission('edit', $this->getPermissionLocationId());
// Build variables for specific sub-settings
if ($this->targetModule === false || empty($this->qry_extra['field'])) {
// Global, or Module specific, but module doesn't have an extra field
@@ -58,8 +54,7 @@ class Page_BaseConfig extends Page
BaseConfigUtil::markShadowedVars($vars, $newValues);
// Validate input
foreach ($vars as $key => $var) {
- if (isset($var['shadowed']))
- continue;
+ // Delete entries where we disabled override
if ($this->targetModule !== false) {
// Module mode
if (is_array($override) && (!isset($override[$key]) || $override[$key] !== 'on')) {
@@ -69,8 +64,11 @@ class Page_BaseConfig extends Page
continue;
}
}
+ // Only after that, check if variable is shadowed (disabled)
+ 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) {
@@ -90,11 +88,11 @@ class Page_BaseConfig extends Page
}
Message::addSuccess('settings-updated');
if ($this->targetModule === false) {
- Util::redirect('?do=BaseConfig');
+ Util::redirect('?do=BaseConfig', true);
} elseif (empty($this->qry_extra['field'])) {
- Util::redirect('?do=BaseConfig&module=' . $this->targetModule);
+ Util::redirect('?do=BaseConfig&module=' . $this->targetModule, true);
} else {
- Util::redirect('?do=BaseConfig&module=' . $this->targetModule . '&' . $this->qry_extra['field'] . '=' . $this->qry_extra['field_value']);
+ Util::redirect('?do=BaseConfig&module=' . $this->targetModule . '&' . $this->qry_extra['field'] . '=' . $this->qry_extra['field_value'], true);
}
}
// Load categories so we can define them as sub menu items
@@ -103,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)
);
}
}
@@ -118,76 +116,59 @@ class Page_BaseConfig extends Page
Util::redirect('?do=BaseConfig');
}
}
- if ($this->targetModule === 'locations') {
- User::assertPermission('view', $this->qry_extra['field_value']);
- $editForbidden = !User::hasPermission('edit', $this->qry_extra['field_value']);
- } else {
- User::assertPermission('view', 0);
- $editForbidden = !User::hasPermission('edit', 0);
- }
+ $lid = $this->getPermissionLocationId();
+ 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();
}
+ $parents = $this->getInheritanceData();
// List config options
$settings = array();
- $vars = BaseConfigUtil::getVariables();
+ $varsFromJson = BaseConfigUtil::getVariables();
// Remember missing variables
- $missing = $vars;
+ $missing = $varsFromJson;
// Populate structure with existing config from db
- $this->fillSettings($vars, $settings, $missing, $this->qry_extra['table'], $fields, $where, $params, false);
- if (isset($this->qry_extra['getfallback']) && !empty($missing)) {
- $method = explode('::', $this->qry_extra['getfallback']);
- $fieldValue = $this->qry_extra['field_value'];
- $tries = 0;
- while (++$tries < 100 && !empty($missing)) {
- $ret = call_user_func($method, $fieldValue);
- if ($ret === false)
- break;
- $fieldValue = $ret['value'];
- $params = array('field_value' => $fieldValue);
- $this->fillSettings($vars, $settings, $missing, $this->qry_extra['table'], $fields, $where, $params, $ret['display']);
- }
- }
- if ($this->targetModule !== false && !empty($missing)) {
- $this->fillSettings($vars, $settings, $missing, 'setting_global', '', '', array(), 'Global');
- }
+ $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 ($vars as $key => $var) {
+ foreach ($varsFromJson as $key => $var) {
if ($this->targetModule !== false && !isset($settings[$var['catid']]['settings'][$key])) {
// Module specific - value is not set in DB
- $settings[$var['catid']]['settings'][$key] = $var + array(
+ $settings[$var['catid']]['settings'][$key] = array(
'setting' => $key
);
}
- if (!isset($settings[$var['catid']]['settings'][$key]['displayvalue'])) {
- $settings[$var['catid']]['settings'][$key]['displayvalue'] = $var['defaultvalue'];
- }
- if (!isset($settings[$var['catid']]['settings'][$key]['defaultvalue'])) {
- $settings[$var['catid']]['settings'][$key]['defaultvalue'] = $var['defaultvalue'];
+ $entry =& $settings[$var['catid']]['settings'][$key];
+ if (!isset($entry['displayvalue'])) {
+ if (isset($parents[$key][0]['value'])) {
+ $entry['displayvalue'] = $parents[$key][0]['value'];
+ } else {
+ $entry['displayvalue'] = $var['defaultvalue'];
+ }
}
- if (!isset($settings[$var['catid']]['settings'][$key]['shadows'])) {
- $settings[$var['catid']]['settings'][$key]['shadows'] = isset($var['shadows']) ? $var['shadows'] : null;
+ if (!isset($entry['shadows'])) {
+ $entry['shadows'] = $var['shadows'] ?? null;
}
- $settings[$var['catid']]['settings'][$key] += array(
+ $entry += array(
'item' => $this->makeInput(
$var['validator'],
$key,
- $settings[$var['catid']]['settings'][$key]['displayvalue'],
- $settings[$var['catid']]['settings'][$key]['shadows'],
+ $entry['displayvalue'],
+ $entry['shadows'],
$editForbidden
),
'description' => Util::markup(Dictionary::translateFileModule($var['module'], 'config-variables', $key)),
'setting' => $key,
+ 'tree' => $parents[$key] ?? false,
);
}
- //die();
+ unset($entry);
// Sort categories
@@ -209,25 +190,23 @@ class Page_BaseConfig extends Page
'categories' => array_values($settings),
'target_module' => $this->targetModule,
'edit_disabled' => $editForbidden ? 'disabled' : '',
+ 'redirect' => Request::get('redirect'),
) + $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']]);
- if ($sourceName !== false) {
- $row['defaultvalue'] = '';
- $row['defaultsource'] = $sourceName;
- } elseif ($this->targetModule !== false) {
+ if ($this->targetModule !== false) {
$row['checked'] = 'checked';
}
$row += $vars[$row['setting']];
@@ -280,13 +259,29 @@ class Page_BaseConfig extends Page
$this->targetModule = $module;
$this->qry_extra = $hook;
}
+
+ private function getPermissionLocationId(): int
+ {
+ if (!isset($this->qry_extra['locationResolver']) || !isset($this->qry_extra['field_value']))
+ return 0;
+ $func = explode('::', $this->qry_extra['locationResolver']);
+ return (int)call_user_func($func, $this->qry_extra['field_value']);
+ }
+
+ private function getInheritanceData()
+ {
+ if (!isset($this->qry_extra['getInheritance']) || !isset($this->qry_extra['field_value'])) {
+ BaseConfig::prepareDefaults();
+ return ConfigHolder::getRecursiveConfig(true);
+ }
+ $func = explode('::', $this->qry_extra['getInheritance']);
+ return call_user_func($func, $this->qry_extra['field_value']);
+ }
/**
* 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);
@@ -296,7 +291,7 @@ class Page_BaseConfig extends Page
if ($disabled) {
$args['disabled'] = true;
}
- $inner = "";
+ $extra = $inner = "";
/* -- */
$parts = explode(':', $validator, 2);
@@ -316,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]);
@@ -364,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 7f7c33d0..5faff391 100644
--- a/modules-available/baseconfig/templates/_page.html
+++ b/modules-available/baseconfig/templates/_page.html
@@ -6,6 +6,9 @@
<p>{{lang_clientRelatedConfig}}</p>
<form action="?do=BaseConfig" method="post">
<input type="hidden" name="token" value="{{token}}">
+ {{#redirect}}
+ <input type="hidden" name="redirect" value="{{redirect}}">
+ {{/redirect}}
{{#override}}
<input name="module" type="hidden" value="{{target_module}}">
<input name="{{field}}" type="hidden" value="{{field_value}}">
@@ -18,52 +21,53 @@
<a name="category_{{category_id}}"></a>
{{category_name}}
</div>
- <div class="panel-body">
- <div class="list-group">
- {{#settings}}
- <div class="list-group-item">
- <div class="row">
- <div class="col-md-5 slx-cfg-toggle">
- <div>{{setting}}</div>
- <div class="slx-default">
- {{#defaultvalue}}{{lang_defaultValue}}:{{/defaultvalue}}
- {{defaultvalue}}
- </div>
- {{#override}}
- <div class="checkbox">
- <input name="override[{{setting}}]" class="override-cb" id="CB_{{setting}}" type="checkbox" {{checked}} {{edit_disabled}}>
- <label for="CB_{{setting}}">
- {{lang_enableOverride}}
- </label>
- </div>
- {{/override}}
+ <div class="list-group">
+ {{#settings}}
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-md-5 slx-cfg-toggle">
+ <div>{{setting}}</div>
+ {{#override}}
+ <div class="checkbox">
+ <input name="override[{{setting}}]" class="override-cb" id="CB_{{setting}}" type="checkbox" {{checked}} {{edit_disabled}}>
+ <label for="CB_{{setting}}">
+ {{lang_enableOverride}}
+ </label>
</div>
- <div class="col-md-5 config-container">
- {{{item}}}
- <div class="slx-default">
- {{#defaultsource}}{{lang_inheritSource}}:{{/defaultsource}}
- {{defaultsource}}
- </div>
+ {{/override}}
+ </div>
+ <div class="col-md-5 config-container">
+ {{{item}}}
+ <div class="slx-default">
+ {{#tree.0}}
+ <a href="#tree-{{setting}}" data-toggle="collapse">{{lang_showParents}}</a>
+ <div class="hidden" id="default-{{setting}}">{{value}}</div>
+ {{/tree.0}}
</div>
- <div class="col-md-2">
- <a class="btn btn-default" data-toggle="modal" data-target="#help-{{setting}}"><span class="glyphicon glyphicon-question-sign"></span></a>
+ <div class="slx-default collapse text-nowrap" id="tree-{{setting}}">
+ {{#tree}}
+ <div class="slx-strike"><b>{{name}}</b>: {{value}}</div>
+ {{/tree}}
</div>
</div>
+ <div class="col-md-2">
+ <a class="btn btn-default" data-toggle="modal" data-target="#help-{{setting}}"><span class="glyphicon glyphicon-question-sign"></span></a>
+ </div>
</div>
- <div class="modal fade" id="help-{{setting}}" tabindex="-1" role="dialog">
- <div class="modal-dialog">
- <div class="modal-content">
- <div class="modal-header">
- <button type="button" class="close" data-dismiss="modal">&times;</button>
- <h4 class="modal-title"><b>{{setting}}</b></h4>
+ </div>
+ <div class="modal fade" id="help-{{setting}}" tabindex="-1" role="dialog">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <button type="button" class="close" data-dismiss="modal">&times;</button>
+ <h4 class="modal-title"><b>{{setting}}</b></h4>
- </div>
- <div class="modal-body">{{{description}}}</div>
</div>
+ <div class="modal-body">{{{description}}}</div>
</div>
</div>
- {{/settings}}
</div>
+ {{/settings}}
</div>
</div>
{{/categories}}
@@ -109,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');
@@ -135,25 +144,44 @@ document.addEventListener("DOMContentLoaded", function () {
var updateCheckbox = function() {
var id = '#CB_' + $(this).attr('id');
- $(id).prop('checked', true);
+ var $cb = $(id).prop('checked', true);
+ if ($cb.length > 0) {
+ syncCheckbox.call($cb[0]);
+ }
+ };
+ var syncCheckbox = function() {
+ var setting = this.id.substr(3);
+ var $itm = $('#tree-' + setting + ' > div:first-child');
+ if (this.checked) {
+ $itm.addClass('slx-strike');
+ } else {
+ $itm.removeClass('slx-strike');
+ }
};
var $cont = $('.config-container');
$cont.find('select, input').on('change', updateCheckbox);
$cont.find('input').on('input', updateCheckbox);
$('.override-cb').on('change', function() {
- if (this.checked) return;
- var input = document.getElementById(this.id.substr(3));
+ var setting = this.id.substr(3);
+ syncCheckbox.call(this);
+ var input = document.getElementById(setting);
if (!input) return;
+ var defaults = this.checked ? false : ('' + $('#default-' + setting).text());
if (input.tagName.toUpperCase() === 'SELECT') {
+ var items = defaults === false ? false : defaults.split(/\s+/);
$(input).find('option').each(function() {
- $(this).prop('selected', this.defaultSelected);
+ $(this).prop('selected', items === false ? this.defaultSelected : (items.indexOf(this.value) !== -1));
});
$(input).filter('.multilist').multiselect('refresh');
} else if (input.type && input.type.toUpperCase() === 'CHECKBOX') {
- $(input).prop('checked', input.defaultChecked);
- } else if (input.defaultValue !== undefined) {
- $(input).val(input.defaultValue);
- }
+ $(input).prop('checked', defaults === false ? input.defaultChecked : !!defaults);
+ } else {
+ $(input).val(defaults === false ? input.defaultValue : defaults);
+ } // TODO: Make this work for multiinput/selectize (or get rid of them)
+ $allShadowingFields.each(updateShadows);
+ }).each(syncCheckbox);
+ window.addEventListener('unload', function() {
+ $('.multilist').multiselect('refresh');
});
});
</script>
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 ffe29bcb..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",
@@ -103,6 +109,12 @@
"permissions": "2",
"validator": "function:linuxPassword"
},
+ "SLX_TTY_SWITCH": {
+ "catid": "sysconfig",
+ "defaultvalue": "yes",
+ "permissions": "2",
+ "validator": "list:no|yes"
+ },
"SLX_SHUTDOWN_SCHEDULE": {
"catid": "power",
"defaultvalue": "22:10 00:00",
@@ -181,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": "",
@@ -200,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",
@@ -221,5 +239,29 @@
"defaultvalue": "",
"permissions": "2",
"validator": "regex:\/^(([0-9a-f]{4}:[0-9a-f]{4}\\s*)+|)$\/i"
+ },
+ "SLX_FORCE_RESOLUTION": {
+ "catid": "other",
+ "defaultvalue": "",
+ "permissions": "2",
+ "validator": "regex:/^(\\s*(\\d+x\\d+)(\\s+\\d+x\\d+)*\\s*|)$/i"
+ },
+ "SLX_RESOLUTION_MAPPING": {
+ "catid": "other",
+ "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 3ba21375..41292d25 100644
--- a/modules-available/baseconfig_bwlp/lang/de/config-variables.json
+++ b/modules-available/baseconfig_bwlp/lang/de/config-variables.json
@@ -5,13 +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.",
@@ -21,6 +26,7 @@
"SLX_PVS_DEFAULT": "Legt fest, ob der Haken zur PVS-Teilnahme im vmChooser standardm\u00e4\u00dfig gesetzt ist oder nicht.",
"SLX_REBOOT_SCHEDULE": "Feste Uhrzeit, zu der sich die Rechner neustarten, auch wenn noch ein Benutzer aktiv ist.\r\nMehrere Zeitpunkte k\u00f6nnen durch Leerzeichen getrennt angegeben werden.",
"SLX_REMOTE_LOG_SESSIONS": "Legt fest, ob Logins und Logouts der Benutzer an den Satelliten gemeldet werden sollen.\r\n*yes* = Mit Benutzerkennung loggen\r\n*anonymous* = Anonym loggen\r\n*no* = Nicht loggen",
+ "SLX_RESOLUTION_MAPPING": "Hier k\u00f6nnen Sie statische Zuweisung von Bildschirmanschl\u00fcssen zu Aufl\u00f6sungen aus *SLX_FORCE_RESOLUTION* vornehmen. Das Format ist eine durch Leerzeichen getrennte Liste von OUTPUTNAME=Index, z.B.\r\n\r\nHDMI1=0 HDMI2=0 HDMI3=1\r\n\r\nSofern die Variable *SLX_FORCE_RESOLUTION* den Wert *1024x768 800x600* hat, werden jetzt die Ausg\u00e4nge HDMI1 und HDMI2 die Aufl\u00f6sung 1024x768 haben und den gleichen Inhalt zeigen (\"linker Bildschirm\"), und HDMI3 wird die Aufl\u00f6sung 800x600 haben, und den Desktop nach rechts erweitern.\r\n\r\nDie Verwendung dieser Konfigurationsoption sollte nur f\u00fcr ungew\u00f6hnliche Bildschirm-Setups notwendig sein.",
"SLX_ROOT_PASS": "Das root-Passwort des Grundsystems. Wird nur f\u00fcr Diagnosezwecke am Client ben\u00f6tigt.\r\nFeld leer lassen, um root-Logins 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_SCREEN_SAVER_GRACE_TIME": "Wenn sich der Bildschirmschoner nach dem konfigurierten Timeout automatisch aktiviert, kann er f\u00fcr die hier angegebene Zeit (in Sekunden) durch Tastendruck oder Mausbewegen wieder deaktiviert werden, ohne dass das Benutzerkennwort angefordert wird.",
"SLX_SCREEN_SAVER_TIMEOUT": "Zeit in Sekunden, nach der sich bei Nutzerinaktivit\u00e4t der Bildschirmschoner aktiviert. Der Bildschirmschoner sperrt zugleich die Sitzung und fordert zum Entsperren das Nutzerkennwort an.",
@@ -28,6 +34,7 @@
"SLX_SHUTDOWN_SCHEDULE": "Feste Uhrzeit, zu der sich die Rechner ausschalten, auch wenn noch ein Benutzer aktiv ist.\r\nMehrere Zeitpunkte k\u00f6nnen durch Leerzeichen getrennt angegeben werden.",
"SLX_SHUTDOWN_TIMEOUT": "Zeit in Sekunden, nach der ein Rechner abgeschaltet wird, sofern kein Benutzer angemeldet ist.\r\nFeld leer lassen, um die Funktion zu deaktivieren.",
"SLX_SYSTEM_STANDBY_TIMEOUT": "Zeit in Sekunden, nach der ein Rechner in den Standby-Modus versetzt wird, sofern kein Benutzer angemeldet ist.\r\nFeld leer lassen, um die Funktion zu deaktivieren.\r\n\r\nBitte beachten Sie, dass der Standby-Modus auf bestimmter Hardware nicht oder nur unzuverl\u00e4ssig funktioniert. Es empfiehlt sich, diese Funktion nur in R\u00e4umen zu aktivieren bei denen vorher \u00fcberpr\u00fcft wurde, dass der Standby-Modus ordnungsgem\u00e4\u00df funktioniert.\r\nDer Standby-Modus kann manuell ausgel\u00f6st werden, indem auf dem Client als root-Benutzer der Befehl *systemctl suspend* ausgef\u00fchrt wird.",
+ "SLX_TTY_SWITCH": "Legt fest, ob der X-Server einen Wechsel zur Textkonsole mittels Strg-Alt-F1 etc. zul\u00e4sst.",
"SLX_VMCHOOSER_FORLOCATION": "Legt das Verhalten fest, wenn es Veranstaltungen gibt, die an einen bestimmten Ort\/Raum gebunden sind.\r\n*IGNORE*: Mit den restlichen, globalen Veranstaltungen alphabetisch sortiert auflisten.\r\n*BUMP*: Die spezifischen Veranstaltungen oben auflisten, die globalen darunter.\r\n*EXCLUSIVE*: Spezifische Veranstaltungen oben auflisten, globale Veranstaltungen zun\u00e4chst ausblenden. Die globalen Veranstaltungen befinden sich unter einem eingeklappten Listenknoten.",
"SLX_VMCHOOSER_TAB": "Bestimmt, welcher Karteireiter im vmChooser standardm\u00e4\u00dfig ausgew\u00e4hlt wird.\r\n*0*: Native Linux-Sessions\r\n*1*: Nutzerspezifische Kurse\r\n*2*: Alle Kurse\r\n*AUTO*: Hat der Rechner beschr\u00e4nkte Ressourcen, werden die Linux-Sitzungen angezeigt, sonst alle Kurse\r\n\r\nHat der Benutzer ein persistentes Home-Verzeichnis, wirkt sich diese Einstellung nur beim ersten Anmelden aus. Bei sp\u00e4teren Sitzungen markiert der vmChooser die zuletzt gestartete Sitzung und wechselt zum entsprechenden Karteireiter.",
"SLX_VMCHOOSER_TEMPLATES": "Legt fest, wie Veranstaltungen in der Sortierung behandelt werden, welche auf eine VM linken, die eine Vorlage ist.\r\n*IGNORE*: Wie regul\u00e4re Veranstaltungen behandeln\r\n*BUMP*: Weiter oben in der Liste einsortieren",
diff --git a/modules-available/baseconfig_bwlp/lang/en/config-variables.json b/modules-available/baseconfig_bwlp/lang/en/config-variables.json
index b35aefbe..adec0cc2 100644
--- a/modules-available/baseconfig_bwlp/lang/en/config-variables.json
+++ b/modules-available/baseconfig_bwlp/lang/en/config-variables.json
@@ -4,14 +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.",
@@ -21,14 +26,16 @@
"SLX_PVS_DEFAULT": "Set whether the \"Join PVS\" checkbox in vmChooser is checked by default.",
"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_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_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_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.",
"SLX_SHUTDOWN_SCHEDULE": "Fixed time to turn off the computer, even if there is a user active.\r\nSeveral times can be specified, separated by spaces.",
"SLX_SHUTDOWN_TIMEOUT": "Time in seconds after which a computer is switched off, if no user is logged on.\r\nLeave blank to disable the function.",
"SLX_SYSTEM_STANDBY_TIMEOUT": "Timeout in seconds after which the computer enters stand-by mode if no user is logged on.\r\nLeave blank to disable.\r\n\r\nNote that some hardware might not properly support stand-by and crash or freeze on wakeup. It's recommended to only enable this feature for rooms where it's known that the hardware supports stand-by mode. You can manually trigger stand-by mode on a client by logging in as root and executing *systemctl suspend*.",
- "SLX_VMCHOOSER_FORLOCATION": "Defines how lectures special to the user's location are handled in the vmChooser.\r\n*IGNORE*: Sort them alphabetically among the global lectures.\r\n*BUMP*: Put them atop the global lectures.\r\n*EXCLUSIVE*: Put them atop the global lectures and aditionally collapse the node which contains the global lectures.",
+ "SLX_TTY_SWITCH": "Whether or not the X-server allows switching to the text console via Ctrl-Alt-F1 etc.",
+ "SLX_VMCHOOSER_FORLOCATION": "Defines how lectures special to the user's location are handled in the vmChooser.\r\n*IGNORE*: Sort them alphabetically among the global lectures.\r\n*BUMP*: Put them atop the global lectures.\r\n*EXCLUSIVE*: Put them atop the global lectures and additionally collapse the node which contains the global lectures.",
"SLX_VMCHOOSER_TAB": "Defines which tab is show by default, if the user doesn't have stored a last used session on his persistent home directory.\r\n*0*: Native Linux sessions\r\n*1*: User specific lectures\r\n*2*: All lectures\r\n*AUTO*: If the computer has low system specs, show the Linux sessions, otherwise, show all lectures",
"SLX_VMCHOOSER_TEMPLATES": "Defines how lectures that link to template VMs are treated wrt sorting.\r\n*IGNORE*: Sort among regular lectures\r\n*BUMP*: Move to top of list",
"SLX_VMCHOOSER_TIMEOUT": "Timeout in seconds after which the session will be closed if the user doesn't make any selection in vmChooser. Mouse or keyboard activity resets this timeout.\r\n\r\nIf *SLX_AUTOLOGIN* is enabled, this setting is not available.",
diff --git a/modules-available/dnbd3/baseconfig/getconfig.inc.php b/modules-available/dnbd3/baseconfig/getconfig.inc.php
index 6f484fc5..eff821fc 100644
--- a/modules-available/dnbd3/baseconfig/getconfig.inc.php
+++ b/modules-available/dnbd3/baseconfig/getconfig.inc.php
@@ -1,16 +1,21 @@
<?php
-if (!Dnbd3::isEnabled()) return;
+/** @var ?string $uuid */
+/** @var ?string $ip */
-if (!Dnbd3::hasNfsFallback()) {
- ConfigHolder::add("SLX_VM_NFS", false, 1000);
- ConfigHolder::add("SLX_VM_NFS_USER", false, 1000);
- ConfigHolder::add("SLX_VM_NFS_PASSWD", false, 1000);
+if (Dnbd3::isEnabled()) {
+ if (!Dnbd3::hasNfsFallback()) {
+ ConfigHolder::add("SLX_VM_NFS", false, 1000);
+ ConfigHolder::add("SLX_VM_NFS_USER", false, 1000);
+ ConfigHolder::add("SLX_VM_NFS_PASSWD", false, 1000);
+ }
+
+ ConfigHolder::add('SLX_VM_DNBD3', 'yes');
}
// Locations from closest to furthest (order)
$locations = ConfigHolder::get('SLX_LOCATIONS');
-if ($locations === false) {
+if ($locations === null) {
$locationIds = [0];
} else {
$locationIds = explode(' ', $locations);
@@ -27,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;
@@ -48,7 +53,7 @@ while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
$row['locationid'] = $serverLoc;
}
}
- $old = isset($servers[$ip]) ? $servers[$ip] : $defPrio;
+ $old = $servers[$ip] ?? PHP_INT_MAX;
if (is_null($row['locationid']) || !isset($locationsAssoc[$row['locationid']])) {
$servers[$ip] = min($defPrio . '.' . mt_rand(), $old);
} else {
@@ -62,5 +67,4 @@ foreach ($servers as $k => $v) {
asort($servers, SORT_NUMERIC | SORT_ASC);
ConfigHolder::add('SLX_DNBD3_SERVERS', implode(' ', array_keys($servers)));
-ConfigHolder::add('SLX_DNBD3_FALLBACK', implode(' ', array_keys($fallback)));
-ConfigHolder::add('SLX_VM_DNBD3', 'yes');
+ConfigHolder::add('SLX_DNBD3_FALLBACK', implode(' ', array_keys($fallback))); \ No newline at end of file
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/runmode/config.json b/modules-available/dnbd3/hooks/runmode/config.json
index 683e0280..7d2fe3c9 100644
--- a/modules-available/dnbd3/hooks/runmode/config.json
+++ b/modules-available/dnbd3/hooks/runmode/config.json
@@ -1,7 +1,6 @@
{
"isClient": false,
"configHook": "Dnbd3Util::runmodeConfigHook",
- "noSysconfig": true,
"systemdDefaultTarget": "dnbd3-proxy",
"allowGenericEditor": true,
"deleteUrlSnippet": "dummyparam=",
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 9607c544..def4e062 100644
--- a/modules-available/dnbd3/inc/dnbd3.inc.php
+++ b/modules-available/dnbd3/inc/dnbd3.inc.php
@@ -4,35 +4,57 @@ 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)
{
Property::set(self::PROP_ENABLED, $bool ? 1 : 0);
- $task = Taskmanager::submit('Systemctl', array(
- 'operation' => ($bool ? 'start' : 'stop'),
- 'service' => 'dnbd3-server'
- ));
- return $task;
+ 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 getLocalStatus()
+ 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]);
}
-} \ No newline at end of file
+}
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 6ede18d5..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;
}
@@ -57,19 +57,19 @@ class Dnbd3Util {
if (!$hasSelf) {
Database::exec("INSERT IGNORE INTO dnbd3_server (fixedip) VALUES ('<self>')");
}
- // Delete orphaned entires with machineuuid from dnbd3_server where we don't have a runmode entry
+ // Delete orphaned entries with machineuuid from dnbd3_server where we don't have a runmode entry
Database::exec('DELETE s FROM dnbd3_server s
LEFT JOIN runmode r USING (machineuuid)
WHERE s.machineuuid IS NOT NULL AND r.module IS NULL');
// 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;
}
@@ -190,6 +185,14 @@ class Dnbd3Util {
// Background replication
ConfigHolder::add('SLX_DNBD3_BGR', '1');
}
+ // Extra config options
+ if (!empty($modeData['advancedSettings'])) {
+ $str = '';
+ foreach ($modeData['advancedSettings'] as $k => $v) {
+ $str .= str_replace(["\n", "\r", "\t", ' '], '', $k . '=' . $v) . ' ';
+ }
+ ConfigHolder::add('SLX_DNBD3_EXTRA', $str);
+ }
ConfigHolder::add('SLX_ADDONS', '', 1000);
ConfigHolder::add('SLX_SHUTDOWN_TIMEOUT', '', 1000);
ConfigHolder::add('SLX_SHUTDOWN_SCHEDULE', '', 1000);
@@ -207,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);
@@ -244,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 de673450..d58c033f 100644
--- a/modules-available/dnbd3/lang/de/template-tags.json
+++ b/modules-available/dnbd3/lang/de/template-tags.json
@@ -1,12 +1,14 @@
{
"lang_addServer": "Server hinzuf\u00fcgen",
+ "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.",
"lang_bytesSent": "Gesendet",
- "lang_changeDnbd3Status": "DNBD3 ein-\/ausschalten",
+ "lang_changeDnbd3Status": "VM-Auslieferung via DNBD3 ein-\/ausschalten",
"lang_client": "Client",
"lang_clientCount": "Clients",
"lang_clientList": "Liste der Clients",
@@ -21,7 +23,8 @@
"lang_dnbd3Management": "DNBD3 Verwaltung",
"lang_dnbd3Status": "DNBD3 Status",
"lang_editProxyHeading": "Proxy-Einstellungen bearbeiten",
- "lang_enableDnbd3": "DNBD3 aktivieren",
+ "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",
@@ -33,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.",
@@ -47,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",
@@ -66,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 509f0436..890aa0c2 100644
--- a/modules-available/dnbd3/lang/en/template-tags.json
+++ b/modules-available/dnbd3/lang/en/template-tags.json
@@ -1,12 +1,14 @@
{
"lang_addServer": "Add server",
+ "lang_advancedConfigDesc": "These are settings to further customize the proxy server. Usually the default values are fine, but under certain local circumstances it might make sense to change certain values to improve performance. Entering bogus values for some of these might effectively render the proxy unusable. Any change in these values requires a restart of the proxy.",
+ "lang_advancedProxyConfig": "Advanced configuration",
"lang_allowNfsFallback": "NFS fallback",
"lang_allowedSubnets": "Allowed subnets",
"lang_altservers": "Uplinks",
"lang_backgroundReplication": "Background replication",
"lang_backgroundReplicationInfo": "If a VM is requested by this proxy, the proxy mirrors the complete VM in the background, not only the requested data blocks.",
"lang_bytesSent": "Sent",
- "lang_changeDnbd3Status": "Enable\/disable DNBD3",
+ "lang_changeDnbd3Status": "Enable\/disable DNBD3 for VMs",
"lang_client": "Client",
"lang_clientCount": "Clients",
"lang_clientList": "List of clients",
@@ -21,7 +23,8 @@
"lang_dnbd3Management": "DNBD3 management",
"lang_dnbd3Status": "DNBD3 status",
"lang_editProxyHeading": "Edit proxy settings",
- "lang_enableDnbd3": "Enable DNBD3",
+ "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",
@@ -33,20 +36,21 @@
"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 formated 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.",
+ "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.",
"lang_manualRefresh": "Manual refresh",
"lang_manualRefreshInfo": "All servers are queried every 5 minutes to update the table below. Hit the refresh button to update the table immediately.",
"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",
@@ -66,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 87169a03..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]);
@@ -66,23 +69,31 @@ class Page_Dnbd3 extends Page
'fixedip' => $overrideIp,
));
}
+ $advancedSettings = [];
+ foreach (Request::post('extra', [], 'array') as $name => $value) {
+ $value = preg_replace('/[^0-9KMGTmhdBbtruefals]/', '', $value);
+ if ($value === '')
+ continue;
+ $advancedSettings[$name] = $value;
+ }
RunMode::setRunMode($server['machineuuid'], 'dnbd3', 'proxy',
- json_encode(compact('bgr', 'firewall')), false);
+ 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');
- $task = Dnbd3::setEnabled($enabled);
+ $preferLocal = Request::post('prefer-local', false, 'bool');
+ Dnbd3::setEnabled($enabled);
Dnbd3::setNfsFallback($nfs);
- Taskmanager::waitComplete($task, 5000);
+ Dnbd3::setPreferLocal($preferLocal);
}
private function saveServerLocations()
{
- $server = $this->getServerById();
+ $server = $this->getServerFromQuery();
$this->assertPermission($server);
$locids = Request::post('location', [], 'array');
if (empty($locids)) {
@@ -123,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;
@@ -168,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']])) {
@@ -202,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 {
@@ -245,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);
@@ -254,9 +267,11 @@ class Page_Dnbd3 extends Page
private function showProxyDetails()
{
User::assertPermission('view.details');
- $server = $this->getServerById();
+ Module::isAvailable('js_stupidtable');
+ $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;
@@ -276,15 +291,6 @@ class Page_Dnbd3 extends Page
$stats['tab_altservers'] = is_array($stats['altservers']);
Render::addTemplate('page-proxy-stats', $stats);
Render::openTag('div', ['class' => 'tab-content']);
- $ips = array();
- $sort = array();
- foreach ($stats['clients'] as &$c) {
- $c['bytesSent_s'] = Util::readableFileSize($c['bytesSent']);
- $sort[] = $c['bytesSent'];
- $ips[preg_replace('/:\d+$/', '', $c['address'])] = true;
- }
- $ips = array_keys($ips);
- array_multisort($sort, SORT_DESC, $stats['clients']);
// Config
if (is_string($stats['config'])) {
preg_match_all('/^((?<sec>\[.*\])|(?<key>[^=]+)=(?<val>.*)|(?<other>[^\[][^=]*))$/m', $stats['config'], $out, PREG_SET_ORDER);
@@ -333,6 +339,17 @@ class Page_Dnbd3 extends Page
unset($as);
Render::addTemplate('page-proxy-altservers', $stats);
}
+ // CLIENT TAB
+ $ips = array();
+ $sort = array();
+ foreach ($stats['clients'] as &$c) {
+ $c['bytesSent_s'] = Util::readableFileSize($c['bytesSent']);
+ $sort[] = $c['bytesSent'];
+ $c['ip'] = preg_replace('/:\d+$/', '', $c['address']);
+ $ips[$c['ip']] = true;
+ }
+ $ips = array_keys($ips);
+ array_multisort($sort, SORT_DESC, $stats['clients']);
// Count locations
$res = Database::simpleQuery("SELECT locationid, Count(*) AS cnt FROM machine
WHERE clientip IN (:ips) AND state IN ('IDLE', 'OCCUPIED') GROUP BY locationid", compact('ips'));
@@ -345,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'];
@@ -373,7 +391,7 @@ class Page_Dnbd3 extends Page
if (isset($image['idle'])) {
if ($image['idle'] < 90) {
$image['idle_s'] = Dictionary::translate('now');
- } elseif ($image['idle'] < $stats['uptime']) {
+ } elseif ($image['idle'] < 86400000) { // 1000 days
$image['idle_s'] = Util::formatDuration($image['idle'], false);
} else {
$image['idle_s'] = '∞';
@@ -398,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',
@@ -424,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');
@@ -483,6 +499,8 @@ class Page_Dnbd3 extends Page
$this->ajaxReboot();
} elseif ($action === 'cachemap') {
$this->ajaxCacheMap();
+ } elseif ($action === 'stats') {
+ $this->ajaxStats();
} else {
die($action . '???');
}
@@ -504,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"}');
@@ -518,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;
@@ -531,12 +549,26 @@ class Page_Dnbd3 extends Page
}
$modeData = (array)json_decode($rm[$server['machineuuid']]['modedata'], true);
$server += $modeData + Dnbd3Util::defaultRunmodeConfig();
+ $extraSettings = $server['advancedSettings'] ?? [];
+ $server['advancedSettings'] = [];
+ foreach (['dnbd3.serverPenalty',
+ 'dnbd3.clientPenalty',
+ 'dnbd3.bgrMinClients',
+ 'dnbd3.bgrWindowSize',
+ 'dnbd3.autoFreeDiskSpaceDelay',
+ 'dnbd3.sparseFiles',
+ 'limits.maxClients',
+ 'limits.maxImages',
+ 'limits.maxPayload',
+ 'limits.maxReplicationSize'] as $item) {
+ $server['advancedSettings'][] = ['name' => $item, 'value' => $extraSettings[$item] ?? ''];
+ }
echo Render::parse('fragment-server-settings', $server);
}
private function ajaxReboot()
{
- $server = $this->getServerById();
+ $server = $this->getServerFromQuery();
if (!isset($server['machineuuid'])) {
die('Not automatic server.');
}
@@ -575,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");
}
@@ -594,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 '';
- }
+ $lookup = Dnbd3::getActiveServers();
+ $result = Dnbd3Rpc::getStatsMulti(array_keys($lookup), [Dnbd3Rpc::QUERY_STATS]);
+ $return = [];
+ foreach ($result as $ip => $data) {
+ $return[$lookup[$ip]] = $data;
}
- 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 = '';
- }
- $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 be3e74e2..794c5fd9 100644
--- a/modules-available/dnbd3/templates/fragment-server-settings.html
+++ b/modules-available/dnbd3/templates/fragment-server-settings.html
@@ -1,22 +1,42 @@
<input type="hidden" name="server" value="{{serverid}}">
-<div class="checkbox">
- <input type="checkbox" name="bgr" id="bgr" {{#bgr}}checked{{/bgr}}>
- <label for="bgr"><b>{{lang_backgroundReplication}}</b></label>
-</div>
-<i>{{lang_backgroundReplicationInfo}}</i>
-<br><br>
+<div class="row">
+ <div class="col-md-6">
+ <div class="checkbox">
+ <input type="checkbox" name="bgr" id="bgr" {{#bgr}}checked{{/bgr}}>
+ <label for="bgr"><b>{{lang_backgroundReplication}}</b></label>
+ </div>
+ <i>{{lang_backgroundReplicationInfo}}</i>
+ <br><br>
-<div class="checkbox">
- <input type="checkbox" name="firewall" id="firewall" {{#firewall}}checked{{/firewall}}>
- <label for="firewall"><b>{{lang_firewalled}}</b></label>
-</div>
-<i>{{lang_firewallInfo}}</i>
-<br><br>
+ <div class="checkbox">
+ <input type="checkbox" name="firewall" id="firewall" {{#firewall}}checked{{/firewall}}>
+ <label for="firewall"><b>{{lang_firewalled}}</b></label>
+ </div>
+ <i>{{lang_firewallInfo}}</i>
+ <br><br>
-<div>
- <label for="fixedip">{{lang_overrideIp}}</label>
- <input class="form-control" type="text" name="fixedip" id="fixedip" value="{{fixedip}}">
-</div>
-<i>{{lang_overrideIpInfo}}</i>
-<br>
+ <div>
+ <label for="fixedip">{{lang_overrideIp}}</label>
+ <input class="form-control" type="text" name="fixedip" id="fixedip" value="{{fixedip}}">
+ </div>
+ <i>{{lang_overrideIpInfo}}</i>
+ <br><br>
+
+ </div>
+ <div class="col-md-6">
+ <h4>{{lang_advancedProxyConfig}}</h4>
+ <p><i>{{lang_advancedConfigDesc}}</i></p>
+ {{#advancedSettings}}
+ <div class="row">
+ <div class="col-sm-8">
+ <label for="ex-{{name}}">{{name}}</label>
+ </div>
+ <div class="col-sm-4">
+ <input id="ex-{{name}}" class="form-control" type="text" pattern="[0-9KMGTmhdtruefals]*" value="{{value}}"
+ name="extra[{{name}}]">
+ </div>
+ </div>
+ {{/advancedSettings}}
+ </div>
+</div> \ No newline at end of file
diff --git a/modules-available/dnbd3/templates/page-proxy-clients.html b/modules-available/dnbd3/templates/page-proxy-clients.html
index daf741e2..6e2cece7 100644
--- a/modules-available/dnbd3/templates/page-proxy-clients.html
+++ b/modules-available/dnbd3/templates/page-proxy-clients.html
@@ -32,20 +32,26 @@
<div class="col-md-6">
<h2>{{lang_clientList}}</h2>
- <table class="table table-condensed">
+ <table id="client-table" class="table table-condensed">
<tr>
<th>{{lang_client}}</th>
+ <th></th>
<th class="text-right">{{lang_bytesSent}}</th>
</tr>
{{#clients}}
<tr>
<td>
{{#isServer}}
- <span class="glyphicon glyphicon-hdd" title="{{lang_isProxy}}"></span>
+ <span class="glyphicon glyphicon-hdd" title="{{lang_isProxy}}"></span>
{{/isServer}}
- {{address}}
+ <a href="?do=statistics&amp;show=list&amp;filter[clientip]=1&amp;op[clientip]=%3D&amp;arg[clientip]={{ip}}">{{address}}</a>
</td>
- <td data-sort="int" data-sort-value="{{bytesSent}}" class="text-right">
+ <td class="slx-smallcol">
+ <a href="#tab-images" class="show-image" data-image-id="{{imageId}}">
+ <span class="glyphicon glyphicon-folder-open"></span>
+ </a>
+ </td>
+ <td data-sort="int" data-sort-value="{{bytesSent}}" class="text-right text-nowrap slx-smallcol">
{{bytesSent_s}}
</td>
</tr>
@@ -53,4 +59,15 @@
</table>
</div>
</div>
-</div> \ No newline at end of file
+</div>
+
+<script>
+ document.addEventListener('DOMContentLoaded', function() {
+ $('.show-image').click(function () {
+ $('#client-table tr').removeClass('warning');
+ $('#img-table tr').removeClass('warning');
+ var x = $('#img-table a[data-image-id=' + $(this).data('image-id') + ']').closest('tr').addClass('warning')[0];
+ setTimeout(function() { if (x.scrollIntoViewIfNeeded) x.scrollIntoViewIfNeeded(true); else x.scrollIntoView({block: "center"}) }, 10);
+ });
+ });
+</script> \ No newline at end of file
diff --git a/modules-available/dnbd3/templates/page-proxy-images.html b/modules-available/dnbd3/templates/page-proxy-images.html
index 54c18497..0dd06801 100644
--- a/modules-available/dnbd3/templates/page-proxy-images.html
+++ b/modules-available/dnbd3/templates/page-proxy-images.html
@@ -1,29 +1,32 @@
<div role="tabpanel" class="tab-pane" id="tab-images">
<h2>{{lang_imageList}}</h2>
- <table class="table table-condensed">
+ <table id="img-table" class="table table-condensed stupidtable">
+ <thead>
<tr>
- <th>{{lang_image}}</th>
- <th class="text-right slx-smallcol">{{lang_clients}}</th>
- <th class="text-right slx-smallcol">{{lang_size}}</th>
- <th class="text-right slx-smallcol">{{lang_complete}}</th>
- <th class="text-right slx-smallcol">{{lang_unusedFor}}</th>
- <th class="slx-smallcol">{{lang_uplink}}</th>
+ <th data-sort="string">{{lang_image}}</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>
</tr>
+ </thead>
+ <tbody>
{{#images}}
<tr>
<td class="text-nowrap">
{{name}}:{{rid}}
</td>
<td class="text-right text-nowrap">
- {{users}}
+ <a href="#tab-clients" class="show-clients" data-image-id="{{id}}">{{users}}</a>
</td>
- <td class="text-right text-nowrap">
+ <td class="text-right text-nowrap" data-sort-value="{{size}}">
{{size_s}}
</td>
<td class="text-right text-nowrap">
<a data-imgid="{{id}}" class="cache-map" href="#">{{complete}}&thinsp;%</a>
</td>
- <td class="text-right text-nowrap">
+ <td class="text-right text-nowrap" data-sort-value="{{idle}}">
{{idle_s}}
</td>
<td class="text-nowrap">
@@ -31,6 +34,7 @@
</td>
</tr>
{{/images}}
+ </tbody>
</table>
</div>
@@ -115,5 +119,11 @@
};
xhr.send();
});
+ $('.show-clients').click(function () {
+ $('#img-table tr').removeClass('warning');
+ $('#client-table tr').removeClass('warning');
+ var x = $('#client-table a[data-image-id=' + $(this).data('image-id') + ']').closest('tr').addClass('warning')[0];
+ setTimeout(function() { if (x.scrollIntoViewIfNeeded) x.scrollIntoViewIfNeeded(true); else x.scrollIntoView({block: "center"}) }, 10);
+ });
});
</script>
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 c44eef0a..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>
@@ -223,8 +242,8 @@
</div>
</div>
-<div id="server-edit-modal" class="modal fade" role="dialog">
- <div class="modal-dialog">
+<div id="server-edit-modal" class="fade modal" role="dialog">
+ <div class="modal-dialog modal-lg">
<div class="modal-content">
<form method="post" action="?do=dnbd3">
<input type="hidden" name="token" value="{{token}}">
@@ -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 a2d6a0ae..c904b0c8 100644
--- a/modules-available/dozmod/lang/de/messages.json
+++ b/modules-available/dozmod/lang/de/messages.json
@@ -1,29 +1,28 @@
{
"all-templates-reset": "Alle Templates wurden zur\u00fcckgesetzt",
- "delete-images": "L\u00f6schung: {{0}}",
- "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}}",
@@ -33,4 +32,4 @@
"timeout": "Zeit\u00fcberschreitung",
"unknown-targetid": "Target {{0}} nicht bekannt",
"unknown-userid": "Unbekannter Nutzer, {{0}}"
-}
+} \ No newline at end of file
diff --git a/modules-available/dozmod/lang/de/permissions.json b/modules-available/dozmod/lang/de/permissions.json
index 8e743e5c..6159362d 100644
--- a/modules-available/dozmod/lang/de/permissions.json
+++ b/modules-available/dozmod/lang/de/permissions.json
@@ -8,6 +8,8 @@
"networkrules.view": "Netzwerk-Regeln einsehen.",
"networkshares.save": "\u00c4nderungen an den Netzlaufwerken speichern.",
"networkshares.view": "Netzlaufwerke einsehen.",
+ "orphaned.delete": "Verwaiste Dateien vom VM-Store l\u00f6schen.",
+ "orphaned.scan": "Nach Verwaisten Dateien auf VM-Store suchen.",
"runscripts.save": "Startkripte erstellen\/bearbeiten.",
"runscripts.view": "Startscripte auflisten.",
"runtimeconfig.save": "\u00c4nderungen an der Laufzeit-Konfiguration speichern.",
diff --git a/modules-available/dozmod/lang/de/template-tags.json b/modules-available/dozmod/lang/de/template-tags.json
index 5bc2f2b1..338e8e42 100644
--- a/modules-available/dozmod/lang/de/template-tags.json
+++ b/modules-available/dozmod/lang/de/template-tags.json
@@ -5,22 +5,26 @@
"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",
"lang_bwlehrpoolsuite": "bwLehrpool-Suite",
"lang_canLoginOrganization": "Nutzer dieser Einrichtung k\u00f6nnen sich am Satelliten anmelden",
"lang_canLoginUser": "Nutzer kann sich am Satelliten anmelden",
+ "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",
+ "lang_defaultImagePermissionLink": "Veranstaltung verkn\u00fcpfen",
"lang_defaultImagePermissions": "F\u00fcr VMs",
"lang_defaultLecturePermissions": "F\u00fcr Veranstaltungen",
"lang_defaultPermissions": "Standardberechtigungen",
"lang_delButton": "Gew\u00e4hlte VMs endg\u00fcltig l\u00f6schen",
+ "lang_deleteExpiredHeading": "Zu l\u00f6schende VM-Versionen",
"lang_descriptionPermissionConfig": "Dies sind die Berechtigungen, die ein Benutzer standardm\u00e4\u00dfig f\u00fcr fremde VMs\/Veranstaltungen hat. Sie werden angewandt, wenn der Besitzer keine anderweitigen Berechtigungen w\u00e4hlt.",
"lang_descriptionRuntimeLimits": "Hier k\u00f6nnen Sie verschiedene Limits festlegen, z.B. wie lange eine VM nach dem Hochladen g\u00fcltig ist. Nach Ablauf dieses Zeitraums ist der Verantwortliche gezwungen, eine neue Version der VM hochzuladen. Damit k\u00f6nnen Sie das Ansammeln nicht mehr ben\u00f6tigter VMs eind\u00e4mmen. Weiterhin k\u00f6nnen Sie die maximale Anzahl gleichzeitiger Transfers pro Benutzer einschr\u00e4nken.\r\n\r\nVer\u00e4nderte Einstellungen wirken sich nicht auf bereits bestehende VMs aus.",
"lang_description_delete_images": "Diese Liste zeigt VMs, die entweder abgelaufen sind, oder deren Datei besch\u00e4digt, verschoben oder gel\u00f6scht wurde. Diese Images sind zur Zeit im Lehrpool nicht verf\u00fcgbar, ihre endg\u00fcltige L\u00f6schung muss aber manuell best\u00e4tigt werden, um gr\u00f6\u00dfere Katastrophen durch Softwarefehler, verstellte Systemuhren etc. zu vermeiden.",
@@ -33,14 +37,15 @@
"lang_emailNotifications": "EMail-Benachrichtigungen aktiviert",
"lang_error": "Fehler",
"lang_event": "Ereignis",
+ "lang_fileName": "Dateiname",
"lang_fileSize": "Dateigr\u00f6\u00dfe",
"lang_followingPlaceholdersUnused": "Folgende Platzhalter m\u00fcssen im Template verwendet werden",
"lang_hasNewer": "Neuere Version existiert",
"lang_hash": "Hash",
- "lang_heading": "Zu l\u00f6schende VM-Versionen",
"lang_hidden": "Versteckt",
"lang_host": "Host",
"lang_image": "VM",
+ "lang_lastBoot": "Letzter Start",
"lang_lastEditor": "Zuletzt bearbeitet von",
"lang_lastLogin": "Letzte Anmeldung",
"lang_latestVersion": "Neuste Version",
@@ -65,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",
@@ -75,8 +81,12 @@
"lang_networksharesIntro": "Hier k\u00f6nnen Sie vordefinierte Netzlaufwerke anlegen, die den Nutzern der bwLehrpool-Suite zur Auswahl gestellt werden. Es ist den Nutzern der bwLehrpool-Suite weiterhin m\u00f6glich, komplett eigene Netzwerkfreigaben zu definieren. Die Angaben hier sollen lediglich das Hinzuf\u00fcgen h\u00e4ufig genutzter Laufwerke vereinfachen, bzw. das \u00c4ndern eines Netzwerkpfades vereinfachen, da in diesem Fall nur der Zentrale Eintrag hier angepasst werden muss, und nicht mehr wie zuvor jede Veranstaltung einzeln.",
"lang_none": "(Keiner)",
"lang_normal": "Normal",
+ "lang_numberBoots": "Anzahl Starts",
"lang_organization": "Einrichtung",
"lang_organizationListHeader": "Nutzungsrechte f\u00fcr den Satelliten festlegen",
+ "lang_orphanDeleteButton": "Dateien l\u00f6schen",
+ "lang_orphanedFilesDesc": "Hier aufgelistete Dateien geh\u00f6ren zu keiner aus der Datenbank bekannten VM. Sollten Sie sich sicher sein, dass diese Dateien nicht anderweitig verwendet werden, oder h\u00e4ndisch auf dem VM-Store abgelegt wurden, k\u00f6nnen Sie diese Dateien l\u00f6schen, um Speicherplatz zu gewinnen. Wenn Sie sich nicht sicher sind, untersuchen Sie die Situation auf dem VM-Store h\u00e4ndisch.",
+ "lang_orphanedFilesHeading": "Verwaiste Images und Dateien auf dem VM-Store",
"lang_os": "Betriebssystem",
"lang_owner": "Besitzer",
"lang_passwordCleartextHint": "Bitte beachten Sie, dass ein hier explizit angegebenes Passwort im Klartext an das Poolsystem \u00fcbergeben wird. Sie sollten also nur dedizierte Funktionsaccounts nutzen, die keinen Zugriff auf weitere Systeme erm\u00f6glichen.",
@@ -93,6 +103,7 @@
"lang_runScriptDeleteConfirmation": "Skript wirklich l\u00f6schen?",
"lang_runtimeConfig": "Laufzeit-Konfiguration",
"lang_runtimeConfigLimits": "Beschr\u00e4nkungen",
+ "lang_scanButton": "VM-Store durchsuchen",
"lang_scriptContent": "Skriptinhalt",
"lang_scriptExtension": "Dateinamenerweiterung",
"lang_scriptExtensionHead": "Erweiterung",
@@ -108,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",
@@ -121,6 +132,7 @@
"lang_sslExplicit": "Explizites SSL (\"STARTTLS\")",
"lang_sslImplicit": "Implizites SSL",
"lang_sslNone": "Kein SSL",
+ "lang_status": "Status",
"lang_superUser": "Ist SuperUser (darf alle Veranstaltungen und VMs bearbeiten\/l\u00f6schen)",
"lang_system": "System",
"lang_target": "Ziel",
diff --git a/modules-available/dozmod/lang/en/messages.json b/modules-available/dozmod/lang/en/messages.json
index 84677402..1b46339b 100644
--- a/modules-available/dozmod/lang/en/messages.json
+++ b/modules-available/dozmod/lang/en/messages.json
@@ -1,6 +1,5 @@
{
"all-templates-reset": "All templates have been reset",
- "delete-images": "Delete: {{0}}",
"dozmod-error": "Error communicating with the bwLehrpool-Suite server: {{0}}",
"images-pending-delete-exist": "VMs marked for deletion: {{0}}",
"ldap-filter-created": "LDAP filter was successfully created",
diff --git a/modules-available/dozmod/lang/en/module.json b/modules-available/dozmod/lang/en/module.json
index 5bcee464..81996b10 100644
--- a/modules-available/dozmod/lang/en/module.json
+++ b/modules-available/dozmod/lang/en/module.json
@@ -1,14 +1,14 @@
{
"module_name": "bwLehrpool-Suite",
"page_title": "Manage the bwLehrpool-Suite",
- "submenu_actionlog": "action log",
+ "submenu_actionlog": "Action log",
"submenu_expiredimages": "Expired VM versions",
"submenu_ldapfilters": "LDAP filters",
- "submenu_mailconfig": "email configuration",
+ "submenu_mailconfig": "Email configuration",
"submenu_networkrules": "Network Rules",
"submenu_networkshares": "Network Shares",
"submenu_runscripts": "Startup scripts",
- "submenu_runtimeconfig": "limits and defaults",
- "submenu_templates": "templates",
- "submenu_users": "users and permissions"
+ "submenu_runtimeconfig": "Limits and defaults",
+ "submenu_templates": "Templates",
+ "submenu_users": "Users and permissions"
} \ No newline at end of file
diff --git a/modules-available/dozmod/lang/en/permissions.json b/modules-available/dozmod/lang/en/permissions.json
index b0fbb071..479b150b 100644
--- a/modules-available/dozmod/lang/en/permissions.json
+++ b/modules-available/dozmod/lang/en/permissions.json
@@ -8,6 +8,8 @@
"networkrules.view": "View network rules.",
"networkshares.save": "Save network drives.",
"networkshares.view": "View network drives.",
+ "orphaned.delete": "Delete orphaned files from VM store.",
+ "orphaned.scan": "Scan for orphaned files on VM store.",
"runscripts.save": "Save startup scripts.",
"runscripts.view": "View startup scripts.",
"runtimeconfig.save": "Save limits and defaults of a runtime configuration.",
@@ -15,7 +17,7 @@
"templates.save": "Save email templates.",
"users.setlogin": "Enable\/Disable Login.",
"users.setmail": "Enable\/Disable Email Notification.",
- "users.setorglogin": "Enalbe\/Disable Login for Users from certain organisations.",
+ "users.setorglogin": "Enable\/Disable Login for Users from certain organisations.",
"users.setsu": "Set User to superuser.",
"users.view": "View user list."
} \ No newline at end of file
diff --git a/modules-available/dozmod/lang/en/template-tags.json b/modules-available/dozmod/lang/en/template-tags.json
index af64d000..b741e03d 100644
--- a/modules-available/dozmod/lang/en/template-tags.json
+++ b/modules-available/dozmod/lang/en/template-tags.json
@@ -5,12 +5,15 @@
"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",
"lang_bwlehrpoolsuite": "bwLehrpool-Suite",
"lang_canLoginOrganization": "Users from this organization can login",
"lang_canLoginUser": "This user can login",
+ "lang_confirmDeleteOrphanedFiles": "Are you sure you want to permanently delete the files listed below?",
"lang_createTime": "Created",
"lang_currentFilter": "Current filter",
"lang_defaultImagePermissionAdmin": "Administrate",
@@ -21,6 +24,7 @@
"lang_defaultLecturePermissions": "For lectures",
"lang_defaultPermissions": "Default permissions",
"lang_delButton": "Permanently delete selected images",
+ "lang_deleteExpiredHeading": "Images marked for deletion",
"lang_descriptionPermissionConfig": "These are the default permissions being used for VMs and lectures if the owner does not specify any.",
"lang_descriptionRuntimeLimits": "Here you can define some limits, e.g. how long a newly uploaded VM will be valid. This should make sure that you don't end up with a lot of old, unused VMs over time.\r\n\r\nModified settings won't apply for already existing VMs.",
"lang_description_delete_images": "This is a list of VMs that either expired, or where the disk image is damaged or missing. These VMs are not available in bwLehrpool currently, but you have to manually confirm the deletion of the disk images for safety reasons (clock skew etc.)",
@@ -33,17 +37,18 @@
"lang_emailNotifications": "E-Mail notifications enabled",
"lang_error": "Error",
"lang_event": "Event",
+ "lang_fileName": "File name",
"lang_fileSize": "File size",
"lang_followingPlaceholdersUnused": "The following placeholders are not being used",
"lang_hasNewer": "newer version exists",
"lang_hash": "Hash",
- "lang_heading": "Images Marked for Deletion",
"lang_hidden": "Hidden",
"lang_host": "Host",
"lang_image": "VM",
+ "lang_lastBoot": "Last started",
"lang_lastEditor": "Edited by",
"lang_lastLogin": "Last login",
- "lang_latestVersion": "latest version",
+ "lang_latestVersion": "Latest version",
"lang_ldapFilterAdd": "Add LDAP filter",
"lang_ldapFilterAttribute": "Attribute",
"lang_ldapFilterDeleteConfirmation": "Do you really want to delete this LDAP filter.",
@@ -62,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",
@@ -75,8 +81,12 @@
"lang_networksharesIntro": "This is the list of predefined network shares. bwLehrpool-Suite users can still add custom network shares to their lectures, however having commonly used network shares as predefined entries should be much more convenient. Another advantage is that changing the path of a network share centrally avoids having to edit a dozen lectures' configuration manually.",
"lang_none": "(none)",
"lang_normal": "Normal",
+ "lang_numberBoots": "Use count",
"lang_organization": "Organization",
"lang_organizationListHeader": "Set access permissions for organizations",
+ "lang_orphanDeleteButton": "Delete files",
+ "lang_orphanedFilesDesc": "Files listed here could not be matched to any VM from the database. If you're sure that these files aren't used for anything else (e.g. shared VM store), you can delete these files to free up space. If you're not sure, leave them alone or manually examine the situation on the file system.",
+ "lang_orphanedFilesHeading": "Orphaned files on VM store",
"lang_os": "Operating System",
"lang_owner": "Owner",
"lang_passwordCleartextHint": "Please not that explicitly provided credentials will be passed in clear text. You should only use dedicated accounts that don't give access to anything else than the desired network share(s).",
@@ -93,6 +103,7 @@
"lang_runScriptDeleteConfirmation": "Do you want to delete this run-script?",
"lang_runtimeConfig": "Limits and Defaults",
"lang_runtimeConfigLimits": "Limitations",
+ "lang_scanButton": "Scan VM store",
"lang_scriptContent": "Script content",
"lang_scriptExtension": "Script extension",
"lang_scriptExtensionHead": "Extension",
@@ -121,6 +132,7 @@
"lang_sslExplicit": "Explicit SSL (\"STARTTLS\")",
"lang_sslImplicit": "Implicit SSL",
"lang_sslNone": "No SSL",
+ "lang_status": "Status",
"lang_superUser": "Is super user (can edit\/delete all lectures and VMs)",
"lang_system": "System",
"lang_target": "Target",
diff --git a/modules-available/dozmod/page.inc.php b/modules-available/dozmod/page.inc.php
index 67b791d1..4a43d881 100644
--- a/modules-available/dozmod/page.inc.php
+++ b/modules-available/dozmod/page.inc.php
@@ -5,7 +5,8 @@ class Page_DozMod extends Page
/** @var bool true if we have a proper subpage */
private $haveSubPage = false;
- private $validSections = ['expiredimages', 'mailconfig', 'templates', 'runtimeconfig', 'users', 'actionlog', 'networkshares', 'ldapfilters', 'runscripts', 'networkrules'];
+ private $validSections = ['expiredimages', 'mailconfig', 'templates', 'runtimeconfig', 'users', 'actionlog',
+ 'networkshares', 'ldapfilters', 'runscripts', 'networkrules', 'special'];
private $section;
@@ -15,15 +16,13 @@ class Page_DozMod extends Page
return;
/* different pages for different sections */
$this->section = Request::any('section', false, 'string');
- if ($this->section === 'blockstats') // HACK HACK
- return;
if ($this->section === false) {
foreach ($this->validSections as $this->section) {
if (User::hasPermission($this->section . '.*'))
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,8 +67,8 @@ class Page_DozMod extends Page
/* add sub-menus */
foreach ($this->validSections as $section) {
- if (User::hasPermission($section . '.*')) {
- Dashboard::addSubmenu('?do=dozmod&section=' . $section, Dictionary::translate('submenu_' . $section, true));
+ if ($section !== 'special' && User::hasPermission($section . '.*')) {
+ Dashboard::addSubmenu('?do=dozmod&section=' . $section, Dictionary::translate('submenu_' . $section));
}
}
}
@@ -79,31 +78,10 @@ class Page_DozMod extends Page
/* different pages for different sections */
if ($this->haveSubPage !== false) {
SubPage::doRender();
- return;
- }
-
- if ($this->section === 'blockstats') {
- $this->showBlockStats();
}
}
- private function showBlockStats()
- {
- $res = Database::simpleQuery("SELECT blocksha1, blocksize, Count(*) AS blockcount FROM sat.imageblock"
- . " 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)) {
- $row['hash_hex'] = bin2hex($row['blocksha1']);
- $row['blocksize_s'] = Util::readableFileSize($row['blocksize']);
- $data['hashes'][] = $row;
- $spaceWasted += $row['blocksize'] * ($row['blockcount'] - 1);
- }
- $data['spacewasted'] = Util::readableFileSize($spaceWasted);
- Render::addTemplate('blockstats', $data);
- }
-
protected function doAjax()
{
User::load();
@@ -111,43 +89,8 @@ class Page_DozMod extends Page
if ($this->haveSubPage !== false) {
SubPage::doAjax();
- return;
}
- $action = Request::post('action');
-
- if ($action === 'getblockinfo') {
- $this->ajaxGetBlockInfo();
- }
- }
-
- private function ajaxGetBlockInfo()
- {
- $hash = Request::any('hash', false, 'string');
- $size = Request::any('size', false, 'string');
- if ($hash === false || $size === false) {
- die('Missing parameter');
- }
- if (!is_numeric($size) || strlen($hash) !== 40 || !preg_match('/^[a-f0-9]+$/i', $hash)) {
- die('Malformed parameter');
- }
- $res = Database::simpleQuery("SELECT i.displayname, v.createtime, v.filesize, Count(*) AS blockcount FROM sat.imageblock ib"
- . " INNER JOIN sat.imageversion v USING (imageversionid)"
- . " INNER JOIN sat.imagebase i USING (imagebaseid)"
- . " WHERE ib.blocksha1 = :hash AND ib.blocksize = :size"
- . " GROUP BY ib.imageversionid"
- . " ORDER BY i.displayname ASC, v.createtime ASC",
- array('hash' => hex2bin($hash), 'size' => $size), true);
- if ($res === false) {
- die('Database error: ' . Database::lastError());
- }
- $data = array('rows' => array());
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- $row['createtime_s'] = date('d.m.Y H:i', $row['createtime']);
- $row['filesize_s'] = Util::readableFileSize($row['filesize']);
- $data['rows'][] = $row;
- }
- die(Render::parse('blockstats-details', $data));
}
}
diff --git a/modules-available/dozmod/pages/actionlog.inc.php b/modules-available/dozmod/pages/actionlog.inc.php
index a014ddf7..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');
}
@@ -20,15 +20,15 @@ class SubPage
{
Render::addTemplate('actionlog-header');
if (self::$action === '') {
- self::generateLog("SELECT al.dateline, al.targetid, al.description,"
- . " img.displayname AS imgname, tu.firstname AS tfirstname, tu.lastname AS tlastname, l.displayname AS lecturename,"
- . " al.userid AS uuserid, usr.firstname AS ufirstname, usr.lastname AS ulastname"
- . " FROM sat.actionlog al"
- . " LEFT JOIN sat.imagebase img ON (img.imagebaseid = targetid)"
- . " LEFT JOIN sat.user usr ON (usr.userid = al.userid)"
- . " LEFT JOIN sat.user tu ON (tu.userid = al.targetid)"
- . " LEFT JOIN sat.lecture l ON (l.lectureid = targetid)"
- . " ORDER BY al.dateline DESC LIMIT 500", array(), true, true);
+ self::generateLog("SELECT al.dateline, al.targetid, al.description,
+ img.displayname AS imgname, tu.firstname AS tfirstname, tu.lastname AS tlastname, l.displayname AS lecturename,
+ al.userid AS uuserid, usr.firstname AS ufirstname, usr.lastname AS ulastname
+ FROM sat.actionlog al
+ LEFT JOIN sat.imagebase img ON (img.imagebaseid = targetid)
+ LEFT JOIN sat.user usr ON (usr.userid = al.userid)
+ LEFT JOIN sat.user tu ON (tu.userid = al.targetid)
+ LEFT JOIN sat.lecture l ON (l.lectureid = targetid)
+ ORDER BY al.dateline DESC LIMIT 500", array(), true, true);
} elseif (self::$action === 'showuser') {
self::listUser();
} else {
@@ -39,11 +39,11 @@ class SubPage
private static function listUser()
{
// Query user
- $user = Database::queryFirst('SELECT userid, firstname, lastname, email, lastlogin,'
- . ' organization.displayname AS orgname FROM sat.user'
- . ' LEFT JOIN sat.organization USING (organizationid)'
- . ' WHERE userid = :uuid'
- . ' LIMIT 1', array('uuid' => self::$uuid));
+ $user = Database::queryFirst('SELECT userid, firstname, lastname, email, lastlogin,
+ organization.displayname AS orgname FROM sat.user
+ LEFT JOIN sat.organization USING (organizationid)
+ WHERE userid = :uuid
+ LIMIT 1', array('uuid' => self::$uuid));
if ($user === false) {
Message::addError('unknown-userid', self::$uuid);
Util::redirect('?do=dozmod&section=actionlog');
@@ -52,14 +52,14 @@ class SubPage
$user['lastlogin_s'] = date('d.m.Y H:i', $user['lastlogin']);
Render::addTemplate('actionlog-user', $user);
// Finally add the actionlog
- self::generateLog("SELECT al.dateline, al.targetid, al.description,"
- . " img.displayname AS imgname, usr.firstname AS tfirstname, usr.lastname AS tlastname, l.displayname AS lecturename"
- . " FROM sat.actionlog al"
- . " LEFT JOIN sat.imagebase img ON (img.imagebaseid = targetid)"
- . " LEFT JOIN sat.user usr ON (usr.userid = targetid)"
- . " LEFT JOIN sat.lecture l ON (l.lectureid = targetid)"
- . " WHERE al.userid = :uuid"
- . " ORDER BY al.dateline DESC LIMIT 500", array('uuid' => self::$uuid), false, true);
+ self::generateLog("SELECT al.dateline, al.targetid, al.description,
+ img.displayname AS imgname, usr.firstname AS tfirstname, usr.lastname AS tlastname, l.displayname AS lecturename
+ FROM sat.actionlog al
+ LEFT JOIN sat.imagebase img ON (img.imagebaseid = targetid)
+ LEFT JOIN sat.user usr ON (usr.userid = targetid)
+ LEFT JOIN sat.lecture l ON (l.lectureid = targetid)
+ WHERE al.userid = :uuid
+ ORDER BY al.dateline DESC LIMIT 500", array('uuid' => self::$uuid), false, true);
}
private static function listTarget()
@@ -72,54 +72,68 @@ class SubPage
}
// Finally add the actionlog
- self::generateLog("SELECT al.dateline, al.userid AS uuserid, al.description,"
- . " usr.firstname AS ufirstname, usr.lastname AS ulastname"
- . " FROM sat.actionlog al"
- . " LEFT JOIN sat.user usr ON (usr.userid = al.userid)"
- . " WHERE al.targetid = :uuid"
- . " ORDER BY al.dateline DESC LIMIT 500", array('uuid' => self::$uuid), true, false);
+ self::generateLog("SELECT al.dateline, al.userid AS uuserid, al.description,
+ usr.firstname AS ufirstname, usr.lastname AS ulastname
+ FROM sat.actionlog al
+ LEFT JOIN sat.user usr ON (usr.userid = al.userid)
+ WHERE al.targetid = :uuid
+ ORDER BY al.dateline DESC LIMIT 500", array('uuid' => self::$uuid), true, false);
}
- private static function addImageHeader()
+ private static function mangleHtml($desc)
{
- $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,'
- . ' img.displayname, img.description, img.createtime, img.updatetime,'
- . ' os.displayname AS osname'
- . ' FROM sat.imagebase img'
- . ' LEFT JOIN sat.user o ON (img.ownerid = o.userid)'
- . ' LEFT JOIN sat.user u ON (img.updaterid = u.userid)'
- . ' LEFT JOIN sat.operatingsystem os ON (img.osid = os.osid)'
- . ' WHERE img.imagebaseid = :uuid'
- . ' LIMIT 1', array('uuid' => self::$uuid));
+ if (substr($desc, 0, 5) === '<html') {
+ $desc = strip_tags($desc,
+ '<strong><b><i><u><ul><li><font><span><p><div><hr><h1><h2><h3><h4><h5><h6>');
+ $desc = preg_replace('/\b(on\w+|style)[\s\r\n]*=[\s\r\n]*(\'.*?\'|".*?"|[^\'"]\S*)/si', '', $desc);
+ } else {
+ $desc = nl2br(htmlspecialchars($desc));
+ }
+ return $desc;
+ }
+
+ 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,
+ img.displayname, img.description, img.createtime, img.updatetime,
+ os.displayname AS osname
+ FROM sat.imagebase img
+ LEFT JOIN sat.user o ON (img.ownerid = o.userid)
+ LEFT JOIN sat.user u ON (img.updaterid = u.userid)
+ LEFT JOIN sat.operatingsystem os ON (img.osid = os.osid)
+ WHERE img.imagebaseid = :uuid
+ LIMIT 1', array('uuid' => self::$uuid));
if ($image !== false) {
// Mangle date and render
$image['createtime_s'] = date('d.m.Y H:i', $image['createtime']);
$image['updatetime_s'] = date('d.m.Y H:i', $image['updatetime']);
- $image['descriptionHtml'] = nl2br(htmlspecialchars($image['description']));
+ $image['descriptionHtml'] = self::mangleHtml($image['description']);
Render::addTemplate('actionlog-image', $image);
}
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,'
- . ' l.displayname, l.description, l.createtime, l.updatetime,'
- . ' img.displayname AS imgname, img.imagebaseid'
- . ' FROM sat.lecture l'
- . ' LEFT JOIN sat.user o ON (l.ownerid = o.userid)'
- . ' LEFT JOIN sat.user u ON (l.updaterid = u.userid)'
- . ' LEFT JOIN sat.imageversion ver ON (ver.imageversionid = l.imageversionid)'
- . ' LEFT JOIN sat.imagebase img ON (img.imagebaseid = ver.imagebaseid)'
- . ' WHERE l.lectureid = :uuid'
- . ' LIMIT 1', array('uuid' => self::$uuid));
+ $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,
+ l.displayname, l.description, l.createtime, l.updatetime, l.usecount, l.lastused,
+ img.displayname AS imgname, img.imagebaseid
+ FROM sat.lecture l
+ LEFT JOIN sat.user o ON (l.ownerid = o.userid)
+ LEFT JOIN sat.user u ON (l.updaterid = u.userid)
+ LEFT JOIN sat.imageversion ver ON (ver.imageversionid = l.imageversionid)
+ LEFT JOIN sat.imagebase img ON (img.imagebaseid = ver.imagebaseid)
+ WHERE l.lectureid = :uuid
+ LIMIT 1', array('uuid' => self::$uuid));
if ($lecture !== false) {
// Mangle date and render
$lecture['createtime_s'] = date('d.m.Y H:i', $lecture['createtime']);
$lecture['updatetime_s'] = date('d.m.Y H:i', $lecture['updatetime']);
- $lecture['descriptionHtml'] = nl2br(htmlspecialchars($lecture['description']));
+ $lecture['lastused_s'] = date('d.m.Y H:i', $lecture['lastused']);
+
+ $lecture['descriptionHtml'] = self::mangleHtml($lecture['description']);
Render::addTemplate('actionlog-lecture', $lecture);
}
return $lecture !== false;
@@ -130,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 2b5a2274..ab563273 100644
--- a/modules-available/dozmod/pages/expiredimages.inc.php
+++ b/modules-available/dozmod/pages/expiredimages.inc.php
@@ -5,57 +5,24 @@ class SubPage
public static function doPreprocess()
{
- $action = Request::post('action', false, 'string');
- if ($action === 'delimages') {
- if (User::hasPermission("expiredimages.delete")) {
- $result = self::handleDeleteImages();
- if (!empty($result)) {
- Message::addInfo('delete-images', $result);
- }
- Util::redirect('?do=DozMod');
- }
- }
- }
-
- private static function handleDeleteImages()
- {
- $images = Request::post('images', false);
- if (is_array($images)) {
- foreach ($images as $image => $val) {
- if (strtolower($val) !== 'on')
- continue;
- Database::exec("UPDATE sat.imageversion SET deletestate = 'WANT_DELETE'"
- . " WHERE deletestate = 'SHOULD_DELETE' AND imageversionid = :imageversionid", array(
- 'imageversionid' => $image
- ));
- }
- if (!empty($images)) {
- $ret = Download::asStringPost('http://127.0.0.1:9080/do/delete-images', false, 10, $code);
- if ($code == 999) {
- $ret .= "\nConnection to DMSD failed.";
- }
- return $ret;
- }
- }
- return false;
}
- private static function loadExpiredImages()
+ private static function loadExpiredImages(): array
{
- $res = Database::simpleQuery("SELECT b.displayname,"
- . " own.firstname, own.lastname, own.email,"
- . " v.imageversionid, v.createtime, v.filesize, v.deletestate,"
- . " lat.expiretime AS latexptime, lat.deletestate AS latdelstate"
- . " FROM sat.imageversion v"
- . " INNER JOIN sat.imagebase b ON (b.imagebaseid = v.imagebaseid)"
- . " INNER JOIN sat.user own ON (b.ownerid = own.userid)"
- . " LEFT JOIN sat.imageversion lat ON (b.latestversionid = lat.imageversionid)"
- . " WHERE v.deletestate <> 'KEEP'"
- . " ORDER BY b.displayname ASC, v.createtime ASC");
+ $res = Database::simpleQuery("SELECT b.displayname,
+ own.firstname, own.lastname, own.userid,
+ v.imageversionid, v.createtime, v.filesize, v.deletestate,
+ lat.expiretime AS latexptime, lat.deletestate AS latdelstate
+ FROM sat.imageversion v
+ INNER JOIN sat.imagebase b ON (b.imagebaseid = v.imagebaseid)
+ INNER JOIN sat.user own ON (b.ownerid = own.userid)
+ LEFT JOIN sat.imageversion lat ON (b.latestversionid = lat.imageversionid)
+ WHERE v.deletestate <> 'KEEP'
+ 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';
@@ -76,22 +43,81 @@ class SubPage
public static function doRender()
{
$expiredImages = self::loadExpiredImages();
-
- if (empty($expiredImages)) {
- Message::addSuccess('no-expired-images');
- } else {
- Render::addTemplate('images-delete', array('images' => $expiredImages, 'allowedDelete' => User::hasPermission("expiredimages.delete")));
- }
+ $data = ['images' => $expiredImages];
+ Permission::addGlobalTags($data['perm'], null, ['expiredimages.delete', 'orphaned.scan']);
+ Render::addTemplate('images-delete', $data);
}
public static function doAjax()
{
$action = Request::post('action');
if ($action === 'delimages') {
- User::assertPermission("expiredimages.delete");
- die(self::handleDeleteImages());
+ self::handleDeleteImages();
+ } elseif ($action === 'orphaned') {
+ self::handleOrphaned();
+ } else {
+ echo 'Huh?';
+ }
+ }
+
+ private static function handleDeleteImages()
+ {
+ User::assertPermission("expiredimages.delete");
+ $images = Request::post('images', false);
+ $result = false;
+ if (is_array($images)) {
+ foreach ($images as $image => $val) {
+ if (strtolower($val) !== 'on')
+ continue;
+ Database::exec("UPDATE sat.imageversion SET deletestate = 'WANT_DELETE'"
+ . " WHERE deletestate = 'SHOULD_DELETE' AND imageversionid = :imageversionid", array(
+ 'imageversionid' => $image
+ ));
+ }
+ if (!empty($images)) {
+ $result = Download::asStringPost('http://127.0.0.1:9080/do/delete-images', false, 10, $code);
+ if ($code == 999) {
+ $result .= "\nConnection to DMSD failed.";
+ }
+ }
+ }
+ if (!empty($result)) {
+ echo $result;
+ }
+ }
+
+ private static function handleOrphaned()
+ {
+ if (Request::post('delete', 0, 'int') !== 0) {
+ User::assertPermission("orphaned.delete");
+ $action = 'delete';
+ } else {
+ User::assertPermission("orphaned.scan");
+ $action = 'scan';
+ }
+ // Talk to dmsd
+ $result = Download::asStringPost('http://127.0.0.1:9080/do/scan-orphaned-files', ['action' => $action],
+ 10, $code);
+ if ($code == 999) {
+ $result = '<div class="alert alert-warning">'
+ . $result . ' - Connection to DMSD failed.</div>';
+ } else {
+ $json = json_decode($result, true);
+ if (is_array($json)) {
+ $result = [];
+ $showDelete = false;
+ foreach ($json as $k => $v) {
+ $result[] = ['file' => $k, 'status' => $v];
+ if ($v === 'EXISTS') {
+ $showDelete = true;
+ }
+ }
+ $data = ['files' => $result, 'show_delete' => $showDelete];
+ Permission::addGlobalTags($data['perm'], null, ['orphaned.delete']);
+ $result = Render::parse('images-orphaned', $data);
+ }
}
- die('Huh?');
+ echo $result;
}
}
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
new file mode 100644
index 00000000..d6ac53d6
--- /dev/null
+++ b/modules-available/dozmod/pages/special.inc.php
@@ -0,0 +1,85 @@
+<?php
+
+class SubPage
+{
+
+
+ public static function doPreprocess()
+ {
+
+ }
+
+ public static function doRender()
+ {
+ $res = Database::simpleQuery("SELECT blocksha1, blocksize, Count(*) AS blockcount FROM sat.imageblock"
+ . " GROUP BY blocksha1, blocksize HAVING blockcount > 1 ORDER BY blockcount DESC, blocksha1 ASC");
+ $data = array('hashes' => array());
+ $spaceWasted = 0;
+ foreach ($res as $row) {
+ $row['hash_hex'] = bin2hex($row['blocksha1']);
+ $row['blocksize_s'] = Util::readableFileSize($row['blocksize']);
+ $data['hashes'][] = $row;
+ $spaceWasted += $row['blocksize'] * ($row['blockcount'] - 1);
+ }
+ $data['spacewasted'] = Util::readableFileSize($spaceWasted);
+ Render::addTemplate('blockstats', $data);
+ }
+
+ public static function doAjax()
+ {
+ $action = Request::any('action');
+
+ if ($action === 'getblockinfo') {
+ self::ajaxGetBlockInfo();
+ } elseif ($action === 'dmsd-status') {
+ self::ajaxDmsdStatus();
+ }
+ }
+
+ private static function ajaxGetBlockInfo()
+ {
+ $hash = Request::any('hash', false, 'string');
+ $size = Request::any('size', false, 'string');
+ if ($hash === false || $size === false) {
+ die('Missing parameter');
+ }
+ if (!is_numeric($size) || strlen($hash) !== 40 || !preg_match('/^[a-f0-9]+$/i', $hash)) {
+ die('Malformed parameter');
+ }
+ $res = Database::simpleQuery("SELECT i.displayname, v.createtime, v.filesize, Count(*) AS blockcount FROM sat.imageblock ib"
+ . " INNER JOIN sat.imageversion v USING (imageversionid)"
+ . " INNER JOIN sat.imagebase i USING (imagebaseid)"
+ . " WHERE ib.blocksha1 = :hash AND ib.blocksize = :size"
+ . " GROUP BY ib.imageversionid"
+ . " ORDER BY i.displayname ASC, v.createtime ASC",
+ array('hash' => hex2bin($hash), 'size' => $size), true);
+ if ($res === false) {
+ die('Database error: ' . Database::lastError());
+ }
+ $data = array('rows' => array());
+ 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;
+ }
+ die(Render::parse('blockstats-details', $data));
+ }
+
+ private static function ajaxDmsdStatus()
+ {
+ $ret = Download::asStringPost('http://127.0.0.1:9080/status/fileserver', false, 2, $code);
+ $args = array();
+ if ($code != 200) {
+ $args['error'] = true;
+ } else {
+ $data = @json_decode($ret, true);
+ if (is_array($data)) {
+ $args['uploads'] = $data['activeUploads'];
+ $args['downloads'] = $data['activeDownloads'];
+ }
+ }
+ Header('Content-Type: application/json');
+ echo json_encode($args);
+ }
+
+} \ No newline at end of file
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/permissions/permissions.json b/modules-available/dozmod/permissions/permissions.json
index c8958089..12d0acd3 100644
--- a/modules-available/dozmod/permissions/permissions.json
+++ b/modules-available/dozmod/permissions/permissions.json
@@ -2,6 +2,12 @@
"expiredimages.delete": {
"location-aware": false
},
+ "orphaned.scan": {
+ "location-aware": false
+ },
+ "orphaned.delete": {
+ "location-aware": false
+ },
"actionlog.view": {
"location-aware": false
},
diff --git a/modules-available/dozmod/templates/actionlog-lecture.html b/modules-available/dozmod/templates/actionlog-lecture.html
index 4fb2b4d0..6b8701b7 100644
--- a/modules-available/dozmod/templates/actionlog-lecture.html
+++ b/modules-available/dozmod/templates/actionlog-lecture.html
@@ -25,6 +25,14 @@
<td><a href="?do=dozmod&amp;section=actionlog&amp;action=showtarget&amp;uuid={{imagebaseid}}">{{imgname}}</a></td>
</tr>
<tr>
+ <th class="text-nowrap">{{lang_lastBoot}}</th>
+ <td>{{lastused_s}}</td>
+ </tr>
+ <tr>
+ <th class="text-nowrap">{{lang_numberBoots}}</th>
+ <td>{{usecount}}</td>
+ </tr>
+ <tr>
<td colspan="2">{{{descriptionHtml}}}</td>
</tr>
</table> \ No newline at end of file
diff --git a/modules-available/dozmod/templates/blockstats.html b/modules-available/dozmod/templates/blockstats.html
index cba3b476..b71d219c 100644
--- a/modules-available/dozmod/templates/blockstats.html
+++ b/modules-available/dozmod/templates/blockstats.html
@@ -30,8 +30,9 @@
<script type="application/javascript"><!--
function slxLoadBlocks(hash, size) {
$('#block-details .modal-header').text(hash + '/' + size);
- $('#block-details .modal-body').html('<div class="slx-rotation"><span class="glyphicon glyphicon-refresh"></span></div>');
+ $('#block-details .modal-body')
+ .html('<div class="slx-rotation"><span class="glyphicon glyphicon-refresh"></span></div>')
+ .load('?do=dozmod&section=special', { token: TOKEN, action: 'getblockinfo', hash: hash, size: size });
$('#block-details').modal('show');
- $('#block-details .modal-body').load('?do=dozmod&section=blockstats', { token: TOKEN, action: 'getblockinfo', hash: hash, size: size });
}
//--></script> \ No newline at end of file
diff --git a/modules-available/dozmod/templates/images-delete.html b/modules-available/dozmod/templates/images-delete.html
index 78690426..5bbebdc3 100644
--- a/modules-available/dozmod/templates/images-delete.html
+++ b/modules-available/dozmod/templates/images-delete.html
@@ -2,96 +2,142 @@
<div class="panel panel-default">
<div class="panel-heading">
- {{lang_heading}}
+ {{lang_deleteExpiredHeading}}
</div>
<div class="panel-body">
<p>{{lang_description_delete_images}}</p>
<div class="table-responsive">
- <form id="delform" method="post" action="?do=DozMod" onsubmit="return slxPostdel()">
+ <form id="delform" method="post" action="?do=dozmod">
<input type="hidden" name="token" value="{{token}}">
<input type="hidden" name="section" value="expiredimages">
<input type="hidden" name="action" value="delimages">
<table class="table table-stripped table-condensed stupidtable">
<thead>
- <tr>
- <th data-sort="string">{{lang_image}}</th>
- <th data-sort="int">{{lang_version}}</th>
- <th data-sort="string">{{lang_owner}}</th>
- <th><span class="glyphicon glyphicon-upload" title="{{lang_hasNewer}}"></span></th>
- <th data-sort="int">{{lang_size}}</th>
- <th>
- <div class="checkbox">
- <input id="del-all" type="checkbox" onclick="slxChangeAll()">
- <label for="del-all"></label>
- <span class="glyphicon glyphicon-trash" title="{{lang_delete}}"></span>
- </div>
- </th>
- </tr>
+ <tr>
+ <th data-sort="string">{{lang_image}}</th>
+ <th data-sort="int">{{lang_version}}</th>
+ <th data-sort="string">{{lang_owner}}</th>
+ <th><span class="glyphicon glyphicon-upload" title="{{lang_hasNewer}}"></span></th>
+ <th data-sort="int">{{lang_size}}</th>
+ <th>
+ <div class="checkbox">
+ <input id="del-all" type="checkbox">
+ <label for="del-all"></label>
+ <span class="glyphicon glyphicon-trash" title="{{lang_delete}}"></span>
+ </div>
+ </th>
+ </tr>
</thead>
<tbody>
- {{#images}}
- <tr>
- <td class="text-left text-nowrap {{name_extra_class}}">{{displayname}}<br><span class="small">{{imageversionid}}</span></td>
- <td class="text-left text-nowrap" data-sort-value="{{createtime}}" >{{version}}</td>
- <td class="text-left text-nowrap"><a href="mailto:{{email}}">{{lastname}}, {{firstname}}</a></td>
- <td class="text-left text-nowrap"><span class="glyphicon {{hasNewerClass}}"></span></td>
- <td class="text-left text-nowrap" data-sort-value="{{rawfilesize}}">{{filesize}}</td>
- <td>
- <div class="checkbox">
- <input type="checkbox" id="images[{{imageversionid}}]" class="del-check" name="images[{{imageversionid}}]" {{checked}}>
- <label for="images[{{imageversionid}}]"></label>
- </div>
- </td>
- </tr>
- {{/images}}
+ {{#images}}
+ <tr>
+ <td class="text-left text-nowrap {{name_extra_class}}">{{displayname}}<br><span class="small">{{imageversionid}}</span>
+ </td>
+ <td class="text-left text-nowrap" data-sort-value="{{createtime}}">{{version}}</td>
+ <td class="text-left text-nowrap"><a href="?do=dozmod&amp;section=actionlog&amp;action=showuser&amp;uuid={{userid}}">{{lastname}}, {{firstname}}</a></td>
+ <td class="text-left text-nowrap"><span class="glyphicon {{hasNewerClass}}"></span></td>
+ <td class="text-left text-nowrap" data-sort-value="{{rawfilesize}}">{{filesize}}</td>
+ <td>
+ <div class="checkbox">
+ <input type="checkbox" id="images[{{imageversionid}}]" class="del-check"
+ name="images[{{imageversionid}}]" {{checked}}>
+ <label for="images[{{imageversionid}}]"></label>
+ </div>
+ </td>
+ </tr>
+ {{/images}}
</tbody>
</table>
- <button {{^allowedDelete}}disabled{{/allowedDelete}} style="margin-left: 20px" id="delbtn" class="btn btn-danger pull-right" type="submit" name="button" value="save"><span class="glyphicon glyphicon-trash"></span> {{lang_delButton}}</button>
+ <button {{perm.expiredimages.delete.disabled}} id="expired-delete"
+ class="btn btn-danger pull-right" type="submit">
+ <span class="glyphicon glyphicon-trash"></span>
+ {{lang_delButton}}
+ </button>
</form>
<pre style="display:none" id="deloutput"></pre>
</div>
</div>
</div>
-<script type="text/javascript"><!--
+<div class="panel panel-default">
+ <div class="panel-heading">
+ {{lang_orphanedFilesHeading}}
+ </div>
+ <div class="panel-body">
+ <p>{{lang_orphanedFilesDesc}}</p>
+ <div id="orphan-ajax">
+ <button {{perm.orphaned.scan.disabled}} id="orphan-scan"
+ class="btn btn-default pull-right" type="button">
+ <span class="glyphicon glyphicon-search"></span>
+ {{lang_scanButton}}
+ </button>
+ </div>
+ </div>
+</div>
-function slxPostdel() {
- var f = $('#delform');
- $('#delbtn').prop('disabled', true);
- $.post('?do=DozMod', f.serialize()).done(function (data) {
- $('#deloutput').text(data).css('display', '');
- }).fail(function () {
- $('#deloutput').text('ERROR').css('display', '');
- });
- return false;
-}
+<div class="hidden" id="confirm-orphan-delete">
+ {{lang_confirmDeleteOrphanedFiles}}
+</div>
-function slxChangeAll()
-{
- if ($('#del-all').is(':checked')) {
- $('.del-check').prop('checked', true);
- } else {
- $('.del-check').prop('checked', false);
- }
-}
+<script type="text/javascript"><!--
-function slxChangeSingle()
-{
- var ons = 0;
- var offs = 0;
- $('.del-check').each(function(idx, elem) {
- if (elem.checked) {
- ons++;
+document.addEventListener("DOMContentLoaded", function () {
+ $('#del-all').click(function () {
+ if ($(this).is(':checked')) {
+ $('.del-check').prop('checked', true);
} else {
- offs++;
+ $('.del-check').prop('checked', false);
}
});
- $('#del-all').prop('checked', offs === 0).prop('indeterminate', ons > 0 && offs > 0);
-}
-
-document.addEventListener("DOMContentLoaded", function() {
+ var slxChangeSingle = function () {
+ var ons = 0;
+ var offs = 0;
+ $('.del-check').each(function (idx, elem) {
+ if (elem.checked) {
+ ons++;
+ } else {
+ offs++;
+ }
+ });
+ // TODO indeterminate doesn't work with styled checkbox
+ $('#del-all').prop('checked', offs === 0).prop('indeterminate', ons > 0 && offs > 0);
+ };
$('.del-check').click(slxChangeSingle);
slxChangeSingle();
+ // Handler for delete expired images button
+ var delform = $('#delform');
+ delform.submit(function (e) {
+ e.preventDefault();
+ $('#expired-delete').prop('disabled', true);
+ $.post('?do=dozmod', delform.serialize()).done(function (data) {
+ $('#deloutput').text(data).css('display', '');
+ }).fail(function () {
+ $('#deloutput').text('ERROR').css('display', '');
+ });
+ });
+ // Handler for scanning/deleting orphaned files
+ var slxOrphans = function (del) {
+ $.post('?do=dozmod', {
+ token: TOKEN, section: 'expiredimages', action: 'orphaned', delete: del
+ }).done(function (data) {
+ $('#orphan-ajax').html(data).find('#orphan-delete')
+ .click(slxModalConfirmHandler)
+ .click(function () {
+ slxOrphans(1);
+ });
+ }).fail(function () {
+ $('#orphan-ajax').text('ERROR');
+ });
+ };
+ // Handler for scanning for orphaned files
+ $('#orphan-scan').click(function () {
+ $('#orphan-scan').prop('disabled', true);
+ slxOrphans(0);
+ });
+ window.addEventListener('unload', function () {
+ // Do something here that forces browsers to throw away any JS state on nav.back
+ $('input[type=checkbox]').prop('checked', false);
+ });
}, false);
//--> </script>
diff --git a/modules-available/dozmod/templates/images-orphaned.html b/modules-available/dozmod/templates/images-orphaned.html
new file mode 100644
index 00000000..d1b74a42
--- /dev/null
+++ b/modules-available/dozmod/templates/images-orphaned.html
@@ -0,0 +1,25 @@
+<table class="table">
+ <thead>
+ <tr>
+ <th>{{lang_fileName}}</th>
+ <th>{{lang_status}}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{#files}}
+ <tr>
+ <td>{{file}}</td>
+ <td>{{status}}</td>
+ </tr>
+ {{/files}}
+ </tbody>
+</table>
+
+{{#show_delete}}
+<button {{perm.orphaned.delete.disabled}} id="orphan-delete"
+ class="btn btn-danger pull-right" type="button"
+ data-confirm="#confirm-orphan-delete">
+ <span class="glyphicon glyphicon-search"></span>
+ {{lang_orphanDeleteButton}}
+</button>
+{{/show_delete}} \ No newline at end of file
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 027acf87..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() - dateline) > 86400 * 190");
+ // 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 10aa1d84..7e4a70df 100644
--- a/modules-available/exams/baseconfig/getconfig.inc.php
+++ b/modules-available/exams/baseconfig/getconfig.inc.php
@@ -1,28 +1,32 @@
<?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);
+ ConfigHolder::add('SLX_RUNMODE_MODULE', 'exams', 10000);
+ // 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/lang/de/template-tags.json b/modules-available/exams/lang/de/template-tags.json
index 0fbaf0a1..d52a3199 100644
--- a/modules-available/exams/lang/de/template-tags.json
+++ b/modules-available/exams/lang/de/template-tags.json
@@ -10,6 +10,7 @@
"lang_begin": "Beginn",
"lang_begin_date": "Beginn Datum",
"lang_begin_time": "Uhrzeit",
+ "lang_checkLocationSelectionHint": "Stellen Sie sicher, dass die gew\u00fcnschte(n) Pr\u00fcfungsveranstaltung(en) in ihrer Raumbeschr\u00e4nkung mit den hier ausgew\u00e4hlten R\u00e4umen \u00fcbereinstimmen.",
"lang_comfirmGlobalExam": "Wollen Sie wirklich eine globale Pr\u00fcfung definieren? Im gew\u00e4hlten Zeitraum werden s\u00e4mtliche R\u00e4ume in den Pr\u00fcfungsmodus geschaltet.",
"lang_dateTime": "Datum\/Uhrzeit",
"lang_deleteConfirmation": "Wirklich l\u00f6schen?",
@@ -18,7 +19,12 @@
"lang_end": "Ende",
"lang_end_date": "Ende Datum",
"lang_end_time": "Uhrzeit",
+ "lang_examEndAfterLectureEnd": "Der gew\u00e4hlte Klausurzeitraum endet sp\u00e4ter, als die gew\u00e4hlte Veranstaltung endet.",
+ "lang_examEndBeforeLectureEnd": "Der gew\u00e4hlte Klausurzeitraum endet fr\u00fcher, als die gew\u00e4hlte Veranstaltung endet.",
"lang_examModeDescription": "Hier k\u00f6nnen Sie bwLehrpool-R\u00e4ume zeitgesteuert in den Pr\u00fcfungsmodus versetzen. Im Pr\u00fcfungsmodus ist das Client-System st\u00e4rker abgeriegelt, sodass es sich zum Schreiben von E-Pr\u00fcfungen eignet. Nach dem Ein- bzw. Ausschalten des Pr\u00fcfungsmodus ist es notwendig, die Rechner in den betroffenen R\u00e4umen neuzustarten.",
+ "lang_examStartAfterLectureEnd": "Der gew\u00e4hlte Klausurzeitraum beginnt, nachdem die gew\u00e4hlte Veranstaltung endet, oder kurz vor derem Ende.",
+ "lang_examStartAfterLectureStart": "Der gew\u00e4hlte Klausurzeitraum beginnt sp\u00e4ter, als die gew\u00e4hlte Veranstaltung startet.",
+ "lang_examStartBeforeLectureStart": "Der gew\u00e4hlte Klausurzeitraum beginnt, bevor die gew\u00e4hlte Veranstaltung startet. Ein Autostart der Veranstaltung wird fehlschlagen.",
"lang_global": "Global",
"lang_headingAddExam": "Zeitraum hinzuf\u00fcgen",
"lang_headingAllExamLectures": "Ausstehende Pr\u00fcfungsveranstaltungen (30 Tage)",
@@ -27,12 +33,16 @@
"lang_headingMain": "bwLehrpool Pr\u00fcfungsmodus",
"lang_id": "ID",
"lang_lectureName": "Veranstaltungsname",
- "lang_lectureOutOfRange": "Achtung: Der oben angegebene Zeitraum ist k\u00fcrzer als die Dauer der Veranstaltung",
+ "lang_lectureNotForLocation": "Diese Veranstaltung findet nicht im oben ausgew\u00e4hlten Raum statt",
+ "lang_lectureTimespan": "Dauer der Veranstaltung",
"lang_location": "Raum\/Ort",
"lang_locationInfo": "W\u00e4hlen Sie hier die R\u00e4ume und Orte aus, die w\u00e4hrend des unten ausgew\u00e4hlten Zeitraums in den Pr\u00fcfungsmodus versetzt werden. Wenn sie hier keine Auswahl treffen, werden alle R\u00e4ume in den Pr\u00fcfungsmodus versetzt.",
"lang_locations": "R\u00e4ume\/Orte",
"lang_moreThanOneDay": "Mehr als ein Tag",
"lang_noDescription": "Keine Beschreibung",
"lang_none": "(Keine)",
+ "lang_sanityCheck": "Plausibilit\u00e4tspr\u00fcfung",
+ "lang_startAfterEnd": "Ende liegt vor Start",
+ "lang_startOrEndInvalid": "Start- oder Endzeitpunkt ung\u00fcltig",
"lang_timeFrame": "Zeitraum"
} \ No newline at end of file
diff --git a/modules-available/exams/lang/en/template-tags.json b/modules-available/exams/lang/en/template-tags.json
index 52173740..3359a28a 100644
--- a/modules-available/exams/lang/en/template-tags.json
+++ b/modules-available/exams/lang/en/template-tags.json
@@ -10,6 +10,7 @@
"lang_begin": "Begin",
"lang_begin_date": "Begin Date",
"lang_begin_time": "Time",
+ "lang_checkLocationSelectionHint": "Make sure that the according lecture(s) will have their location restrictions set accordingly.",
"lang_comfirmGlobalExam": "Do you really want to create a global exam? Every single room will be set to lecture mode during the selected time period.",
"lang_dateTime": "Date\/Time",
"lang_deleteConfirmation": "Are you sure?",
@@ -18,7 +19,12 @@
"lang_end": "End",
"lang_end_date": "End Date",
"lang_end_time": "Time",
+ "lang_examEndAfterLectureEnd": "Specified exam interval ends after selected lecture ends.",
+ "lang_examEndBeforeLectureEnd": "Specified exam interval ends before selected lecture ends.",
"lang_examModeDescription": "Here you can define time spans during which selected rooms will be set to exam mode. In exam mode, the client computers are more locked down than usual so it is suitable for writing electronic exams.",
+ "lang_examStartAfterLectureEnd": "Specified exam interval starts after selected lecture ends (or shortly before it ends).",
+ "lang_examStartAfterLectureStart": "Specified exam interval starts after selected lecture starts.",
+ "lang_examStartBeforeLectureStart": "Specified exam interval starts before selected lecture starts. (Auto-)starting the lecture before it's valid will fail.",
"lang_global": "Global",
"lang_headingAddExam": "Add Exam Period",
"lang_headingAllExamLectures": "Upcoming Lectures Marked As Exams (30 Days)",
@@ -27,12 +33,16 @@
"lang_headingMain": "bwLehrpool Exam Mode",
"lang_id": "ID",
"lang_lectureName": "Lecture name",
- "lang_lectureOutOfRange": "Hint: The exam period given above is shorter than the duration of the given lecture",
+ "lang_lectureNotForLocation": "This lecture is not visible at the location selected above",
+ "lang_lectureTimespan": "Lecture time span",
"lang_location": "Room\/Location",
"lang_locationInfo": "Select the rooms and locations you want to enable the exam mode in. Selecting nothing at all means that all clients will boot into exam mode during the given time period.",
"lang_locations": "Rooms\/Locations",
"lang_moreThanOneDay": "More than one day",
"lang_noDescription": "No description",
"lang_none": "(None)",
+ "lang_sanityCheck": "Sanity check",
+ "lang_startAfterEnd": "Start lies after end",
+ "lang_startOrEndInvalid": "Start oder end is invalid",
"lang_timeFrame": "Time frame"
} \ No newline at end of file
diff --git a/modules-available/exams/page.inc.php b/modules-available/exams/page.inc.php
index 23a5bc39..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)) {
@@ -79,7 +79,7 @@ class Page_Exams extends Page
protected function readLectures()
{
$tmp = Database::simpleQuery(
- "SELECT lectureid, Group_Concat(locationid) as lids, displayname, starttime, endtime, isenabled, firstname, lastname, email " .
+ "SELECT lectureid, Group_Concat(locationid) as lids, islocationprivate, displayname, starttime, endtime, isenabled, firstname, lastname, email " .
"FROM sat.lecture " .
"INNER JOIN sat.user ON (user.userid = lecture.ownerid) " .
"NATURAL LEFT JOIN sat.lecture_x_location " .
@@ -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,10 +216,16 @@ 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();
+ $cutoff = strtotime('-90 days');
+ $foundActive = false;
+ $hasCollapsed = false;
if (is_array($this->exams)) {
foreach ($this->exams as $exam) {
if ($exam['endtime'] < $now) {
@@ -225,16 +234,27 @@ class Page_Exams extends Page
$exam['liesInPast'] = true;
} else {
$exam['btnClass'] = 'btn-danger';
+ if ($exam['starttime'] < $now) {
+ $exam['rowClass'] = 'slx-bold';
+ }
+ }
+ if (!$foundActive) {
+ if ($exam['endtime'] > $cutoff) {
+ $foundActive = true;
+ } else {
+ $exam['rowClass'] .= ' collapse';
+ $hasCollapsed = true;
+ }
}
$exam['starttime_s'] = date('Y-m-d H:i', $exam['starttime']);
$exam['endtime_s'] = date('Y-m-d H:i', $exam['endtime']);
$out[] = $exam;
}
}
- return $out;
+ return ['exams' => $out, 'decollapse' => $hasCollapsed];
}
- protected function makeLectureExamList()
+ protected function makeLectureExamList(): array
{
$out = [];
$now = time();
@@ -274,15 +294,15 @@ class Page_Exams extends Page
] + $source;
}
- private function isDateSane($time)
+ private function isDateSane(int $time): bool
{
- return ($time >= $this->rangeMin && $time <= $this->rangeMax);
+ return ($time >= strtotime('-10 years') && $time <= strtotime('+10 years'));
}
private function saveExam()
{
if (!Request::isPost()) {
- Util::traceError('Is not post');
+ ErrorHandler::traceError('Is not post');
}
/* process form-data */
$locationids = Request::post('locations', [], "ARRAY");
@@ -439,11 +459,17 @@ class Page_Exams extends Page
} elseif ($this->action === false) {
- Util::traceError("action not implemented");
+ ErrorHandler::traceError("action not implemented");
}
}
+ private function getLocationLookupJson()
+ {
+ $locs = Location::getLocationsAssoc(); // Add key x so we get an object, not array
+ return json_encode(['x' => 0] + array_map(function ($item) { return $item['children']; }, $locs));
+ }
+
protected function doRender()
{
if ($this->action === "show") {
@@ -452,7 +478,7 @@ class Page_Exams extends Page
// General title and description
Render::addTemplate('page-main-heading');
// List of defined exam periods
- $params = ['exams' => $this->makeExamsForTemplate()];
+ $params = $this->makeExamsForTemplate();
Permission::addGlobalTags($params['perms'], NULL, ['exams.edit']);
Render::addTemplate('page-exams', $params);
// List of upcoming lectures marked as exam
@@ -481,7 +507,7 @@ class Page_Exams extends Page
} elseif ($this->action === "add") {
Render::setTitle(Dictionary::translate('title_add-exam'));
- $data = [];
+ $data = ['locmap' => $this->getLocationLookupJson()];
$baseLecture = Request::any('lectureid', false, 'string');
$locations = null;
if ($baseLecture !== false) {
@@ -519,10 +545,12 @@ class Page_Exams extends Page
}
}
- $data = [];
- $data['exam'] = $exam;
- $data['locations'] = $this->locations;
- $data['lectures'] = $this->lectures;
+ $data = [
+ 'locmap' => $this->getLocationLookupJson(),
+ 'exam' => $exam,
+ 'locations' => $this->locations,
+ 'lectures' => $this->lectures,
+ ];
// if user has no permission to edit for this location, disable the location in the select
foreach ($data['locations'] as &$loc) {
diff --git a/modules-available/exams/templates/page-add-edit-exam.html b/modules-available/exams/templates/page-add-edit-exam.html
index a45cbac2..43ac46dc 100644
--- a/modules-available/exams/templates/page-add-edit-exam.html
+++ b/modules-available/exams/templates/page-add-edit-exam.html
@@ -10,6 +10,8 @@
<form class="form" method="POST" action="?do=exams" id="tolleform">
+ <!-- fake button to prevent return from messing things up -->
+ <button type="submit" hidden onclick="return false"></button>
<div class="panel panel-default">
<div class="panel-heading"><label for="locations">{{lang_location}}</label></div>
<div class="panel-body">
@@ -23,6 +25,7 @@
{{/locations}}
</select>
</div>
+ {{lang_checkLocationSelectionHint}}
</div>
</div>
@@ -77,10 +80,10 @@
</div>
</div>
- <div class="panel">
- <div class="panel-body">
- {{lang_duration}}: <span id="exam-duration">-</span>
- </div>
+ <div>
+ {{lang_duration}}: <span id="exam-duration">-</span>
+ <span class="hidden" id="txt-invalid">{{lang_startOrEndInvalid}}</span>
+ <span class="hidden" id="txt-reverse">{{lang_startAfterEnd}}</span>
</div>
</div>
</div>
@@ -88,29 +91,43 @@
<div class="panel panel-default">
<div class="panel-heading"><label for="lecturelist">{{lang_autoStartLecture}}</label></div>
<div class="panel-body">
- <div class="row form-group">
- <div class="form-group col-xs-12">
- <p><i>{{lang_autoStartInfo}}</i></p>
- <div class="input-group">
- <span class="input-group-addon">
- <span class="glyphicon glyphicon-pencil"></span>
- </span>
- <select class="form-control" id="lecturelist" name="lectureid">
- <option value="">{{lang_none}}</option>
- {{#lectures}}
- <option data-from="{{starttime}}" data-to="{{endtime}}" value="{{lectureid}}" {{selected}} >{{displayname}}</option>
- {{/lectures}}
- </select>
- </div>
+ <div class="form-group">
+ <p><i>{{lang_autoStartInfo}}</i></p>
+ <div class="input-group">
+ <span class="input-group-addon">
+ <span class="glyphicon glyphicon-pencil"></span>
+ </span>
+ <select class="form-control" id="lecturelist" name="lectureid">
+ <option value="">{{lang_none}}</option>
+ {{#lectures}}
+ <option data-from="{{starttime}}" data-to="{{endtime}}"
+ {{#islocationprivate}}data-locations="{{lids}}"{{/islocationprivate}}
+ value="{{lectureid}}" {{selected}} >{{displayname}}</option>
+ {{/lectures}}
+ </select>
</div>
- <div class="form-group col-xs-12">
- <div class="checkbox"><input id="autologin" type="checkbox" name="autologin" value="demo" class="form-control" {{#exam.autologin}}checked{{/exam.autologin}}><label for="autologin">{{lang_autoLogin}}</label></div>
- <p><i>{{lang_autoLoginInfo}}</i></p>
+ <b id="sanity-check" class="slx-smallspace collapse">{{lang_sanityCheck}}</b>
+ <div id="warn-range" class="collapse">
+ <div class="text-warning">
+ <div class="item start-before-lecture-start text-danger">{{lang_examStartBeforeLectureStart}}</div>
+ <div class="item start-after-lecture-start">{{lang_examStartAfterLectureStart}}</div>
+ <div class="item start-after-lecture-end text-danger">{{lang_examStartAfterLectureEnd}}</div>
+ <div class="item end-before-lecture-end">{{lang_examEndBeforeLectureEnd}}</div>
+ <div class="item end-after-lecture-end">{{lang_examEndAfterLectureEnd}}</div>
+ </div>
+ {{lang_lectureTimespan}}:
+ <span class="lecture-range"></span>
</div>
- <div class="col-xs-12" id="lecture-info">
- -
+ <div id="warn-locations" class="text-danger collapse">
+ {{lang_lectureNotForLocation}}:
+ <span class="locname"></span>
</div>
</div>
+ <div class="slx-space"></div>
+ <div class="form-group">
+ <div class="checkbox"><input id="autologin" type="checkbox" name="autologin" value="demo" class="form-control" {{#exam.autologin}}checked{{/exam.autologin}}><label for="autologin">{{lang_autoLogin}}</label></div>
+ <p><i>{{lang_autoLoginInfo}}</i></p>
+ </div>
</div>
</div>
@@ -126,7 +143,7 @@
<input type="hidden" name="examid" value="{{exam.examid}}">
<div class="text-right" style="margin-bottom: 20px">
<button type="button" id="cancelButton" class="btn btn-default" style="margin-right: 10px">{{lang_cancel}}</button>
- <button type="button" onclick="checkGlobalExam()" id="saveButton" class="btn btn-primary"><span class="glyphicon glyphicon-floppy-disk"></span> {{lang_save}}</button>
+ <button type="button" id="saveButton" class="btn btn-primary"><span class="glyphicon glyphicon-floppy-disk"></span> {{lang_save}}</button>
</div>
<div class ="modal fade" id="confirmGlobalModal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel">
@@ -149,18 +166,11 @@
<script type="application/javascript"><!--
-function checkGlobalExam() {
- if ($('#locations option:selected').length === 0 && $('#locations option').length > 1) {
- $("#confirmGlobalModal").modal();
- } else {
- $('#tolleform').submit();
- }
-}
-
document.addEventListener("DOMContentLoaded", function () {
moment.locale(LANG);
var slxMoment = moment;
+ var locmap = {{{locmap}}};
var dateSettings = {
format: 'yyyy-mm-dd',
@@ -175,7 +185,8 @@ document.addEventListener("DOMContentLoaded", function () {
$('.datepicker').datepicker(dateSettings);
$('.timepicker2').timepicker(timeSettings);
- $('#locations').multiselect({numberDisplayed: 1});
+ var $locations = $('#locations');
+ $locations.multiselect({numberDisplayed: 1});
var start_date = $('#starttime_date');
var start_time = $('#starttime_time');
@@ -197,54 +208,142 @@ document.addEventListener("DOMContentLoaded", function () {
}
});
+ var examStart, examEnd;
+
var startEndChanged = function () {
- var sd = slxMoment(start_date.val() + ' ' + start_time.val(), 'YYYY-MM-DD H:mm');
- var ed = slxMoment(end_date.val() + ' ' + end_time.val(), 'YYYY-MM-DD H:mm');
- if (!sd.isValid() || !ed.isValid()) {
- rspan.text('-');
+ examStart = slxMoment(start_date.val() + ' ' + start_time.val(), 'YYYY-MM-DD H:mm');
+ examEnd = slxMoment(end_date.val() + ' ' + end_time.val(), 'YYYY-MM-DD H:mm');
+ if (!examStart.isValid() || !examEnd.isValid()) {
+ rspan.text($('#txt-invalid').text()).addClass('text-danger');
+ return;
+ }
+ var diff = examEnd.diff(examStart);
+ if (diff <= 0) {
+ rspan.text($('#txt-reverse').text()).addClass('text-danger');
return;
}
- rspan.text(slxMoment.duration(ed.diff(sd)).humanize());
- // Lecture selection
- $('#lecturelist option').each(function (idx, elem) {
- var e = $(elem);
- var from = e.data('from');
- var to = e.data('to');
- if (!from || !to)
+ rspan.text(slxMoment.duration(diff).humanize()).removeClass('text-danger');
+ updateLectureInfo();
+ };
+
+ var $timespanWarning = $('#warn-range');
+ var $locationWarning = $('#warn-locations');
+ var updateLectureInfo = function() {
+ (function() {
+ var $sel = $('#lecturelist option:selected');
+ var lectureStart = $sel.data('from');
+ var lectureEnd = $sel.data('to');
+ if (!lectureStart || !lectureEnd) {
+ $timespanWarning.hide();
return;
- from = slxMoment.unix(from);
- to = slxMoment.unix(to);
- if (from.isBefore(sd) || to.isAfter(ed)) {
- e.css('color', '#999');
- e.data('inrange', false)
+ }
+ lectureStart = slxMoment.unix(lectureStart);
+ lectureEnd = slxMoment.unix(lectureEnd);
+ var diff;
+ var warnings = [];
+ if (examStart.isBefore(lectureStart)) {
+ warnings.push('.start-before-lecture-start');
+ }
+ if (lectureEnd.diff(lectureStart, 'hours') >= 12) {
+ // Lecture is longer than 12 hours -- only consider exam start/end date outside range
+ if (lectureEnd.diff(examStart, 'minutes') < 15) { // Start after end, or very close to end
+ warnings.push('.start-after-lecture-end');
+ }
+ if (examEnd.diff(lectureEnd, 'minutes') > 15) {
+ warnings.push('.end-after-lecture-end');
+ }
} else {
- e.css('color', '');
- e.data('inrange', true);
+ // Lecture is shorter than 12 hours -- assume it's the actual timespan of the exam
+ if (examStart.diff(lectureStart, 'minutes') > 30) {
+ warnings.push('.start-after-lecture-start');
+ }
+ diff = examEnd.diff(lectureEnd, 'minutes');
+ if (diff > 15) {
+ warnings.push('.end-after-lecture-end');
+ } else if (diff < -30) {
+ warnings.push('.end-before-lecture-end');
+ }
}
- });
- updateLectureInfo();
+ if (warnings.length === 0) {
+ $timespanWarning.hide();
+ return;
+ }
+ $timespanWarning.find('.item').hide();
+ for (var i = 0; i < warnings.length; ++i) {
+ $timespanWarning.find(warnings[i]).show();
+ }
+ $timespanWarning.find('.lecture-range').text(slxMoment.unix($sel.data('from'))
+ .format('LLL') + ' – ' + slxMoment.unix($sel.data('to')).format('LLL'));
+ $timespanWarning.show();
+ })();
+ showHeading();
};
- var updateLectureInfo = function() {
- var sel = $('#lecturelist option:selected');
- if (sel.val() === '' || sel.data('inrange')) {
- $('#lecture-info').text('-');
+ var expandLocs = function(locs) {
+ var ret = locs.map(function(n) { return parseInt(n) });
+ for (var i = 0; i < locs.length; ++i) {
+ if ($.isArray(locmap[locs[i]])) {
+ ret.push(...locmap[locs[i]].filter(function (v, i, s) { return ret.indexOf(v) === -1 }));
+ }
+ }
+ return ret;
+ };
+
+ var updateLocationsInfo = function() {
+ (function() {
+ var selectedLocs = $locations.val();
+ var lecLocs = $('#lecturelist option:selected').data('locations');
+ if (!lecLocs || lecLocs.length === 0 || !selectedLocs) {
+ $locationWarning.hide();
+ return;
+ }
+ lecLocs = ('' + lecLocs).split(',');
+ if (!$.isArray(selectedLocs)) {
+ selectedLocs = [selectedLocs];
+ }
+ selectedLocs = expandLocs(selectedLocs);
+ lecLocs = expandLocs(lecLocs);
+ for (var i = 0; i < selectedLocs.length; ++i) {
+ if (lecLocs.indexOf(selectedLocs[i]) === -1) {
+ $locationWarning.find('.locname').text($locations.find('option[value="' + selectedLocs[i] + '"]').text());
+ $locationWarning.show();
+ return;
+ }
+ }
+ $locationWarning.hide();
+ })();
+ showHeading();
+ };
+
+ $('#saveButton').click(function () {
+ if ($('#locations option:selected').length === 0 && $('#locations option').length > 1) {
+ $("#confirmGlobalModal").modal();
+ } else {
+ $('#tolleform').submit();
+ }
+ });
+
+ var $sanity = $('#sanity-check');
+ var showHeading = function() {
+ if ($locationWarning.is(':visible') || $timespanWarning.is(':visible')) {
+ $sanity.show();
} else {
- $('#lecture-info').text('{{lang_lectureOutOfRange}} (' + slxMoment.unix(sel.data('from')).format('YYYY-MM-DD H:mm') + ' - ' + slxMoment.unix(sel.data('to')).format('YYYY-MM-DD H:mm') + ')');
+ $sanity.hide();
}
};
+ startEndChanged();
+ updateLocationsInfo();
start_date.change(startEndChanged);
start_time.change(startEndChanged);
end_date.change(startEndChanged);
end_time.change(startEndChanged);
- $('#lecturelist').change(updateLectureInfo);
+ $('#lecturelist').change(updateLectureInfo).change(updateLocationsInfo);
+ $locations.change(updateLocationsInfo);
$("#cancelButton").click(function () {
- window.location.replace("?do=exams");
+ window.history.back();
});
- startEndChanged();
-
}, false);
// --></script>
diff --git a/modules-available/exams/templates/page-exams.html b/modules-available/exams/templates/page-exams.html
index 89743c95..085b529a 100644
--- a/modules-available/exams/templates/page-exams.html
+++ b/modules-available/exams/templates/page-exams.html
@@ -18,10 +18,20 @@
</tr>
</thead>
<tbody>
+ {{#decollapse}}
+ <tr class="hidden collapse"></tr><!-- need this right before the slx-decollapse -->
+ <tr class="slx-decollapse">
+ <td colspan="5">
+ <span class="btn-group btn-group-justified">
+ <span class="btn btn-default btn-sm"><span class="glyphicon glyphicon-menu-down"></span></span>
+ </span>
+ </td>
+ </tr>
+ {{/decollapse}}
{{#exams}}
<tr class="{{rowClass}}">
<td>{{examid}}</td>
- <td>
+ <td width="100%">
{{locationnames}}
{{^locationnames}}
<i>{{lang_global}}</i>
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/js_ip/clientscript.js b/modules-available/js_ip/clientscript.js
new file mode 100644
index 00000000..930292b1
--- /dev/null
+++ b/modules-available/js_ip/clientscript.js
@@ -0,0 +1,67 @@
+'use strict';
+
+function ip2long(IP) {
+ var i = 0;
+ IP = IP.match(/^([1-9]\d*|0[0-7]*|0x[\da-f]+)(?:\.([1-9]\d*|0[0-7]*|0x[\da-f]+))?(?:\.([1-9]\d*|0[0-7]*|0x[\da-f]+))?(?:\.([1-9]\d*|0[0-7]*|0x[\da-f]+))?$/i);
+ if (!IP) {
+ return false;
+ }
+ IP.push(0, 0, 0, 0);
+ for (i = 1; i < 5; i += 1) {
+ IP[i] = parseInt(IP[i]) || 0;
+ if (IP[i] < 0 || IP[i] > 255)
+ return false;
+ }
+ return IP[1] * 16777216 + IP[2] * 65536 + IP[3] * 256 + IP[4] * 1;
+}
+
+function long2ip(a) {
+ return [
+ a >>> 24,
+ 255 & a >>> 16,
+ 255 & a >>> 8,
+ 255 & a
+ ].join('.');
+}
+
+function cidrToRange(cidr) {
+ var range = [];
+ cidr = cidr.split('/');
+ if (cidr.length !== 2)
+ return false;
+ var cidr_1 = parseInt(cidr[1]);
+ if (cidr_1 <= 0 || cidr_1 > 32)
+ return false;
+ var param = ip2long(cidr[0]);
+ if (param === false)
+ return false;
+ range[0] = long2ip((param) & ((-1 << (32 - cidr_1))));
+ var start = ip2long(range[0]);
+ range[1] = long2ip(start + Math.pow(2, (32 - cidr_1)) - 1);
+ return range;
+}
+
+/**
+ * Add listener to start IP input; when it loses focus, see if we have a
+ * CIDR notation and fill out start+end field.
+ */
+function slxAttachCidr() {
+ $('.cidrmagic').each(function () {
+ var t = $(this);
+ var s = t.find('input.cidrstart');
+ var e = t.find('input.cidrend');
+ if (!s || !e)
+ return;
+ t.removeClass('cidrmagic');
+ s.focusout(function () {
+ var res = cidrToRange(s.val());
+ if (res === false)
+ return;
+ s.val(res[0]);
+ e.val(res[1]);
+ });
+ });
+}
+
+// Attach
+slxAttachCidr(); \ No newline at end of file
diff --git a/modules-available/js_ip/config.json b/modules-available/js_ip/config.json
new file mode 100644
index 00000000..96c02bce
--- /dev/null
+++ b/modules-available/js_ip/config.json
@@ -0,0 +1,7 @@
+{
+ "dependencies": [],
+ "scripts": [
+ "clientscript.js"
+ ],
+ "client-plugin": true
+} \ No newline at end of file
diff --git a/modules-available/js_jqueryui/style.css b/modules-available/js_jqueryui/style.css
index be57c0fa..ba648761 100755
--- a/modules-available/js_jqueryui/style.css
+++ b/modules-available/js_jqueryui/style.css
@@ -885,7 +885,7 @@ button.ui-button::-moz-focus-inner { border: 0; padding: 0; } /* reset extra pad
.ui-spinner-input { border: none; background: none; padding: 0; margin: .2em 0; vertical-align: middle; margin-left: .4em; margin-right: 22px; }
.ui-spinner{}
.ui-spinner-button { width: 16px; height: 50%; font-size: .5em; padding: 0; margin: 0; text-align: center; position: absolute; cursor: default; display: block; overflow: hidden; right: 0; }
-.ui-spinner a.ui-spinner-button { border-top: none; border-bottom: none; border-right: none; } /* more specificity required here to overide default borders */
+.ui-spinner a.ui-spinner-button { border-top: none; border-bottom: none; border-right: none; } /* more specificity required here to override default borders */
.ui-spinner .ui-icon { position: absolute; margin-top: -8px; top: 50%; left: 0; } /* vertical centre icon */
.ui-spinner-up { top: 0; }
.ui-spinner-down { bottom: 0; }
diff --git a/modules-available/js_stupidtable/clientscript.js b/modules-available/js_stupidtable/clientscript.js
index 0baa1546..419335a0 100644
--- a/modules-available/js_stupidtable/clientscript.js
+++ b/modules-available/js_stupidtable/clientscript.js
@@ -150,7 +150,7 @@
if ($table.stupidtable.settings.will_manually_build_table) $table.stupidtable_build();
}
- // Run sorting asynchronously on a timout to force browser redraw after
+ // Run sorting asynchronously on a timeout to force browser redraw after
// `beforetablesort` callback. Also avoids locking up the browser too much.
setTimeout(function() {
if(!$table.stupidtable.settings.will_manually_build_table){
diff --git a/modules-available/js_weekcalendar/clientscript.js b/modules-available/js_weekcalendar/clientscript.js
index 67637e65..45c97707 100755
--- a/modules-available/js_weekcalendar/clientscript.js
+++ b/modules-available/js_weekcalendar/clientscript.js
@@ -193,7 +193,7 @@ function MyDate() {
},
/**
* reads the id(s) of user(s) for who the event should be displayed.
- * @param {Object} calEvent the calEvent to read informations from.
+ * @param {Object} calEvent the calEvent to read information from.
* @param {jQuery} calendar the calendar object.
* @return {number|String|Array} the user id(s) to appened events for.
*/
@@ -202,7 +202,7 @@ function MyDate() {
},
/**
* sets user id(s) to the calEvent
- * @param {Object} calEvent the calEvent to set informations to.
+ * @param {Object} calEvent the calEvent to set information to.
* @param {jQuery} calendar the calendar object.
* @return {Object} the calEvent with modified user id.
*/
@@ -218,7 +218,7 @@ function MyDate() {
displayFreeBusys: false,
/**
* read the id(s) for who the freebusy is available
- * @param {Object} calEvent the calEvent to read informations from.
+ * @param {Object} calEvent the calEvent to read information from.
* @param {jQuery} calendar the calendar object.
* @return {number|String|Array} the user id(s) to appened events for.
*/
@@ -277,7 +277,7 @@ function MyDate() {
title: '%start% - %end%',
/**
* default options to pass to callback
- * you can pass a function returning an object or a litteral object
+ * you can pass a function returning an object or a literal object
* @type {object|function(#calendar)}
*/
jsonOptions: {},
@@ -352,7 +352,7 @@ function MyDate() {
* Go to the previous week relative to the currently displayed week
*/
prevWeek: function() {
- //minus more than 1 day to be sure we're in previous week - account for daylight savings or other anomolies
+ //minus more than 1 day to be sure we're in previous week - account for daylight savings or other anomalies
var newDate = new Date(this.element.data('startDate').getTime() - (MILLIS_IN_WEEK / 6));
this._clearCalendar();
this._loadCalEvents(newDate);
@@ -362,7 +362,7 @@ function MyDate() {
* Go to the next week relative to the currently displayed week
*/
nextWeek: function() {
- //add 8 days to be sure of being in prev week - allows for daylight savings or other anomolies
+ //add 8 days to be sure of being in prev week - allows for daylight savings or other anomalies
var newDate = new Date(this.element.data('startDate').getTime() + MILLIS_IN_WEEK + MILLIS_IN_DAY);
this._clearCalendar();
this._loadCalEvents(newDate);
@@ -1827,7 +1827,7 @@ function MyDate() {
},
/*
- * Add droppable capabilites to weekdays to allow dropping of calEvents only
+ * Add droppable capabilities to weekdays to allow dropping of calEvents only
*/
_addDroppableToWeekDay: function($weekDay) {
var self = this;
@@ -2464,14 +2464,14 @@ function MyDate() {
end = (options.businessHours.limitDisplay ? options.businessHours.end : 24);
$freeBusyPlaceholders.each(function() {
- var $placehoder = $(this);
- var s = self._cloneDate($placehoder.data('startDate')),
+ var $placeholder = $(this);
+ var s = self._cloneDate($placeholder.data('startDate')),
e = self._cloneDate(s);
s.setHours(start);
e.setHours(end);
- $placehoder.find('.wc-freebusy').remove();
- $.each($placehoder.data('wcFreeBusyManager').getFreeBusys(s, e), function() {
- self._renderFreeBusy(this, $placehoder);
+ $placeholder.find('.wc-freebusy').remove();
+ $.each($placeholder.data('wcFreeBusyManager').getFreeBusys(s, e), function() {
+ self._renderFreeBusy(this, $placeholder);
});
});
}
@@ -2537,7 +2537,7 @@ function MyDate() {
},
/**
- * retrives the first freebusy manager matching demand.
+ * retrieves the first freebusy manager matching demand.
*/
getFreeBusyManagersFor: function(date, users) {
var calEvent = {
@@ -2548,21 +2548,21 @@ function MyDate() {
return this.getFreeBusyManagerForEvent(calEvent);
},
/**
- * retrives the first freebusy manager for given event.
+ * retrieves the first freebusy manager for given event.
*/
getFreeBusyManagerForEvent: function(newCalEvent) {
var self = this,
options = this.options,
freeBusyManager;
if (options.displayFreeBusys) {
- var $freeBusyPlaceHoders = self.element.find('.wc-grid-row-freebusy .wc-column-freebusy'),
+ var $freeBusyPlaceHolders = self.element.find('.wc-grid-row-freebusy .wc-column-freebusy'),
freeBusy = new FreeBusy({start: newCalEvent.start, end: newCalEvent.end}),
showAsSeparatedUser = options.showAsSeparateUsers && options.users && options.users.length,
userId = showAsSeparatedUser ? self._getEventUserId(newCalEvent) : null;
if (!$.isArray(userId)) {
userId = [userId];
}
- $freeBusyPlaceHoders.each(function() {
+ $freeBusyPlaceHolders.each(function() {
var manager = $(this).data('wcFreeBusyManager'),
has_overlap = manager.isWithin(freeBusy.getEnd()) ||
manager.isWithin(freeBusy.getEnd()) ||
@@ -2585,12 +2585,12 @@ function MyDate() {
options = this.options;
if (options.displayFreeBusys) {
var $toRender,
- $freeBusyPlaceHoders = self.element.find('.wc-grid-row-freebusy .wc-column-freebusy'),
+ $freeBusyPlaceHolders = self.element.find('.wc-grid-row-freebusy .wc-column-freebusy'),
_freeBusys = self._cleanFreeBusys(freeBusys);
$.each(_freeBusys, function(index, _freeBusy) {
- var $weekdays = self._findWeekDaysForFreeBusy(_freeBusy, $freeBusyPlaceHoders);
+ var $weekdays = self._findWeekDaysForFreeBusy(_freeBusy, $freeBusyPlaceHolders);
//if freebusy has a placeholder
if ($weekdays && $weekdays.length) {
$weekdays.each(function(index, day) {
@@ -2751,8 +2751,8 @@ function MyDate() {
* if you do not pass any argument, returns all freebusys.
* if you only pass a start date, only matchinf freebusy will be returned.
* if you pass 2 arguments, then all freebusys available within the time period will be returned
- * @param {Date} start [optionnal] if you do not pass end date, will return the freeBusy within which this date falls.
- * @param {Date} end [optionnal] the date where to stop the search.
+ * @param {Date} start [optional] if you do not pass end date, will return the freeBusy within which this date falls.
+ * @param {Date} end [optional] the date where to stop the search.
* @return {Array} an array of FreeBusy matching arguments.
*/
getFreeBusys: function() {
@@ -2826,7 +2826,7 @@ function MyDate() {
$.each(this.freeBusys, function(index) {
//within the loop, we have following vars:
// curFreeBusyItem: the current iteration freeBusy, part of manager freeBusys list
- // start: the insterted freeBusy start
+ // start: the inserted freeBusy start
// end: the inserted freebusy end
var curFreeBusyItem = this;
if (curFreeBusyItem.isWithin(start) && curFreeBusyItem.isWithin(end)) {
diff --git a/modules-available/locationinfo/api.inc.php b/modules-available/locationinfo/api.inc.php
index c44ec72d..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) {
@@ -99,7 +99,7 @@ function getPcStates($idList, $paneluuid)
}
$locationInfoList = array();
- InfoPanel::appendMachineData($locationInfoList, $idList, true);
+ InfoPanel::appendMachineData($locationInfoList, $idList);
$panel = Database::queryFirst('SELECT paneluuid, panelconfig FROM locationinfo_panel WHERE paneluuid = :paneluuid',
compact('paneluuid'));
@@ -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/clientscript.js b/modules-available/locationinfo/clientscript.js
deleted file mode 100644
index 25c255fb..00000000
--- a/modules-available/locationinfo/clientscript.js
+++ /dev/null
@@ -1,153 +0,0 @@
-/*
- * Generic helpers.
- */
-
-/**
- * Initialize timepicker on given element.
- */
-function setTimepicker($e) {
- $e.timepicker({
- minuteStep: 15,
- appendWidgetTo: 'body',
- showSeconds: false,
- showMeridian: false,
- defaultTime: false
- });
-}
-
-function getTime(str) {
- if (!str) return false;
- str = str.split(':');
- if (str.length !== 2) return false;
- var h = parseInt(str[0].replace(/^0/, ''));
- var m = parseInt(str[1].replace(/^0/, ''));
- if (h < 0 || h > 23) return false;
- if (m < 0 || m > 59) return false;
- return h * 60 + m;
-}
-
-const allDays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
-
-/*
- * Opening times related...
- */
-
-var slxIdCounter = 0;
-
-/**
- * Adds a new opening time to the table in expert mode.
- */
-function newOpeningTime(vals) {
- var $row = $('#expert-template').find('div.row').clone();
- if (vals['days'] && Array.isArray(vals['days'])) {
- for (var i = 0; i < allDays.length; ++i) {
- $row.find('.i-' + allDays[i]).prop('checked', vals['days'].indexOf(allDays[i]) !== -1);
- }
- }
- $row.find('input').each(function() {
- var $inp = $(this);
- if ($inp.length === 0) return;
- slxIdCounter++;
- $inp.prop('id', 'id-inp-' + slxIdCounter);
- $inp.siblings('label').prop('for', 'id-inp-' + slxIdCounter);
- });
- $row.find('.i-openingtime').val(vals['openingtime']);
- $row.find('.i-closingtime').val(vals['closingtime']);
- $('#expert-table').append($row);
- return $row;
-}
-
-/**
- * Convert fields from simple mode view to entries in expert mode.
- * @returns {Array}
- */
-function simpleToExpert() {
- var retval = [];
- if ($('#week-open').val() || $('#week-close').val()) {
- retval.push({
- 'days': ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
- 'openingtime': $('#week-open').val(),
- 'closingtime': $('#week-close').val(),
- 'tag': '#week'
- });
- }
- if ($('#saturday-open').val() || $('#saturday-close').val()) {
- retval.push({
- 'days': ['Saturday'],
- 'openingtime': $('#saturday-open').val(),
- 'closingtime': $('#saturday-close').val(),
- 'tag': '#saturday'
- });
- }
- if ($('#sunday-open').val() || $('#sunday-close').val()) {
- retval.push({
- 'days': ['Sunday'],
- 'openingtime': $('#sunday-open').val(),
- 'closingtime': $('#sunday-close').val(),
- 'tag': '#sunday'
- });
- }
- return retval;
-}
-
-/**
- * Triggered when the form is submitted
- */
-function submitLocationSettings(event) {
- var schedule, s, e;
- var badFormat = false;
- $('#settings-outer').find('.red-bg').removeClass('red-bg');
- if ($('#week-open').length > 0) {
- schedule = simpleToExpert();
- for (var i = 0; i < schedule.length; ++i) {
- s = getTime(schedule[i].openingtime);
- e = getTime(schedule[i].closingtime);
- if (s === false) {
- $(schedule[i].tag + '-open').addClass('red-bg');
- badFormat = true;
- }
- if (e === false || e <= s) {
- $(schedule[i].tag + '-close').addClass('red-bg');
- badFormat = true;
- }
- }
- } else {
- // Serialize
- schedule = [];
- $('#expert-table').find('.expert-row').each(function () {
- var $t = $(this);
- if ($t.find('.i-delete').is(':checked')) return; // Skip marked as delete
- var entry = {
- 'days': [],
- 'openingtime': $t.find('.i-openingtime').val(),
- 'closingtime': $t.find('.i-closingtime').val()
- };
- for (var i = 0; i < allDays.length; ++i) {
- if ($t.find('.i-' + allDays[i]).is(':checked')) {
- entry['days'].push(allDays[i]);
- }
- }
- if (entry.openingtime.length === 0 && entry.closingtime.length === 0 && entry.days.length === 0) return; // Also ignore empty lines
- s = getTime(entry.openingtime);
- e = getTime(entry.closingtime);
- if (s === false) {
- $t.find('.i-openingtime').addClass('red-bg');
- badFormat = true;
- }
- if (e === false || e <= s) {
- $t.find('.i-closingtime').addClass('red-bg');
- badFormat = true;
- }
- if (entry.days.length === 0) {
- $t.find('.days-box').addClass('red-bg');
- badFormat = true;
- }
- if (badFormat) return;
- schedule.push(entry);
- });
- }
- if (badFormat) {
- event.preventDefault();
- }
- $('#json-openingtimes').val(JSON.stringify(schedule));
-} \ No newline at end of file
diff --git a/modules-available/locationinfo/config.json b/modules-available/locationinfo/config.json
index 15298ea1..4fa2859e 100644
--- a/modules-available/locationinfo/config.json
+++ b/modules-available/locationinfo/config.json
@@ -1,8 +1,6 @@
{
"category": "main.content",
"dependencies": [
- "js_jqueryui",
- "bootstrap_timepicker",
"locations",
"bootstrap_switch"
]
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/PhpEws/Type/WellKnownResponseObjectType.php b/modules-available/locationinfo/exchange-includes/jamesiarmes/PhpEws/Type/WellKnownResponseObjectType.php
index f659cbeb..9095f4ff 100644
--- a/modules-available/locationinfo/exchange-includes/jamesiarmes/PhpEws/Type/WellKnownResponseObjectType.php
+++ b/modules-available/locationinfo/exchange-includes/jamesiarmes/PhpEws/Type/WellKnownResponseObjectType.php
@@ -8,7 +8,7 @@ namespace jamesiarmes\PhpEws\Type;
use \jamesiarmes\PhpEws\Type;
/**
- * Base class fot meeting request replies.
+ * Base class for meeting request replies.
*
* @package php-ews\Type
*/
diff --git a/modules-available/locationinfo/exchange-includes/jamesiarmes/PhpEws/assets/types.xsd b/modules-available/locationinfo/exchange-includes/jamesiarmes/PhpEws/assets/types.xsd
index 52f4f189..78150344 100644
--- a/modules-available/locationinfo/exchange-includes/jamesiarmes/PhpEws/assets/types.xsd
+++ b/modules-available/locationinfo/exchange-includes/jamesiarmes/PhpEws/assets/types.xsd
@@ -2242,7 +2242,7 @@
</xs:restriction>
</xs:complexContent>
</xs:complexType>
- <!-- Smart reponses: ReplyToItem, ReplyAllToItem, ForwardItem-->
+ <!-- Smart responses: ReplyToItem, ReplyAllToItem, ForwardItem-->
<xs:complexType name="SmartResponseBaseType">
<xs:complexContent>
<xs:restriction base="t:ResponseObjectType">
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/frontend/frontendscript.js b/modules-available/locationinfo/frontend/frontendscript.js
index efe4d5b6..f39f2be8 100644
--- a/modules-available/locationinfo/frontend/frontendscript.js
+++ b/modules-available/locationinfo/frontend/frontendscript.js
@@ -67,6 +67,9 @@ function GetTimeDiferenceAsString(a, b, globalConfig) {
str += hours + 'h ';
}
str += minutes + 'min ';
+ if (globalConfig && !globalConfig.eco) {
+ str += seconds + 's ';
+ }
return str;
}
diff --git a/modules-available/locationinfo/hooks/runmode/config.json b/modules-available/locationinfo/hooks/runmode/config.json
index 4bba0b5f..d94165ca 100644
--- a/modules-available/locationinfo/hooks/runmode/config.json
+++ b/modules-available/locationinfo/hooks/runmode/config.json
@@ -1,8 +1,7 @@
{
- "getModeName": "LocationInfo::getPanelName",
+ "getModeName": "LocationInfoHooks::getPanelName",
"isClient": false,
- "configHook": "LocationInfo::configHook",
- "noSysconfig": true,
+ "configHook": "LocationInfoHooks::configHook",
"systemdDefaultTarget": "kiosk-mode",
"permission": ".locationinfo.panel.assign-client"
} \ No newline at end of file
diff --git a/modules-available/locationinfo/inc/coursebackend.inc.php b/modules-available/locationinfo/inc/coursebackend.inc.php
index bc5b059e..ea1bebac 100644
--- a/modules-available/locationinfo/inc/coursebackend.inc.php
+++ b/modules-available/locationinfo/inc/coursebackend.inc.php
@@ -19,7 +19,7 @@ abstract class CourseBackend
*/
protected $error = false;
/**
- * @var array list of errors that occured, fill using addError()
+ * @var array list of errors that occurred, fill using addError()
*/
private $errors;
/**
@@ -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,36 +126,45 @@ 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;
- private static function fixTime(&$start, &$end)
+ /**
+ * In case you want to sanitize or otherwise mangle a property for your backend,
+ * override this.
+ */
+ public function mangleProperty(string $prop, $value)
+ {
+ return $value;
+ }
+
+ private static function fixTime(string &$start, string &$end): bool
{
- if (!preg_match('/^\d+-\d+-\d+T\d+:\d+:\d+$/', $start) || !preg_match('/^\d+-\d+-\d+T\d+:\d+:\d+$/', $end))
+ if (!preg_match('/^(\d{2}|\d{4})-?\d{2}-?\d{2}-?T\d{1,2}:?\d{2}:?(\d{2})?$/', $start))
return false;
$start = strtotime($start);
$end = strtotime($end);
- if ($start >= $end)
+ if ($start === false || $end === false || $start >= $end)
return false;
$start = date('Y-m-d\TH:i:s', $start);
$end = date('Y-m-d\TH:i:s', $end);
@@ -166,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);
@@ -183,14 +188,13 @@ abstract class CourseBackend
array('locations' => $requestedLocationIds));
$returnValue = [];
$remoteIds = [];
- while ($row = $dbquery1->fetch(PDO::FETCH_ASSOC)) {
- //Check if in cache if lastUpdate is null then it is interpreted as 1970
- if ($row['lastcalendarupdate'] + $this->getCacheTime() > $NOW) {
- $returnValue[$row['locationid']] = json_decode($row['calendar']);
- } else {
+ 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'];
}
-
+ // Always add to return value - if updating fails, we better use the stale data than nothing
+ $returnValue[$row['locationid']] = json_decode($row['calendar'], true);
}
// No need for additional round trips to backend
if (empty($remoteIds)) {
@@ -211,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'];
}
}
@@ -222,15 +226,12 @@ abstract class CourseBackend
// if, nothing bad will happen...
Database::exec("UPDATE locationinfo_locationconfig SET lastcalendarupdate = :time
WHERE lastcalendarupdate < :time AND serverid = :serverid AND serverlocationid IN (:slocs)", [
- 'time' => $NOW - $this->getCacheTime() / 2,
+ 'time' => $NOW - ($this->getCacheTime() - 60), // Protect for one minute max.
'serverid' => $this->serverId,
'slocs' => array_values($remoteIds),
]);
}
$backendResponse = $this->fetchSchedulesInternal(array_unique($remoteIds));
- if ($backendResponse === false) {
- return false;
- }
// Fetching might have taken a while, get current time again
$NOW = time();
@@ -254,15 +255,15 @@ abstract class CourseBackend
'serverid' => $this->serverId,
'serverlocationid' => $serverRoomId,
'ttable' => $value,
- 'now' => $NOW
+ 'now' => $NOW, // Set real "lastupdate" here
));
}
-
- unset($calendar);
}
+ unset($calendar);
// Add rooms that were requested to the final return value
foreach ($remoteIds as $location => $serverRoomId) {
- if (isset($backendResponse[$serverRoomId]) && in_array($location, $requestedLocationIds)) {
+ if (isset($backendResponse[$serverRoomId]) && is_array($backendResponse[$serverRoomId])
+ && in_array($location, $requestedLocationIds)) {
// Only add if we can map it back to our location id AND it was not an unsolicited coalesced refresh
$returnValue[$location] = $backendResponse[$serverRoomId];
}
@@ -271,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])) {
@@ -291,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 occured during processing.
+ * @return array list of errors that occurred during processing.
*/
- public final function getErrors()
+ public final function getErrors(): array
{
return $this->errors;
}
@@ -367,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 {
@@ -379,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);
}
}
@@ -403,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 60561586..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 shedule array contains an array in this format:
+ * 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 4664a011..55d5ed4b 100644
--- a/modules-available/locationinfo/inc/coursebackend/coursebackend_hisinone.inc.php
+++ b/modules-available/locationinfo/inc/coursebackend/coursebackend_hisinone.inc.php
@@ -1,391 +1,84 @@
<?php
-class CourseBackend_HisInOne extends CourseBackend
+class CourseBackend_HisInOne extends ICalCourseBackend
{
- private $username = '';
- private $password = '';
- private $open = true;
- private $location;
- private $verifyHostname = true;
- private $verifyCert = true;
- /**
- * @var bool|resource
- */
- private $curlHandle = false;
-
- public function setCredentialsInternal($data)
+ public function setCredentialsInternal(array $data): bool
{
- if (!$data['open']) {
- // If not using OpenCourseService, require credentials
- foreach (['username', 'password'] as $field) {
- if (empty($data[$field])) {
- $this->addError('setCredentials: Missing field ' . $field, true);
- return false;
- }
- }
- }
if (empty($data['baseUrl'])) {
$this->addError("No url is given", true);
return false;
}
- $this->username = $data['username'];
- if (!empty($data['role'])) {
- $this->username .= "\t" . $data['role'];
- }
- $this->password = $data['password'];
- $this->open = $data['open'] !== 'CourseService';
- $url = preg_replace('#(/+qisserver(/+services\d+(/+OpenCourseService)?)?)?\W*$#i', '', $data['baseUrl']);
- if ($this->open) {
- $this->location = $url . "/qisserver/services2/OpenCourseService";
- } else {
- $this->location = $url . "/qisserver/services2/CourseService";
- }
- $this->verifyHostname = $data['verifyHostname'];
- $this->verifyCert = $data['verifyCert'];
+ $this->init($this->mangleProperty('baseUrl', $data['baseUrl']),
+ $data['verifyCert'], $data['verifyHostname']);
return true;
}
- public function getCredentialDefinitions()
+ public function getCredentialDefinitions(): array
{
return [
new BackendProperty('baseUrl', 'string'),
- new BackendProperty('username', 'string'),
- new BackendProperty('role', 'string'),
- new BackendProperty('password', 'password'),
- new BackendProperty('open', ['OpenCourseService', 'CourseService'], 'OpenCourseService'),
new BackendProperty('verifyCert', 'bool', true),
new BackendProperty('verifyHostname', 'bool', true)
];
}
- public function checkConnection()
+ public function mangleProperty(string $prop, $value)
{
- if (empty($this->location)) {
- $this->addError("Credentials are not set", true);
- return false;
- }
- return $this->findUnit(123456789, date('Y-m-d'), true) !== false;
- }
-
- /**
- * @param int $roomId his in one room id to get
- * @param bool $connectionCheckOnly true will only check if no soapError is returned, return value will be empty
- * @return array|bool if successful an array with the event ids that take place in the room
- */
- public function findUnit($roomId, $day, $connectionCheckOnly = false)
- {
- $doc = new DOMDocument('1.0', 'utf-8');
- $doc->formatOutput = true;
- $envelope = $doc->createElementNS('http://schemas.xmlsoap.org/soap/envelope/', 'SOAP-ENV:Envelope');
- $doc->appendChild($envelope);
- if ($this->open) {
- $envelope->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:ns1', 'http://www.his.de/ws/OpenCourseService');
- } else {
- $envelope->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:ns1', 'http://www.his.de/ws/CourseService');
- }
- $header = $this->getHeader($doc);
- $envelope->appendChild($header);
- //Body of the request
- $body = $doc->createElement('SOAP-ENV:Body');
- $envelope->appendChild($body);
- $findUnit = $doc->createElement('ns1:findUnit');
- $body->appendChild($findUnit);
- $findUnit->appendChild($doc->createElement('ns1:individualDatesExecutionDate', $day));
- $findUnit->appendChild($doc->createElement('ns1:roomId', $roomId));
-
- $soap_request = $doc->saveXML();
- $response1 = $this->postToServer($soap_request, "findUnit");
- if ($response1 === false) {
- $this->addError('Could not fetch room ' . $roomId, true);
- return false;
- }
- $response2 = $this->xmlStringToArray($response1, $err);
- if (!is_array($response2)) {
- $this->addError("Parsing room $roomId: $err", false);
- return false;
- }
- if (!isset($response2['soapenvBody'])) {
- $this->addError('Backend reply is missing element soapenvBody', true);
- return false;
- }
- if (isset($response2['soapenvBody']['soapenvFault'])) {
- $this->addError('SOAP-Fault (' . $response2['soapenvBody']['soapenvFault']['faultcode'] . ") " . $response2['soapenvBody']['soapenvFault']['faultstring'], true);
- return false;
- }
- // We only need to check if the connection is working (URL ok, credentials ok, ..) so bail out early
- if ($connectionCheckOnly) {
- return array();
- }
- if ($this->open) {
- $path = '/soapenvBody/hisfindUnitResponse/hisunits';
- $subpath = '/hisunit/hisid';
- } else {
- $path = '/soapenvBody/hisfindUnitResponse/hisunitIds';
- $subpath = '/hisid';
- }
- $idSubDoc = $this->getArrayPath($response2, $path);
- if ($idSubDoc === false) {
- $this->addError('Cannot find ' . $path, false);
- //@file_put_contents('/tmp/findUnit-1.' . $roomId . '.' . microtime(true), print_r($response2, true));
- return false;
- }
- if (empty($idSubDoc))
- return $idSubDoc;
- $idList = $this->getArrayPath($idSubDoc, $subpath);
- if ($idList === false) {
- $this->addError('Cannot find ' . $subpath . ' after ' . $path, false);
- @file_put_contents('/tmp/bwlp-findUnit-2.' . $roomId . '.' . microtime(true), print_r($idSubDoc, true));
+ if ($prop === 'baseUrl') {
+ // Update form SOAP to iCal url
+ if (preg_match(',^(http.*?)/qisserver,', $value, $out)) {
+ $value = $out[1] . '/qisserver/pages/cm/exa/timetable/roomScheduleCalendarExport.faces?roomId=%ID%';
+ } elseif (preg_match(',(.*[/=])\d*$', $value, $out)) {
+ $value = $out[1] . '%ID%';
+ } elseif (substr_count($value, '/') <= 3) {
+ if (substr($value, -1) !== '/') {
+ $value .= '/';
+ }
+ $value .= 'qisserver/pages/cm/exa/timetable/roomScheduleCalendarExport.faces?roomId=%ID%';
+ }
}
- return $idList;
+ return $value;
}
- /**
- * @param $doc DOMDocument
- * @return DOMElement
- */
- private function getHeader($doc)
+ protected function toTitle(ICalEvent $event): string
{
- $header = $doc->createElement('SOAP-ENV:Header');
- $security = $doc->createElementNS('http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd', 'ns2:Security');
- $mustunderstand = $doc->createAttribute('SOAP-ENV:mustUnderstand');
- $mustunderstand->value = 1;
- $security->appendChild($mustunderstand);
- $header->appendChild($security);
- $token = $doc->createElement('ns2:UsernameToken');
- $security->appendChild($token);
- $user = $doc->createElement('ns2:Username', $this->username);
- $token->appendChild($user);
- $pass = $doc->createElement('ns2:Password', $this->password);
- $type = $doc->createAttribute('Type');
- $type->value = 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText';
- $pass->appendChild($type);
- $token->appendChild($pass);
- return $header;
+ $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);
}
- /**
- * @param $request string with xml SOAP request
- * @param $action string with the name of the SOAP action
- * @return bool|string if successful the answer xml from the SOAP server
- */
- private function postToServer($request, $action)
+ public function checkConnection(): bool
{
- $header = array(
- 'Content-type: text/xml;charset="utf-8"',
- 'SOAPAction: "' . $action . '"',
- );
-
- if ($this->curlHandle === false) {
- $this->curlHandle = curl_init();
- }
-
- $options = array(
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_FOLLOWLOCATION => true,
- CURLOPT_SSL_VERIFYHOST => $this->verifyHostname ? 2 : 0,
- CURLOPT_SSL_VERIFYPEER => $this->verifyCert ? 1 : 0,
- CURLOPT_URL => $this->location,
- CURLOPT_POSTFIELDS => $request,
- CURLOPT_HTTPHEADER => $header,
- CURLOPT_TIMEOUT => 15,
- CURLOPT_CONNECTTIMEOUT => 3,
- );
-
- curl_setopt_array($this->curlHandle, $options);
-
- $output = curl_exec($this->curlHandle);
-
- if ($output === false) {
- $this->addError('Curl error: ' . curl_error($this->curlHandle), false);
- }
- return $output;
+ if (!$this->isOK())
+ return false;
+ // Unfortunately HisInOne returns an internal server error if you pass an invalid roomId.
+ // So we just try a bunch and see if anything works. Even if this fails, using
+ // the backend should work, given the URL is actually correct.
+ foreach ([60, 100, 5, 10, 50, 110, 200, 210, 250, 300, 333, 500, 1000, 2000] as $roomId) {
+ if ($this->downloadIcal($roomId) !== null)
+ return true;
+ }
+ 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";
}
- public function fetchSchedulesInternal($requestedRoomIds)
- {
- if (empty($requestedRoomIds)) {
- return array();
- }
- $currentWeek = $this->getCurrentWeekDates();
- $tTables = [];
- //get all eventIDs in a given room
- $eventIds = [];
- foreach ($requestedRoomIds as $roomId) {
- $ok = false;
- foreach ($currentWeek as $day) {
- $roomEventIds = $this->findUnit($roomId, $day, false);
- if ($roomEventIds === false)
- continue;
- $ok = true;
- $eventIds = array_merge($eventIds, $roomEventIds);
- }
- if ($ok) {
- $tTables[$roomId] = [];
- }
- }
- $eventIds = array_unique($eventIds);
- if (empty($eventIds)) {
- return $tTables;
- }
- $eventDetails = [];
- //get all information on each event
- foreach ($eventIds as $eventId) {
- $event = $this->readUnit(intval($eventId));
- if ($event === false)
- continue;
- $eventDetails = array_merge($eventDetails, $event);
- }
- $name = false;
- $now = time();
- foreach ($eventDetails as $event) {
- foreach (array('/hisdefaulttext',
- '/hisshorttext',
- '/hisshortcomment') as $path) {
- $name = $this->getArrayPath($event, $path);
- if (!empty($name) && !empty($name[0]))
- break;
- $name = false;
- }
- if ($name === false) {
- $name = ['???'];
- }
- $planElements = $this->getArrayPath($event, '/hisplanelements/hisplanelement');
- if ($planElements === false) {
- $this->addError('Cannot find ./hisplanelements/hisplanelement', false);
- //error_log('Cannot find ./hisplanelements/hisplanelement');
- //error_log(print_r($event, true));
- continue;
- }
- foreach ($planElements as $planElement) {
- if (empty($planElement['hisplannedDates']))
- continue;
- // Do not use -- is set improperly for some courses :-(
- /*
- $checkDate = $this->getArrayPath($planElement, '/hisplannedDates/hisplannedDate/hisenddate');
- if (!empty($checkDate) && strtotime($checkDate[0]) + 86400 < $now)
- continue; // Course ended
- $checkDate = $this->getArrayPath($planElement, '/hisplannedDates/hisplannedDate/hisstartdate');
- if (!empty($checkDate) && strtotime($checkDate[0]) - 86400 > $now)
- continue; // Course didn't start yet
- */
- $cancelled = $this->getArrayPath($planElement, '/hiscancelled');
- $cancelled = $cancelled !== false && is_array($cancelled) && ($cancelled[0] > 0 || strtolower($cancelled[0]) === 'true');
- $unitPlannedDates = $this->getArrayPath($planElement,
- '/hisplannedDates/hisplannedDate/hisindividualDates/hisindividualDate');
- if ($unitPlannedDates === false) {
- $this->addError('Cannot find ./hisplannedDates/hisplannedDate/hisindividualDates/hisindividualDate', false);
- //error_log('Cannot find ./hisplannedDates/hisplannedDate/hisindividualDates/hisindividualDate');
- //error_log(print_r($planElement, true));
- continue;
- }
- $localName = $this->getArrayPath($planElement, '/hisdefaulttext');
- if ($localName === false || empty($localName[0])) {
- $localName = $name;
- }
- foreach ($unitPlannedDates as $plannedDate) {
- $eventRoomId = $this->getArrayPath($plannedDate, '/hisroomId')[0];
- $eventDate = $this->getArrayPath($plannedDate, '/hisexecutiondate')[0];
- if (in_array($eventRoomId, $requestedRoomIds) && in_array($eventDate, $currentWeek)) {
- $startTime = $this->getArrayPath($plannedDate, '/hisstarttime')[0];
- $endTime = $this->getArrayPath($plannedDate, '/hisendtime')[0];
- $tTables[$eventRoomId][] = array(
- 'title' => $localName[0],
- 'start' => $eventDate . "T" . $startTime,
- 'end' => $eventDate . "T" . $endTime,
- 'cancelled' => $cancelled,
- );
- }
- }
- }
- }
- return $tTables;
- }
-
-
- /**
- * @param $unit int ID of the subject in HisInOne database
- * @return bool|array false if there was an error otherwise an array with the information about the subject
- */
- public function readUnit($unit)
- {
- $doc = new DOMDocument('1.0', 'utf-8');
- $doc->formatOutput = true;
- $envelope = $doc->createElementNS('http://schemas.xmlsoap.org/soap/envelope/', 'SOAP-ENV:Envelope');
- $doc->appendChild($envelope);
- if ($this->open) {
- $envelope->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:ns1', 'http://www.his.de/ws/OpenCourseService');
- } else {
- $envelope->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:ns1', 'http://www.his.de/ws/CourseService');
- }
- $header = $this->getHeader($doc);
- $envelope->appendChild($header);
- //body of the request
- $body = $doc->createElement('SOAP-ENV:Body');
- $envelope->appendChild($body);
- $readUnit = $doc->createElement('ns1:readUnit');
- $body->appendChild($readUnit);
- $readUnit->appendChild($doc->createElement('ns1:unitId', $unit));
-
- $soap_request = $doc->saveXML();
- $response1 = $this->postToServer($soap_request, "readUnit");
- if ($response1 === false) {
- return false;
- }
- $response2 = $this->xmlStringToArray($response1, $err);
- if ($response2 === false) {
- $this->addError("Cannot parse unit $unit as XML: $err", false);
- return false;
- }
- if (!isset($response2['soapenvBody'])) {
- $this->addError('Backend reply is missing element soapenvBody', true);
- return false;
- }
- if (isset($response2['soapenvBody']['soapenvFault'])) {
- $this->addError('SOAP-Fault (' . $response2['soapenvBody']['soapenvFault']['faultcode'] . ") " . $response2['soapenvBody']['soapenvFault']['faultstring'], true);
- return false;
- }
- return $this->getArrayPath($response2, '/soapenvBody/hisreadUnitResponse/hisunit');
- }
-
- /**
- * @return array with days of the current week in datetime format
- */
- private function getCurrentWeekDates()
- {
- $returnValue = array();
- $date = date('Y-m-d', strtotime('last Monday'));
- for ($i = 0; $i < 14; $i++) {
- $returnValue[] = $date;
- $date = date('Y-m-d', strtotime($date.' +1 day'));
- }
- return $returnValue;
- }
-
- public function __destruct()
- {
- if ($this->curlHandle !== false) {
- curl_close($this->curlHandle);
- }
- }
-
}
diff --git a/modules-available/locationinfo/inc/coursebackend/coursebackend_ical.inc.php b/modules-available/locationinfo/inc/coursebackend/coursebackend_ical.inc.php
new file mode 100644
index 00000000..f1791c4e
--- /dev/null
+++ b/modules-available/locationinfo/inc/coursebackend/coursebackend_ical.inc.php
@@ -0,0 +1,61 @@
+<?php
+
+class CourseBackend_ICal extends ICalCourseBackend
+{
+
+ /** @var string room ID for testing connection */
+ private $testId;
+
+ public function setCredentialsInternal(array $data): bool
+ {
+ if (empty($data['baseUrl'])) {
+ $this->addError("No url is given", true);
+ return false;
+ }
+
+ $this->init($data['baseUrl'], $data['verifyCert'], $data['verifyHostname'], $data['authMethod'],
+ $data['user'], $data['pass']);
+ $this->testId = $data['testId'];
+
+ return true;
+ }
+
+ public function getCredentialDefinitions(): array
+ {
+ return [
+ new BackendProperty('baseUrl', 'string'),
+ new BackendProperty('verifyCert', 'bool', true),
+ new BackendProperty('verifyHostname', 'bool', true),
+ new BackendProperty('testId', 'string'),
+ new BackendProperty('authMethod', ['NONE', 'BASIC', 'DIGEST', 'GSSNEGOTIATE', 'NTLM'], 'NONE'),
+ new BackendProperty('user', 'string'),
+ new BackendProperty('pass', 'string'),
+ ];
+ }
+
+ public function checkConnection(): bool
+ {
+ if (!$this->isOK())
+ return false;
+ if (empty($this->testId))
+ return true;
+ return ($this->downloadIcal($this->testId) !== null);
+ }
+
+ public function getCacheTime(): int
+ {
+ return 30 * 60;
+ }
+
+ public function getRefreshTime(): int
+ {
+ return 60 * 60;
+ }
+
+
+ public function getDisplayName(): string
+ {
+ return "iCal";
+ }
+
+}
diff --git a/modules-available/locationinfo/inc/icalcoursebackend.inc.php b/modules-available/locationinfo/inc/icalcoursebackend.inc.php
new file mode 100644
index 00000000..838d18b7
--- /dev/null
+++ b/modules-available/locationinfo/inc/icalcoursebackend.inc.php
@@ -0,0 +1,148 @@
+<?php
+
+abstract class ICalCourseBackend extends CourseBackend
+{
+
+ /** @var string */
+ private $location;
+ /** @var string */
+ private $authMethod;
+ /** @var string */
+ private $user;
+ /** @var string */
+ private $pass;
+ /** @var bool */
+ private $verifyHostname;
+ /** @var bool */
+ private $verifyCert;
+ /** @var bool|resource */
+ private $curlHandle = false;
+
+ protected function init(
+ string $location, bool $verifyCert, bool $verifyHostname,
+ string $authMethod = 'NONE', string $user = '', string $pass = '')
+ {
+ $this->verifyCert = $verifyCert;
+ $this->verifyHostname = $verifyHostname;
+ if (strpos($location, '%ID%') === false) {
+ $location .= '%ID%';
+ }
+ $this->location = $location;
+ $this->authMethod = $authMethod;
+ $this->user = $user;
+ $this->pass = $pass;
+ }
+
+ /**
+ * @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(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();
+ }
+
+ $options = [
+ CURLOPT_WRITEFUNCTION => function ($ch, $data) use ($ical) {
+ $ical->feedData($data);
+ return strlen($data);
+ },
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_SSL_VERIFYHOST => $this->verifyHostname ? 2 : 0,
+ CURLOPT_SSL_VERIFYPEER => $this->verifyCert ? 1 : 0,
+ CURLOPT_URL => str_replace('%ID%', $roomId, $this->location),
+ CURLOPT_TIMEOUT => 60,
+ CURLOPT_CONNECTTIMEOUT => 4,
+ ];
+ if ($this->authMethod !== 'NONE' && defined('CURLAUTH_' . $this->authMethod)) {
+ $options[CURLOPT_HTTPAUTH] = constant('CURLAUTH_' . $this->authMethod);
+ $options[CURLOPT_USERPWD] = $this->user;
+ if (!empty($this->pass)) {
+ $options[CURLOPT_USERPWD] .= ':' . $this->pass;
+ }
+ }
+
+ curl_setopt_array($this->curlHandle, $options);
+
+ $curlRet = curl_exec($this->curlHandle);
+ if (!$curlRet) {
+ $this->addError('Curl error: ' . curl_error($this->curlHandle) . ' for ' . $roomId, false);
+ }
+ $ical->finish();
+ if (!$ical->isValid()) {
+ if ($curlRet) {
+ $this->addError("Did not find a VCALENDAR in returned data for $roomId", false);
+ }
+ return null;
+ }
+ return $ical->events();
+ }
+
+ public function fetchSchedulesInternal(array $requestedRoomIds): array
+ {
+ if (empty($requestedRoomIds) || !$this->isOK()) {
+ return array();
+ }
+ $tTables = [];
+ foreach ($requestedRoomIds as $roomId) {
+ $data = $this->downloadIcal($roomId);
+ if ($data === null) {
+ $this->addError("Downloading ical for $roomId failed", false);
+ continue;
+ }
+ foreach ($data as $event) {
+ $tTables[$roomId][] = array(
+ 'title' => $this->toTitle($event),
+ 'start' => $event->dtstart,
+ 'end' => $event->dtend,
+ 'cancelled' => false, // ??? How
+ );
+ }
+ }
+ return $tTables;
+ }
+
+ /**
+ * Get a usable title from either SUMMARY or DESCRIPTION
+ */
+ protected function toTitle(ICalEvent $event): string
+ {
+ $title = $event->summary;
+ if (empty($title)) {
+ $title = $event->description;
+ }
+ if (empty($title)) {
+ $title = 'Unknown';
+ }
+ return (string)preg_replace([',(\s*<br\s*/?>\s*|\r|\n|\\\r|\\\n)+,', '/\\\\([,;:])/'], ["\n", '$1'], $title);
+ }
+
+ protected function isOK(): bool
+ {
+ if (empty($this->location)) {
+ $this->addError("Credentials are not set", true);
+ return false;
+ }
+ return true;
+ }
+
+ public function __destruct()
+ {
+ if ($this->curlHandle !== false) {
+ curl_close($this->curlHandle);
+ }
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/locationinfo/inc/icalevent.inc.php b/modules-available/locationinfo/inc/icalevent.inc.php
new file mode 100644
index 00000000..c5aea349
--- /dev/null
+++ b/modules-available/locationinfo/inc/icalevent.inc.php
@@ -0,0 +1,254 @@
+<?php
+
+class ICalEvent
+{
+ // 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
new file mode 100644
index 00000000..eacb67b1
--- /dev/null
+++ b/modules-available/locationinfo/inc/icalparser.inc.php
@@ -0,0 +1,2052 @@
+<?php
+
+/*
+ * Modified for slx-admin to support streaming, some functions removed that are not needed,
+ * Carbon removed, Honor window size when calculating recurring events, ...
+ */
+
+/**
+ * This PHP class will read an ICS (`.ics`, `.ical`, `.ifb`) file, parse it and return an
+ * array of its contents.
+ *
+ * PHP 5 (≥ 5.3.9)
+ *
+ * @author Jonathan Goode <https://github.com/u01jmg3>
+ * @license https://opensource.org/licenses/mit-license.php MIT License
+ * @version 2.1.20
+ */
+class ICalParser
+{
+ // phpcs:disable Generic.Arrays.DisallowLongArraySyntax
+
+ const DATE_TIME_FORMAT = 'Ymd\THis';
+ const ICAL_DATE_TIME_TEMPLATE = 'TZID=%s:';
+ const ISO_8601_WEEK_START = 'MO';
+ const RECURRENCE_EVENT = 'Generated recurrence event';
+ const TIME_ZONE_UTC = 'UTC';
+ const UNIX_FORMAT = 'U';
+
+ /**
+ * Tracks the number of alarms in the current iCal feed
+ *
+ * @var integer
+ */
+ public $alarmCount = 0;
+
+ /**
+ * Tracks the number of events in the current iCal feed
+ *
+ * @var integer
+ */
+ public $eventCount = 0;
+
+ /**
+ * Tracks the free/busy count in the current iCal feed
+ *
+ * @var integer
+ */
+ public $freeBusyCount = 0;
+
+ /**
+ * Tracks the number of todos in the current iCal feed
+ *
+ * @var integer
+ */
+ public $todoCount = 0;
+
+ /**
+ * The value in years to use for indefinite, recurring events
+ *
+ * @var integer
+ */
+ public $defaultSpan = 2;
+
+ /**
+ * Enables customisation of the default time zone
+ *
+ * @var string
+ */
+ public $defaultTimeZone;
+
+ /**
+ * The two letter representation of the first day of the week
+ *
+ * @var string
+ */
+ public $defaultWeekStart = self::ISO_8601_WEEK_START;
+
+ /**
+ * Toggles whether to skip the parsing of recurrence rules
+ *
+ * @var boolean
+ */
+ public $skipRecurrence = false;
+
+ /**
+ * With this being non-null the parser will ignore all events more than roughly this many days after now.
+ *
+ * @var integer
+ */
+ public $filterDaysBefore;
+
+ /**
+ * With this being non-null the parser will ignore all events more than roughly this many days before now.
+ *
+ * @var integer
+ */
+ public $filterDaysAfter;
+
+ /**
+ * @var string Which object type we're currently handling while parsing.
+ */
+ private $parseStateComponent = '';
+
+ /**
+ * @var string Current line being read (in case of continuation).
+ */
+ private $currentLineBuffer = '';
+
+ /**
+ * @var string Chunk of data currently being handled - might stop mid-line.
+ */
+ private $feedBuffer = '';
+
+ /**
+ * @var bool whether we ever saw a BEGIN:VCALENDAR in the data
+ */
+ private $hasSeenStart = false;
+
+ /**
+ * The parsed calendar
+ *
+ * @var array
+ */
+ public $cal = array();
+
+ /**
+ * Tracks the VFREEBUSY component
+ *
+ * @var integer
+ */
+ protected $freeBusyIndex = 0;
+
+ /**
+ * Variable to track the previous keyword
+ *
+ * @var string
+ */
+ protected $lastKeyword;
+
+ /**
+ * Cache valid IANA time zone IDs to avoid unnecessary lookups
+ *
+ * @var array
+ */
+ protected $validIanaTimeZones = array();
+
+ /**
+ * Event recurrence instances that have been altered
+ *
+ * @var array
+ */
+ protected $alteredRecurrenceInstances = array();
+
+ /**
+ * An associative array containing weekday conversion data
+ *
+ * The order of the days in the array follow the ISO-8601 specification of a week.
+ *
+ * @var array
+ */
+ protected $weekdays = array(
+ 'MO' => 'monday',
+ 'TU' => 'tuesday',
+ 'WE' => 'wednesday',
+ 'TH' => 'thursday',
+ 'FR' => 'friday',
+ 'SA' => 'saturday',
+ 'SU' => 'sunday',
+ );
+
+ /**
+ * An associative array containing frequency conversion terms
+ *
+ * @var array
+ */
+ protected $frequencyConversion = array(
+ 'DAILY' => 'day',
+ 'WEEKLY' => 'week',
+ 'MONTHLY' => 'month',
+ 'YEARLY' => 'year',
+ );
+
+ /**
+ * Define which variables can be configured
+ *
+ * @var array
+ */
+ private static $configurableOptions = array(
+ 'defaultSpan',
+ 'defaultTimeZone',
+ 'defaultWeekStart',
+ 'filterDaysAfter',
+ 'filterDaysBefore',
+ 'skipRecurrence',
+ );
+
+ /**
+ * CLDR time zones mapped to IANA time zones.
+ *
+ * @var array
+ */
+ private static $cldrTimeZonesMap = array(
+ '(UTC-12:00) International Date Line West' => 'Etc/GMT+12',
+ '(UTC-11:00) Coordinated Universal Time-11' => 'Etc/GMT+11',
+ '(UTC-10:00) Hawaii' => 'Pacific/Honolulu',
+ '(UTC-09:00) Alaska' => 'America/Anchorage',
+ '(UTC-08:00) Pacific Time (US & Canada)' => 'America/Los_Angeles',
+ '(UTC-07:00) Arizona' => 'America/Phoenix',
+ '(UTC-07:00) Chihuahua, La Paz, Mazatlan' => 'America/Chihuahua',
+ '(UTC-07:00) Mountain Time (US & Canada)' => 'America/Denver',
+ '(UTC-06:00) Central America' => 'America/Guatemala',
+ '(UTC-06:00) Central Time (US & Canada)' => 'America/Chicago',
+ '(UTC-06:00) Guadalajara, Mexico City, Monterrey' => 'America/Mexico_City',
+ '(UTC-06:00) Saskatchewan' => 'America/Regina',
+ '(UTC-05:00) Bogota, Lima, Quito, Rio Branco' => 'America/Bogota',
+ '(UTC-05:00) Chetumal' => 'America/Cancun',
+ '(UTC-05:00) Eastern Time (US & Canada)' => 'America/New_York',
+ '(UTC-05:00) Indiana (East)' => 'America/Indianapolis',
+ '(UTC-04:00) Asuncion' => 'America/Asuncion',
+ '(UTC-04:00) Atlantic Time (Canada)' => 'America/Halifax',
+ '(UTC-04:00) Caracas' => 'America/Caracas',
+ '(UTC-04:00) Cuiaba' => 'America/Cuiaba',
+ '(UTC-04:00) Georgetown, La Paz, Manaus, San Juan' => 'America/La_Paz',
+ '(UTC-04:00) Santiago' => 'America/Santiago',
+ '(UTC-03:30) Newfoundland' => 'America/St_Johns',
+ '(UTC-03:00) Brasilia' => 'America/Sao_Paulo',
+ '(UTC-03:00) Cayenne, Fortaleza' => 'America/Cayenne',
+ '(UTC-03:00) City of Buenos Aires' => 'America/Buenos_Aires',
+ '(UTC-03:00) Greenland' => 'America/Godthab',
+ '(UTC-03:00) Montevideo' => 'America/Montevideo',
+ '(UTC-03:00) Salvador' => 'America/Bahia',
+ '(UTC-02:00) Coordinated Universal Time-02' => 'Etc/GMT+2',
+ '(UTC-01:00) Azores' => 'Atlantic/Azores',
+ '(UTC-01:00) Cabo Verde Is.' => 'Atlantic/Cape_Verde',
+ '(UTC) Coordinated Universal Time' => 'Etc/GMT',
+ '(UTC+00:00) Casablanca' => 'Africa/Casablanca',
+ '(UTC+00:00) Dublin, Edinburgh, Lisbon, London' => 'Europe/London',
+ '(UTC+00:00) Monrovia, Reykjavik' => 'Atlantic/Reykjavik',
+ '(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna' => 'Europe/Berlin',
+ '(UTC+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague' => 'Europe/Budapest',
+ '(UTC+01:00) Brussels, Copenhagen, Madrid, Paris' => 'Europe/Paris',
+ '(UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb' => 'Europe/Warsaw',
+ '(UTC+01:00) West Central Africa' => 'Africa/Lagos',
+ '(UTC+02:00) Amman' => 'Asia/Amman',
+ '(UTC+02:00) Athens, Bucharest' => 'Europe/Bucharest',
+ '(UTC+02:00) Beirut' => 'Asia/Beirut',
+ '(UTC+02:00) Cairo' => 'Africa/Cairo',
+ '(UTC+02:00) Chisinau' => 'Europe/Chisinau',
+ '(UTC+02:00) Damascus' => 'Asia/Damascus',
+ '(UTC+02:00) Harare, Pretoria' => 'Africa/Johannesburg',
+ '(UTC+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius' => 'Europe/Kiev',
+ '(UTC+02:00) Jerusalem' => 'Asia/Jerusalem',
+ '(UTC+02:00) Kaliningrad' => 'Europe/Kaliningrad',
+ '(UTC+02:00) Tripoli' => 'Africa/Tripoli',
+ '(UTC+02:00) Windhoek' => 'Africa/Windhoek',
+ '(UTC+03:00) Baghdad' => 'Asia/Baghdad',
+ '(UTC+03:00) Istanbul' => 'Europe/Istanbul',
+ '(UTC+03:00) Kuwait, Riyadh' => 'Asia/Riyadh',
+ '(UTC+03:00) Minsk' => 'Europe/Minsk',
+ '(UTC+03:00) Moscow, St. Petersburg, Volgograd' => 'Europe/Moscow',
+ '(UTC+03:00) Nairobi' => 'Africa/Nairobi',
+ '(UTC+03:30) Tehran' => 'Asia/Tehran',
+ '(UTC+04:00) Abu Dhabi, Muscat' => 'Asia/Dubai',
+ '(UTC+04:00) Baku' => 'Asia/Baku',
+ '(UTC+04:00) Izhevsk, Samara' => 'Europe/Samara',
+ '(UTC+04:00) Port Louis' => 'Indian/Mauritius',
+ '(UTC+04:00) Tbilisi' => 'Asia/Tbilisi',
+ '(UTC+04:00) Yerevan' => 'Asia/Yerevan',
+ '(UTC+04:30) Kabul' => 'Asia/Kabul',
+ '(UTC+05:00) Ashgabat, Tashkent' => 'Asia/Tashkent',
+ '(UTC+05:00) Ekaterinburg' => 'Asia/Yekaterinburg',
+ '(UTC+05:00) Islamabad, Karachi' => 'Asia/Karachi',
+ '(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi' => 'Asia/Calcutta',
+ '(UTC+05:30) Sri Jayawardenepura' => 'Asia/Colombo',
+ '(UTC+05:45) Kathmandu' => 'Asia/Katmandu',
+ '(UTC+06:00) Astana' => 'Asia/Almaty',
+ '(UTC+06:00) Dhaka' => 'Asia/Dhaka',
+ '(UTC+06:30) Yangon (Rangoon)' => 'Asia/Rangoon',
+ '(UTC+07:00) Bangkok, Hanoi, Jakarta' => 'Asia/Bangkok',
+ '(UTC+07:00) Krasnoyarsk' => 'Asia/Krasnoyarsk',
+ '(UTC+07:00) Novosibirsk' => 'Asia/Novosibirsk',
+ '(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi' => 'Asia/Shanghai',
+ '(UTC+08:00) Irkutsk' => 'Asia/Irkutsk',
+ '(UTC+08:00) Kuala Lumpur, Singapore' => 'Asia/Singapore',
+ '(UTC+08:00) Perth' => 'Australia/Perth',
+ '(UTC+08:00) Taipei' => 'Asia/Taipei',
+ '(UTC+08:00) Ulaanbaatar' => 'Asia/Ulaanbaatar',
+ '(UTC+09:00) Osaka, Sapporo, Tokyo' => 'Asia/Tokyo',
+ '(UTC+09:00) Pyongyang' => 'Asia/Pyongyang',
+ '(UTC+09:00) Seoul' => 'Asia/Seoul',
+ '(UTC+09:00) Yakutsk' => 'Asia/Yakutsk',
+ '(UTC+09:30) Adelaide' => 'Australia/Adelaide',
+ '(UTC+09:30) Darwin' => 'Australia/Darwin',
+ '(UTC+10:00) Brisbane' => 'Australia/Brisbane',
+ '(UTC+10:00) Canberra, Melbourne, Sydney' => 'Australia/Sydney',
+ '(UTC+10:00) Guam, Port Moresby' => 'Pacific/Port_Moresby',
+ '(UTC+10:00) Hobart' => 'Australia/Hobart',
+ '(UTC+10:00) Vladivostok' => 'Asia/Vladivostok',
+ '(UTC+11:00) Chokurdakh' => 'Asia/Srednekolymsk',
+ '(UTC+11:00) Magadan' => 'Asia/Magadan',
+ '(UTC+11:00) Solomon Is., New Caledonia' => 'Pacific/Guadalcanal',
+ '(UTC+12:00) Anadyr, Petropavlovsk-Kamchatsky' => 'Asia/Kamchatka',
+ '(UTC+12:00) Auckland, Wellington' => 'Pacific/Auckland',
+ '(UTC+12:00) Coordinated Universal Time+12' => 'Etc/GMT-12',
+ '(UTC+12:00) Fiji' => 'Pacific/Fiji',
+ "(UTC+13:00) Nuku'alofa" => 'Pacific/Tongatapu',
+ '(UTC+13:00) Samoa' => 'Pacific/Apia',
+ '(UTC+14:00) Kiritimati Island' => 'Pacific/Kiritimati',
+ );
+
+ /**
+ * Maps Windows (non-CLDR) time zone ID to IANA ID. This is pragmatic but not 100% precise as one Windows zone ID
+ * maps to multiple IANA IDs (one for each territory). For all practical purposes this should be good enough, though.
+ *
+ * Source: http://unicode.org/repos/cldr/trunk/common/supplemental/windowsZones.xml
+ *
+ * @var array
+ */
+ private static $windowsTimeZonesMap = array(
+ 'AUS Central Standard Time' => 'Australia/Darwin',
+ 'AUS Eastern Standard Time' => 'Australia/Sydney',
+ 'Afghanistan Standard Time' => 'Asia/Kabul',
+ 'Alaskan Standard Time' => 'America/Anchorage',
+ 'Aleutian Standard Time' => 'America/Adak',
+ 'Altai Standard Time' => 'Asia/Barnaul',
+ 'Arab Standard Time' => 'Asia/Riyadh',
+ 'Arabian Standard Time' => 'Asia/Dubai',
+ 'Arabic Standard Time' => 'Asia/Baghdad',
+ 'Argentina Standard Time' => 'America/Buenos_Aires',
+ 'Astrakhan Standard Time' => 'Europe/Astrakhan',
+ 'Atlantic Standard Time' => 'America/Halifax',
+ 'Aus Central W. Standard Time' => 'Australia/Eucla',
+ 'Azerbaijan Standard Time' => 'Asia/Baku',
+ 'Azores Standard Time' => 'Atlantic/Azores',
+ 'Bahia Standard Time' => 'America/Bahia',
+ 'Bangladesh Standard Time' => 'Asia/Dhaka',
+ 'Belarus Standard Time' => 'Europe/Minsk',
+ 'Bougainville Standard Time' => 'Pacific/Bougainville',
+ 'Canada Central Standard Time' => 'America/Regina',
+ 'Cape Verde Standard Time' => 'Atlantic/Cape_Verde',
+ 'Caucasus Standard Time' => 'Asia/Yerevan',
+ 'Cen. Australia Standard Time' => 'Australia/Adelaide',
+ 'Central America Standard Time' => 'America/Guatemala',
+ 'Central Asia Standard Time' => 'Asia/Almaty',
+ 'Central Brazilian Standard Time' => 'America/Cuiaba',
+ 'Central Europe Standard Time' => 'Europe/Budapest',
+ 'Central European Standard Time' => 'Europe/Warsaw',
+ 'Central Pacific Standard Time' => 'Pacific/Guadalcanal',
+ 'Central Standard Time (Mexico)' => 'America/Mexico_City',
+ 'Central Standard Time' => 'America/Chicago',
+ 'Chatham Islands Standard Time' => 'Pacific/Chatham',
+ 'China Standard Time' => 'Asia/Shanghai',
+ 'Cuba Standard Time' => 'America/Havana',
+ 'Dateline Standard Time' => 'Etc/GMT+12',
+ 'E. Africa Standard Time' => 'Africa/Nairobi',
+ 'E. Australia Standard Time' => 'Australia/Brisbane',
+ 'E. Europe Standard Time' => 'Europe/Chisinau',
+ 'E. South America Standard Time' => 'America/Sao_Paulo',
+ 'Easter Island Standard Time' => 'Pacific/Easter',
+ 'Eastern Standard Time (Mexico)' => 'America/Cancun',
+ 'Eastern Standard Time' => 'America/New_York',
+ 'Egypt Standard Time' => 'Africa/Cairo',
+ 'Ekaterinburg Standard Time' => 'Asia/Yekaterinburg',
+ 'FLE Standard Time' => 'Europe/Kiev',
+ 'Fiji Standard Time' => 'Pacific/Fiji',
+ 'GMT Standard Time' => 'Europe/London',
+ 'GTB Standard Time' => 'Europe/Bucharest',
+ 'Georgian Standard Time' => 'Asia/Tbilisi',
+ 'Greenland Standard Time' => 'America/Godthab',
+ 'Greenwich Standard Time' => 'Atlantic/Reykjavik',
+ 'Haiti Standard Time' => 'America/Port-au-Prince',
+ 'Hawaiian Standard Time' => 'Pacific/Honolulu',
+ 'India Standard Time' => 'Asia/Calcutta',
+ 'Iran Standard Time' => 'Asia/Tehran',
+ 'Israel Standard Time' => 'Asia/Jerusalem',
+ 'Jordan Standard Time' => 'Asia/Amman',
+ 'Kaliningrad Standard Time' => 'Europe/Kaliningrad',
+ 'Korea Standard Time' => 'Asia/Seoul',
+ 'Libya Standard Time' => 'Africa/Tripoli',
+ 'Line Islands Standard Time' => 'Pacific/Kiritimati',
+ 'Lord Howe Standard Time' => 'Australia/Lord_Howe',
+ 'Magadan Standard Time' => 'Asia/Magadan',
+ 'Magallanes Standard Time' => 'America/Punta_Arenas',
+ 'Marquesas Standard Time' => 'Pacific/Marquesas',
+ 'Mauritius Standard Time' => 'Indian/Mauritius',
+ 'Middle East Standard Time' => 'Asia/Beirut',
+ 'Montevideo Standard Time' => 'America/Montevideo',
+ 'Morocco Standard Time' => 'Africa/Casablanca',
+ 'Mountain Standard Time (Mexico)' => 'America/Chihuahua',
+ 'Mountain Standard Time' => 'America/Denver',
+ 'Myanmar Standard Time' => 'Asia/Rangoon',
+ 'N. Central Asia Standard Time' => 'Asia/Novosibirsk',
+ 'Namibia Standard Time' => 'Africa/Windhoek',
+ 'Nepal Standard Time' => 'Asia/Katmandu',
+ 'New Zealand Standard Time' => 'Pacific/Auckland',
+ 'Newfoundland Standard Time' => 'America/St_Johns',
+ 'Norfolk Standard Time' => 'Pacific/Norfolk',
+ 'North Asia East Standard Time' => 'Asia/Irkutsk',
+ 'North Asia Standard Time' => 'Asia/Krasnoyarsk',
+ 'North Korea Standard Time' => 'Asia/Pyongyang',
+ 'Omsk Standard Time' => 'Asia/Omsk',
+ 'Pacific SA Standard Time' => 'America/Santiago',
+ 'Pacific Standard Time (Mexico)' => 'America/Tijuana',
+ 'Pacific Standard Time' => 'America/Los_Angeles',
+ 'Pakistan Standard Time' => 'Asia/Karachi',
+ 'Paraguay Standard Time' => 'America/Asuncion',
+ 'Romance Standard Time' => 'Europe/Paris',
+ 'Russia Time Zone 10' => 'Asia/Srednekolymsk',
+ 'Russia Time Zone 11' => 'Asia/Kamchatka',
+ 'Russia Time Zone 3' => 'Europe/Samara',
+ 'Russian Standard Time' => 'Europe/Moscow',
+ 'SA Eastern Standard Time' => 'America/Cayenne',
+ 'SA Pacific Standard Time' => 'America/Bogota',
+ 'SA Western Standard Time' => 'America/La_Paz',
+ 'SE Asia Standard Time' => 'Asia/Bangkok',
+ 'Saint Pierre Standard Time' => 'America/Miquelon',
+ 'Sakhalin Standard Time' => 'Asia/Sakhalin',
+ 'Samoa Standard Time' => 'Pacific/Apia',
+ 'Sao Tome Standard Time' => 'Africa/Sao_Tome',
+ 'Saratov Standard Time' => 'Europe/Saratov',
+ 'Singapore Standard Time' => 'Asia/Singapore',
+ 'South Africa Standard Time' => 'Africa/Johannesburg',
+ 'Sri Lanka Standard Time' => 'Asia/Colombo',
+ 'Sudan Standard Time' => 'Africa/Tripoli',
+ 'Syria Standard Time' => 'Asia/Damascus',
+ 'Taipei Standard Time' => 'Asia/Taipei',
+ 'Tasmania Standard Time' => 'Australia/Hobart',
+ 'Tocantins Standard Time' => 'America/Araguaina',
+ 'Tokyo Standard Time' => 'Asia/Tokyo',
+ 'Tomsk Standard Time' => 'Asia/Tomsk',
+ 'Tonga Standard Time' => 'Pacific/Tongatapu',
+ 'Transbaikal Standard Time' => 'Asia/Chita',
+ 'Turkey Standard Time' => 'Europe/Istanbul',
+ 'Turks And Caicos Standard Time' => 'America/Grand_Turk',
+ 'US Eastern Standard Time' => 'America/Indianapolis',
+ 'US Mountain Standard Time' => 'America/Phoenix',
+ 'UTC' => 'Etc/GMT',
+ 'UTC+12' => 'Etc/GMT-12',
+ 'UTC+13' => 'Etc/GMT-13',
+ 'UTC-02' => 'Etc/GMT+2',
+ 'UTC-08' => 'Etc/GMT+8',
+ 'UTC-09' => 'Etc/GMT+9',
+ 'UTC-11' => 'Etc/GMT+11',
+ 'Ulaanbaatar Standard Time' => 'Asia/Ulaanbaatar',
+ 'Venezuela Standard Time' => 'America/Caracas',
+ 'Vladivostok Standard Time' => 'Asia/Vladivostok',
+ 'W. Australia Standard Time' => 'Australia/Perth',
+ 'W. Central Africa Standard Time' => 'Africa/Lagos',
+ 'W. Europe Standard Time' => 'Europe/Berlin',
+ 'W. Mongolia Standard Time' => 'Asia/Hovd',
+ 'West Asia Standard Time' => 'Asia/Tashkent',
+ 'West Bank Standard Time' => 'Asia/Hebron',
+ 'West Pacific Standard Time' => 'Pacific/Port_Moresby',
+ 'Yakutsk Standard Time' => 'Asia/Yakutsk',
+ );
+
+ /**
+ * If `$filterDaysBefore` or `$filterDaysAfter` are set then the events are filtered according to the window defined
+ * by this field and `$windowMaxTimestamp`.
+ *
+ * @var integer
+ */
+ private $windowMinTimestamp;
+
+ /**
+ * If `$filterDaysBefore` or `$filterDaysAfter` are set then the events are filtered according to the window defined
+ * by this field and `$windowMinTimestamp`.
+ *
+ * @var integer
+ */
+ private $windowMaxTimestamp;
+
+ /**
+ * `true` if either `$filterDaysBefore` or `$filterDaysAfter` are set.
+ *
+ * @var boolean
+ */
+ private $shouldFilterByWindow;
+
+ /**
+ * Creates the ICal object
+ *
+ * @param array $options
+ * @return void
+ * @throws Exception
+ */
+ public function __construct(array $options = array())
+ {
+ foreach ($options as $option => $value) {
+ if (in_array($option, self::$configurableOptions)) {
+ $this->{$option} = $value;
+ }
+ }
+
+ // Fallback to use the system default time zone
+ if (!isset($this->defaultTimeZone) || !$this->isValidTimeZoneId($this->defaultTimeZone)) {
+ $this->defaultTimeZone = date_default_timezone_get();
+ }
+
+ $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);
+ }
+
+ /**
+ * Feed more data to the parser. This can be a chunk of arbitrary length, it
+ * is not required to end on a line break.
+ *
+ * @param string $data
+ */
+ public function feedData(string $data)
+ {
+ $this->feedBuffer .= $data;
+ $start = 0;
+ $bufferLen = strlen($this->feedBuffer);
+ while (($newLine = strcspn($this->feedBuffer, "\r\n", $start) + $start) !== $bufferLen) {
+ $length = $newLine - $start;
+ if ($length > 1) {
+ if ($this->feedBuffer[$start] === ' ' || $this->feedBuffer[$start] === '\t') {
+ // Continuation of previous line
+ $this->currentLineBuffer .= substr($this->feedBuffer, $start + 1, $length - 1);
+ } else {
+ // New line, flush previous one
+ $this->handleLine($this->currentLineBuffer);
+ $this->currentLineBuffer = substr($this->feedBuffer, $start, $length);
+ }
+ }
+ $start = $newLine + 1;
+ }
+ $this->feedBuffer = substr($this->feedBuffer, $start);
+ }
+
+ /**
+ * Finish feeding more data to the parser, process the data.
+ */
+ public function finish()
+ {
+ // Flush
+ $this->feedData("\n*\n");
+ $this->currentLineBuffer = '';
+ $this->feedBuffer = '';
+ $this->processEvents();
+
+ if (!$this->skipRecurrence) {
+ $this->processRecurrences();
+
+ // Apply changes to altered recurrence instances
+ if (!empty($this->alteredRecurrenceInstances)) {
+ $events = $this->cal['VEVENT'];
+
+ foreach ($this->alteredRecurrenceInstances as $alteredRecurrenceInstance) {
+ if (isset($alteredRecurrenceInstance['altered-event'])) {
+ $alteredEvent = $alteredRecurrenceInstance['altered-event'];
+ $key = key($alteredEvent);
+ $events[$key] = $alteredEvent[$key];
+ }
+ }
+
+ $this->cal['VEVENT'] = $events;
+ }
+ }
+
+ if ($this->shouldFilterByWindow) {
+ $this->reduceEventsToMinMaxRange();
+ }
+
+ $this->processDateConversions();
+ }
+
+ /**
+ * True if this resembles a calendar, i.e. we've seen the
+ * BEGIN:VCALENDAR line at some point.
+ *
+ * @return bool
+ */
+ public function isValid(): bool
+ {
+ return $this->hasSeenStart;
+ }
+
+ /**
+ * Process next completed line from file
+ *
+ * @param string $line
+ */
+ protected function handleLine(string $line)
+ {
+ $line = rtrim($line); // Trim trailing whitespace
+ $line = $this->removeUnprintableChars($line);
+
+ if (empty($line)) {
+ return;
+ }
+
+ $add = $this->keyValueFromString($line);
+
+ if ($add === null) {
+ return;
+ }
+
+ $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
+ $values[] = $blankArray;
+ } else {
+ $values = array(); // Use blank array to ignore this line
+ }
+ } elseif (empty($values[0])) {
+ $values = array(); // Use blank array to ignore this line
+ }
+
+ // Reverse so that our array of properties is processed first
+ $values = array_reverse($values);
+
+ foreach ($values as $value) {
+ switch ($line) {
+ // https://www.kanzaki.com/docs/ical/vtodo.html
+ case 'BEGIN:VTODO':
+ if (!is_array($value)) {
+ $this->todoCount++;
+ }
+
+ $this->parseStateComponent = 'VTODO';
+
+ break;
+
+ // https://www.kanzaki.com/docs/ical/vevent.html
+ case 'BEGIN:VEVENT':
+ if (!is_array($value)) {
+ $this->eventCount++;
+ }
+
+ $this->parseStateComponent = 'VEVENT';
+
+ break;
+
+ // https://www.kanzaki.com/docs/ical/vfreebusy.html
+ case 'BEGIN:VFREEBUSY':
+ if (!is_array($value)) {
+ $this->freeBusyIndex++;
+ }
+
+ $this->parseStateComponent = 'VFREEBUSY';
+
+ break;
+
+ case 'BEGIN:VALARM':
+ if (!is_array($value)) {
+ $this->alarmCount++;
+ }
+
+ $this->parseStateComponent = 'VALARM';
+
+ break;
+
+ case 'END:VALARM':
+ $this->parseStateComponent = 'VEVENT';
+
+ break;
+
+ case 'BEGIN:DAYLIGHT':
+ case 'BEGIN:STANDARD':
+ case 'BEGIN:VTIMEZONE':
+ $this->parseStateComponent = $value;
+
+ break;
+
+ case 'END:DAYLIGHT':
+ case 'END:STANDARD':
+ case 'END:VFREEBUSY':
+ case 'END:VTIMEZONE':
+ case 'END:VTODO':
+ $this->parseStateComponent = 'VCALENDAR';
+
+ break;
+
+ case 'BEGIN:VCALENDAR':
+ $this->hasSeenStart = true;
+ $this->parseStateComponent = $value;
+
+ break;
+
+ case 'END:VCALENDAR':
+ $this->parseStateComponent = '';
+
+ break;
+
+ case 'END:VEVENT':
+ if ($this->shouldFilterByWindow) {
+ $this->removeLastEventIfOutsideWindowAndNonRecurring();
+ }
+
+ $this->parseStateComponent = 'VCALENDAR';
+
+ break;
+
+ default:
+ if (!empty($this->parseStateComponent)) {
+ $this->addCalendarComponentWithKeyAndValue($this->parseStateComponent, $keyword, $value);
+ }
+ break;
+ }
+ }
+ }
+
+ /**
+ * Removes the last event (i.e. most recently parsed) if its start date is outside the window spanned by
+ * `$windowMinTimestamp` / `$windowMaxTimestamp`.
+ *
+ * @return void
+ */
+ protected function removeLastEventIfOutsideWindowAndNonRecurring()
+ {
+ $events = $this->cal['VEVENT'];
+
+ if (!empty($events)) {
+ $lastIndex = count($events) - 1;
+ $lastEvent = $events[$lastIndex];
+
+ if (empty($lastEvent['RRULE']) && $this->doesEventStartOutsideWindow($lastEvent)) {
+ $this->eventCount--;
+
+ unset($events[$lastIndex]);
+ }
+
+ $this->cal['VEVENT'] = $events;
+ }
+ }
+
+ /**
+ * Reduces the number of events to the defined minimum and maximum range
+ *
+ * @return void
+ */
+ protected function reduceEventsToMinMaxRange()
+ {
+ $events = (isset($this->cal['VEVENT'])) ? $this->cal['VEVENT'] : array();
+
+ if (!empty($events)) {
+ foreach ($events as $key => $anEvent) {
+ if ($anEvent === null) {
+ unset($events[$key]);
+ } elseif ($this->doesEventStartOutsideWindow($anEvent)) {
+ $this->eventCount--;
+ unset($events[$key]);
+ }
+ }
+
+ $this->cal['VEVENT'] = $events;
+ }
+ }
+
+ /**
+ * Determines whether the event start date is outside `$windowMinTimestamp` / `$windowMaxTimestamp`.
+ * Returns `true` for invalid dates.
+ *
+ * @param array $event
+ * @return boolean
+ */
+ protected function doesEventStartOutsideWindow(array $event): bool
+ {
+ return !isset($event['DTSTART']) || !$this->isValidDate($event['DTSTART'])
+ || $this->isOutOfRange($event['DTSTART'], $this->windowMinTimestamp, $this->windowMaxTimestamp);
+ }
+
+ /**
+ * Determines whether a valid iCalendar date is within a given range
+ *
+ * @param string $calendarDate
+ * @param integer $minTimestamp
+ * @param integer $maxTimestamp
+ * @return boolean
+ */
+ protected function isOutOfRange(string $calendarDate, int $minTimestamp, int $maxTimestamp): bool
+ {
+ $timestamp = strtotime(explode('T', $calendarDate)[0]);
+
+ return $timestamp < $minTimestamp || $timestamp > $maxTimestamp;
+ }
+
+ /**
+ * Add one key and value pair to the `$this->cal` array
+ *
+ * @param string $component
+ * @param string $keyword
+ * @param string|string[] $value
+ * @return void
+ */
+ protected function addCalendarComponentWithKeyAndValue(string $component, string $keyword, $value)
+ {
+ switch ($component) {
+ case 'VALARM':
+ $key1 = 'VEVENT';
+ $key2 = ($this->eventCount - 1);
+ $key3 = $component;
+
+ if (!isset($this->cal[$key1][$key2][$key3]["{$keyword}_array"])) {
+ $this->cal[$key1][$key2][$key3]["{$keyword}_array"] = array();
+ }
+
+ if (is_array($value)) {
+ // Add array of properties to the end
+ $this->cal[$key1][$key2][$key3]["{$keyword}_array"][] = $value;
+ } else {
+ if (!isset($this->cal[$key1][$key2][$key3][$keyword])) {
+ $this->cal[$key1][$key2][$key3][$keyword] = $value;
+ }
+
+ if ($this->cal[$key1][$key2][$key3][$keyword] !== $value) {
+ $this->cal[$key1][$key2][$key3][$keyword] .= ',' . $value;
+ }
+ }
+ break;
+
+ case 'VEVENT':
+ $key1 = $component;
+ $key2 = ($this->eventCount - 1);
+
+ if (!isset($this->cal[$key1][$key2]["{$keyword}_array"])) {
+ $this->cal[$key1][$key2]["{$keyword}_array"] = array();
+ }
+
+ if (is_array($value)) {
+ // Add array of properties to the end
+ $this->cal[$key1][$key2]["{$keyword}_array"][] = $value;
+ } else {
+ if (!isset($this->cal[$key1][$key2][$keyword])) {
+ $this->cal[$key1][$key2][$keyword] = $value;
+ }
+
+ if ($keyword === 'EXDATE') {
+ if (trim($value) === $value) {
+ $array = array_filter(explode(',', $value));
+ $this->cal[$key1][$key2]["{$keyword}_array"][] = $array;
+ } else {
+ $value = explode(',', implode(',', $this->cal[$key1][$key2]["{$keyword}_array"][1]) . trim($value));
+ $this->cal[$key1][$key2]["{$keyword}_array"][1] = $value;
+ }
+ } else {
+ $this->cal[$key1][$key2]["{$keyword}_array"][] = $value;
+
+ if ($keyword === 'DURATION') {
+ try {
+ $duration = new DateInterval($value);
+ $this->cal[$key1][$key2]["{$keyword}_array"][] = $duration;
+ } catch (Exception $e) {
+ error_log('Ignoring invalid duration ' . $value);
+ }
+ }
+ }
+
+ if ($this->cal[$key1][$key2][$keyword] !== $value) {
+ $this->cal[$key1][$key2][$keyword] .= ',' . $value;
+ }
+ }
+ break;
+
+ case 'VFREEBUSY':
+ $key1 = $component;
+ $key2 = ($this->freeBusyIndex - 1);
+ $key3 = $keyword;
+
+ if ($keyword === 'FREEBUSY') {
+ if (is_array($value)) {
+ $this->cal[$key1][$key2][$key3][][] = $value;
+ } else {
+ $this->freeBusyCount++;
+
+ end($this->cal[$key1][$key2][$key3]);
+ $key = key($this->cal[$key1][$key2][$key3]);
+
+ $value = explode('/', $value);
+ $this->cal[$key1][$key2][$key3][$key][] = $value;
+ }
+ } else {
+ $this->cal[$key1][$key2][$key3][] = $value;
+ }
+ break;
+
+ case 'VTODO':
+ $this->cal[$component][$this->todoCount - 1][$keyword] = $value;
+
+ break;
+
+ default:
+ $this->cal[$component][$keyword] = $value;
+
+ break;
+ }
+
+ // Remove?
+ $this->lastKeyword = $keyword;
+ }
+
+ /**
+ * Gets the key value pair from an iCal string
+ *
+ * @param string $text
+ * @return ?array
+ */
+ protected function keyValueFromString(string $text): ?array
+ {
+ $text = htmlspecialchars($text, ENT_NOQUOTES, 'UTF-8');
+
+ $colon = strpos($text, ':');
+ $quote = strpos($text, '"');
+ if ($colon === false) {
+ $matches = array();
+ } elseif ($quote === false || $colon < $quote) {
+ list($before, $after) = explode(':', $text, 2);
+ $matches = array($text, $before, $after);
+ } else {
+ list($before, $text) = explode('"', $text, 2);
+ $text = '"' . $text;
+ $matches = str_getcsv($text, ':');
+ $combinedValue = '';
+
+ foreach (array_keys($matches) as $key) {
+ if ($key === 0) {
+ if (!empty($before)) {
+ $matches[$key] = $before . '"' . $matches[$key] . '"';
+ }
+ } else {
+ if ($key > 1) {
+ $combinedValue .= ':';
+ }
+
+ $combinedValue .= $matches[$key];
+ }
+ }
+
+ $matches = array_slice($matches, 0, 2);
+ $matches[1] = $combinedValue;
+ array_unshift($matches, $before . $text);
+ }
+
+ if (count($matches) === 0) {
+ return null;
+ }
+
+ 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)) {
+ // Remove first match
+ array_shift($properties);
+ // Fix to ignore everything in keyword after a ; (e.g. Language, TZID, etc.)
+ $matches[0] = $properties[0];
+ array_shift($properties); // Repeat removing first match
+
+ $formatted = array();
+ foreach ($properties as $property) {
+ // Match semicolon separator outside of quoted substrings
+ preg_match_all('~[^' . PHP_EOL . '";]+(?:"[^"\\\]*(?:\\\.[^"\\\]*)*"[^' . PHP_EOL . '";]*)*~', $property, $attributes);
+ // Remove multi-dimensional array and use the first key
+ $attributes = (count($attributes) === 0) ? array($property) : reset($attributes);
+
+ if (is_array($attributes)) {
+ foreach ($attributes as $attribute) {
+ // Match equals sign separator outside of quoted substrings
+ preg_match_all(
+ '~[^' . PHP_EOL . '"=]+(?:"[^"\\\]*(?:\\\.[^"\\\]*)*"[^' . PHP_EOL . '"=]*)*~',
+ $attribute,
+ $values
+ );
+ // Remove multi-dimensional array and use the first key
+ $value = (count($values) === 0) ? null : reset($values);
+
+ if (is_array($value) && isset($value[1])) {
+ // Remove double quotes from beginning and end only
+ $formatted[$value[0]] = trim($value[1], '"');
+ }
+ }
+ }
+ }
+
+ // Assign the keyword property information
+ $properties[0] = $formatted;
+
+ // Add match to beginning of array
+ array_unshift($properties, $matches[1]);
+ $matches[1] = $properties;
+ }
+
+ return $matches;
+ }
+ return null; // Ignore this match
+ }
+
+ /**
+ * Returns a `DateTime` object from an iCal date time format
+ *
+ * @param string $icalDate
+ * @return DateTime
+ */
+ public function iCalDateToDateTime(string $icalDate): DateTime
+ {
+ /**
+ * iCal times may be in 3 formats, (https://www.kanzaki.com/docs/ical/dateTime.html)
+ *
+ * UTC: Has a trailing 'Z'
+ * Floating: No time zone reference specified, no trailing 'Z', use local time
+ * TZID: Set time zone as specified
+ *
+ * Use DateTime class objects to get around limitations with `mktime` and `gmmktime`.
+ * Must have a local time zone set to process floating times.
+ */
+ $pattern = '/^(?:TZID=)?([^:]*|".*")'; // [1]: Time zone
+ $pattern .= ':?'; // Time zone delimiter
+ $pattern .= '([0-9]{8})'; // [2]: YYYYMMDD
+ $pattern .= 'T?'; // Time delimiter
+ $pattern .= '(?(?<=T)([0-9]{6}))'; // [3]: HHMMSS (filled if delimiter present)
+ $pattern .= '(Z?)/'; // [4]: UTC flag
+
+ preg_match($pattern, $icalDate, $date);
+
+ if (empty($date)) {
+ error_log('Invalid iCal date format: ' . $icalDate);
+ return new Datetime('1970-08-08'); // Return something far in the past
+ }
+
+ // A Unix timestamp usually cannot represent a date prior to 1 Jan 1970.
+ // PHP, on the other hand, uses negative numbers for that. Thus we don't
+ // need to special case them.
+
+ if ($date[4] === 'Z') {
+ $dateTimeZone = new DateTimeZone(self::TIME_ZONE_UTC);
+ } elseif (!empty($date[1])) {
+ $dateTimeZone = $this->timeZoneStringToDateTimeZone($date[1]);
+ } else {
+ $dateTimeZone = new DateTimeZone($this->defaultTimeZone);
+ }
+
+ // The exclamation mark at the start of the format string indicates that if a
+ // time portion is not included, the time in the returned DateTime should be
+ // set to 00:00:00. Without it, the time would be set to the current system time.
+ $dateFormat = '!Ymd';
+ $dateBasic = $date[2];
+ if (!empty($date[3])) {
+ $dateBasic .= "T{$date[3]}";
+ $dateFormat .= '\THis';
+ }
+
+ return DateTime::createFromFormat($dateFormat, $dateBasic, $dateTimeZone);
+ }
+
+ /**
+ * Returns a Unix timestamp from an iCal date time format
+ *
+ * @param string $icalDate
+ * @return integer
+ */
+ public function iCalDateToUnixTimestamp(string $icalDate): int
+ {
+ return $this->iCalDateToDateTime($icalDate)->getTimestamp();
+ }
+
+ /**
+ * Returns a date adapted to the calendar time zone depending on the event `TZID`
+ *
+ * @param array $event
+ * @param string $key
+ * @param string $format
+ * @return string|boolean
+ */
+ public function iCalDateWithTimeZone(array $event, string $key, string $format = self::DATE_TIME_FORMAT)
+ {
+ if (!isset($event["{$key}_array"]) || !isset($event[$key])) {
+ return false;
+ }
+
+ $dateArray = $event["{$key}_array"];
+
+ if ($key === 'DURATION') {
+ $dateTime = $this->parseDuration($event['DTSTART'], $dateArray[2], null);
+ } else {
+ // When constructing from a Unix Timestamp, no time zone needs passing.
+ $dateTime = new DateTime("@{$dateArray[2]}");
+ }
+
+ // Set the time zone we wish to use when running `$dateTime->format`.
+ $dateTime->setTimezone(new DateTimeZone($this->calendarTimeZone()));
+
+ if (is_null($format)) {
+ return $dateTime;
+ }
+
+ return $dateTime->format($format);
+ }
+
+ /**
+ * Performs admin tasks on all events as read from the iCal file.
+ * Adds a Unix timestamp to all `{DTSTART|DTEND|RECURRENCE-ID}_array` arrays
+ * Tracks modified recurrence instances
+ *
+ * @return void
+ */
+ protected function processEvents()
+ {
+ if (empty($this->cal['VEVENT']))
+ return;
+ $events =& $this->cal['VEVENT'];
+
+ foreach ($events as $key => $anEvent) {
+ foreach (array('DTSTART', 'DTEND', 'RECURRENCE-ID') as $type) {
+ if (isset($anEvent[$type])) {
+ $date = $anEvent["{$type}_array"][1];
+
+ if (isset($anEvent["{$type}_array"][0]['TZID'])) {
+ $timeZone = $this->escapeParamText($anEvent["{$type}_array"][0]['TZID']);
+ $date = sprintf(self::ICAL_DATE_TIME_TEMPLATE, $timeZone) . $date;
+ }
+
+ $anEvent["{$type}_array"][2] = $this->iCalDateToUnixTimestamp($date);
+ $anEvent["{$type}_array"][3] = $date;
+ }
+ }
+
+ if (isset($anEvent['RECURRENCE-ID'])) {
+ $uid = $anEvent['UID'];
+
+ if (!isset($this->alteredRecurrenceInstances[$uid])) {
+ $this->alteredRecurrenceInstances[$uid] = array();
+ }
+
+ $recurrenceDateUtc = $this->iCalDateToUnixTimestamp($anEvent['RECURRENCE-ID_array'][3]);
+ $this->alteredRecurrenceInstances[$uid][$key] = $recurrenceDateUtc;
+ }
+
+ $events[$key] = $anEvent;
+ }
+
+ $eventKeysToRemove = array();
+
+ foreach ($events as $key => $event) {
+ $checks = !isset($event['RECURRENCE-ID'])
+ && isset($event['UID']) && isset($this->alteredRecurrenceInstances[$event['UID']]);
+
+ 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($event, $events[$alteredEventKey]);
+ $this->alteredRecurrenceInstances[$event['UID']]['altered-event'] = array($key => $alteredEvent);
+ }
+ }
+ }
+
+ foreach ($eventKeysToRemove as $eventKeyToRemove) {
+ $events[$eventKeyToRemove] = null;
+ }
+ }
+
+ /**
+ * Processes recurrence rules
+ *
+ * @return void
+ */
+ protected function processRecurrences()
+ {
+ // If there are no events, then we have nothing to process.
+ if (empty($this->cal['VEVENT']))
+ return;
+ $events =& $this->cal['VEVENT'];
+
+ $allEventRecurrences = array();
+ $eventKeysToRemove = array();
+
+ foreach ($events as $key => $anEvent) {
+ if (!isset($anEvent['RRULE']) || $anEvent['RRULE'] === '') {
+ continue;
+ }
+
+ // Tag as generated by a recurrence rule
+ $anEvent['RRULE_array'][2] = self::RECURRENCE_EVENT;
+
+ // Create new initial starting point.
+ $initialEventDate = $this->icalDateToDateTime($anEvent['DTSTART_array'][3]);
+
+ // Separate the RRULE stanzas, and explode the values that are lists.
+ $rrules = array();
+ foreach (explode(';', $anEvent['RRULE']) as $s) {
+ list($k, $v) = explode('=', $s);
+ if (in_array($k, array('BYSETPOS', 'BYDAY', 'BYMONTHDAY', 'BYMONTH'))) {
+ $rrules[$k] = explode(',', $v);
+ } else {
+ $rrules[$k] = $v;
+ }
+ }
+
+ // Get frequency
+ $frequency = $rrules['FREQ'];
+
+ // Reject RRULE if BYDAY stanza is invalid:
+ // > The BYDAY rule part MUST NOT be specified with a numeric value
+ // > when the FREQ rule part is not set to MONTHLY or YEARLY.
+ if (isset($rrules['BYDAY']) && !in_array($frequency, array('MONTHLY', 'YEARLY'))) {
+ $allByDayStanzasValid = array_reduce($rrules['BYDAY'], function ($carry, $weekday) {
+ return $carry && substr($weekday, -2) === $weekday;
+ }, true);
+
+ if (!$allByDayStanzasValid) {
+ error_log("ICal::ProcessRecurrences: A \"{$frequency}\" RRULE should not contain BYDAY values with numeric prefixes");
+
+ continue;
+ }
+ }
+
+ // Get Interval
+ $interval = (empty($rrules['INTERVAL'])) ? 1 : $rrules['INTERVAL'];
+
+ // Throw an error if this isn't an integer.
+ if (!is_int($this->defaultSpan)) {
+ trigger_error('ICal::defaultSpan: User defined value is not an integer', E_USER_NOTICE);
+ }
+
+ // Compute EXDATEs
+ $exdates = $this->parseExdates($anEvent);
+
+ // Determine if the initial date is also an EXDATE
+ $initialDateIsExdate = array_reduce($exdates, function ($carry, $exdate) use ($initialEventDate) {
+ return $carry || $exdate->getTimestamp() === $initialEventDate->getTimestamp();
+ }, false);
+
+ if ($initialDateIsExdate) {
+ $eventKeysToRemove[] = $key;
+ }
+
+ /**
+ * Determine at what point we should stop calculating recurrences
+ * by looking at the UNTIL or COUNT rrule stanza, or, if neither
+ * if set, using a fallback.
+ *
+ * If the initial date is also an EXDATE, it shouldn't be included
+ * in the count.
+ *
+ * Syntax:
+ * UNTIL={enddate}
+ * COUNT=<positive integer>
+ *
+ * Where:
+ * enddate = <icalDate> || <icalDateTime>
+ */
+ $count = 1;
+ $countLimit = (isset($rrules['COUNT'])) ? intval($rrules['COUNT']) : 0;
+ $until = date_create()->modify("{$this->defaultSpan} years")->setTime(23, 59, 59)->getTimestamp();
+
+ if (isset($rrules['UNTIL'])) {
+ $until = min($until, $this->iCalDateToUnixTimestamp($rrules['UNTIL']));
+ }
+ $until = min($until, $this->windowMaxTimestamp);
+
+ $eventRecurrences = array();
+
+ $frequencyRecurringDateTime = clone $initialEventDate;
+ while ($frequencyRecurringDateTime->getTimestamp() <= $until) {
+ $candidateDateTimes = array();
+
+ // phpcs:ignore Squiz.ControlStructures.SwitchDeclaration.MissingDefault
+ switch ($frequency) {
+ case 'DAILY':
+ $candidateDateTimes[] = clone $frequencyRecurringDateTime;
+
+ break;
+
+ case 'WEEKLY':
+ $initialDayOfWeek = $frequencyRecurringDateTime->format('N');
+ $matchingDays = array($initialDayOfWeek);
+
+ if (!empty($rrules['BYDAY'])) {
+ // setISODate() below uses the ISO-8601 specification of weeks: start on
+ // a Monday, end on a Sunday. However, RRULEs (or the caller of the
+ // parser) may state an alternate WeeKSTart.
+ $wkstTransition = 7;
+
+ if (empty($rrules['WKST'])) {
+ if ($this->defaultWeekStart !== self::ISO_8601_WEEK_START) {
+ $wkstTransition = array_search($this->defaultWeekStart, array_keys($this->weekdays));
+ }
+ } elseif ($rrules['WKST'] !== self::ISO_8601_WEEK_START) {
+ $wkstTransition = array_search($rrules['WKST'], array_keys($this->weekdays));
+ }
+
+ $matchingDays = array_map(
+ function ($weekday) use ($initialDayOfWeek, $wkstTransition, $interval) {
+ $day = array_search($weekday, array_keys($this->weekdays));
+
+ if ($day < $initialDayOfWeek) {
+ $day += 7;
+ }
+
+ if ($day >= $wkstTransition) {
+ $day += 7 * ($interval - 1);
+ }
+
+ // Ignoring alternate week starts, $day at this point will have a
+ // value between 0 and 6. But setISODate() expects a value of 1 to 7.
+ // Even with alternate week starts, we still need to +1 to set the
+ // correct weekday.
+ $day++;
+
+ return $day;
+ },
+ $rrules['BYDAY']
+ );
+ }
+
+ sort($matchingDays);
+
+ foreach ($matchingDays as $day) {
+ $clonedDateTime = clone $frequencyRecurringDateTime;
+ $candidateDateTimes[] = $clonedDateTime->setISODate(
+ $frequencyRecurringDateTime->format('o'),
+ $frequencyRecurringDateTime->format('W'),
+ $day
+ );
+ }
+ break;
+
+ case 'MONTHLY':
+ $matchingDays = array();
+
+ if (!empty($rrules['BYMONTHDAY'])) {
+ $matchingDays = $rrules['BYMONTHDAY'];
+ } elseif (!empty($rrules['BYDAY'])) {
+ $matchingDays = $this->getDaysOfMonthMatchingByDayRRule($rrules['BYDAY'], $frequencyRecurringDateTime);
+ }
+
+ if (!empty($rrules['BYSETPOS'])) {
+ $matchingDays = $this->filterValuesUsingBySetPosRRule($rrules['BYSETPOS'], $matchingDays);
+ }
+
+ foreach ($matchingDays as $day) {
+ // Skip invalid dates (e.g. 30th February)
+ if ($day > $frequencyRecurringDateTime->format('t')) {
+ continue;
+ }
+
+ $clonedDateTime = clone $frequencyRecurringDateTime;
+ $candidateDateTimes[] = $clonedDateTime->setDate(
+ $frequencyRecurringDateTime->format('Y'),
+ $frequencyRecurringDateTime->format('m'),
+ $day
+ );
+ }
+ break;
+
+ case 'YEARLY':
+ if (!empty($rrules['BYMONTH'])) {
+ foreach ($rrules['BYMONTH'] as $byMonth) {
+ $clonedDateTime = clone $frequencyRecurringDateTime;
+ $bymonthRecurringDatetime = $clonedDateTime->setDate(
+ $frequencyRecurringDateTime->format('Y'),
+ $byMonth,
+ $frequencyRecurringDateTime->format('d')
+ );
+
+ if (!empty($rrules['BYDAY'])) {
+ // Get all days of the month that match the BYDAY rule.
+ $matchingDays = $this->getDaysOfMonthMatchingByDayRRule($rrules['BYDAY'], $bymonthRecurringDatetime);
+
+ // And add each of them to the list of recurrences
+ foreach ($matchingDays as $day) {
+ $clonedDateTime = clone $bymonthRecurringDatetime;
+ $candidateDateTimes[] = $clonedDateTime->setDate(
+ $frequencyRecurringDateTime->format('Y'),
+ $bymonthRecurringDatetime->format('m'),
+ $day
+ );
+ }
+ } else {
+ $candidateDateTimes[] = clone $bymonthRecurringDatetime;
+ }
+ }
+ } else {
+ $candidateDateTimes[] = clone $frequencyRecurringDateTime;
+ }
+ break;
+ }
+
+ foreach ($candidateDateTimes as $candidate) {
+ $timestamp = $candidate->getTimestamp();
+ if ($timestamp <= $initialEventDate->getTimestamp()) {
+ continue;
+ }
+
+ if ($timestamp > $until) {
+ break;
+ }
+
+ // Exclusions
+ $isExcluded = ($this->shouldFilterByWindow && $timestamp + 160000 < $this->windowMinTimestamp);
+
+ if (!$isExcluded) {
+ $isExcluded = array_filter($exdates, function ($exdate) use ($timestamp) {
+ return $exdate->getTimestamp() == $timestamp;
+ });
+ }
+
+ if (!$isExcluded && isset($this->alteredRecurrenceInstances[$anEvent['UID']])) {
+ if (in_array($timestamp, $this->alteredRecurrenceInstances[$anEvent['UID']])) {
+ $isExcluded = true;
+ }
+ }
+
+ if (!$isExcluded) {
+ $eventRecurrences[] = $candidate;
+ $this->eventCount++;
+ }
+
+ // Count all evaluated candidates including excluded ones
+ if (isset($rrules['COUNT'])) {
+ $count++;
+
+ // If RRULE[COUNT] is reached then break
+ if ($count >= $countLimit) {
+ break 2;
+ }
+ }
+ }
+
+ // Move forwards $interval $frequency.
+ $monthPreMove = $frequencyRecurringDateTime->format('m');
+ $frequencyRecurringDateTime->modify("{$interval} {$this->frequencyConversion[$frequency]}");
+
+ // As noted in Example #2 on https://www.php.net/manual/en/datetime.modify.php,
+ // there are some occasions where adding months doesn't give the month you might
+ // expect. For instance: January 31st + 1 month == March 3rd (March 2nd on a leap
+ // year.) The following code crudely rectifies this.
+ if ($frequency === 'MONTHLY') {
+ $monthDiff = $frequencyRecurringDateTime->format('m') - $monthPreMove;
+
+ if (($monthDiff > 0 && $monthDiff > $interval) || ($monthDiff < 0 && $monthDiff > $interval - 12)) {
+ $frequencyRecurringDateTime->modify('-1 month');
+ }
+ }
+ }
+
+ // Determine event length
+ $eventLength = 0;
+ if (isset($anEvent['DURATION'])) {
+ $clonedDateTime = clone $initialEventDate;
+ $endDate = $clonedDateTime->add($anEvent['DURATION_array'][2]);
+ $eventLength = $endDate->getTimestamp() - $anEvent['DTSTART_array'][2];
+ } elseif (isset($anEvent['DTEND_array'])) {
+ $eventLength = $anEvent['DTEND_array'][2] - $anEvent['DTSTART_array'][2];
+ }
+
+ // Whether or not the initial date was UTC
+ $initialDateWasUTC = substr($anEvent['DTSTART'], -1) === 'Z';
+
+ // Build the param array
+ $dateParamArray = array();
+ if (
+ !$initialDateWasUTC
+ && isset($anEvent['DTSTART_array'][0]['TZID'])
+ && $this->isValidTimeZoneId($anEvent['DTSTART_array'][0]['TZID'])
+ ) {
+ $dateParamArray['TZID'] = $anEvent['DTSTART_array'][0]['TZID'];
+ }
+
+ // Populate the `DT{START|END}[_array]`s
+ $eventRecurrences = array_map(
+ function ($recurringDatetime) use ($anEvent, $eventLength, $initialDateWasUTC, $dateParamArray) {
+ $tzidPrefix = (isset($dateParamArray['TZID'])) ? 'TZID=' . $this->escapeParamText($dateParamArray['TZID']) . ':' : '';
+
+ foreach (array('DTSTART', 'DTEND') as $dtkey) {
+ $anEvent[$dtkey] = $recurringDatetime->format(self::DATE_TIME_FORMAT) . (($initialDateWasUTC) ? 'Z' : '');
+
+ $anEvent["{$dtkey}_array"] = array(
+ $dateParamArray, // [0] Array of params (incl. TZID)
+ $anEvent[$dtkey], // [1] ICalDateTime string w/o TZID
+ $recurringDatetime->getTimestamp(), // [2] Unix Timestamp
+ "{$tzidPrefix}{$anEvent[$dtkey]}", // [3] Full ICalDateTime string
+ );
+
+ if ($dtkey !== 'DTEND') {
+ $recurringDatetime->modify("{$eventLength} seconds");
+ }
+ }
+
+ return $anEvent;
+ },
+ $eventRecurrences
+ );
+
+ $allEventRecurrences = array_merge($allEventRecurrences, $eventRecurrences);
+ }
+
+ // Nullify the initial events that are also EXDATEs
+ foreach ($eventKeysToRemove as $eventKeyToRemove) {
+ $events[$eventKeyToRemove] = null;
+ }
+
+ $events = array_merge($events, $allEventRecurrences);
+ }
+
+ /**
+ * Find all days of a month that match the BYDAY stanza of an RRULE.
+ *
+ * With no {ordwk}, then return the day number of every {weekday}
+ * within the month.
+ *
+ * With a +ve {ordwk}, then return the {ordwk} {weekday} within the
+ * month.
+ *
+ * With a -ve {ordwk}, then return the {ordwk}-to-last {weekday}
+ * within the month.
+ *
+ * RRule Syntax:
+ * BYDAY={bywdaylist}
+ *
+ * Where:
+ * bywdaylist = {weekdaynum}[,{weekdaynum}...]
+ * weekdaynum = [[+]{ordwk} || -{ordwk}]{weekday}
+ * ordwk = 1 to 53
+ * weekday = SU || MO || TU || WE || TH || FR || SA
+ *
+ * @param array $byDays
+ * @param DateTime $initialDateTime
+ * @return array
+ */
+ protected function getDaysOfMonthMatchingByDayRRule(array $byDays, DateTime $initialDateTime): array
+ {
+ $matchingDays = array();
+
+ foreach ($byDays as $weekday) {
+ $bydayDateTime = clone $initialDateTime;
+
+ $ordwk = intval(substr($weekday, 0, -2));
+
+ // Quantise the date to the first instance of the requested day in a month
+ // (Or last if we have a -ve {ordwk})
+ $bydayDateTime->modify(
+ (($ordwk < 0) ? 'Last' : 'First')
+ . ' '
+ . $this->weekdays[substr($weekday, -2)] // e.g. "Monday"
+ . ' of ' . $initialDateTime->format('F') // e.g. "June"
+ );
+
+ if ($ordwk < 0) { // -ve {ordwk}
+ $bydayDateTime->modify((++$ordwk) . ' week');
+ $matchingDays[] = $bydayDateTime->format('j');
+ } elseif ($ordwk > 0) { // +ve {ordwk}
+ $bydayDateTime->modify((--$ordwk) . ' week');
+ $matchingDays[] = $bydayDateTime->format('j');
+ } else { // No {ordwk}
+ while ($bydayDateTime->format('n') === $initialDateTime->format('n')) {
+ $matchingDays[] = $bydayDateTime->format('j');
+ $bydayDateTime->modify('+1 week');
+ }
+ }
+ }
+
+ // Sort into ascending order.
+ sort($matchingDays);
+
+ return $matchingDays;
+ }
+
+ /**
+ * Filters a provided values-list by applying a BYSETPOS RRule.
+ *
+ * Where a +ve {daynum} is provided, the {ordday} position'd value as
+ * measured from the start of the list of values should be retained.
+ *
+ * Where a -ve {daynum} is provided, the {ordday} position'd value as
+ * measured from the end of the list of values should be retained.
+ *
+ * RRule Syntax:
+ * BYSETPOS={bysplist}
+ *
+ * Where:
+ * bysplist = {setposday}[,{setposday}...]
+ * setposday = {daynum}
+ * daynum = [+ || -] {ordday}
+ * ordday = 1 to 366
+ *
+ * @param array $bySetPos
+ * @param array $valuesList
+ * @return array
+ */
+ protected function filterValuesUsingBySetPosRRule(array $bySetPos, array $valuesList): array
+ {
+ $filteredMatches = array();
+
+ foreach ($bySetPos as $setPosition) {
+ if ($setPosition < 0) {
+ $setPosition = count($valuesList) + ++$setPosition;
+ }
+
+ // Positioning starts at 1, array indexes start at 0
+ if (isset($valuesList[$setPosition - 1])) {
+ $filteredMatches[] = $valuesList[$setPosition - 1];
+ }
+ }
+
+ return $filteredMatches;
+ }
+
+ /**
+ * Processes date conversions using the time zone
+ *
+ * Add keys `DTSTART_tz` and `DTEND_tz` to each Event
+ * These keys contain dates adapted to the calendar
+ * time zone depending on the event `TZID`.
+ *
+ * @return void
+ */
+ protected function processDateConversions()
+ {
+ if (empty($this->cal['VEVENT']))
+ return;
+
+ $events =& $this->cal['VEVENT'];
+ foreach ($events as $key => $anEvent) {
+ if (is_null($anEvent) || !$this->isValidDate($anEvent['DTSTART'])) {
+ unset($events[$key]);
+ $this->eventCount--;
+
+ continue;
+ }
+
+ $events[$key]['DTSTART_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DTSTART');
+
+ if ($this->iCalDateWithTimeZone($anEvent, 'DTEND')) {
+ $events[$key]['DTEND_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DTEND');
+ } elseif ($this->iCalDateWithTimeZone($anEvent, 'DURATION')) {
+ $events[$key]['DTEND_tz'] = $this->iCalDateWithTimeZone($anEvent, 'DURATION');
+ } else {
+ $events[$key]['DTEND_tz'] = $events[$key]['DTSTART_tz'];
+ }
+ }
+ }
+
+ /**
+ * Returns an array of Events.
+ * Every event is a class with the event
+ * details being properties within it.
+ *
+ * @return ICalEvent[]
+ */
+ public function events(): array
+ {
+ if (empty($this->cal) || empty($this->cal['VEVENT']))
+ return [];
+
+ $events = array();
+ foreach ($this->cal['VEVENT'] as $event) {
+ $events[] = new ICalEvent($event);
+ }
+
+ return $events;
+ }
+
+ /**
+ * Returns the calendar name
+ *
+ * @return string
+ */
+ public function calendarName(): string
+ {
+ return $this->cal['VCALENDAR']['X-WR-CALNAME'] ?? '';
+ }
+
+ /**
+ * Returns the calendar description
+ *
+ * @return string
+ */
+ public function calendarDescription(): string
+ {
+ return $this->cal['VCALENDAR']['X-WR-CALDESC'] ?? '';
+ }
+
+ /**
+ * Returns the calendar time zone
+ *
+ * @param boolean $ignoreUtc
+ * @return string
+ */
+ public function calendarTimeZone(bool $ignoreUtc = false): ?string
+ {
+ if (isset($this->cal['VCALENDAR']['X-WR-TIMEZONE'])) {
+ $timeZone = $this->cal['VCALENDAR']['X-WR-TIMEZONE'];
+ } elseif (isset($this->cal['VTIMEZONE']['TZID'])) {
+ $timeZone = $this->cal['VTIMEZONE']['TZID'];
+ } else {
+ $timeZone = $this->defaultTimeZone;
+ }
+
+ // Validate the time zone, falling back to the time zone set in the PHP environment.
+ $timeZone = $this->timeZoneStringToDateTimeZone($timeZone)->getName();
+
+ if ($ignoreUtc && strtoupper($timeZone) === self::TIME_ZONE_UTC) {
+ return null;
+ }
+
+ return $timeZone;
+ }
+
+ /**
+ * Returns an array of arrays with all free/busy events.
+ * Every event is an associative array and each property
+ * is an element it.
+ *
+ * @return array
+ */
+ public function freeBusyEvents(): array
+ {
+ $array = $this->cal;
+
+ return $array['VFREEBUSY'] ?? array();
+ }
+
+ /**
+ * Returns a sorted array of the events in a given range,
+ * or an empty array if no events exist in the range.
+ *
+ * Events will be returned if the start or end date is contained within the
+ * range (inclusive), or if the event starts before and end after the range.
+ *
+ * If a start date is not specified or of a valid format, then the start
+ * of the range will default to the current time and date of the server.
+ *
+ * If an end date is not specified or of a valid format, then the end of
+ * the range will default to the current time and date of the server,
+ * plus 20 years.
+ *
+ * Note that this function makes use of Unix timestamps. This might be a
+ * problem for events on, during, or after 29 Jan 2038.
+ * See https://en.wikipedia.org/wiki/Unix_time#Representing_the_number
+ *
+ * @param string|null $rangeStart
+ * @param string|null $rangeEnd
+ * @return array
+ * @throws Exception
+ */
+ public function eventsFromRange(string $rangeStart = null, string $rangeEnd = null): array
+ {
+ // Sort events before processing range
+ $events = $this->sortEventsWithOrder($this->events());
+
+ if (empty($events)) {
+ return array();
+ }
+
+ $extendedEvents = array();
+
+ if (!is_null($rangeStart)) {
+ try {
+ $rangeStart = new DateTime($rangeStart, new DateTimeZone($this->defaultTimeZone));
+ } catch (Exception $exception) {
+ error_log("ICal::eventsFromRange: Invalid date passed ({$rangeStart})");
+ $rangeStart = false;
+ }
+ } else {
+ $rangeStart = new DateTime('now', new DateTimeZone($this->defaultTimeZone));
+ }
+
+ if (!is_null($rangeEnd)) {
+ try {
+ $rangeEnd = new DateTime($rangeEnd, new DateTimeZone($this->defaultTimeZone));
+ } catch (Exception $exception) {
+ error_log("ICal::eventsFromRange: Invalid date passed ({$rangeEnd})");
+ $rangeEnd = false;
+ }
+ } else {
+ $rangeEnd = new DateTime('now', new DateTimeZone($this->defaultTimeZone));
+ $rangeEnd->modify('+20 years');
+ }
+
+ // If start and end are identical and are dates with no times...
+ if ($rangeEnd->format('His') == 0 && $rangeStart->getTimestamp() === $rangeEnd->getTimestamp()) {
+ $rangeEnd->modify('+1 day');
+ }
+
+ $rangeStart = $rangeStart->getTimestamp();
+ $rangeEnd = $rangeEnd->getTimestamp();
+
+ foreach ($events as $anEvent) {
+ $eventStart = $anEvent->dtstart_array[2];
+ $eventEnd = (isset($anEvent->dtend_array[2])) ? $anEvent->dtend_array[2] : null;
+
+ if (
+ ($eventStart >= $rangeStart && $eventStart < $rangeEnd) // Event start date contained in the range
+ || ($eventEnd !== null
+ && (
+ ($eventEnd > $rangeStart && $eventEnd <= $rangeEnd) // Event end date contained in the range
+ || ($eventStart < $rangeStart && $eventEnd > $rangeEnd) // Event starts before and finishes after range
+ )
+ )
+ ) {
+ $extendedEvents[] = $anEvent;
+ }
+ }
+
+ if (empty($extendedEvents)) {
+ return array();
+ }
+
+ return $extendedEvents;
+ }
+
+ /**
+ * Sorts events based on a given sort order
+ *
+ * @param array $events
+ * @param integer $sortOrder Either SORT_ASC, SORT_DESC, SORT_REGULAR, SORT_NUMERIC, SORT_STRING
+ * @return array
+ */
+ public function sortEventsWithOrder(array $events, int $sortOrder = SORT_ASC): array
+ {
+ $extendedEvents = array();
+ $timestamp = array();
+
+ foreach ($events as $key => $anEvent) {
+ $extendedEvents[] = $anEvent;
+ $timestamp[$key] = $anEvent->dtstart_array[2];
+ }
+
+ array_multisort($timestamp, $sortOrder, $extendedEvents);
+
+ return $extendedEvents;
+ }
+
+ /**
+ * Checks if a time zone is valid (IANA, CLDR, or Windows)
+ *
+ * @param string $timeZone
+ * @return boolean
+ */
+ protected function isValidTimeZoneId(string $timeZone): bool
+ {
+ return $this->isValidIanaTimeZoneId($timeZone) !== false
+ || $this->isValidCldrTimeZoneId($timeZone) !== false
+ || $this->isValidWindowsTimeZoneId($timeZone) !== false;
+ }
+
+ /**
+ * Checks if a time zone is a valid IANA time zone
+ *
+ * @param string $timeZone
+ * @return boolean
+ */
+ protected function isValidIanaTimeZoneId(string $timeZone): bool
+ {
+ if (in_array($timeZone, $this->validIanaTimeZones)) {
+ return true;
+ }
+
+ $valid = array();
+ $tza = timezone_abbreviations_list();
+
+ foreach ($tza as $zone) {
+ foreach ($zone as $item) {
+ $valid[$item['timezone_id']] = true;
+ }
+ }
+
+ unset($valid['']);
+
+ if (isset($valid[$timeZone]) || in_array($timeZone, timezone_identifiers_list(DateTimeZone::ALL_WITH_BC))) {
+ $this->validIanaTimeZones[] = $timeZone;
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Checks if a time zone is a valid CLDR time zone
+ *
+ * @param string $timeZone
+ * @return boolean
+ */
+ public function isValidCldrTimeZoneId(string $timeZone): bool
+ {
+ return array_key_exists(html_entity_decode($timeZone), self::$cldrTimeZonesMap);
+ }
+
+ /**
+ * Checks if a time zone is a recognised Windows (non-CLDR) time zone
+ *
+ * @param string $timeZone
+ * @return boolean
+ */
+ public function isValidWindowsTimeZoneId(string $timeZone): bool
+ {
+ return array_key_exists(html_entity_decode($timeZone), self::$windowsTimeZonesMap);
+ }
+
+ /**
+ * Parses a duration and applies it to a date
+ *
+ * @return integer|DateTime
+ */
+ protected function parseDuration(string $date, DateInterval $duration, ?string $format = self::UNIX_FORMAT)
+ {
+ $dateTime = date_create($date);
+ $dateTime->modify("{$duration->y} year");
+ $dateTime->modify("{$duration->m} month");
+ $dateTime->modify("{$duration->d} day");
+ $dateTime->modify("{$duration->h} hour");
+ $dateTime->modify("{$duration->i} minute");
+ $dateTime->modify("{$duration->s} second");
+
+ if (is_null($format)) {
+ $output = $dateTime;
+ } elseif ($format === self::UNIX_FORMAT) {
+ $output = $dateTime->getTimestamp();
+ } else {
+ $output = $dateTime->format($format);
+ }
+
+ return $output;
+ }
+
+ /**
+ * Removes unprintable ASCII and UTF-8 characters
+ *
+ * @param string $data
+ * @return string
+ */
+ protected function removeUnprintableChars(string $data): string
+ {
+ return preg_replace('/[\x00-\x1F\x7F\xA0]/u', '', $data);
+ }
+
+ /**
+ * Places double-quotes around texts that have characters not permitted
+ * in parameter-texts, but are permitted in quoted-texts.
+ *
+ * @param string $candidateText
+ * @return string
+ */
+ protected function escapeParamText(string $candidateText): string
+ {
+ if (strpbrk($candidateText, ':;,') !== false) {
+ return '"' . $candidateText . '"';
+ }
+
+ return $candidateText;
+ }
+
+ /**
+ * Parses a list of excluded dates
+ * to be applied to an Event
+ *
+ * @param array $event
+ * @return array
+ */
+ public function parseExdates(array $event): array
+ {
+ if (empty($event['EXDATE_array'])) {
+ return array();
+ }
+ $exdates = $event['EXDATE_array'];
+
+ $output = array();
+ $currentTimeZone = $this->defaultTimeZone;
+
+ foreach ($exdates as $subArray) {
+ end($subArray);
+ $finalKey = key($subArray);
+
+ foreach (array_keys($subArray) as $key) {
+ if ($key === 'TZID') {
+ $currentTimeZone = $subArray[$key];
+ } elseif (is_numeric($key)) {
+ $icalDate = $subArray[$key];
+
+ if (substr($icalDate, -1) === 'Z') {
+ $currentTimeZone = self::TIME_ZONE_UTC;
+ }
+
+ $output[] = new DateTimeImmutable($icalDate, $this->timeZoneStringToDateTimeZone($currentTimeZone));
+
+ if ($key === $finalKey) {
+ // Reset to default
+ $currentTimeZone = $this->defaultTimeZone;
+ }
+ }
+ }
+ }
+
+ return $output;
+ }
+
+ /**
+ * Checks if a date string is a valid date
+ *
+ * @param string $value
+ * @return boolean
+ */
+ public function isValidDate(string $value): bool
+ {
+ if (!$value) {
+ return false;
+ }
+
+ try {
+ new DateTime($value);
+
+ return true;
+ } catch (Exception $exception) {
+ return false;
+ }
+ }
+
+ /**
+ * 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 DateTimeZone|string $timeZoneString
+ * @return DateTimeZone
+ */
+ public function timeZoneStringToDateTimeZone($timeZoneString): DateTimeZone
+ {
+ if ($timeZoneString instanceof DateTimeZone)
+ return $timeZoneString;
+ // Some time zones contain characters that are not permitted in param-texts,
+ // but are within quoted texts. We need to remove the quotes as they're not
+ // actually part of the time zone.
+ $timeZoneString = trim($timeZoneString, '"');
+ $timeZoneString = html_entity_decode($timeZoneString);
+
+ if ($this->isValidIanaTimeZoneId($timeZoneString)) {
+ return new DateTimeZone($timeZoneString);
+ }
+
+ if ($this->isValidCldrTimeZoneId($timeZoneString)) {
+ return new DateTimeZone(self::$cldrTimeZonesMap[$timeZoneString]);
+ }
+
+ if ($this->isValidWindowsTimeZoneId($timeZoneString)) {
+ return new DateTimeZone(self::$windowsTimeZonesMap[$timeZoneString]);
+ }
+
+ return new DateTimeZone($this->defaultTimeZone);
+ }
+}
diff --git a/modules-available/locationinfo/inc/infopanel.inc.php b/modules-available/locationinfo/inc/infopanel.inc.php
index 7b0c6fe0..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']);
@@ -65,7 +65,7 @@ class InfoPanel
$config['locations'][$lid]['config'] = $overrides[$lid];
}
}
- self::appendMachineData($config['locations'], $lids, true);
+ self::appendMachineData($config['locations'], $lids, true, $config['hostname']);
}
self::appendOpeningTimes($config['locations'], $lids);
@@ -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)
+ 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')) {
@@ -101,13 +98,21 @@ class InfoPanel
$ignoreList = RunMode::getAllClients(false, false);
}
- $positionCol = $withPosition ? 'm.position,' : '';
- $query = "SELECT m.locationid, m.machineuuid, $positionCol m.logintime, m.lastseen, m.lastboot, m.state FROM machine m
+ $extraCols = '';
+ if ($withPosition) {
+ $extraCols .= 'm.position,';
+ }
+ if ($withHostname) {
+ $extraCols .= 'm.hostname,';
+ }
+ $query = "SELECT m.locationid, m.fixedlocationid, m.machineuuid, $extraCols m.logintime,
+ m.lastseen, m.lastboot, m.state, m.currentrunmode
+ FROM machine m
WHERE m.locationid IN (:idlist)";
$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');
@@ -119,7 +124,8 @@ class InfoPanel
}
// Compact the pc data in one array.
$pc = array('id' => $row['machineuuid']);
- if ($withPosition && !empty($row['position'])) {
+ if ($withPosition && $row['locationid'] == $row['fixedlocationid'] && !empty($row['position'])) {
+ // check fixed* == locationid to ignore stale position data in relocated clients
$position = json_decode($row['position'], true);
if (isset($position['gridCol']) && isset($position['gridRow'])) {
$pc['x'] = $position['gridCol'];
@@ -129,6 +135,19 @@ class InfoPanel
}
}
}
+ if ($withHostname) {
+ if (ip2long($row['hostname']) !== false) {
+ $pc['host'] = $row['hostname'];
+ } else {
+ $i = strpos($row['hostname'], '.');
+ if ($i === false) {
+ $pc['host'] = $row['hostname'];
+ } else {
+ $pc['host'] = substr($row['hostname'], 0, $i);
+ }
+ }
+ }
+ $pc['runmode'] = $row['currentrunmode'];
$pc['pcState'] = LocationInfo::getPcState($row);
//$pc['pcState'] = ['BROKEN', 'OFFLINE', 'IDLE', 'OCCUPIED', 'STANDBY'][mt_rand(0,4)]; // XXX
@@ -143,17 +162,17 @@ 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
$allIds = self::getLocationsWithParents($idList);
if (empty($allIds))
return;
- $res = Database::simpleQuery("SELECT locationid, openingtime FROM locationinfo_locationconfig
+ $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
@@ -185,7 +204,6 @@ class InfoPanel
$currentId = $locations[$currentId]['parentlocationid'];
}
}
- return;
}
@@ -196,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']);
}
}
@@ -212,14 +230,14 @@ class InfoPanel
/**
* Format the openingtime in the frontend needed format.
- * One key per week day, wich contains an array of {
+ * One key per week day, which contains an array of {
* 'HourOpen' => hh, 'MinutesOpen' => mm,
* '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 23a20a94..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,
@@ -105,6 +107,7 @@ class LocationInfo
'roomupdate' => 15,
'configupdate' => 180,
'overrides' => [],
+ 'hostname' => false,
);
}
if ($type === 'SUMMARY') {
@@ -117,58 +120,120 @@ class LocationInfo
}
if ($type === 'URL') {
return array(
- 'iswhitelist' => 0,
- 'urllist' => '',
+ 'whitelist' => '*',
+ 'blacklist' => '',
'insecure-ssl' => 0,
'reload-minutes' => 0,
+ 'split-login' => 0,
+ 'browser' => 'slx-browser',
+ 'interactive' => 0,
+ 'bookmarks' => '',
+ 'allow-tty' => '',
+ 'url' => '',
+ 'zoom-factor' => 100,
);
}
return array();
}
/**
- * @param string $uuid panel uuid
- * @return bool|string panel name if exists, false otherwise
+ * Gets the calendar of the given ids.
+ *
+ * @param int[] $idList list with the location ids.
+ * @return array Calendar.
*/
- public static function getPanelName($uuid)
+ public static function getCalendar(array $idList, bool $forceCached = false): array
{
- $ret = Database::queryFirst('SELECT panelname FROM locationinfo_panel WHERE paneluuid = :uuid', compact('uuid'));
- if ($ret === false) return false;
- return $ret['panelname'];
- }
+ if (empty($idList))
+ return [];
- /**
- * 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)
- {
- $type = InfoPanel::getConfig($panelUuid, $data);
- if ($type === false)
- 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.)
- if ($data['insecure-ssl'] !== 0) {
- ConfigHolder::add('SLX_BROWSER_INSECURE', '1');
+ $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),
+ ];
}
- if ($data['reload-minutes'] > 0) {
- ConfigHolder::add('SLX_BROWSER_RELOAD_SECS', $data['reload-minutes'] * 60);
+ 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()
+ );
}
- ConfigHolder::add('SLX_BROWSER_URL', $data['url']);
- ConfigHolder::add('SLX_BROWSER_URLLIST', $data['urllist']);
- ConfigHolder::add('SLX_BROWSER_IS_WHITELIST', $data['iswhitelist']);
- } 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
+ $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'];
}
- ConfigHolder::add('SLX_ADDONS', '', 1000);
- ConfigHolder::add('SLX_LOGOUT_TIMEOUT', '', 1000);
- ConfigHolder::add('SLX_SCREEN_STANDBY_TIMEOUT', '', 1000);
- ConfigHolder::add('SLX_SYSTEM_STANDBY_TIMEOUT', '', 1000);
- ConfigHolder::add('SLX_AUTOLOGIN', '1', 1000);
+ return '';
}
}
diff --git a/modules-available/locationinfo/inc/locationinfohooks.inc.php b/modules-available/locationinfo/inc/locationinfohooks.inc.php
new file mode 100644
index 00000000..8ec217cc
--- /dev/null
+++ b/modules-available/locationinfo/inc/locationinfohooks.inc.php
@@ -0,0 +1,99 @@
+<?php
+
+class LocationInfoHooks
+{
+
+ /**
+ * @param string $uuid panel uuid
+ * @return false|string panel name if exists, false otherwise
+ */
+ public static function getPanelName(string $uuid)
+ {
+ $ret = Database::queryFirst('SELECT panelname FROM locationinfo_panel WHERE paneluuid = :uuid', compact('uuid'));
+ if ($ret === false)
+ return false;
+ return $ret['panelname'];
+ }
+
+ /**
+ * Hook called by runmode module where we should modify the client config according to our
+ * needs. Disable standby/logout timeouts, enable autologin, set URL.
+ */
+ public static function configHook(string $machineUuid, string $panelUuid): void
+ {
+ $type = InfoPanel::getConfig($panelUuid, $data);
+ 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.)
+ if ($data['insecure-ssl'] !== 0) {
+ ConfigHolder::add('SLX_BROWSER_INSECURE', '1');
+ }
+ if ($data['reload-minutes'] > 0) {
+ ConfigHolder::add('SLX_BROWSER_RELOAD_SECS', $data['reload-minutes'] * 60);
+ }
+ ConfigHolder::add('SLX_BROWSER_URL', $data['url']);
+ // Mangle non-upgraded panels
+ if (empty($data['blacklist']) && $data['whitelist'] === '*' && !empty($data['urllist'])) {
+ if ($data['iswhitelist']) {
+ $data['whitelist'] = str_replace(' ', "\n", $data['urllist']);
+ } else {
+ $data['blacklist'] = str_replace(' ', "\n", $data['urllist']);
+ }
+ }
+ ConfigHolder::add('SLX_BROWSER_WHITELIST', self::mangleList($data['whitelist']));
+ ConfigHolder::add('SLX_BROWSER_BLACKLIST', self::mangleList($data['blacklist']));
+ // Additionally, update runmode "isclient" flag depending on whether split-login is allowed or not
+ if (isset($data['split-login']) && $data['split-login']) {
+ RunMode::updateClientFlag($machineUuid, 'locationinfo', true);
+ } else { // Automatic login
+ RunMode::updateClientFlag($machineUuid, 'locationinfo', false);
+ ConfigHolder::add('SLX_AUTOLOGIN', 'ON', 1000);
+ ConfigHolder::add('SLX_ADDONS', '', 1000);
+ }
+ if (!empty($data['browser'])) {
+ if ($data['browser'] === 'chromium') {
+ $browser = 'chromium chrome';
+ } else {
+ $browser = 'slxbrowser slx-browser';
+ $data['interactive'] = (isset($data['split-login']) && $data['split-login']);
+ }
+ ConfigHolder::add('SLX_BROWSER', $browser, 1000);
+ }
+ if (isset($data['interactive']) && $data['interactive']) {
+ ConfigHolder::add('SLX_BROWSER_INTERACTIVE', '1', 1000);
+ }
+ if (!empty($data['bookmarks'])) {
+ ConfigHolder::add('SLX_BROWSER_BOOKMARKS', $data['bookmarks'], 1000);
+ }
+ 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_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);
+ }
+
+ /**
+ * Turn multiline list into space separated list, removing any
+ * comments (starting with #)
+ */
+ private static function mangleList(string $list): string
+ {
+ return preg_replace('/\s*(#[^\n]*)?(\n|$)/', ' ', $list);
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/locationinfo/install.inc.php b/modules-available/locationinfo/install.inc.php
index 7e58315b..42bc8234 100644
--- a/modules-available/locationinfo/install.inc.php
+++ b/modules-available/locationinfo/install.inc.php
@@ -6,7 +6,6 @@ $t1 = $res[] = tableCreate('locationinfo_locationconfig', '
`locationid` INT(11) NOT NULL,
`serverid` INT(10) UNSIGNED,
`serverlocationid` VARCHAR(150),
- `openingtime` BLOB,
`calendar` BLOB,
`lastcalendarupdate` INT(10) UNSIGNED NOT NULL DEFAULT 0,
`lastuse` INT(10) UNSIGNED NOT NULL DEFAULT 0,
@@ -53,7 +52,7 @@ if ($t1 === UPDATE_NOOP) {
Database::exec('INSERT INTO locationinfo_panel (paneluuid, panelname, locationids, paneltype, panelconfig, lastchange)'
. " SELECT UUID(), Concat('Import: ', l.locationname), o.locationid, 'DEFAULT', o.config, 0 "
. " FROM locationinfo_locationconfig o INNER JOIN location l USING (locationid)"
- . ' WHERE Length(o.config) > 10 OR Length(o.openingtime) > 10');
+ . ' WHERE Length(o.config) > 10');
}
Database::exec("ALTER TABLE locationinfo_locationconfig CHANGE `serverid` `serverid` INT(10) UNSIGNED NULL");
tableDropColumn('locationinfo_locationconfig', 'hidden');
diff --git a/modules-available/locationinfo/lang/de/backend-hisinone.json b/modules-available/locationinfo/lang/de/backend-hisinone.json
index 6ea1a933..5a2ad69f 100644
--- a/modules-available/locationinfo/lang/de/backend-hisinone.json
+++ b/modules-available/locationinfo/lang/de/backend-hisinone.json
@@ -1,14 +1,6 @@
{
"baseUrl": "Basis-URL",
- "baseUrl_helptext": "URL zur HisInOne-Installation",
- "open": "Service",
- "open_helptext": "Legt den zu verwendenden Web Service fest. OpenCourseService bietet anonymisierte Belegungspl\u00e4ne und erfordert keine Authentifizierung und ist i.d.R. bevorzugt.",
- "password": "Passwort",
- "password_helptext": "Das Passwort, das in HisInOne verwendet wird.",
- "role": "Rolle",
- "role_helptext": "Die Rolle die der Nutzername in HisInOne verwendet.",
- "username": "Nutzername",
- "username_helptext": "Der Nutzername, der in HisInOne verwendet wird.",
+ "baseUrl_helptext": "URL zur HisInOne-Installation, bzw. die URL, um einen Raumbelegungsplan als iCal-Datei herunterzuladen, z.B. \"https:\/\/<hisinoneserver>\/qisserver\/pages\/cm\/exa\/timetable\/roomScheduleCalendarExport.faces?roomId=\"",
"verifyCert": "Zertifikat pr\u00fcfen",
"verifyCert_helptext": "Wenn das Zertifikat abgelaufen ist, oder von keiner bekannten CA ausgestellt wurde, wird die Verbindung abgelehnt.",
"verifyHostname": "Hostnamen pr\u00fcfen",
diff --git a/modules-available/locationinfo/lang/de/backend-ical.json b/modules-available/locationinfo/lang/de/backend-ical.json
new file mode 100644
index 00000000..9a91eb9f
--- /dev/null
+++ b/modules-available/locationinfo/lang/de/backend-ical.json
@@ -0,0 +1,16 @@
+{
+ "authMethod": "Authentifizierung",
+ "authMethod_helptext": "Falls eine Authentifizierung per HTTP-Header erforderlich ist, kann hier die gew\u00fcnschte Methode gew\u00e4hlt werden.",
+ "baseUrl": "Basis-URL",
+ "baseUrl_helptext": "URL zum iCal-File f\u00fcr diesen Raum. Ersetzen Sie den Part, der den Raum identifiziert durch %ID%, z.B. \"http:\/\/example.com\/calendars\/%ID%\". Die spezifische ID tragen Sie dann in der Raum\u00fcbersicht f\u00fcr jeden Raum individuell ein.",
+ "pass": "Passwort",
+ "pass_helptext": "Optional. Passwort f\u00fcr Authentifizierung.",
+ "testId": "Testraum",
+ "testId_helptext": "Optional. Tragen Sie hier eine g\u00fcltige Raum-ID f\u00fcr dieses Backend ein, um diese f\u00fcr Verbondungs-checks zu nutzen (Klick auf blauen \"Verbindung pr\u00fcfen\" Button in Backend-\u00dcbersicht).",
+ "user": "Nutzername f\u00fcr Authentifizierung",
+ "user_helptext": "Optional. Nutzername f\u00fcr Authentifizierung.",
+ "verifyCert": "Zertifikat pr\u00fcfen",
+ "verifyCert_helptext": "Wenn das Zertifikat abgelaufen ist, oder von keiner bekannten CA ausgestellt wurde, wird die Verbindung abgelehnt.",
+ "verifyHostname": "Hostnamen pr\u00fcfen",
+ "verifyHostname_helptext": "Der im Zertifikat angegebene Hostname muss mit dem Hostnamen aus der URL \u00fcbereinstimmen, sonst wird die Verbindung abgelehnt."
+} \ No newline at end of file
diff --git a/modules-available/locationinfo/lang/de/messages.json b/modules-available/locationinfo/lang/de/messages.json
index bcaf687d..fe024d44 100644
--- a/modules-available/locationinfo/lang/de/messages.json
+++ b/modules-available/locationinfo/lang/de/messages.json
@@ -1,9 +1,5 @@
{
"config-saved": "Einstellungen erfolgreich gespeichert.",
- "ignored-invalid-end": "Eintrag mit ung\u00fcltiger Endzeit ignoriert",
- "ignored-invalid-range": "Eintrag mit ung\u00fcltiger Range ignoriert",
- "ignored-invalid-start": "Eintrag mit ung\u00fcltiger Startzeit ignoriert",
- "ignored-line-no-days": "Eintrag ohne ausgew\u00e4hlte Tage ignoriert",
"invalid-backend-type": "Ung\u00fcltiger Backend-Typ '{{0}}'",
"invalid-panel-id": "Ung\u00fcltige Panel-ID '{{0}}'",
"invalid-panel-type": "Ung\u00fcltiger Panel-Typ '{{0}}'",
diff --git a/modules-available/locationinfo/lang/de/module.json b/modules-available/locationinfo/lang/de/module.json
index c344581c..a285351e 100644
--- a/modules-available/locationinfo/lang/de/module.json
+++ b/modules-available/locationinfo/lang/de/module.json
@@ -1,3 +1,11 @@
{
- "module_name": "Infoscreen"
+ "friday": "Freitag",
+ "module_name": "Infoscreen",
+ "monday": "Montag",
+ "page_title": "Inforscreens und Rechercheterminals",
+ "saturday": "Samstag",
+ "sunday": "Sonntag",
+ "thursday": "Donnerstag",
+ "tuesday": "Dienstag",
+ "wednesday": "Mittwoch"
} \ No newline at end of file
diff --git a/modules-available/locationinfo/lang/de/template-tags.json b/modules-available/locationinfo/lang/de/template-tags.json
index b63ffc5a..fe6a3e53 100644
--- a/modules-available/locationinfo/lang/de/template-tags.json
+++ b/modules-available/locationinfo/lang/de/template-tags.json
@@ -1,22 +1,28 @@
{
"lang_addServer": "Server",
+ "lang_allowTtySwitch": "Wechsel auf Textkonsole erlauben",
+ "lang_allowTtySwitchTooltip": "Legt fest, ob ein wechsel auf die Textkonsole mittels Strg-Alt-F1 erlaubt ist",
"lang_autoScale": "Auto Tage",
"lang_autoscaleTooltip": "Berechnet anhand der Bildschirmbreite die optimale Anzahl an Tagen, die der Kalender anzeigt",
"lang_backend": "Backend",
+ "lang_backendSettings": "Backend konfigurieren",
"lang_backends": "Backends",
- "lang_calendar": "Kalender",
+ "lang_blacklist": "Blacklist",
+ "lang_bookmarks": "Lesezeichen",
+ "lang_bookmarksTooltip": "F\u00fcge Lesezeichen hinzu, die der Browser erhalten soll",
+ "lang_browser": "Browser",
+ "lang_browserTooltip": "Welcher Browser soll genutzt werden",
"lang_calendarUpdate": "Kalender Update",
"lang_calupdateTooltip": "Zeit nachdem der Kalender aktualisiert wird (in Minuten)",
"lang_checkConnection": "Verbindung pr\u00fcfen",
+ "lang_chromium": "Chromium",
"lang_closed": "Geschlossen",
- "lang_closingTime": "Schlie\u00dfungszeit",
- "lang_configOverride": "Konfiguration überschreiben",
- "lang_countIp": "über IP-Adressbereich",
- "lang_countRoomplan": "über Raumplaner",
+ "lang_configOverride": "Konfiguration \u00fcberschreiben",
+ "lang_countIp": "\u00fcber IP-Adressbereich",
+ "lang_countRoomplan": "\u00fcber Raumplaner",
"lang_createPanel": "Panel anlegen",
"lang_credentials": "Anmeldung",
"lang_currentDay": "Aktueller Tag",
- "lang_day": "Tag",
"lang_daysToShow": "Tage",
"lang_daysToShowTooltip": "Legt die Anzahl an Tagen im Kalender fest, die angezeigt werden",
"lang_defaultPanel": "Standard-Panel",
@@ -25,22 +31,26 @@
"lang_displayName": "Name",
"lang_displayNameTooltip": "Anzeigename f\u00fcr dieses Panel",
"lang_ecoMode": "E-Ink Modus",
- "lang_ecoTooltip": "Anstelle der Farb-basierten PC-Status Bilder, werden Symbol-basierte PC Bilder verwendet",
+ "lang_ecoTooltip": "Niedrigere Aktualisierungsrate, Countdown ohne Sekunden",
"lang_editDefaultPanelHints": "Hier k\u00f6nnen Sie ein Panel (z.B. digitales T\u00fcrschild) in Aussehen und Funktionsweise definieren. Um im Kalender \u00d6ffnungszeiten anzeigen zu k\u00f6nnen, m\u00fcssen Sie im Tab \"Raum-\/Ortsbezogene Einstellungen\" f\u00fcr den ausgew\u00e4hlten Raum entsprechend \u00d6ffnungszeiten eintragen. Damit im Kalender Veranstaltungen und andere Termine angezeigt werden k\u00f6nnen, muss ein funktionierendes Backend konfiguriert und den ausgew\u00e4hlten R\u00e4umen zugewiesen worden sein.",
"lang_editPanel": "Panel bearbeiten",
"lang_editSummaryPanelHints": "Hier k\u00f6nnen Sie ein \u00dcbersichts-Panel definieren. Das Panel zeigt eine \u00dcbersicht der in den R\u00e4umen enthalten PCs.",
"lang_editUrlPanelHints": "Hier k\u00f6nnen Sie konfigurieren, welche URL das Panel aufrufen soll. Dies erm\u00f6glicht Ihnen z.B. in Eingangsbereichen aktuelle Meldungen der Hochschule oder sonstige Webseiten anzuzeigen.",
"lang_entryName": "Name",
"lang_error": "Fehler",
- "lang_expertMode": "Expertenmodus",
"lang_for": "f\u00fcr",
"lang_fourLocsHint": "Hier k\u00f6nnen Sie bis zu vier Orte ausw\u00e4hlen, die in diesem Panel angezeigt werden.",
"lang_free": "Ge\u00f6ffnet",
"lang_friday": "Freitag",
"lang_general": "Allgemein",
"lang_generalSettings": "Allgemeine Einstellungen",
+ "lang_goToLocation": "Gehe zu Raum",
+ "lang_goToLocationWarning": "Weiterleitung zum Raum-Modul",
+ "lang_hostnameTooltip": "Zeige kurzen Hostnamen in Rechner-Piktogramm",
"lang_ignoreSslTooltip": "Akzeptiere ung\u00fcltige, abgelaufene oder selbstsignierte SSL-Zertifikate",
"lang_insecureSsl": "Unsicheres SSL",
+ "lang_interactive": "Interaktiver Browser",
+ "lang_interactiveTooltip": "Volles UI anzeigen (tabs, bookmarks, ...)",
"lang_language": "Sprache",
"lang_languageTooltip": "Legt die Sprache der angezeigten Oberfl\u00e4che fest",
"lang_lastCalendarUpdate": "Kalender Update",
@@ -48,7 +58,7 @@
"lang_locationSettings": "Raum-\/Ortsbezogene Einstellungen",
"lang_locations": "Orte",
"lang_locationsTable": "R\u00e4ume \/ Orte",
- "lang_locationsTableHints": "Hier k\u00f6nnen Sie f\u00fcr die R\u00e4ume und Orte Ihrer Einrichtung \u00d6ffnungszeiten hinterlegen, sowie die Verkn\u00fcpfung mit Raum-IDs aus konfigurierten Backends (z.B. HISinOne) vornehmen, damit Belegungspl\u00e4ne abgerufen werden k\u00f6nnen.",
+ "lang_locationsTableHints": "Hier k\u00f6nnen Sie f\u00fcr die R\u00e4ume und Orte Ihrer Einrichtung die Verkn\u00fcpfung mit Raum-IDs aus konfigurierten Backends (z.B. HISinOne) vornehmen, damit Belegungspl\u00e4ne abgerufen werden k\u00f6nnen.",
"lang_locsHint": "Hier k\u00f6nnen Sie die Orte ausw\u00e4hlen, die in diesem Panel angezeigt werden.",
"lang_longFri": "Freitag",
"lang_longMon": "Montag",
@@ -65,7 +75,6 @@
"lang_mode4": "Wechselnd",
"lang_modeTooltip": "Die Anzeigemodi, welche das Frontend unterst\u00fctzt",
"lang_monday": "Montag",
- "lang_monTilFr": "Montag - Freitag",
"lang_nameTooltip": "Legt den Namen des Servers fest",
"lang_noLocationsWarning": "Bitte w\u00e4hlen Sie mindestens einen Ort aus, der vom Panel angezeigt werden soll.",
"lang_noServer": "<Kein Server>",
@@ -81,15 +90,15 @@
"lang_prettytimeTooltip": "Verwende ein anderes Anzeigeformat f\u00fcr die Uhrzeit",
"lang_recursiveServerSet": "Auch f\u00fcr alle untergeordneten R\u00e4ume setzen",
"lang_recursiveSetTooltip": "Wenn aktiviert, wird der Backend-Server auch f\u00fcr alle untergeordneten R\u00e4ume auf den hier gew\u00e4hlten Wert gesetzt",
- "lang_reloadIntervalMins": "Neuladen alle X Minuten",
- "lang_reloadIntervalTooltip": "Setzen Sie dieses Feld auf einen Wert > 0 (in Minuten), um die Seite auf den Clients regelm\u00e4\u00dfig neu zu laden. Feld auf 0 setzen oder leer lassen deaktiviert diese Funktion.",
+ "lang_reloadIntervalMins": "Neuladen der Startseite alle X Minuten (bei Inaktivit\u00e4t)",
+ "lang_reloadIntervalTooltip": "Setzen Sie dieses Feld auf einen Wert > 0 (in Minuten), um die Seite auf den Clients regelm\u00e4\u00dfig neu zu laden. Der Z\u00e4hler wird bei Nutzeraktivit\u00e4t (Maus\/Tastatur) zur\u00fcckgesetzt, sodass nicht mitten in einer Sitzung zur\u00fcck auf die Startseite navigiert wird. Feld auf 0 setzen oder leer lassen deaktiviert diese Funktion.",
"lang_remoteSchedule": "Abruf Belegungsplan",
"lang_room": "Raum",
"lang_roomId": "Raum ID",
"lang_roomIdTooltip": "Die Raum ID, die der Server ben\u00f6tigt, um Kalenderdaten abzurufen (bei Exchange die Postfachadresse)",
- "lang_roomplannerTooltip": "Legt fest, ob Rechner anhand der Zuordnung über IP oder über den Raumplan gezählt werden",
- "lang_roomupdateTooltip": "Zeit nach der die PCs aktualisiert werden (in Sekunden)",
"lang_roomUpdate": "Raum Update",
+ "lang_roomplannerTooltip": "Legt fest, ob Rechner anhand der Zuordnung \u00fcber IP oder \u00fcber den Raumplan gez\u00e4hlt werden",
+ "lang_roomupdateTooltip": "Zeit nach der die PCs aktualisiert werden (in Sekunden)",
"lang_rotation": "Rotation",
"lang_rotation0": "0\u00b0",
"lang_rotation1": "90\u00b0 \u27f2",
@@ -107,22 +116,19 @@
"lang_serverTooltip": "Legt fest, von welchem Backend-Server die Kalenderdaten bezogen werden",
"lang_serverType": "Typ",
"lang_shortFri": "Fr",
- "lang_shortFriday": "Fr",
"lang_shortMon": "Mo",
- "lang_shortMonday": "Mo",
"lang_shortSat": "Sa",
- "lang_shortSaturday": "Sa",
"lang_shortSun": "So",
- "lang_shortSunday": "So",
"lang_shortThu": "Do",
- "lang_shortThursday": "Do",
"lang_shortTue": "Di",
- "lang_shortTuesday": "Di",
"lang_shortWed": "Mi",
- "lang_shortWednesday": "Mi",
+ "lang_showHostname": "Hostname anzeigen",
"lang_showLog": "Log",
+ "lang_slxbrowser": "SLX Browser",
+ "lang_splitlogin": "Geteiltes Login",
+ "lang_splitloginTooltip": "Erlaube nur Gast-Login oder Gast+Nutzer-Login wenn aktiviert",
"lang_startDay": "Start Tag",
- "lang_startDayTooltip": "Der Wochentag an dem der Kalender anfängt",
+ "lang_startDayTooltip": "Der Wochentag an dem der Kalender anf\u00e4ngt",
"lang_summaryPanel": "\u00dcbersichts-Panel",
"lang_summaryUpdateIntervalTooltip": "Aktualisierungsintervall (Sekunden)",
"lang_sunday": "Sonntag",
@@ -134,14 +140,16 @@
"lang_typeTooltip": "Legt fest um welchen Server-Typ es sich handelt",
"lang_updateRates": "Aktualisierungsintervall",
"lang_url": "URL",
- "lang_urlBlacklist": "Blacklist",
- "lang_urlListHelp": "Sie k\u00f6nnen hier eine Liste von URLs oder Hostnamen angeben, die dann entweder als Whitelist oder Blacklist interpretiert wird. Unterst\u00fctzt werden die Sonderzeichen '?' (ein beliebiges Zeichen), '*' (beliebig viele Zeichen, au\u00dfer '\/') und '**' (beliebig viele Zeichen, inkl. '\/') als Platzhalter. Beispielangaben sind \"*.wikipedia.org\", \"https:\/\/www.bwlehrpool.de\/**\" oder \"*:\/\/*.uni-freiburg.de\/*.html\".",
+ "lang_urlListHelp": "Sie k\u00f6nnen hier Listen von URLs oder Hostnamen angeben, die dann als Whitelist bzw. Blacklist interpretiert werden. Je nach gew\u00e4hltem Browser hat entweder die Whitelist Vorrang, oder die \"genauere\" Regel. Wenn keine Regel zutrifft, wird der Zugriff erlaubt, es sei denn, es gibt den Eintrag \"*\" in der Blacklist. Je nach verwendetem Browser wird \"*\" als Wildcard an verschiedenen Stellen unterst\u00fctzt. Sie k\u00f6nnen Kommentare hinter oder zwischen den Zeilen mittels \"#\" einleiten. Weitere Informationen finden Sie im bwLehrpool-Wiki.",
"lang_urlPanel": "URL-Panel",
"lang_urlTooltip": "URL die aufgerufen wird",
- "lang_urlWhitelist": "Whitelist",
- "lang_useRoomplanner": "Rechner zählen",
+ "lang_useDefault": "Vorgabe f\u00fcr Raum\/Rechner verwenden",
+ "lang_useRoomplanner": "Rechner z\u00e4hlen",
"lang_vertical": "Vertikaler Modus",
"lang_verticalTooltip": "Legt fest, ob Kalender und Raum \u00fcbereinander angezeigt werden sollen",
"lang_wednesday": "Mittwoch",
- "lang_when": "Wann"
+ "lang_when": "Wann",
+ "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/backend-hisinone.json b/modules-available/locationinfo/lang/en/backend-hisinone.json
index 616b4c83..ee44a62e 100644
--- a/modules-available/locationinfo/lang/en/backend-hisinone.json
+++ b/modules-available/locationinfo/lang/en/backend-hisinone.json
@@ -1,16 +1,8 @@
{
"baseUrl": "Base URL",
- "baseUrl_helptext": "URL to HisInOne installation",
- "open": "Service",
- "open_helptext": "Sets the Web Service to use. OpenCourseService is anonymized and doesn't require authentication, so it's usually the preferred way.",
- "password": "Password",
- "password_helptext": "Account password. Only required if using CourseService",
- "role": "Role",
- "role_helptext": "Role of the user accessing the CourseService.",
- "username": "Username",
- "username_helptext": "Authenticating user (only required for CourseService).",
+ "baseUrl_helptext": "URL to HisInOne installation, or more precisely the URL to download a room's events as an iCal file, usually something like \"https:\/\/<hisinoneserver>\/qisserver\/pages\/cm\/exa\/timetable\/roomScheduleCalendarExport.faces?roomId=\"",
"verifyCert": "Verify certificate",
"verifyCert_helptext": "If the certificate expired or was not signed by a known CA, the connection will be aborted.",
"verifyHostname": "Verify host name",
"verifyHostname_helptext": "The certificate's host name must match the host name given in the URL, otherwise the connection will be aborted."
-}
+} \ No newline at end of file
diff --git a/modules-available/locationinfo/lang/en/backend-ical.json b/modules-available/locationinfo/lang/en/backend-ical.json
new file mode 100644
index 00000000..a5b26efd
--- /dev/null
+++ b/modules-available/locationinfo/lang/en/backend-ical.json
@@ -0,0 +1,16 @@
+{
+ "authMethod": "Athentication",
+ "authMethod_helptext": "If backend requires authentication via HTTP header, select appropriate method here.",
+ "baseUrl": "Base URL",
+ "baseUrl_helptext": "URL to iCal file for this room. Replace the part of the URL that identifies a specific room by %ID%, f.i. \"http:\/\/example.com\/locations\/%ID%\". Then switch to the \"location-specific settings\" tab and add the according ID to each room.",
+ "pass": "Password",
+ "pass_helptext": "Optional. Password for authentication.",
+ "testId": "Test room",
+ "testId_helptext": "Optional. Provide a valid room id for this backend for use when clicking the blue \"check connection\" button in the backend list.",
+ "user": "Username",
+ "user_helptext": "Optional. Username for authentication.",
+ "verifyCert": "Verify certificate",
+ "verifyCert_helptext": "If the certificate expired or was not signed by a known CA, the connection will be aborted.",
+ "verifyHostname": "Verify host name",
+ "verifyHostname_helptext": "The certificate's host name must match the host name given in the URL, otherwise the connection will be aborted."
+} \ No newline at end of file
diff --git a/modules-available/locationinfo/lang/en/messages.json b/modules-available/locationinfo/lang/en/messages.json
index 5111a408..c459f1ee 100644
--- a/modules-available/locationinfo/lang/en/messages.json
+++ b/modules-available/locationinfo/lang/en/messages.json
@@ -1,9 +1,5 @@
{
"config-saved": "Config successfully saved.",
- "ignored-invalid-end": "Ignored entry with invalid end time",
- "ignored-invalid-range": "Ignored entry with invalid range",
- "ignored-invalid-start": "Ignored entry with invalid start time",
- "ignored-line-no-days": "Ignored entry with no days selected",
"invalid-backend-type": "Invalid backend type '{{0}}'",
"invalid-panel-id": "Invalid panel id '{{0}}'",
"invalid-panel-type": "Invalid panel type '{{0}}'",
diff --git a/modules-available/locationinfo/lang/en/module.json b/modules-available/locationinfo/lang/en/module.json
index 2fd14353..183b1877 100644
--- a/modules-available/locationinfo/lang/en/module.json
+++ b/modules-available/locationinfo/lang/en/module.json
@@ -1,3 +1,11 @@
{
- "module_name": "Infoscreen"
-}
+ "friday": "Friday",
+ "module_name": "Infoscreen",
+ "monday": "Monday",
+ "page_title": "Info screens and research terminals",
+ "saturday": "Saturday",
+ "sunday": "Sunday",
+ "thursday": "Thursday",
+ "tuesday": "Tuesday",
+ "wednesday": "Wednesday"
+} \ 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 bf51b0b2..5f612d16 100644
--- a/modules-available/locationinfo/lang/en/template-tags.json
+++ b/modules-available/locationinfo/lang/en/template-tags.json
@@ -1,22 +1,28 @@
{
"lang_addServer": "Server",
+ "lang_allowTtySwitch": "Allow switching to text console",
+ "lang_allowTtySwitchTooltip": "Set whether the user can switch to the text console via Ctrl-Alt-F1",
"lang_autoScale": "Auto Days",
"lang_autoscaleTooltip": "Calculates the optimum amount of days to show from the display width",
"lang_backend": "Backend",
+ "lang_backendSettings": "Configure backend",
"lang_backends": "Backends",
- "lang_calendar": "Calendar",
+ "lang_blacklist": "Blacklist",
+ "lang_bookmarks": "Bookmarks",
+ "lang_bookmarksTooltip": "Add bookmarks to the browser",
+ "lang_browser": "Browser",
+ "lang_browserTooltip": "Which browser shall be used",
"lang_calendarUpdate": "Calendar Update",
- "lang_calupdateTooltip": "Time the calender querys for updates (in minutes)",
+ "lang_calupdateTooltip": "Time the calendar queries for updates (in minutes)",
"lang_checkConnection": "Check connection",
+ "lang_chromium": "Chromium",
"lang_closed": "Closed",
- "lang_closingTime": "Closing time",
"lang_configOverride": "Configuration override",
"lang_countIp": "by IP-Range",
"lang_countRoomplan": "by Roomplanner",
"lang_createPanel": "Create panel",
"lang_credentials": "Login",
"lang_currentDay": "Current Day",
- "lang_day": "Day",
"lang_daysToShow": "Days",
"lang_daysToShowTooltip": "Defines the amount of days to show in the calendar",
"lang_defaultPanel": "Default panel",
@@ -25,30 +31,34 @@
"lang_displayName": "Name",
"lang_displayNameTooltip": "Display name for this panel",
"lang_ecoMode": "E-Ink mode",
- "lang_ecoTooltip": "Symbolic based pc state pictures are used instead of the colour based ones",
+ "lang_ecoTooltip": "Lower update rate, countdown doesn't show seconds",
"lang_editDefaultPanelHints": "Here you can define panel properties for e.g. a digital door sign. To show opening times for a room you need to define corresponding times in the settings.\r\nIf you want to show calendar events you have to define a functioning backend first and link it to corresponding rooms.",
"lang_editPanel": "Edit panel",
"lang_editSummaryPanelHints": "Here you can define a summary panel which shows a overview of clients in your locations.",
"lang_editUrlPanelHints": "Here you can define which URL is opened by the panel. This enables you to show news about your university or any other website.",
"lang_entryName": "Name",
"lang_error": "Error",
- "lang_expertMode": "Expert mode",
"lang_for": "for",
"lang_fourLocsHint": "You can pick up to four locations that will be shown in this panel.",
"lang_free": "Open",
"lang_friday": "Friday",
"lang_general": "General",
"lang_generalSettings": "General Settings",
+ "lang_goToLocation": "GoTo Location",
+ "lang_goToLocationWarning": "Forward to Location-Module",
+ "lang_hostnameTooltip": "Show simple hostname inside computer icon",
"lang_ignoreSslTooltip": "Accept invalid, expired or self-signed ssl certificates",
"lang_insecureSsl": "Insecure SSL",
+ "lang_interactive": "Interactive Browser",
+ "lang_interactiveTooltip": "Show full browser UI (tabs, bookmarks, ...)",
"lang_language": "Language",
"lang_languageTooltip": "The language the frontend uses",
"lang_lastCalendarUpdate": "Calendar update",
"lang_locationName": "Name",
- "lang_locationSettings": "Location specific settings",
+ "lang_locationSettings": "Location-specific settings",
"lang_locations": "Locations",
"lang_locationsTable": "Rooms \/ Locations",
- "lang_locationsTableHints": "Here you can define opening times for your locations and link the location ID to a configured backend (e.g. HISinOne) to show calendar events.",
+ "lang_locationsTableHints": "Here you can link the location ID to a configured backend (e.g. HISinOne) to show calendar events.",
"lang_locsHint": "You can pick up the locations that will be shown in this panel.",
"lang_longFri": "Friday",
"lang_longMon": "Monday",
@@ -65,7 +75,6 @@
"lang_mode4": "Switching",
"lang_modeTooltip": "The display modes the frontend supports",
"lang_monday": "Monday",
- "lang_monTilFr": "Monday - Friday",
"lang_nameTooltip": "Defines the name of the server",
"lang_noLocationsWarning": "Please select at least one location this panel should display.",
"lang_noServer": "<no server>",
@@ -81,15 +90,15 @@
"lang_prettytimeTooltip": "Use a different display format for the time",
"lang_recursiveServerSet": "Also set for all child locations",
"lang_recursiveSetTooltip": "If checked, all direct and indirect child locations will be configured to use the backend server selected above",
- "lang_reloadIntervalMins": "Reload every X minutes",
- "lang_reloadIntervalTooltip": "Set this field to a value > 0 (in minutes) to reload the page periodically. Set to 0 or leave blank to disable.",
+ "lang_reloadIntervalMins": "Reload start URL every X minutes (when idle)",
+ "lang_reloadIntervalTooltip": "Set this field to a value > 0 (in minutes) to reload the page periodically. The idle timeout will reset on user input (mouse\/keyboard), so the page will not reset while a user is using the browser. Set to 0 or leave blank to disable any resets.",
"lang_remoteSchedule": "Time table retrieval",
"lang_room": "Room",
"lang_roomId": "Room ID",
"lang_roomIdTooltip": "The ID of the room the server needs, for querying the calendar data (when using exchange the room mailbox)",
+ "lang_roomUpdate": "Room Update",
"lang_roomplannerTooltip": "Determine if PCs are counted on the basis of IP-range or by roomplanner",
"lang_roomupdateTooltip": "Time the PCs in the room gets updated (in seconds)",
- "lang_roomUpdate": "Room Update",
"lang_rotation": "Rotation",
"lang_rotation0": "0\u00b0",
"lang_rotation1": "90\u00b0 \u27f2",
@@ -107,20 +116,17 @@
"lang_serverTooltip": "Defines from which server the room queries the calendar data",
"lang_serverType": "Type",
"lang_shortFri": "Fri",
- "lang_shortFriday": "Fri",
"lang_shortMon": "Mon",
- "lang_shortMonday": "Mon",
"lang_shortSat": "Sat",
- "lang_shortSaturday": "Sat",
"lang_shortSun": "Sun",
- "lang_shortSunday": "Sun",
"lang_shortThu": "Thu",
- "lang_shortThursday": "Thu",
"lang_shortTue": "Tue",
- "lang_shortTuesday": "Tue",
"lang_shortWed": "Wed",
- "lang_shortWednesday": "Wed",
+ "lang_showHostname": "Show hostname",
"lang_showLog": "Log",
+ "lang_slxbrowser": "SLX Browser",
+ "lang_splitlogin": "Split Login",
+ "lang_splitloginTooltip": "Allow only guest-login or guest+user-login if activated",
"lang_startDay": "Start Day",
"lang_startDayTooltip": "The day of the week at which the calendar starts",
"lang_summaryPanel": "Summary panel",
@@ -134,14 +140,16 @@
"lang_typeTooltip": "Defines on which type of server you want to connect to",
"lang_updateRates": "Update rates",
"lang_url": "URL",
- "lang_urlBlacklist": "Blacklist",
- "lang_urlListHelp": "You can specify a list of URLs or host names that will be used as either a blacklist or a whitelist. You can use the special characters '?' to represent any character, '*' to represent any number of characters excluding '\/', or '**' meaning any number of characters, including '\/'. Examples are \"*.wikipedia.org\", \"https:\/\/www.bwlehrpool.de\/**\" or \"*:\/\/*.uni-freiburg.de\/*.html\".",
+ "lang_urlListHelp": "You can specify lists of URLs or hostnames here, which are then interpreted as a whitelist or blacklist. Depending on the selected browser, either the whitelist or the \"more precise\" rule takes precedence. If no rule applies, access will be allowed unless there is an entry \"*\" in the blacklist. Depending on the browser used, \"*\" is supported as a wildcard in different places. You can introduce comments at the end of or between lines using \"#\". Further information can be found in the bwLehrpool-Wiki.",
"lang_urlPanel": "URL panel",
"lang_urlTooltip": "URL which is shown by the panel",
- "lang_urlWhitelist": "Whitelist",
+ "lang_useDefault": "Use setting from location\/machine",
"lang_useRoomplanner": "Count PCs",
"lang_vertical": "Vertical mode",
"lang_verticalTooltip": "Defines whether the room and calendar are shown above each other",
"lang_wednesday": "Wednesday",
- "lang_when": "When"
+ "lang_when": "When",
+ "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 5ba555df..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);
@@ -167,101 +169,20 @@ class Page_LocationInfo extends Page
$ignoreServer = 0;
}
- // Opening times
- $openingtimes = Request::post('openingtimes', '', 'string');
- if ($openingtimes !== '') {
- $openingtimes = json_decode($openingtimes, true);
- if (!is_array($openingtimes)) {
- $openingtimes = '';
- } else {
- $mangled = array();
- foreach (array_keys($openingtimes) as $key) {
- $entry = $openingtimes[$key];
- if (!isset($entry['days']) || !is_array($entry['days']) || empty($entry['days'])) {
- Message::addError('ignored-line-no-days');
- continue;
- }
- $s = $this->getTime($entry['openingtime']);
- $e = $this->getTime($entry['closingtime']);
- if ($s === false) {
- Message::addError('ignored-invalid-start', $entry['openingtime']);
- continue;
- }
- if ($e === false) {
- Message::addError('ignored-invalid-end', $entry['closingtime']);
- continue;
- }
- if ($e <= $s) {
- Message::addError('ignored-invalid-range', $entry['openingtime'], $entry['closingtime']);
- continue;
- }
- unset($entry['tag']);
- $mangled[] = $entry;
- }
- if (empty($mangled)) {
- $openingtimes = null;
- } else {
- $openingtimes = json_encode($mangled);
- }
- }
- }
$NOW = time();
- // Check if openingtimes changed
- $res = Database::queryFirst('SELECT openingtime FROM locationinfo_locationconfig WHERE locationid = :locationid', compact('locationid'));
- $otChanged = $res === false || $res['openingtime'] !== $openingtimes;
- Database::exec("INSERT INTO `locationinfo_locationconfig` (locationid, serverid, serverlocationid, openingtime, lastcalendarupdate, lastchange)
- VALUES (:id, :insertserverid, :serverlocationid, :openingtimes, 0, :now)
+ Database::exec("INSERT INTO `locationinfo_locationconfig` (locationid, serverid, serverlocationid, lastcalendarupdate, lastchange)
+ VALUES (:id, :insertserverid, :serverlocationid, 0, :now)
ON DUPLICATE KEY UPDATE serverid = IF(:ignore_server AND serverid IS NULL, NULL, :serverid), serverlocationid = VALUES(serverlocationid),
- openingtime = VALUES(openingtime), lastcalendarupdate = 0, lastchange = VALUES(lastchange)", array(
+ lastcalendarupdate = 0, lastchange = VALUES(lastchange)", array(
'id' => $locationid,
'insertserverid' => $insertServerId,
'serverid' => $serverid,
- 'openingtimes' => $openingtimes,
'serverlocationid' => $serverlocationid,
'ignore_server' => $ignoreServer,
'now' => $NOW,
));
- if ($otChanged) {
- $tree = Location::getLocationsAssoc();
- $todo = array();
- $done = array();
- foreach ($tree as $l) {
- if ($l['parentlocationid'] == $locationid) {
- $todo[] = $l['locationid'];
- }
- }
- while (!empty($todo)) {
- $loc = array_pop($todo);
- if (in_array($loc, $done))
- continue;
- $done[] = $loc;
- // See if this one inherits
- $res = Database::queryFirst('SELECT openingtime FROM locationinfo_locationconfig WHERE locationid = :loc', compact('loc'));
- if ($res === false) {
- $res = Database::exec('INSERT INTO locationinfo_locationconfig (locationid, lastchange)
- VALUES (:locationid, :now) ON DUPLICATE KEY UPDATE lastchange = :now',
- array('locationid' => $loc, 'now' => $NOW));
- } elseif (strlen($res['openingtime']) < 5) {
- $res = Database::exec('UPDATE locationinfo_locationconfig SET lastchange = :now, openingtime = NULL
- WHERE locationid = :locationid',
- array('locationid' => $loc, 'now' => $NOW));
- } else {
- $res = 0;
- }
- if ($res > 0) {
- // Row was updated, which means the openingtime column was empty, which means the openingtime is inherited, descend further
- $todo = array_merge($todo, $tree[$loc]['children']);
- foreach ($tree as $l) {
- if ($l['parentlocationid'] == $loc) {
- $todo[] = $l['locationid'];
- }
- }
- }
- }
- }
-
if ($changeServerRecursive) {
// Recursive overwriting of serverid
$children = Location::getRecursiveFlat($locationid);
@@ -279,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');
}
@@ -311,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');
@@ -334,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();
@@ -357,21 +272,24 @@ 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);
}
- // Build json struct
+ // Build struct from POST
$conf = array(
'language' => Request::post('language', 'en', 'string'),
'mode' => Request::post('mode', 1, 'int'),
'vertical' => Request::post('vertical', false, 'bool'),
'eco' => Request::post('eco', false, 'bool'),
'prettytime' => Request::post('prettytime', false, 'bool'),
- 'roomplanner' => Request::post('roomplanner', false, 'bool'),
+ 'roomplanner' => Request::post('roomplanner', true, 'bool'),
'startday' => Request::post('startday', 0, 'int'),
'scaledaysauto' => Request::post('scaledaysauto', false, 'bool'),
'daystoshow' => Request::post('daystoshow', 7, 'int'),
@@ -380,6 +298,7 @@ class Page_LocationInfo extends Page
'switchtime' => Request::post('switchtime', 20, 'int'),
'calupdate' => Request::post('calupdate', 120, 'int'),
'roomupdate' => Request::post('roomupdate', 30, 'int'),
+ 'hostname' => Request::post('hostname', false, 'bool'),
);
if ($conf['roomupdate'] < 15) {
$conf['roomupdate'] = 15;
@@ -400,7 +319,8 @@ class Page_LocationInfo extends Page
'scaledaysauto' => Request::post('override'.$locationids[$i].'scaledaysauto', false, 'bool'),
'daystoshow' => Request::post('override'.$locationids[$i].'daystoshow', 7, 'int'),
'rotation' => Request::post('override'.$locationids[$i].'rotation', 0, 'int'),
- 'scale' => Request::post('override'.$locationids[$i].'scale', 50, 'int')
+ 'scale' => Request::post('override'.$locationids[$i].'scale', 50, 'int'),
+ 'switchtime' => Request::post('override'.$locationids[$i].'switchtime', 60, 'int'),
);
$overrides[$locationids[$i]] = $overrideArray;
}
@@ -410,19 +330,43 @@ 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');
+ $bookmarkString = '';
+ for ($i = 0; $i < count($bookmarkNames); $i++) {
+ if ($bookmarkNames[$i] == '' || $bookmarkUrls[$i] == '') continue;
+ $bookmarkString .= rawurlencode($bookmarkNames[$i]);
+ $bookmarkString .= ",";
+ $bookmarkString .= rawurlencode($bookmarkUrls[$i]);
+ $bookmarkString .= " ";
+ }
+ $bookmarkString = substr($bookmarkString, 0, -1);
+
$conf = array(
'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')),
- 'iswhitelist' => Request::post('iswhitelist', 0, 'int'),
- 'urllist' => preg_replace("/[\r\n\\s]+/ms", ' ', Request::post('urllist', '', '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 ?: '',
+ '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(
@@ -435,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');
@@ -484,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');
@@ -500,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();
@@ -509,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();
@@ -521,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 {
@@ -547,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(
@@ -558,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) {
@@ -574,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);
@@ -583,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)) {
@@ -593,11 +538,12 @@ class Page_LocationInfo extends Page
$locations = Location::getLocations(0, 0, false, true);
// Get hidden state of all locations
- $dbquery = Database::simpleQuery("SELECT li.locationid, li.serverid, li.serverlocationid, li.openingtime, li.lastcalendarupdate, cb.servertype, cb.servername
+ $dbquery = Database::simpleQuery("SELECT li.locationid, li.serverid, li.serverlocationid, loc.openingtime, li.lastcalendarupdate, cb.servertype, cb.servername
FROM `locationinfo_locationconfig` AS li
- LEFT JOIN `locationinfo_coursebackend` AS cb USING (serverid)");
+ 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;
@@ -608,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']),
@@ -636,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;
@@ -654,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;
@@ -663,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);
}
@@ -709,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
@@ -734,12 +692,13 @@ class Page_LocationInfo extends Page
);
$backend['credentials'] = $backendInstance->getCredentialDefinitions();
foreach ($backend['credentials'] as $cred) {
+ /* @var BackendProperty $cred */
if ($backend['active'] && isset($oldCredentials[$cred->property])) {
- $cred->initForRender($oldCredentials[$cred->property]);
+ $cred->initForRender($backendInstance->mangleProperty($cred->property, $oldCredentials[$cred->property]));
} 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);
}
@@ -757,10 +716,13 @@ 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 serverid, serverlocationid, openingtime FROM `locationinfo_locationconfig` WHERE locationid = :id", array('id' => $id));
+ $locConfig = Database::queryFirst("SELECT info.serverid, info.serverlocationid, loc.openingtime
+ FROM `locationinfo_locationconfig` AS info
+ LEFT JOIN `location` AS loc USING (locationid)
+ WHERE locationid = :id", array('id' => $id));
if ($locConfig !== false) {
$openingtimes = json_decode($locConfig['openingtime'], true);
} else {
@@ -778,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;
}
@@ -792,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';
}
@@ -803,10 +765,8 @@ class Page_LocationInfo extends Page
'id' => $id,
'serverlist' => $serverList,
'serverlocationid' => $locConfig['serverlocationid'],
+ 'openingtimes' => $this->compressTimes($openingtimes),
);
- $data['expertMode'] = !$this->isSimpleMode($openingtimes);
- // !! isSimpleMode might have changed $openingtimes, so order is important here...
- $data['schedule_data'] = json_encode($openingtimes);
echo Render::parse('ajax-config-location', $data);
}
@@ -815,71 +775,85 @@ class Page_LocationInfo extends Page
* Checks if simple mode or expert mode is active.
* Tries to merge/compact the opening times schedule, and
* will actually modify the passed array iff it can be
- * transformed into easy opening times.
+ * transformed into simple opening times.
*
- * @param array $array of the saved openingtimes.
- * @return bool True if simple mode, false if expert mode
+ * @return array new optimized openingtimes
*/
- private function isSimpleMode(&$array)
+ private function compressTimes(array $array): array
{
if (empty($array))
- return true;
+ return [];
// Decompose by day
- $new = array();
+ $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;
+ if ($day === -1)
+ continue;
$this->addDay($new, $day, $s, $e);
}
}
- // Merge by timespan, but always keep saturday and sunday separate
- $merged = array();
+ // Merge by timespan
+ $merged = [];
foreach ($new as $day => $ranges) {
foreach ($ranges as $range) {
- if ($day === 'Saturday' || $day === 'Sunday') {
- $add = $day;
- } else {
- $add = '';
+ $range = $range[0] . '#' . $range[1];
+ if (!isset($merged[$range])) {
+ $merged[$range] = [];
}
- $key = '#' . $range[0] . '#' . $range[1] . '#' . $add;
- if (!isset($merged[$key])) {
- $merged[$key] = array();
- }
- $merged[$key][$day] = true;
+ $merged[$range][$day] = true;
}
}
- // Check if it passes as simple mode
- if (count($merged) > 3)
- return false;
- foreach ($merged as $days) {
- if (count($days) === 5) {
- $res = array_keys($days);
- $res = array_intersect($res, array("Monday", "Tuesday", "Wednesday", "Thursday", "Friday"));
- if (count($res) !== 5)
- return false;
- } elseif (count($days) === 1) {
- if (!isset($days['Saturday']) && !isset($days['Sunday'])) {
- return false;
- }
+ // Finally transform to display struct
+ $new = [];
+ foreach ($merged as $span => $days) {
+ $out = explode('#', $span);
+ $new[] = [
+ 'days' => $this->buildDaysString(array_keys($days)),
+ 'open' => sprintf('%02d:%02d', ($out[0] / 60), ($out[0] % 60)),
+ 'close' => sprintf('%02d:%02d', ($out[1] / 60), ($out[1] % 60)),
+ ];
+ }
+ return $new;
+ }
+
+ /**
+ * @param array $daysArray List of days, "Monday", "Tuesday" etc. Must not contain duplicates.
+ * @return string Human-readable representation of list of days
+ */
+ private function buildDaysString(array $daysArray): string
+ {
+ /* Dictionary::translate('monday') Dictionary::translate('tuesday') Dictionary::translate('wednesday')
+ * Dictionary::translate('thursday') Dictionary::translate('friday') Dictionary::translate('saturday')
+ * Dictionary::translate('sunday')
+ */
+ $DAYLIST = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
+ $output = [];
+ $first = $last = -1;
+ sort($daysArray);
+ $daysArray[] = -1; // One trailing element to enforce a flush
+ foreach ($daysArray as $day) {
+ if ($first === -1) {
+ $first = $last = $day;
+ } elseif ($last + 1 === $day) {
+ // Chain
+ $last++;
} else {
- return false;
+ $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]);
+ }
+ $output[] = $string;
+ $first = $last = $day;
}
}
- // Valid simple mode, finally transform back to what we know
- $new = array();
- foreach ($merged as $span => $days) {
- preg_match('/^#(\d+)#(\d+)#/', $span, $out);
- $new[] = array(
- 'days' => array_keys($days),
- 'openingtime' => floor($out[1] / 60) . ':' . ($out[1] % 60),
- 'closingtime' => floor($out[2] / 60) . ':' . ($out[2] % 60),
- );
- }
- $array = $new;
- return true;
+ return implode(', ', $output);
}
private function addDay(&$array, $day, $s, $e)
@@ -909,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);
@@ -917,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) {
@@ -963,7 +935,7 @@ class Page_LocationInfo extends Page
$config = json_decode($panel['panelconfig'], true);
if (!isset($config['roomplanner'])) {
- $config['roomplanner'] = false;
+ $config['roomplanner'] = true;
}
}
@@ -1008,17 +980,47 @@ class Page_LocationInfo extends Page
'locations' => Location::getLocations(),
'locationids' => $panel['locationids'],
'overrides' => json_encode($config['overrides']),
+ 'hostname_checked' => $config['hostname'] ? 'checked' : '',
));
} elseif ($panel['paneltype'] === 'URL') {
+
+ $bookmarksArray = [];
+ if ($config['bookmarks'] !== '') {
+ $bookmarksConfig = explode(' ', $config['bookmarks']);
+ foreach ($bookmarksConfig AS $bookmark) {
+ $bookmark = explode(',', $bookmark);
+ $name = rawurldecode($bookmark[0]);
+ $url = rawurldecode($bookmark[1]);
+ $bookmarksArray[] = [
+ 'name' => $name,
+ 'url' => $url,
+ ];
+ }
+ }
+
+ if (empty($config['blacklist']) && $config['whitelist'] === '*' && !empty($config['urllist'])) {
+ if ($config['iswhitelist']) {
+ $config['whitelist'] = str_replace(' ', "\n", $config['urllist']);
+ } else {
+ $config['blacklist'] = str_replace(' ', "\n", $config['urllist']);
+ }
+ }
+
Render::addTemplate('page-config-panel-url', array(
'new' => $id === 'new',
'uuid' => $id,
'panelname' => $panel['panelname'],
'url' => $config['url'],
+ 'zoom-factor' => $config['zoom-factor'],
'ssl_checked' => $config['insecure-ssl'] ? 'checked' : '',
'reloadminutes' => (int)$config['reload-minutes'],
- 'iswhitelist_' . $config['iswhitelist'] . '_checked' => 'checked',
- 'urllist' => str_replace(' ', "\r\n", $config['urllist']),
+ 'whitelist' => str_replace("\n", "\r\n", $config['whitelist']),
+ 'blacklist' => str_replace("\n", "\r\n", $config['blacklist']),
+ 'split-login_checked' => $config['split-login'] ? 'checked' : '',
+ 'browser' => $config['browser'],
+ 'interactive_checked' => $config['interactive'] ? 'checked' : '',
+ 'bookmarks' => $bookmarksArray,
+ 'allow-tty_' . $config['allow-tty'] . '_checked' => 'checked',
));
} else {
Render::addTemplate('page-config-panel-summary', array(
@@ -1035,7 +1037,7 @@ class Page_LocationInfo extends Page
}
}
- private function showPanel()
+ private function showPanel(): void
{
$uuid = Request::get('uuid', false, 'string');
if ($uuid === false) {
@@ -1043,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');
}
@@ -1053,10 +1055,14 @@ class Page_LocationInfo extends Page
}
$data = array();
- preg_match('#^(.*)/#', $_SERVER['PHP_SELF'], $script);
- preg_match('#^([^?]+)/#', $_SERVER['REQUEST_URI'], $request);
+ preg_match('#^/(.*)/#', $_SERVER['PHP_SELF'], $script);
+ preg_match('#^/([^?]+)/#', $_SERVER['REQUEST_URI'], $request);
if ($script[1] !== $request[1]) {
- $data['dirprefix'] = $script[1] . '/';
+ // Working with server-side redirects
+ $data['api'] = 'api/';
+ } else {
+ // 1:1
+ $data['api'] = 'api.php?do=locationinfo&';
}
if ($type === 'DEFAULT') {
@@ -1066,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') {
@@ -1078,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);
@@ -1087,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/style.css b/modules-available/locationinfo/style.css
index b5fffe75..dce47c42 100644
--- a/modules-available/locationinfo/style.css
+++ b/modules-available/locationinfo/style.css
@@ -1,10 +1,4 @@
-.btn-static {
- background-color: white;
- border: 1px solid lightgrey;
- cursor: default;
-}
-.btn-static:active{
- -moz-box-shadow: inset 0 0 0px white;
- -webkit-box-shadow: inset 0 0 0px white;
- box-shadow: inset 0 0 0px white;
+.spacebottop {
+ margin-bottom: 0;
+ margin-top: 0.5em;
}
diff --git a/modules-available/locationinfo/templates/ajax-config-location.html b/modules-available/locationinfo/templates/ajax-config-location.html
index 47d4ba8a..a5e7e45e 100644
--- a/modules-available/locationinfo/templates/ajax-config-location.html
+++ b/modules-available/locationinfo/templates/ajax-config-location.html
@@ -1,192 +1,59 @@
<input type="hidden" name="locationid" value="{{id}}">
-<div id="settings-outer">
- <h3>{{lang_openingTime}}</h3>
- {{^expertMode}}
- <div id="simple-mode">
-
- <div align="right">
- <a href="#" class="btn btn-default btn-sm" id="btn-show-expert">{{lang_expertMode}}</a>
- </div>
- <div class="clearfix"></div>
-
- <table class="table table-condensed" style="margin-bottom:0">
- <tr>
- <th>{{lang_day}}</th>
- <th>{{lang_openingTime}}</th>
- <th>{{lang_closingTime}}</th>
- </tr>
-
- <tr class="tablerow">
- <td>{{lang_monTilFr}}</td>
- <td>
- <div class="input-group bootstrap-timepicker">
- <span class="input-group-addon">
- <span class="glyphicon glyphicon-time"></span>
- </span>
- <input type="text" class="form-control timepicker2" id="week-open" pattern="[0-9]{1,2}:[0-9]{2}">
- </div>
- </td>
- <td>
- <div class="input-group bootstrap-timepicker">
- <span class="input-group-addon">
- <span class="glyphicon glyphicon-time"></span>
- </span>
- <input type="text" class="form-control timepicker2" id="week-close" pattern="[0-9]{1,2}:[0-9]{2}">
- </div>
- </td>
- </tr>
- <tr class="tablerow">
- <td>{{lang_saturday}}</td>
- <td>
- <div class="input-group bootstrap-timepicker">
- <span class="input-group-addon">
- <span class="glyphicon glyphicon-time"></span>
- </span>
- <input type="text" class="form-control timepicker2" id="saturday-open" pattern="[0-9]{1,2}:[0-9]{2}">
- </div>
- </td>
- <td>
- <div class="input-group bootstrap-timepicker">
- <span class="input-group-addon">
- <span class="glyphicon glyphicon-time"></span>
- </span>
- <input type="text" class="form-control timepicker2" id="saturday-close" pattern="[0-9]{1,2}:[0-9]{2}">
- </div>
- </td>
- </tr>
- <tr class="tablerow">
- <td>{{lang_sunday}}</td>
- <td>
- <div class="input-group bootstrap-timepicker">
- <span class="input-group-addon">
- <span class="glyphicon glyphicon-time"></span>
- </span>
- <input type="text" class="form-control timepicker2" id="sunday-open" pattern="[0-9]{1,2}:[0-9]{2}">
- </div>
- </td>
- <td>
- <div class="input-group bootstrap-timepicker">
- <span class="input-group-addon">
- <span class="glyphicon glyphicon-time"></span>
- </span>
- <input type="text" class="form-control timepicker2" id="sunday-close" pattern="[0-9]{1,2}:[0-9]{2}">
- </div>
- </td>
- </tr>
- </table>
- </div>
- {{/expertMode}}
-
- <div id="expert-mode" style="{{^expertMode}}display:none{{/expertMode}}">
- <div class="pull-right">
- <a class="btn btn-success btn-sm" id="new-openingtime">
- <span class="glyphicon glyphicon-plus-sign"></span>
- {{lang_openingTime}}
- </a>
- </div>
- <div class="clearfix"></div>
- <div id="expert-table">
- <div class="row">
- <div class="col-xs-9">{{lang_day}}</div>
- <div class="col-xs-3 text-right">{{lang_delete}}</div>
- <div class="col-sm-6">{{lang_openingTime}}</div>
- <div class="col-sm-6">{{lang_closingTime}}</div>
- </div>
- </div>
- </div>
+<h3>{{lang_openingTime}}</h3>
+<table class="table">
+ {{#openingtimes}}
+ <tr>
+ <td>{{days}}</td>
+ <td class="text-right">{{open}}&thinsp;-&thinsp;{{close}}</td>
+ </tr>
+ {{/openingtimes}}
+</table>
+
+<div class="row" style="margin-top: 20px; margin-right: 15px;">
+ <a class="pull-right" title="{{lang_goToLocationWarning}}" href='?do=locations#{{id}}'>
+ {{lang_goToLocation}}
+ <span class="glyphicon glyphicon-arrow-right"></span>
+ </a>
</div>
+<hr>
<h3>{{lang_remoteSchedule}}</h3>
<div class="row">
- <div class="col-sm-3">
+ <div class="col-sm-4">
<label for="backend-select">{{lang_backend}}</label>
</div>
- <div class="col-sm-7">
+ <div class="col-sm-8">
<select id="backend-select" class="form-control" name="serverid">
<option value="0">{{lang_noServer}}</option>
{{#serverlist}}
- <option value="{{serverid}}" {{selected}}>{{servername}}</option>
+ <option value="{{serverid}}" {{selected}}>{{servername}}</option>
{{/serverlist}}
</select>
</div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_serverTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
+ <div class="col-sm-12 small text-muted slx-smallspace">
+ {{lang_serverTooltip}}
</div>
</div>
<div class="row">
- <div class="col-sm-3"></div>
- <div class="col-sm-7">
+ <div class="col-sm-4"></div>
+ <div class="col-sm-8">
<div class="checkbox">
<input type="checkbox" name="recursive" id="recursive-check">
<label for="recursive-check">{{lang_recursiveServerSet}}</label>
</div>
</div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_recursiveSetTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
+ <div class="col-sm-12 small text-muted slx-smallspace">
+ {{lang_recursiveSetTooltip}}
</div>
</div>
<div class="row">
- <div class="col-sm-3">
+ <div class="col-sm-4">
<label for="roomid-input">{{lang_roomId}}</label>
</div>
- <div class="col-sm-7">
+ <div class="col-sm-8">
<input id="roomid-input" class="form-control" name="serverlocationid" id="serverlocationid" value="{{serverlocationid}}">
</div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_roomIdTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
+ <div class="col-sm-12 small text-muted slx-smallspace">
+ {{lang_roomIdTooltip}}
</div>
</div>
-
-<script type="application/javascript"><!--
- (function() {
-
- var scheduleData = {{{schedule_data}}};
-
- {{#expertMode}}
- for (var i = 0; i < scheduleData.length; ++i) {
- newOpeningTime(scheduleData[i]);
- }
- {{/expertMode}}
- {{^expertMode}}
- for (var i = 0; i < scheduleData.length; ++i) {
- if (scheduleData[i].days.length === 5) {
- $('#week-open').val(scheduleData[i]['openingtime']);
- $('#week-close').val(scheduleData[i]['closingtime']);
- } else if (scheduleData[i].days.length === 1 && scheduleData[i].days[0] === 'Saturday') {
- $('#saturday-open').val(scheduleData[i]['openingtime']);
- $('#saturday-close').val(scheduleData[i]['closingtime']);
- } else if (scheduleData[i].days.length === 1 && scheduleData[i].days[0] === 'Sunday') {
- $('#sunday-open').val(scheduleData[i]['openingtime']);
- $('#sunday-close').val(scheduleData[i]['closingtime']);
- }
- }
- {{/expertMode}}
-
- setTimepicker($('#settings-outer').find('.timepicker2'));
-
- $('p.helptext').tooltip();
-
- $('#new-openingtime').click(function (e) {
- e.preventDefault();
- setTimepicker(newOpeningTime({}).find('.timepicker2'));
- })
-
- $('#btn-show-expert').click(function (e) {
- e.preventDefault();
- scheduleData = simpleToExpert();
- for (var i = 0; i < scheduleData.length; ++i) {
- setTimepicker(newOpeningTime(scheduleData[i]).find('.timepicker2'));
- }
- $('#simple-mode').remove();
- $('#expert-mode').show();
- });
-
- })();
-
-//--></script>
diff --git a/modules-available/locationinfo/templates/ajax-config-server.html b/modules-available/locationinfo/templates/ajax-config-server.html
index 8c2cb3ba..c61927c0 100644
--- a/modules-available/locationinfo/templates/ajax-config-server.html
+++ b/modules-available/locationinfo/templates/ajax-config-server.html
@@ -1,43 +1,37 @@
<div class="panel panel-default">
<div class="panel-heading">{{lang_general}}</div>
- <div class="panel-body">
- <div class="list-group">
- <div class="list-group-item">
- <div class="row">
- <div class="col-md-3">
- <label>{{lang_entryName}}</label>
- </div>
- <div class="col-md-7">
- <input required class="form-control" name="name" type="text" value="{{name}}" id="name-input"
- form="form-{{currentbackend}}">
- </div>
- <div class="col-md-2">
- <p class="btn btn-static" title="{{lang_nameTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- </div>
+ <div class="list-group">
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-md-4">
+ <label>{{lang_entryName}}</label>
+ </div>
+ <div class="col-md-8">
+ <input required class="form-control" name="name" type="text" value="{{name}}" id="name-input"
+ form="form-{{currentbackend}}">
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_nameTooltip}}
</div>
</div>
- <div class="list-group-item">
- <div class="row">
- <div class="col-md-3">
- <label>{{lang_serverType}}</label>
- </div>
- <div class="col-md-7">
- <select class="form-control" onchange="servertype_changed(this.value)">
- {{#defaultBlank}}
- <option value="" selected>{{lang_pleaseSelect}}</option>
- {{/defaultBlank}}
- {{#backendList}}
- <option value="{{backendtype}}" {{#active}}selected{{/active}}>{{display}}</option>
- {{/backendList}}
- </select>
- </div>
- <div class="col-md-2">
- <p class="btn btn-static" id="help-type" title="{{lang_typeTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- </div>
+ </div>
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-md-4">
+ <label>{{lang_serverType}}</label>
+ </div>
+ <div class="col-md-8">
+ <select class="form-control" onchange="servertype_changed(this.value)">
+ {{#defaultBlank}}
+ <option value="" selected>{{lang_pleaseSelect}}</option>
+ {{/defaultBlank}}
+ {{#backendList}}
+ <option value="{{backendtype}}" {{#active}}selected{{/active}}>{{display}}</option>
+ {{/backendList}}
+ </select>
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_typeTooltip}}
</div>
</div>
</div>
@@ -57,12 +51,10 @@
<div class="panel panel-default">
<div class="panel-heading">{{lang_credentials}}</div>
- <div class="panel-body">
- <div class="list-group">
- {{#credentials}}
- {{{credentialsHtml}}}
- {{/credentials}}
- </div>
+ <div class="list-group">
+ {{#credentials}}
+ {{{credentialsHtml}}}
+ {{/credentials}}
</div>
</div>
</form>
@@ -92,7 +84,6 @@
currentBackend = value;
}
- $('p.btn[title]').tooltip();
$('#myModalSubmitButton').attr('form', 'form-' + currentBackend);
$('.settings-bs-switch').bootstrapSwitch({size:'small'});
diff --git a/modules-available/locationinfo/templates/frontend-default.html b/modules-available/locationinfo/templates/frontend-default.html
index c59679ee..cc62075e 100755
--- a/modules-available/locationinfo/templates/frontend-default.html
+++ b/modules-available/locationinfo/templates/frontend-default.html
@@ -15,7 +15,7 @@ optional:
daystoshow:[1,2,3,4,5,6,7] sets how many days the calendar shows
scale:[10-90] scales the calendar and Roomplan in mode 1
switchtime:[1-120] sets the time between switchen in mode 4 (in seconds)
- calupdate: Time the calender querys for updates,in minutes.
+ calupdate: Time the calendar queries for updates,in minutes.
roomupdate: Time the PCs in the room gets updated,in seconds.
rotation:[0-3] rotation of the roomplan
vertical:[true] only mode 1, sets the calendar above the roomplan
@@ -26,8 +26,8 @@ optional:
<meta name="viewport" content="width=device-width, initial-scale=1.0" charset="utf-8">
<head>
<title>DoorSign</title>
- <link rel='stylesheet' type='text/css' href='{{dirprefix}}modules/js_jqueryui/style.css'/>
- <link rel='stylesheet' type='text/css' href='{{dirprefix}}modules/js_weekcalendar/style.css'/>
+ <link rel='stylesheet' type='text/css' href='modules/js_jqueryui/style.css'/>
+ <link rel='stylesheet' type='text/css' href='modules/js_weekcalendar/style.css'/>
<style type="text/css">
@@ -51,12 +51,21 @@ optional:
box-shadow: 0 0.1875rem 0.375rem rgba(0, 0, 0, 0.25);
margin-bottom: 4px;
width: 100%;
- display: flex;
- flex-wrap: nowrap;
- align-items: center;
- justify-content: space-between;
+ font-size: 25pt;
+ font-size: 1.8vw;
+ font-weight: bold;
}
+ .count-3 {
+ font-size: 16pt;
+ font-size: 1.2vw;
+ }
+
+ .count-1 {
+ font-size: 30pt;
+ font-size: 2.25vw;
+ }
+
.pull-left {
float: left;
}
@@ -66,15 +75,11 @@ optional:
}
.col {
- padding: 0 4px;
+ padding: 3px 5px 0;
color: white;
overflow: hidden;
- flex: 1 1 auto;
text-overflow: ellipsis;
- position: relative;
- display: flex;
- flex-direction: column;
- justify-content: center;
+ line-height: 106%;
}
.col-square {
@@ -90,6 +95,9 @@ optional:
text-align: center;
padding: 0;
overflow: visible;
+ display: flex;
+ justify-content: center;
+ align-items: center;
}
.count-1 .col-square {
@@ -119,30 +127,19 @@ optional:
z-index: 100;
}
- .header-font {
- font-size: 25pt;
- font-size: 1.8vw;
- font-weight: bold;
- padding: 10px;
- }
-
.nowrap {
white-space: nowrap;
overflow: hidden;
}
- .timer {
- color: #ddd;
+ .location-name {
+ font-size: 80%;
+ font-weight: normal;
}
- .count-3 .header-font {
- font-size: 16pt;
- font-size: 1.2vw;
- }
-
- .count-1 .header-font {
- font-size: 30pt;
- font-size: 2.25vw;
+ .timer {
+ color: #ddd;
+ font-size: 80%;
}
.seats-counter {
@@ -215,13 +212,28 @@ optional:
.screen-frame {
position: relative;
- background: black;
+ background: #000;
border-radius: 11%;
width: 100%;
height: 83%;
padding: 6%;
}
+ .screen-foot1 {
+ margin: 0 auto;
+ width: 10%;
+ height: 7%;
+ background: #000;
+ }
+
+ .screen-foot2 {
+ margin: 0 auto;
+ width: 80%;
+ height: 7%;
+ background: #000;
+ border-radius: 30% 30% 0 0;
+ }
+
.screen-inner {
width: 100%;
height: 100%;
@@ -233,6 +245,17 @@ optional:
color: #fff;
}
+ .pcname {
+ display: block;
+ text-wrap: avoid;
+ white-space: nowrap;
+ position: relative;
+ overflow: visible;
+ height: 0;
+ font-size: 10pt;
+ text-shadow: #000 1px 1px;
+ }
+
.BROKEN .screen-inner {
background: #000;
}
@@ -243,31 +266,25 @@ optional:
.IDLE .screen-inner,
.STANDBY .screen-inner {
- background: #250;
+ background: linear-gradient(to bottom, #0d0, #0c0 10%, #250 13%, #050 100%);
}
.OCCUPIED .screen-inner {
background: #d23;
}
+ .OCCUPIED.rm-remoteaccess .screen-inner {
+ background: linear-gradient(to bottom, #d23, #f90 80%);
+ }
+
.OCCUPIED .screen-inner:after {
content: '\01F464';
font-weight: bold;
}
- .screen-foot1 {
- margin: 0 auto;
- width: 10%;
- height: 7%;
- background: black;
- }
-
- .screen-foot2 {
- margin: 0 auto;
- width: 80%;
- height: 7%;
- background: black;
- border-radius: 30% 30% 0 0;
+ .STANDBY .screen-inner:after {
+ content: '\01F319';
+ font-weight: bold;
}
.pc-overlay-container {
@@ -279,15 +296,6 @@ optional:
display: table;
}
- .pc-img {
- position: absolute;
- left: 0;
- top: 0;
- width: 100%;
- height: 100%;
-
- }
-
.overlay {
display: inline-block;
position: relative;
@@ -330,21 +338,33 @@ optional:
overflow-y: hidden !important;
}
- .wc-grid-timeslot-header,
.wc-header .wc-time-column-header {
+ width: 49px;
+ }
+
+ .wc-grid-timeslot-header {
width: 50px;
}
+ .wc-scrollbar-shim {
+ width: 0 !important;
+ }
+
+ .ui-state-active {
+ border-left-width: 3px !important;
+ border-right-width: 3px !important;
+ }
+
#i18n {
display: none;
}
</style>
- <script type='text/javascript' src='{{dirprefix}}script/jquery.js'></script>
- <script type='text/javascript' src='{{dirprefix}}modules/js_jqueryui/clientscript.js'></script>
- <script type='text/javascript' src="{{dirprefix}}modules/js_weekcalendar/clientscript.js"></script>
- <script type='text/javascript' src='{{dirprefix}}modules/locationinfo/frontend/frontendscript.js'></script>
+ <script type='text/javascript' src='script/jquery.js'></script>
+ <script type='text/javascript' src='modules/js_jqueryui/clientscript.js'></script>
+ <script type='text/javascript' src="modules/js_weekcalendar/clientscript.js"></script>
+ <script type='text/javascript' src='modules/locationinfo/frontend/frontendscript.js'></script>
</head>
<body>
@@ -500,7 +520,6 @@ optional:
scaleFactor = 1;
}
if (v === null || !isFinite(v) || isNaN(v) || v < min * scaleFactor || v > max * scaleFactor) {
- console.log(property)
config[property] = defaultval * scaleFactor;
}
}
@@ -545,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
*/
@@ -577,21 +598,21 @@ optional:
left = ((t % 2) * 50) + '%';
}
- var $loc = $("<div>").addClass('location-container');
+ var $loc = $("<div>").addClass('location-container').attr('id', rid);
$loc.css({top: top, left: left, width: width, height: height});
$("body").append($loc);
room.$.container = $loc;
- room.$.locationName = $('<div>').addClass('col').addClass('header-font').addClass('pull-left');
+ room.$.locationName = $('<div>').addClass('col location-name nowrap');
room.$.currentEvent = $("<span>").addClass('nowrap');
- room.$.currentRemain = $("<span>").addClass('nowrap').addClass('timer');
+ room.$.currentRemain = $("<span>").addClass('nowrap timer');
room.$.seatsCounter = $('<span>').addClass('seats-counter');
room.$.seatsBackground = $('<div>').addClass('col col-square').append(room.$.seatsCounter);
- var $header = $('<div>').addClass('row').addClass('count-' + columns);
- $header.append(room.$.locationName);
+ var $header = $('<div>').addClass('row count-' + columns);
$header.append(room.$.seatsBackground);
- $header.append($('<div>').addClass('col header-font center').append(room.$.currentEvent).append(' ').append(room.$.currentRemain));
+ $header.append(room.$.locationName);
+ $header.append($('<div>').addClass('col center').append(room.$.currentEvent).append(' ').append(room.$.currentRemain));
room.$.header = $header;
$loc.append($header);
$header.append('<div class="clearfix">');
@@ -628,11 +649,11 @@ optional:
}
if (hasMode4) {
- generateProgressBar();
+ generateRoomSwitchIntervals();
}
mainUpdateLoop();
- setInterval(mainUpdateLoop, 10000);
+ updateTimer = setInterval(mainUpdateLoop, 10000);
setInterval(updateHeaders, globalConfig.eco ? 10000 : 1000);
}
@@ -660,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;
@@ -736,14 +764,31 @@ optional:
}
/**
+ * Calculate the correct start date based on the number of days shown in the calendar
+ * @param startDay Start week day (0 = current day, 1 = Monday, 7 = Sunday)
+ * @param daysToShow Number of days to show in the calendar
+ * @return {Date} Start date
+ */
+ function getStartDate(startDay, daysToShow) {
+ var now = new Date();
+ var startDate = new Date(now.getTime());
+ if (startDay > 0) {
+ startDate.setDate(startDate.getDate() - startDate.getDay() + (startDay % 7));
+ if (startDate > now) startDate.setDate(startDate.getDate() - 7);
+ var endDayDate = new Date(startDate.getTime());
+ endDayDate.setDate(endDayDate.getDate() + daysToShow);
+ if (endDayDate <= now) startDate.setDate(startDate.getDate() + 7);
+ }
+ return startDate;
+ }
+
+ /**
* inilizes the Calendar for an room
* @param room Room Object
*/
function setUpCalendar(room) {
var daysToShow = room.config.daystoshow;
- var startDay = room.config.startday;
- var startDayDate = new Date();
- if (startDay > 0) startDayDate.setDate((startDayDate.getDate() - (startDayDate.getDay() + 6) % 7) + (startDay - 1));
+ var startDate = getStartDate(room.config.startday, daysToShow);
generateCalendarDiv(room);
room.$.calendar.weekCalendar({
timeslotsPerHour: 1,
@@ -771,7 +816,7 @@ optional:
$event.find(".time").css({"backgroundColor": "#25B002", "border": "1px solid #888"});
}
},
- date: startDayDate,
+ date: startDate,
dateFormat: "j.n",
timeFormat: "G:i",
scrollToHourMillis: 500,
@@ -908,11 +953,11 @@ optional:
}
/**
- * querys the Calendar data
+ * queries the Calendar data
*/
function queryCalendars() {
if (!panelUuid) return;
- var url = "{{dirprefix}}api.php?do=locationinfo&get=calendar&uuid=" + panelUuid;
+ var url = "{{{api}}}get=calendar&uuid=" + panelUuid;
$.ajax({
url: url,
dataType: 'json',
@@ -988,7 +1033,7 @@ optional:
room.state = null;
UpdateRoomHeader(room);
} catch (e) {
- console.log("Error: Couldnt add calendar data");
+ console.log("Error: Couldn't add calendar data");
console.log(e);
}
}
@@ -1032,6 +1077,7 @@ optional:
result = Math.min(Math.max(Math.abs(result), 1), 7);
if (result !== $cal.weekCalendar("option", "daysToShow")) {
$cal.weekCalendar("option", "daysToShow", result);
+ $cal.weekCalendar("gotoDate", getStartDate(room.config.startday, result));
columnWidth = $cal.find(".wc-day-1").width();
}
}
@@ -1059,22 +1105,10 @@ optional:
if (height < 30) {
height = 30;
}
- var fontHeight = Math.min(height, columnWidth / 2.1);
+
// Scale calendar font
- if (fontHeight > 120) {
- $cal.weekCalendar("option", "textSize", 28);
- }
- else if (fontHeight > 100) {
- $cal.weekCalendar("option", "textSize", 24);
- } else if (fontHeight > 80) {
- $cal.weekCalendar("option", "textSize", 22);
- } else if (fontHeight > 70) {
- $cal.weekCalendar("option", "textSize", 20);
- } else if (fontHeight > 60) {
- $cal.weekCalendar("option", "textSize", 14);
- } else {
- $cal.weekCalendar("option", "textSize", 13);
- }
+ $cal.weekCalendar("option", "textSize", (columnWidth+height)/50+10);
+
$cal.weekCalendar("option", "timeslotHeight", height);
if (room.timetable) {
$cal.weekCalendar("option", "data", {events: room.timetable});
@@ -1184,7 +1218,7 @@ optional:
var newText = false, newTime = false;
var seats = room.freePcs;
if (tmp.state === 'closed' || tmp.state === 'CalendarEvent' || tmp.state === 'Free') {
- newTime = GetTimeDiferenceAsString(tmp.end, MyDate(), globalConfig);
+ newTime = GetTimeDiferenceAsString(tmp.end, MyDate(), room.config);
}
if (tmp.state === "closed") {
if (!same) newText = t("closed");
@@ -1301,7 +1335,6 @@ optional:
/========================================== Room Layout =============================================
*/
-
const picSizeX = 3.8;
const picSizeY = 3;
@@ -1407,10 +1440,9 @@ optional:
function setUpRoom(room, layout) {
for (var i = 0; i < layout.length; i++) {
if (!isNaN(layout[i].y) && !isNaN(layout[i].x)) {
- //var $img = $('<img>').prop('id', "pc-img_" + room.id + "_" + layout[i].id).addClass('pc-img');
var $overlays = $('<div>').addClass('pc-overlay-container');
layout[i].$div = $('<div>').prop('id', "pc_" + room.id + "_" + layout[i].id).addClass('pc-container');
- layout[i].$div.append($('<div>').addClass('screen-frame').append($('<div>').addClass('screen-inner')));
+ layout[i].$div.append($('<div>').addClass('screen-frame').append($('<div>').addClass('screen-inner').append($('<div>').addClass('pcname').text(layout[i].host))));
layout[i].$div.append($('<div>').addClass('screen-foot1'));
layout[i].$div.append($('<div>').addClass('screen-foot2'));
//layout[i].$div.append($overlays).append($img);
@@ -1476,13 +1508,13 @@ optional:
*/
function queryPanelChange() {
$.ajax({
- url: "{{dirprefix}}api.php?do=locationinfo&get=timestamp&uuid=" + panelUuid,
+ url: "{{{api}}}get=timestamp&uuid=" + panelUuid,
dataType: 'json',
cache: false,
timeout: 5000,
success: function (result) {
if (!result || !result.ts) {
- console.log('Warning: get=timestamp didnt return json with ts field');
+ console.log("Warning: get=timestamp didn't return json with ts field");
return;
}
if (globalConfig.ts && globalConfig.ts !== result.ts) {
@@ -1499,13 +1531,13 @@ optional:
*/
function queryRooms() {
$.ajax({
- url: "{{dirprefix}}api.php?do=locationinfo&get=machines&uuid=" + panelUuid,
+ url: "{{{api}}}get=machines&uuid=" + panelUuid,
dataType: 'json',
cache: false,
timeout: 30000,
success: function (result) {
if (!result || result.constructor !== Array) {
- console.log('Warning: get=machines didnt return array');
+ console.log("Warning: get=machines didn't return array");
return;
}
for (var i = 0; i < result.length; i++) {
@@ -1548,8 +1580,14 @@ optional:
freePcs++;
}
}
-
- $div.removeClass('BROKEN OFFLINE IDLE OCCUPIED STANDBY'.replace(update[i].pcState, '')).addClass(update[i].pcState);
+ if (!$div.hasClass(update[i].pcState)) {
+ $div.removeClass('BROKEN OFFLINE IDLE OCCUPIED STANDBY'.replace(update[i].pcState, '')).addClass(update[i].pcState);
+ }
+ if (!$div.hasClass('rm-' + update[i].runmode)) {
+ $div.removeClass(function (i, cn) {
+ return (cn.match(/\brm-\S*/g) || []).join(' ');
+ }).addClass('rm-' + update[i].runmode);
+ }
}
room.freePcs = freePcs;
room.numPcs = numPcs;
@@ -1614,7 +1652,6 @@ optional:
scaleCalendar(rooms[property]);
scaleRoom(rooms[property]);
}
- SetProgressBarSpeed();
}, 200);
});
@@ -1663,56 +1700,59 @@ optional:
}
}
-
/**
* Used in Mode 4, switches given room from Timetable to room layout and vice versa
*/
- function switchLayouts() {
+ roomSwitchIntervals = [];
+ progressBarUpdateIntervals = [];
+ lastSwitchTimes = [];
+
+ function switchRoomLayout(room) {
+ if (room.config.mode !== 4) return;
+ if (room.$.layout.is(':visible')) {
+ room.$.layout.hide();
+ room.$.calendar.show();
+ } else {
+ room.$.layout.show();
+ room.$.calendar.hide();
+ }
+ lastSwitchTimes[room.id] = MyDate().getTime();
+ resizeIfRequired(room);
+
+ }
+
+ function generateRoomSwitchIntervals() {
for (var roomKey in rooms) {
- var room = rooms[roomKey];
+ const room = rooms[roomKey];
if (room.config.mode !== 4) continue;
- if (room.$.layout.is(':visible')) {
- room.$.layout.hide();
- room.$.calendar.show();
- } else {
- room.$.layout.show();
- room.$.calendar.hide();
- }
- resizeIfRequired(room);
+ if (roomSwitchIntervals[room.id]) clearInterval(roomSwitchIntervals[room.id]);
+ lastSwitchTimes[room.id] = MyDate().getTime();
+ generateProgressBar(room);
+ var interval = room.config.switchtime;
+ roomSwitchIntervals[room.id] = setInterval(function () {
+ switchRoomLayout(room);
+ }, interval);
}
- lastSwitchTime = MyDate().getTime();
}
- var $pbar = false;
- var pbarTimer = false;
- const PX_PER_SEC_TARGET = 10;
+ function generateProgressBar(room) {
+ if ($('#progressbar_' + room.id).length > 0 ) return;
+ var $progressBar = $('<div class="progressbar">').attr('id', 'progressbar_' + room.id);
+ $('#' + room.id).append($progressBar);
- /**
- * adds a progressbar (id) used in mode 4
- */
- function generateProgressBar() {
- if ($pbar) return;
- $pbar = $('<div class="progressbar">');
- $('body').append($pbar);
- SetProgressBarSpeed();
- }
+ if (progressBarUpdateIntervals[room.id]) clearInterval(progressBarUpdateIntervals[room.id]);
- function SetProgressBarSpeed() {
- if (!$pbar || !globalConfig.switchtime) return;
- if (pbarTimer) clearInterval(pbarTimer);
var interval = 1000;
if (!globalConfig.eco) {
- var pxPerMSec = $('body').width() / globalConfig.switchtime;
- interval = Math.max(1 / (pxPerMSec / PX_PER_SEC_TARGET), 100);
+ var pxPerMSec = $('body').width() / room.config.switchtime;
+ interval = Math.max(1 / (pxPerMSec / 100), 100);
}
- pbarTimer = setInterval(function () {
- var width = ((MyDate().getTime() - lastSwitchTime) / globalConfig.switchtime) * 100;
+
+ progressBarUpdateIntervals[room.id] = setInterval(function() {
+ var width = ((MyDate().getTime() - lastSwitchTimes[room.id]) / room.config.switchtime) * 100;
if (width < 0) width = 0;
- if (width >= 100) {
- width = 100;
- switchLayouts();
- }
- $pbar.width(width + '%');
+ if (width >= 100) width = 100;
+ $('#progressbar_' + room.id).width(width + '%');
}, interval);
}
diff --git a/modules-available/locationinfo/templates/frontend-summary.html b/modules-available/locationinfo/templates/frontend-summary.html
index ae089da5..136ac3a5 100644
--- a/modules-available/locationinfo/templates/frontend-summary.html
+++ b/modules-available/locationinfo/templates/frontend-summary.html
@@ -2,8 +2,8 @@
<html lang="{{language}}">
<meta name="viewport" content="width=device-width, initial-scale=1.0" charset="utf-8">
<head>
- <script type='text/javascript' src='{{dirprefix}}script/jquery.js'></script>
- <script type='text/javascript' src='{{dirprefix}}modules/locationinfo/frontend/frontendscript.js'></script>
+ <script type='text/javascript' src='script/jquery.js'></script>
+ <script type='text/javascript' src='modules/locationinfo/frontend/frontendscript.js'></script>
<style type='text/css'>
@@ -244,12 +244,12 @@
}
/**
- * Querys Pc states
+ * Queries Pc states
* Room are queried with the {{uuid}} of the panel.
*/
function queryRooms() {
$.ajax({
- url: "{{dirprefix}}api.php?do=locationinfo&get=pcstates&uuid={{uuid}}",
+ url: "{{{api}}}get=pcstates&uuid={{uuid}}",
dataType: 'json',
cache: false,
timeout: 30000,
@@ -483,7 +483,7 @@
}
/**
- * Retruns next Opening
+ * Returns next Opening
* @param room Room Object
* @returns bestdate Date Object of next opening
*/
@@ -691,14 +691,14 @@
/**
- * querys the Calendar data
- * Calender is queried with the {{uuid}} of the panel.
+ * queries the Calendar data
+ * Calendar is queried with the {{uuid}} of the panel.
* api.inc.php / page.inc.php is getting the ids with the panel uuid.
*/
function queryCalendars() {
- var url = "{{dirprefix}}api.php?do=locationinfo&get=calendar&uuid={{uuid}}";
+ var url = "{{{api}}}get=calendar&uuid={{uuid}}";
- // Todo reimplement Frontend methode if needed
+ // Todo reimplement Frontend method if needed
/*
if(!(room.config.calendarqueryurl === undefined)) {
url = room.config.calendarqueryurl;
@@ -723,13 +723,13 @@
*/
function queryPanelChange() {
$.ajax({
- url: "{{dirprefix}}api.php?do=locationinfo&get=timestamp&uuid={{uuid}}",
+ url: "{{{api}}}get=timestamp&uuid={{uuid}}",
dataType: 'json',
cache: false,
timeout: 5000,
success: function (result) {
if (!result || !result.ts) {
- console.log('Warning: get=timestamp didnt return json with ts field');
+ console.log("Warning: get=timestamp didn't return json with ts field");
return;
}
if (config.ts && config.ts !== result.ts) {
diff --git a/modules-available/locationinfo/templates/page-config-panel-default.html b/modules-available/locationinfo/templates/page-config-panel-default.html
index eee01875..a289d26a 100644
--- a/modules-available/locationinfo/templates/page-config-panel-default.html
+++ b/modules-available/locationinfo/templates/page-config-panel-default.html
@@ -20,108 +20,117 @@
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">{{lang_generalSettings}}</div>
- <div class="panel-body">
- <div class="list-group">
+ <div class="list-group">
- <div class="list-group-item">
- <div class="row">
- <div class="col-sm-3">
- <label for="panel-title">{{lang_displayName}}</label>
- </div>
- <div class="col-sm-7">
- <input class="form-control" name="name" id="panel-title" type="text" value="{{panelname}}">
- </div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_displayNameTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- </div>
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="panel-title">{{lang_displayName}}</label>
+ </div>
+ <div class="col-sm-8">
+ <input class="form-control" name="name" id="panel-title" type="text" value="{{panelname}}">
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_displayNameTooltip}}
</div>
</div>
+ </div>
- <div class="list-group-item">
- <div class="row">
- <div class="col-sm-3">
- <label for="language">{{lang_language}}</label>
- </div>
- <div class="col-sm-7">
- <select class="form-control" name="language" id="language">
- {{#languages}}
- <option value="{{cc}}" id="lang-{{cc}}" {{selected}}>{{name}}</option>
- {{/languages}}
- </select>
- </div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_languageTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- </div>
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="language">{{lang_language}}</label>
+ </div>
+ <div class="col-sm-8">
+ <select class="form-control" name="language" id="language">
+ {{#languages}}
+ <option value="{{cc}}" id="lang-{{cc}}" {{selected}}>{{name}}</option>
+ {{/languages}}
+ </select>
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_languageTooltip}}
</div>
</div>
+ </div>
- <div class="list-group-item">
- <div class="row">
- <div class="col-sm-3">
- <label for="input-eco">{{lang_ecoMode}}</label>
- </div>
- <div class="col-sm-7">
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="input-eco">{{lang_ecoMode}}</label>
+ </div>
+ <div class="col-sm-8">
+ <div class="checkbox">
<input id="input-eco" type="checkbox" name="eco" {{eco_checked}}>
+ <label></label>
</div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_ecoTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- </div>
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_ecoTooltip}}
</div>
</div>
+ </div>
- <div class="list-group-item">
- <div class="row">
- <div class="col-sm-3">
- <label for="input-prettytime">{{lang_prettytime}}</label>
- </div>
- <div class="col-sm-7">
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="input-prettytime">{{lang_prettytime}}</label>
+ </div>
+ <div class="col-sm-8">
+ <div class="checkbox">
<input id="input-prettytime" type="checkbox" name="prettytime" {{prettytime_checked}}>
+ <label></label>
</div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_prettytimeTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- </div>
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_prettytimeTooltip}}
</div>
</div>
+ </div>
- <div class="list-group-item">
- <div class="row">
- <div class="col-sm-3">
- <label for="input-calupdate">{{lang_calendarUpdate}}</label>
- </div>
- <div class="col-sm-7">
- <input class="form-control" name="calupdate" type="number" min="30" id="input-calupdate"
- max="1440" value="{{calupdate}}" required>
- </div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_calupdateTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
+ <div class="list-group-item m1-s m2-h m3-s m4-s">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="show-hostname">{{lang_showHostname}}</label>
+ </div>
+ <div class="col-sm-8">
+ <div class="checkbox">
+ <input id="show-hostname" class="btstrpCheckbox" type="checkbox" name="hostname" {{hostname_checked}}>
+ <label></label>
</div>
</div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_hostnameTooltip}}
+ </div>
</div>
+ </div>
- <div class="list-group-item">
- <div class="row">
- <div class="col-sm-3">
- <label for="form-roomupdate">{{lang_roomUpdate}}</label>
- </div>
- <div class="col-sm-7">
- <input class="form-control" name="roomupdate" type="number" min="15" id="form-roomupdate"
- max="86400" value="{{roomupdate}}" required>
- </div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_roomupdateTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- </div>
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="input-calupdate">{{lang_calendarUpdate}}</label>
+ </div>
+ <div class="col-sm-8">
+ <input class="form-control" name="calupdate" type="number" min="30" id="input-calupdate"
+ max="1440" value="{{calupdate}}" required>
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_calupdateTooltip}}
+ </div>
+ </div>
+ </div>
+
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="form-roomupdate">{{lang_roomUpdate}}</label>
+ </div>
+ <div class="col-sm-8">
+ <input class="form-control" name="roomupdate" type="number" min="15" id="form-roomupdate"
+ max="86400" value="{{roomupdate}}" required>
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_roomupdateTooltip}}
</div>
</div>
</div>
@@ -132,181 +141,164 @@
<div class="col-md-6">
<div class="panel panel-default" id="extra-div">
<div class="panel-heading">{{lang_mode}}</div>
- <div class="panel-body">
- <div class="list-group" id="overridableConfigs">
- <div class="list-group-item">
- <div class="row">
- <div class="col-sm-3">
- <label for="mode">{{lang_mode}}</label>
- </div>
- <div class="col-sm-7">
- <select class="form-control" name="mode" id="mode" onchange="modeChange()">
- <option value="1" id="mode1">{{lang_mode1}}</option>
- <option value="2" id="mode2">{{lang_mode2}}</option>
- <option value="3" id="mode3">{{lang_mode3}}</option>
- <option value="4" id="mode4">{{lang_mode4}}</option>
- </select>
- </div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_modeTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- </div>
+ <div class="list-group" id="overridableConfigs">
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="mode">{{lang_mode}}</label>
+ </div>
+ <div class="col-sm-8">
+ <select class="form-control" name="mode" id="mode" onchange="modeChange(id)">
+ <option value="1" id="mode1">{{lang_mode1}}</option>
+ <option value="2" id="mode2">{{lang_mode2}}</option>
+ <option value="3" id="mode3">{{lang_mode3}}</option>
+ <option value="4" id="mode4">{{lang_mode4}}</option>
+ </select>
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_modeTooltip}}
</div>
</div>
+ </div>
- <div class="list-group-item">
- <div class="row">
- <div class="col-sm-3">
- <label for="input-roomplanner">{{lang_useRoomplanner}}</label>
- </div>
- <div class="col-sm-7">
- <select class="form-control" name="roomplanner" id="input-roomplanner">
- <option value="0" id="roomplanner0">{{lang_countIp}}</option>
- <option value="1" id="roomplanner1">{{lang_countRoomplan}}</option>
- </select>
- </div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_roomplannerTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- </div>
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="input-roomplanner">{{lang_useRoomplanner}}</label>
+ </div>
+ <div class="col-sm-8">
+ <select class="form-control" name="roomplanner" id="input-roomplanner">
+ <option value="0" id="roomplanner0">{{lang_countIp}}</option>
+ <option value="1" id="roomplanner1">{{lang_countRoomplan}}</option>
+ </select>
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_roomplannerTooltip}}
</div>
</div>
+ </div>
- <div class="list-group-item m1-s m2-h m3-h m4-h">
- <div class="row">
- <div class="col-sm-3">
- <label for="input-vertical">{{lang_vertical}}</label>
- </div>
- <div class="col-sm-7">
- <input id="input-vertical" class="btstrpCheckbox" type="checkbox" name="vertical" {{vertical_checked}}>
- </div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_verticalTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- </div>
+ <div class="list-group-item m1-s m2-h m3-h m4-h">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="input-vertical">{{lang_vertical}}</label>
+ </div>
+ <div class="col-sm-8">
+ <input id="input-vertical" class="btstrpCheckbox" type="checkbox" name="vertical" {{vertical_checked}}>
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_verticalTooltip}}
</div>
</div>
+ </div>
- <div class="list-group-item m1-s m2-s m3-h m4-s">
- <div class="row">
- <div class="col-sm-3">
- <label for="startday">{{lang_startDay}}</label>
- </div>
- <div class="col-sm-7">
- <select class="form-control" id="startday" name="startday">
- <option value="0">{{lang_currentDay}}</option>
- <option value="1">{{lang_monday}}</option>
- <option value="2">{{lang_thuesday}}</option>
- <option value="3">{{lang_wednesday}}</option>
- <option value="4">{{lang_thursday}}</option>
- <option value="5">{{lang_friday}}</option>
- <option value="6">{{lang_saturday}}</option>
- <option value="7">{{lang_sunday}}</option>
- </select>
- </div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_startDayTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- </div>
+ <div class="list-group-item m1-s m2-s m3-h m4-s">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="startday">{{lang_startDay}}</label>
+ </div>
+ <div class="col-sm-8">
+ <select class="form-control" id="startday" name="startday">
+ <option value="0">{{lang_currentDay}}</option>
+ <option value="1">{{lang_monday}}</option>
+ <option value="2">{{lang_thuesday}}</option>
+ <option value="3">{{lang_wednesday}}</option>
+ <option value="4">{{lang_thursday}}</option>
+ <option value="5">{{lang_friday}}</option>
+ <option value="6">{{lang_saturday}}</option>
+ <option value="7">{{lang_sunday}}</option>
+ </select>
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_startDayTooltip}}
</div>
</div>
+ </div>
- <div class="list-group-item m1-s m2-s m3-h m4-s">
- <div class="row">
- <div class="col-sm-3">
- <label for="scaledaysauto">{{lang_autoScale}}</label>
- </div>
- <div class="col-sm-7">
+ <div class="list-group-item m1-s m2-s m3-h m4-s">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="scaledaysauto">{{lang_autoScale}}</label>
+ </div>
+ <div class="col-sm-8">
+ <div class="checkbox">
<input id="scaledaysauto" class="btstrpCheckbox" type="checkbox" name="scaledaysauto" {{scaledaysauto_checked}}>
+ <label></label>
</div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_autoscaleTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- </div>
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_autoscaleTooltip}}
</div>
</div>
+ </div>
- <div class="list-group-item m1-s m2-s m3-h m4-s">
- <div class="row">
- <div class="col-sm-3">
- <label for="daystoshow">{{lang_daysToShow}}</label>
- </div>
- <div class="col-sm-7">
- <select class="form-control" id="daystoshow" name="daystoshow">
- <option value="1">1</option>
- <option value="2">2</option>
- <option value="3">3</option>
- <option value="4">4</option>
- <option value="5">5</option>
- <option value="6">6</option>
- <option value="7">7</option>
- </select>
- </div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_daysToShowTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- </div>
+ <div class="list-group-item m1-s m2-s m3-h m4-s">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="daystoshow">{{lang_daysToShow}}</label>
+ </div>
+ <div class="col-sm-8">
+ <select class="form-control" id="daystoshow" name="daystoshow">
+ <option value="1">1</option>
+ <option value="2">2</option>
+ <option value="3">3</option>
+ <option value="4">4</option>
+ <option value="5">5</option>
+ <option value="6">6</option>
+ <option value="7">7</option>
+ </select>
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_daysToShowTooltip}}
</div>
</div>
+ </div>
- <div class="list-group-item m1-s m2-h m3-s m4-s">
- <div class="row">
- <div class="col-sm-3">
- <label for="rotation">{{lang_rotation}}</label>
- </div>
- <div class="col-sm-7">
- <select class="form-control" id="rotation" name="rotation">
- <option value="0">{{lang_rotation0}}</option>
- <option value="3">{{lang_rotation3}}</option>
- <option value="2">{{lang_rotation2}}</option>
- <option value="1">{{lang_rotation1}}</option>
- </select>
- </div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_rotationTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- </div>
+ <div class="list-group-item m1-s m2-h m3-s m4-s">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="rotation">{{lang_rotation}}</label>
+ </div>
+ <div class="col-sm-8">
+ <select class="form-control" id="rotation" name="rotation">
+ <option value="0">{{lang_rotation0}}</option>
+ <option value="3">{{lang_rotation3}}</option>
+ <option value="2">{{lang_rotation2}}</option>
+ <option value="1">{{lang_rotation1}}</option>
+ </select>
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_rotationTooltip}}
</div>
</div>
+ </div>
- <div class="list-group-item m1-s m2-h m3-h m4-h">
- <div class="row">
- <div class="col-sm-3">
- <label for="input-scale">{{lang_scale}}</label>
- </div>
- <div class="col-sm-7">
- <span><span class="range-display"></span>&thinsp;%</span>
- <input id="input-scale" name="scale" type="range" step="1" min="10" max="90" value="{{scale}}">
- </div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_scaleTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- </div>
+ <div class="list-group-item m1-s m2-h m3-h m4-h">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="input-scale">{{lang_scale}}</label>
+ </div>
+ <div class="col-sm-8">
+ <span><span class="range-display"></span>&thinsp;%</span>
+ <input id="input-scale" name="scale" type="range" step="1" min="10" max="90" value="{{scale}}">
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_scaleTooltip}}
</div>
</div>
+ </div>
- <div class="list-group-item m1-h m2-h m3-h m4-s">
- <div class="row">
- <div class="col-sm-3">
- <label for="input-switchtime">{{lang_switchTime}}</label>
- </div>
- <div class="col-sm-7">
- <span><span class="range-display"></span>&thinsp;{{lang_sec}}</span>
- <input id="input-switchtime" name="switchtime" type="range" step="1" min="1" max="120" value="{{switchtime}}">
- </div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_switchTimeTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- </div>
+ <div class="list-group-item m1-h m2-h m3-h m4-s">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="input-switchtime">{{lang_switchTime}}</label>
+ </div>
+ <div class="col-sm-8">
+ <span><span class="range-display"></span>&thinsp;{{lang_sec}}</span>
+ <input id="input-switchtime" name="switchtime" type="range" step="1" min="1" max="120" value="{{switchtime}}">
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_switchTimeTooltip}}
</div>
</div>
</div>
@@ -384,9 +376,6 @@ document.addEventListener("DOMContentLoaded", function () {
var $owPanels = $('#overrideRoomConfigs');
var overrides = {{{overrides}}}
- // Initialize fancy tooltips
- $('p.helptext').tooltip();
-
// Add listener to range sliders so their label can be updated
var $range = $('input[type="range"]');
$range.change(function () {
@@ -394,8 +383,7 @@ document.addEventListener("DOMContentLoaded", function () {
});
// Set state of input controls that aren't statically initialized server side
$('.modify-inputs input[type="checkbox"]')
- .bootstrapSwitch({size: 'small'})
- .on('switchChange.bootstrapSwitch', function () {
+ .on('click', function () {
if (this.name === 'scaledaysauto') {
$('#daystoshow').prop('disabled', this.checked);
}
@@ -418,7 +406,7 @@ document.addEventListener("DOMContentLoaded", function () {
$('#daystoshow').prop('disabled', document.getElementById('scaledaysauto').checked);
$range.change();
- modeChange();
+ modeChange('mode');
// Adding/removing locations
$locList.find('a').click(function(ev) {
@@ -455,12 +443,12 @@ document.addEventListener("DOMContentLoaded", function () {
// Add Panel for overwriting room specific config
$owPanels.find('#overrideRoom' + id).remove();
$owPanels.append('<div class="col-md-6" id="overrideRoom' + id + '">' +
- '<div class="panel panel-default">' +
+ '<div class="panel panel-default body-dest">' +
'<div class="panel-heading clearfix">' +
name + ' {{lang_configOverride}}' +
'<div class="checkbox-inline pull-right">' +
'<input type="checkbox" id="override' + id + '" name="override' + id + '"/>' +
- '</div></div><div class="panel-body"></div></div></div>');
+ '</div></div></div></div>');
// Load content into panel
@@ -485,26 +473,10 @@ document.addEventListener("DOMContentLoaded", function () {
var overVal = false;
if (overrides !== null && overrides[locId] !== undefined) overVal = true;
- // Make Bootstrap switches to normal checkboxes to be able to clone them correctly
- var btstrpCheckboxes = $('.modify-inputs input[class="btstrpCheckbox"]');
- btstrpCheckboxes.bootstrapSwitch('destroy');
-
// Clone needed content
var $contentCopy = $('#overridableConfigs').clone(true);
$contentCopy.closest('#overridableConfigs').prop('id', '');
- // Change mode show/hide classes
- $contentCopy.find('.m1-s, .m1-h, .m2-s, .m2-h, .m3-s, .m3-h, .m4-s, .m4-h').each(function() {
- if ($( this ).hasClass('m1-s')) $( this ).removeClass('m1-s').addClass('om1-s');
- else if ($( this ).hasClass('m1-h')) $( this ).removeClass('m1-h').addClass('om1-h');
- if ($( this ).hasClass('m2-s')) $( this ).removeClass('m2-s').addClass('om2-s');
- else if ($( this ).hasClass('m2-h')) $( this ).removeClass('m2-h').addClass('om2-h');
- if ($( this ).hasClass('m3-s')) $( this ).removeClass('m3-s').addClass('om3-s');
- else if ($( this ).hasClass('m3-h')) $( this ).removeClass('m3-h').addClass('om3-h');
- if ($( this ).hasClass('m4-s')) $( this ).removeClass('m4-s').addClass('om4-s');
- else if ($( this ).hasClass('m4-h')) $( this ).removeClass('m4-h').addClass('om4-h');
- });
-
// Change labels
$contentCopy.find('label').each(function() {
var oldFor = $( this ).attr('for');
@@ -515,7 +487,6 @@ document.addEventListener("DOMContentLoaded", function () {
$contentCopy.find('select').each(function() {
var oldId = $( this ).attr('id');
var oldName = $( this ).attr('name');
- if (oldId === 'mode') $( this ).attr('onchange', 'modeChangeOverride(id)');
$( this ).attr('id', 'override' + locId + oldId);
$( this ).attr('name', 'override' + locId + oldName);
$( this ).val(overVal ? overrides[locId][oldName] : $('#' + oldId).val());
@@ -539,20 +510,19 @@ document.addEventListener("DOMContentLoaded", function () {
if ($( this ).attr('type') === 'range') $( this ).val(overVal ? overrides[locId][oldName] : $('#' + oldId).val());
else if ($( this ).attr('type') === 'checkbox') {
$( this )
- .bootstrapSwitch({size: 'small'})
- .on('switchChange.bootstrapSwitch', function () {
+ .on('click', function () {
var regex = RegExp('[a-b0-9]*scaledaysauto');
var substr = this.name.substring(0, this.name.length - 13);
if (regex.test(this.name)) {
$('#' + substr + 'daystoshow').prop('disabled', this.checked);
}
});
- $( this ).bootstrapSwitch('state', overVal ? overrides[locId][oldName] : $('#' + oldId).val());
+ $( this ).prop('checked', overVal ? overrides[locId][oldName] : $('#' + oldId).prop('checked'));
}
});
// Append copied content to location specific <div>
- var $panelBody = $('#overrideRoom' + locId).find('div.panel-body');
+ var $panelBody = $('#overrideRoom' + locId).find('div.body-dest');
$panelBody.append($contentCopy);
// Specific extra stuff needed
@@ -561,18 +531,7 @@ document.addEventListener("DOMContentLoaded", function () {
$('#override' + locId + 'daystoshow').prop('disabled', document.getElementById('override' + locId + 'scaledaysauto').checked);
// Call modeChange once to correctly show/hide fields
- modeChangeOverride('override'+ locId + 'mode');
-
- // Recreate Bootstrap switches from checkboxes
- btstrpCheckboxes
- .bootstrapSwitch({size: 'small'})
- .on('switchChange.bootstrapSwitch', function () {
- var regex = RegExp('[a-b0-9]*scaledaysauto');
- var substr = this.name.substring(0, this.name.length - 13);
- if (regex.test(this.name)) {
- $('#' + substr + 'daystoshow').prop('disabled', this.checked);
- }
- });
+ modeChange('override'+ locId + 'mode');
// Add listener to range inputs for updating value text
var range = $('input[type="range"]');
@@ -583,7 +542,7 @@ document.addEventListener("DOMContentLoaded", function () {
}
function unloadOverrideContent(id) {
- var $panelBody = $('#overrideRoom' + id).find('div.panel-body');
+ var $panelBody = $('#overrideRoom' + id).find('div.body-dest');
$panelBody.find('div.list-group').remove();
if (overrides !== null && overrides[id] !== undefined) delete overrides[id]
}
@@ -609,18 +568,13 @@ document.addEventListener("DOMContentLoaded", function () {
/**
* If the mode was changed the mode settings have to be adjusted.
*/
-function modeChange() {
- var value = $('#mode').val();
- $('.m' + value + '-h').hide();
- $('.m' + value + '-s').show();
-}
-function modeChangeOverride(id) {
- id = id.replace('override', '');
- id = id.replace('mode', '');
- var value = $('#override' + id + 'mode').val();
- $('#overrideRoom' + id + ' .om' + value + '-h').hide();
- $('#overrideRoom' + id + ' .om' + value + '-s').show();
+function modeChange(id) {
+ var isOverride = (id !== 'mode');
+ if (isOverride) id = id.replace('override', '').replace('mode', '');
+ var value = $('#' + (isOverride ? 'override' + id : '') + 'mode').val();
+ $('#' + (isOverride ? 'overrideRoom' + id : 'extra-div') + ' .m' + value + '-h').hide();
+ $('#' + (isOverride ? 'overrideRoom' + id : 'extra-div') + ' .m' + value + '-s').show();
}
//--></script>
diff --git a/modules-available/locationinfo/templates/page-config-panel-summary.html b/modules-available/locationinfo/templates/page-config-panel-summary.html
index 2dc556ce..a7a34217 100644
--- a/modules-available/locationinfo/templates/page-config-panel-summary.html
+++ b/modules-available/locationinfo/templates/page-config-panel-summary.html
@@ -16,97 +16,88 @@
<div class="col-md-6">
<div class="modify-inputs panel panel-default">
<div class="panel-heading">{{lang_display}}</div>
- <div class="panel-body">
- <div class="list-group">
+ <div class="list-group">
- <div class="list-group-item">
- <div class="row">
- <div class="col-sm-3">
- <label for="panel-title">{{lang_displayName}}</label>
- </div>
- <div class="col-sm-7">
- <input class="form-control" name="name" id="panel-title" type="text" value="{{panelname}}">
- </div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_displayNameTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- </div>
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="panel-title">{{lang_displayName}}</label>
+ </div>
+ <div class="col-sm-8">
+ <input class="form-control" name="name" id="panel-title" type="text" value="{{panelname}}">
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_displayNameTooltip}}
</div>
</div>
+ </div>
- <div class="list-group-item">
- <div class="row">
- <div class="col-sm-3">
- <label for="language">{{lang_language}}</label>
- </div>
- <div class="col-sm-7">
- <select class="form-control" name="language" id="language">
- {{#languages}}
- <option value="{{cc}}" id="lang-{{cc}}" {{selected}}>{{name}}</option>
- {{/languages}}
- </select>
- </div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_languageTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- </div>
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="language">{{lang_language}}</label>
+ </div>
+ <div class="col-sm-8">
+ <select class="form-control" name="language" id="language">
+ {{#languages}}
+ <option value="{{cc}}" id="lang-{{cc}}" {{selected}}>{{name}}</option>
+ {{/languages}}
+ </select>
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_languageTooltip}}
</div>
</div>
+ </div>
- <div class="list-group-item">
- <div class="row">
- <div class="col-sm-3">
- <label for="input-eco">{{lang_ecoMode}}</label>
- </div>
- <div class="col-sm-7">
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="input-eco">{{lang_ecoMode}}</label>
+ </div>
+ <div class="col-sm-8">
+ <div class="checkbox">
<input id="input-eco" type="checkbox" name="eco" {{eco_checked}}>
- </div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_ecoTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
+ <label></label>
</div>
</div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_ecoTooltip}}
+ </div>
</div>
+ </div>
- <div class="list-group-item">
- <div class="row">
- <div class="col-sm-3">
- <label for="input-roomplanner">{{lang_useRoomplanner}}</label>
- </div>
- <div class="col-sm-7">
- <select class="form-control" name="roomplanner" id="input-roomplanner">
- <option value="0" id="roomplanner0">{{lang_countIp}}</option>
- <option value="1" id="roomplanner1">{{lang_countRoomplan}}</option>
- </select>
- </div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_roomplannerTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- </div>
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="input-roomplanner">{{lang_useRoomplanner}}</label>
+ </div>
+ <div class="col-sm-8">
+ <select class="form-control" name="roomplanner" id="input-roomplanner">
+ <option value="0" id="roomplanner0">{{lang_countIp}}</option>
+ <option value="1" id="roomplanner1">{{lang_countRoomplan}}</option>
+ </select>
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_roomplannerTooltip}}
</div>
</div>
+ </div>
<!--
- <div class="list-group-item">
- <div class="row">
- <div class="col-sm-3">
- <label for="input-prettytime">{{lang_prettytime}}</label>
- </div>
- <div class="col-sm-7">
- <input id="input-prettytime" type="checkbox" name="prettytime" {{prettytime_checked}}>
- </div>
- <div class="col-sm-2">
- <a class="btn btn-default helptext" title="{{lang_prettytimeTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </a>
- </div>
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="input-prettytime">{{lang_prettytime}}</label>
+ </div>
+ <div class="col-sm-8">
+ <input id="input-prettytime" type="checkbox" name="prettytime" {{prettytime_checked}}>
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_prettytimeTooltip}}
</div>
</div>
--->
</div>
+-->
</div>
</div>
</div>
@@ -114,27 +105,23 @@
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">{{lang_updateRates}}</div>
- <div class="panel-body">
- <div class="list-group">
+ <div class="list-group">
- <div class="list-group-item">
- <div class="row">
- <div class="col-sm-3">
- <label for="form-panelupdate">{{lang_panel}}</label>
- </div>
- <div class="col-sm-7">
- <input class="form-control" name="panelupdate" type="number" min="15" id="form-panelupdate"
- max="86400" value="{{panelupdate}}" required>
- </div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_summaryUpdateIntervalTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- </div>
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="form-panelupdate">{{lang_panel}}</label>
+ </div>
+ <div class="col-sm-8">
+ <input class="form-control" name="panelupdate" type="number" min="15" id="form-panelupdate"
+ max="86400" value="{{panelupdate}}" required>
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_summaryUpdateIntervalTooltip}}
</div>
</div>
-
</div>
+
</div>
</div>
</div>
@@ -202,16 +189,11 @@ document.addEventListener("DOMContentLoaded", function () {
var $locList = $('#location-list');
var $locInput = $('#locationids');
- // Initialize fancy tooltips
- $('p.helptext').tooltip();
// Add listener to range sliders so their label can be updated
$('input[type="range"]').change(function () {
$(this).siblings().find('.range-display').text($(this).val());
});
// Set state of input controls that aren't statically initialized server side
- $('.modify-inputs input[type="checkbox"]')
- .bootstrapSwitch({size: 'small'});
-
var lids = $locInput.val().split(',');
$selLocs.empty();
for (var i = 0; i < lids.length; ++i) {
diff --git a/modules-available/locationinfo/templates/page-config-panel-url.html b/modules-available/locationinfo/templates/page-config-panel-url.html
index 57b518ce..3aaf8620 100644
--- a/modules-available/locationinfo/templates/page-config-panel-url.html
+++ b/modules-available/locationinfo/templates/page-config-panel-url.html
@@ -13,102 +13,227 @@
<div class="panel panel-default">
<div class="panel-heading">{{lang_display}}</div>
- <div class="panel-body">
- <div class="list-group">
+ <div class="list-group">
- <div class="list-group-item">
- <div class="row">
- <div class="col-sm-3">
- <label for="panel-title">{{lang_displayName}}</label>
- </div>
- <div class="col-sm-7">
- <input class="form-control" name="name" id="panel-title" type="text" value="{{panelname}}">
- </div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_displayNameTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- </div>
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="panel-title">{{lang_displayName}}</label>
+ </div>
+ <div class="col-sm-8">
+ <input class="form-control" name="name" id="panel-title" type="text" value="{{panelname}}">
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_displayNameTooltip}}
</div>
</div>
+ </div>
- <div class="list-group-item">
- <div class="row">
- <div class="col-sm-3">
- <label for="panel-url">{{lang_url}}</label>
- </div>
- <div class="col-sm-7">
- <input class="form-control" name="url" id="panel-url" type="text" value="{{url}}"
- placeholder="http://www.bwlehrpool.de/" pattern=".*://.*" required>
- </div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_urlTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- </div>
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="panel-url">{{lang_url}}</label>
+ </div>
+ <div class="col-sm-8">
+ <input class="form-control" name="url" id="panel-url" type="text" value="{{url}}"
+ placeholder="http://www.bwlehrpool.de/" pattern=".*://.*" required>
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_urlTooltip}}
</div>
</div>
+ </div>
- <div class="list-group-item">
- <div class="row">
- <div class="col-sm-3">
- <label for="input-ssl">{{lang_insecureSsl}}</label>
- </div>
- <div class="col-sm-7">
- <div class="checkbox">
- <input id="input-ssl" type="checkbox" name="insecure-ssl" {{ssl_checked}} value="1">
- <label></label>
- </div>
- </div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_ignoreSslTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="input-ssl">{{lang_insecureSsl}}</label>
+ </div>
+ <div class="col-sm-8">
+ <div class="checkbox">
+ <input id="input-ssl" type="checkbox" name="insecure-ssl" {{ssl_checked}} value="1">
+ <label></label>
</div>
</div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_ignoreSslTooltip}}
+ </div>
</div>
+ </div>
- <div class="list-group-item">
- <div class="row">
- <div class="col-sm-3">
- <label for="input-reload">{{lang_reloadIntervalMins}}</label>
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-4">
+ <label>{{lang_allowTtySwitch}}</label>
+ </div>
+ <div class="col-sm-8">
+ <div class="radio">
+ <input id="tty1" type="radio" name="allow-tty" {{allow-tty__checked}} value="">
+ <label for="tty1">{{lang_useDefault}}</label>
</div>
- <div class="col-sm-7">
- <input class="form-control" id="input-reload" type="number" min="0" max="999" name="reloadminutes" pattern="\d*" value="{{reloadminutes}}">
+ <div class="radio">
+ <input id="tty2" type="radio" name="allow-tty" {{allow-tty_yes_checked}} value="yes">
+ <label for="tty2">{{lang_yes}}</label>
</div>
- <div class="col-sm-2">
- <p class="btn btn-static helptext" title="{{lang_reloadIntervalTooltip}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
+ <div class="radio">
+ <input id="tty3" type="radio" name="allow-tty" {{allow-tty_no_checked}} value="no">
+ <label for="tty3">{{lang_no}}</label>
</div>
</div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_allowTtySwitchTooltip}}
+ </div>
+ </div>
+ </div>
+
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="input-reload">{{lang_reloadIntervalMins}}</label>
+ </div>
+ <div class="col-sm-8">
+ <input class="form-control" id="input-reload" type="number" min="0" max="999" name="reloadminutes" pattern="\d*" value="{{reloadminutes}}">
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_reloadIntervalTooltip}}
+ </div>
+ </div>
+ </div>
+
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-6">
+ <label for="whitelist">{{lang_whitelist}}</label>
+ <textarea id="whitelist" name="whitelist" rows="10" class="form-control">{{whitelist}}</textarea>
+ </div>
+ <div class="col-sm-6">
+ <label for="blacklist">{{lang_blacklist}}</label>
+ <textarea id="blacklist" name="blacklist" rows="10" class="form-control">{{blacklist}}</textarea>
+ </div>
+ <div class="col-sm-12 slx-smallspace">
+ {{lang_urlListHelp}}
+ </div>
</div>
+ </div>
- <div class="list-group-item">
- <div class="row">
- <div class="col-sm-3">
- <div class="radio">
- <input type="radio" name="iswhitelist" value="1" {{iswhitelist_1_checked}} id="iswhitelist1" class="form-control">
- <label for="iswhitelist1">
- {{lang_urlWhitelist}}
- </label>
- </div>
- <div class="radio">
- <input type="radio" name="iswhitelist" value="0" {{iswhitelist_0_checked}} id="iswhitelist0" class="form-control">
- <label for="iswhitelist0">
- {{lang_urlBlacklist}}
- </label>
- </div>
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="split-login">{{lang_splitlogin}}</label>
+ </div>
+ <div class="col-sm-8">
+ <div class="checkbox">
+ <input id="split-login" type="checkbox" name="split-login" {{split-login_checked}} value="1">
+ <label></label>
</div>
- <div class="col-sm-7">
- <textarea name="urllist" rows="10" class="form-control">{{urllist}}</textarea>
- <p>{{lang_urlListHelp}}</p>
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_splitloginTooltip}}
+ </div>
+ </div>
+ </div>
+
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="browser">{{lang_browser}}</label>
+ </div>
+ <div class="col-sm-8">
+ <select class="form-control" name="browser" id="browser" onchange="browserChange()">
+ <option value="slx-browser" id="slx">{{lang_slxbrowser}}</option>
+ <option value="chromium" id="chrome">{{lang_chromium}}</option>
+ </select>
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_browserTooltip}}
+ </div>
+ </div>
+ </div>
+
+ <div class="list-group-item b0-h">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="interactive">{{lang_interactive}}</label>
+ </div>
+ <div class="col-sm-8">
+ <div class="checkbox">
+ <input id="interactive" type="checkbox" name="interactive" {{interactive_checked}} value="1">
+ <label></label>
</div>
- <div class="col-sm-2"></div>
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_interactiveTooltip}}
+ </div>
+ </div>
+ </div>
+
+ <div class="list-group-item b0-h" id="bookmarks">
+ <div class="row">
+ <div class="col-sm-4">
+ <label for="bookmarks">{{lang_bookmarks}}</label>
+ </div>
+ <div class="col-sm-8">
+ <button type="button" class="btn btn-success" onclick="addBookmark()">
+ <span class="glyphicon glyphicon-plus"></span>
+ </button>
+ </div>
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{lang_bookmarksTooltip}}
+ </div>
+ </div>
+ <div class="row" style="margin-top: 1em;" id="bookmarkRow" hidden>
+ <div class="col-sm-3 col-sm-offset-3">
+ <input class="form-control" name="bookmarkNames[]" type="text" value=""
+ placeholder="bwLehrpool">
+ </div>
+ <div class="col-sm-3">
+ <input class="form-control" name="bookmarkUrls[]" type="text" value=""
+ placeholder="https://www.bwlehrpool.de/" pattern=".*://.*">
+ </div>
+ <div class="col-sm-1">
+ <button type="button" class="btn btn-danger" onclick="$(this).closest('.row').remove()">
+ <span class="glyphicon glyphicon-minus"></span>
+ </button>
</div>
</div>
+ {{#bookmarks}}
+ <div class="row" style="margin-top: 1em;">
+ <div class="col-sm-3 col-sm-offset-3">
+ <input class="form-control" name="bookmarkNames[]" type="text" value="{{name}}"
+ placeholder="bwLehrpool" required>
+ </div>
+ <div class="col-sm-3">
+ <input class="form-control" name="bookmarkUrls[]" type="text" value="{{url}}"
+ 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()">
+ <span class="glyphicon glyphicon-minus"></span>
+ </button>
+ </div>
+ </div>
+ {{/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">
@@ -120,12 +245,41 @@
</div>
</form>
-<script type="text/javascript"><!--
+<script>
document.addEventListener("DOMContentLoaded", function () {
- // Initialize fancy tooltips
- $('p.helptext').tooltip();
-
+ // load value to dropdown menus
+ $('#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();
});
-//--></script>
+// Hide interactive-input if slx-browser is selected
+function browserChange() {
+ var value = $('#browser').val();
+ if (value !== 'slx-browser') {
+ $('.b0-h').show();
+ } else {
+ $('.b0-h').hide();
+ }
+}
+
+// Add another bookmark input field to the form
+function addBookmark() {
+ var rowCopy = $('#bookmarkRow').clone();
+ rowCopy.attr('id', '');
+ rowCopy.show();
+ rowCopy.find('input').each(function() {
+ $( this ).val('');
+ $( this ).prop('required', true);
+ });
+ $('#bookmarks').append(rowCopy);
+}
+
+</script>
diff --git a/modules-available/locationinfo/templates/page-locations.html b/modules-available/locationinfo/templates/page-locations.html
index f90a0f35..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}}
@@ -50,7 +50,7 @@
<input type="hidden" name="token" value="{{token}}">
<input type="hidden" name="action" value="writeLocationConfig">
<input type="hidden" name="openingtimes" id="json-openingtimes" value="">
- <div class="modal-header"><h2 id="location-modal-header"></h2></div>
+ <div class="modal-header"><h3 id="location-modal-header"></h3></div>
<div class="modal-body"></div>
<div class="modal-footer">
<a class="btn btn-default" data-dismiss="modal">{{lang_close}}</a>
@@ -65,35 +65,7 @@
</div>
</div>
-<div class="hidden" id="expert-template">
- <div class="row expert-row" style="margin-top:1em;border-top:1px solid #ddd">
- <div class="col-xs-12 days-box">
- <div class="pull-right checkbox checkbox-inline"><input type="checkbox" class="i-delete"><label><span class="glyphicon glyphicon-trash"></span></label></div>
- <div class="checkbox checkbox-inline"><input type="checkbox" class="i-Monday"><label>{{lang_shortMonday}}</label></div>
- <div class="checkbox checkbox-inline"><input type="checkbox" class="i-Tuesday"><label>{{lang_shortTuesday}}</label></div>
- <div class="checkbox checkbox-inline"><input type="checkbox" class="i-Wednesday"><label>{{lang_shortWednesday}}</label></div>
- <div class="checkbox checkbox-inline"><input type="checkbox" class="i-Thursday"><label>{{lang_shortThursday}}</label></div>
- <div class="checkbox checkbox-inline"><input type="checkbox" class="i-Friday"><label>{{lang_shortFriday}}</label></div>
- <div class="checkbox checkbox-inline"><input type="checkbox" class="i-Saturday"><label>{{lang_shortSaturday}}</label></div>
- <div class="checkbox checkbox-inline"><input type="checkbox" class="i-Sunday"><label>{{lang_shortSunday}}</label></div>
- </div>
- <div class="col-sm-6">
- <div class="input-group bootstrap-timepicker">
- <span class="input-group-addon"><span class="glyphicon glyphicon-time"></span></span>
- <input type="text" class="form-control timepicker2 i-openingtime" pattern="[0-9]{1,2}:[0-9]{2}">
- </div>
- </div>
- <div class="col-sm-6">
- <div class="input-group bootstrap-timepicker">
- <span class="input-group-addon"><span class="glyphicon glyphicon-time"></span></span>
- <input type="text" class="form-control timepicker2 i-closingtime" pattern="[0-9]{1,2}:[0-9]{2}">
- </div>
- </div>
- </div>
-</div>
-
-<script type="text/javascript"><!--
-
+<script>
document.addEventListener("DOMContentLoaded", function () {
/**
* Load a opening time modal of a location.
@@ -103,9 +75,7 @@ document.addEventListener("DOMContentLoaded", function () {
var locationId = $(this).data('locationid');
var locationName = $(this).text();
$('#location-modal-header').text("[" + locationId + "] " + locationName);
- $('#location-modal').modal('show').find('.modal-body').load("?do=locationinfo&action=config-location&id=" + locationId);
+ $('#location-modal').modal('show').find('.modal-body').text('...').load("?do=locationinfo&action=config-location&id=" + locationId);
});
- $('#settings-form').submit(submitLocationSettings);
});
-
-//--></script>
+</script>
diff --git a/modules-available/locationinfo/templates/page-servers.html b/modules-available/locationinfo/templates/page-servers.html
index 2f692078..86adecca 100644
--- a/modules-available/locationinfo/templates/page-servers.html
+++ b/modules-available/locationinfo/templates/page-servers.html
@@ -66,7 +66,7 @@
<div class="modal fade" id="myModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
- <div class="modal-header" id="myModalHeader"></div>
+ <div class="modal-header" id="myModalHeader"><h3>{{lang_backendSettings}}</h3></div>
<div class="modal-body" id="myModalBody"></div>
<div class="modal-footer">
<a class="btn btn-default" data-dismiss="modal">{{lang_close}}</a>
@@ -102,10 +102,9 @@
* @param serverid The id of the server.
*/
function loadServerSettingsModal(serverid) {
- $('#myModalHeader').text("{{lang_locationSettings}}").css("font-weight", "Bold");
$('#myModal .modal-dialog').css('width', '');
$('#myModal').modal('show');
- $('#myModalBody').load("?do=locationinfo&action=serverSettings&id=" + serverid);
+ $('#myModalBody').text('...').load("?do=locationinfo&action=serverSettings&id=" + serverid);
}
// ########### Server Table ###########
diff --git a/modules-available/locationinfo/templates/server-prop-bool.html b/modules-available/locationinfo/templates/server-prop-bool.html
index bd9dcc64..ee2b8121 100644
--- a/modules-available/locationinfo/templates/server-prop-bool.html
+++ b/modules-available/locationinfo/templates/server-prop-bool.html
@@ -1,16 +1,12 @@
<div class="list-group-item">
<div class="row">
- <div class="col-md-3"><label for="prop-{{property}}">{{title}}</label></div>
- <div class="col-md-7">
+ <div class="col-md-4"><label for="prop-{{property}}">{{title}}</label></div>
+ <div class="col-md-8">
<input class="settings-bs-switch" id="prop-{{property}}" type="checkbox" name="prop-{{property}}" value="1"
{{#currentvalue}}checked{{/currentvalue}}>
</div>
- <div class="col-md-2">
- {{#helptext}}
- <p class="btn btn-static" title="{{helptext}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- {{/helptext}}
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{helptext}}
</div>
</div>
</div>
diff --git a/modules-available/locationinfo/templates/server-prop-dropdown.html b/modules-available/locationinfo/templates/server-prop-dropdown.html
index d1351551..bcb0cd5a 100644
--- a/modules-available/locationinfo/templates/server-prop-dropdown.html
+++ b/modules-available/locationinfo/templates/server-prop-dropdown.html
@@ -1,19 +1,15 @@
<div class="list-group-item">
<div class="row">
- <div class="col-md-3"><label for="prop-{{property}}">{{title}}</label></div>
- <div class="col-md-7">
+ <div class="col-md-4"><label for="prop-{{property}}">{{title}}</label></div>
+ <div class="col-md-8">
<select class="form-control" id="prop-{{property}}" name="prop-{{property}}">
{{#select_list}}
<option {{#active}}selected{{/active}}>{{option}}</option>
{{/select_list}}
</select>
</div>
- <div class="col-md-2">
- {{#helptext}}
- <p class="btn btn-static" title="{{helptext}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- {{/helptext}}
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{helptext}}
</div>
</div>
</div>
diff --git a/modules-available/locationinfo/templates/server-prop-generic.html b/modules-available/locationinfo/templates/server-prop-generic.html
index 23ff1e4e..ca8234fe 100644
--- a/modules-available/locationinfo/templates/server-prop-generic.html
+++ b/modules-available/locationinfo/templates/server-prop-generic.html
@@ -1,16 +1,12 @@
<div class="list-group-item">
<div class="row">
- <div class="col-md-3"><label for="prop-{{property}}">{{title}}</label></div>
- <div class="col-md-7">
+ <div class="col-md-4"><label for="prop-{{property}}">{{title}}</label></div>
+ <div class="col-md-8">
<input class="form-control" id="prop-{{property}}" type="{{inputtype}}" name="prop-{{property}}"
value="{{currentvalue}}">
</div>
- <div class="col-md-2">
- {{#helptext}}
- <p class="btn btn-static" title="{{helptext}}">
- <span class="glyphicon glyphicon-question-sign"></span>
- </p>
- {{/helptext}}
+ <div class="col-sm-12 small text-muted spacebottop">
+ {{helptext}}
</div>
</div>
</div>
diff --git a/modules-available/locations/baseconfig/getconfig.inc.php b/modules-available/locations/baseconfig/getconfig.inc.php
index f21503f1..1bed5de7 100644
--- a/modules-available/locations/baseconfig/getconfig.inc.php
+++ b/modules-available/locations/baseconfig/getconfig.inc.php
@@ -1,8 +1,13 @@
<?php
+/** @var ?string $uuid */
+/** @var ?string $ip */
+
// Location handling: figure out location
$locationId = false;
-if (Request::any('force', 0, 'int') === 1 && Request::any('module', false, 'string') === 'locations') {
+if (BaseConfig::hasOverride('locationid')) {
+ $locationId = BaseConfig::getOverride('locationid');
+} elseif (Request::any('force', 0, 'int') === 1 && Request::any('module', false, 'string') === 'locations') {
// Force location for testing, but require logged in admin
if (User::load()) {
$locationId = Request::any('value', 0, 'int');
@@ -10,6 +15,8 @@ if (Request::any('force', 0, 'int') === 1 && Request::any('module', false, 'stri
}
if ($locationId === false) {
+ if ($ip === null) // Required at this point, bail out if not given
+ return;
$locationId = Location::getFromIpAndUuid($ip, $uuid);
}
@@ -23,19 +30,30 @@ if ($locationId !== false) {
// Query location specific settings (from bottom to top)
if (!empty($matchingLocations)) {
// First get all settings for all locations we're in
- $list = implode(',', $matchingLocations);
- $res = Database::simpleQuery("SELECT locationid, setting, value FROM setting_location WHERE locationid IN ($list)");
+ $res = Database::simpleQuery("SELECT locationid, setting, value, displayvalue
+ FROM setting_location WHERE locationid IN (:list)",
+ ['list' => $matchingLocations]);
$tmp = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- $tmp[(int)$row['locationid']][$row['setting']] = $row['value'];
+ foreach ($res as $row) {
+ $tmp[(int)$row['locationid']][$row['setting']] = $row; // Put whole row so we have value and displayvalue
}
+ // Callback for pretty printing
+ $cb = function($id) {
+ if (substr($id, 0, 9) !== 'location-')
+ return ['name' => $id, 'locationid' => 0];
+ $lid = (int)substr($id, 9);
+ return [
+ 'name' => Location::getName($lid),
+ 'locationid' => $lid,
+ ];
+ };
// $matchingLocations contains the location ids sorted from closest to furthest, so we use it to make sure the order
// in which they override is correct (closest setting wins, e.g. room setting beats department setting)
$prio = count($matchingLocations) + 1;
foreach ($matchingLocations as $lid) {
if (!isset($tmp[$lid]))
continue;
- ConfigHolder::setContext('location-' . $lid);
+ ConfigHolder::setContext('location-' . $lid, $cb);
foreach ($tmp[$lid] as $setting => $value) {
ConfigHolder::add($setting, $value, $prio);
}
diff --git a/modules-available/locations/baseconfig/hook.json b/modules-available/locations/baseconfig/hook.json
index b7b3581b..fabb4686 100644
--- a/modules-available/locations/baseconfig/hook.json
+++ b/modules-available/locations/baseconfig/hook.json
@@ -1,6 +1,7 @@
{
"table": "setting_location",
"field": "locationid",
+ "locationResolver": "LocationHooks::baseconfigLocationResolver",
"tostring": "Location::getName",
- "getfallback": "Location::getBaseconfigParent"
+ "getInheritance": "LocationHooks::baseconfigInheritance"
} \ No newline at end of file
diff --git a/modules-available/locations/clientscript.js b/modules-available/locations/clientscript.js
index ad3e6c43..9a434e04 100644
--- a/modules-available/locations/clientscript.js
+++ b/modules-available/locations/clientscript.js
@@ -1,66 +1,161 @@
-function ip2long(IP) {
- var i = 0;
- IP = IP.match(/^([1-9]\d*|0[0-7]*|0x[\da-f]+)(?:\.([1-9]\d*|0[0-7]*|0x[\da-f]+))?(?:\.([1-9]\d*|0[0-7]*|0x[\da-f]+))?(?:\.([1-9]\d*|0[0-7]*|0x[\da-f]+))?$/i);
- if (!IP) {
- return false;
- }
- IP.push(0, 0, 0, 0);
- for (i = 1; i < 5; i += 1) {
- IP[i] = parseInt(IP[i]) || 0;
- if (IP[i] < 0 || IP[i] > 255)
- return false;
- }
- return IP[1] * 16777216 + IP[2] * 65536 + IP[3] * 256 + IP[4] * 1;
+/*
+ * Generic helpers.
+ */
+
+/**
+ * Initialize timepicker on given element.
+ */
+function setTimepicker($e) {
+ $e.timepicker({
+ minuteStep: 15,
+ appendWidgetTo: 'body',
+ showSeconds: false,
+ showMeridian: false,
+ defaultTime: false
+ });
}
-function long2ip(a) {
- return [
- a >>> 24,
- 255 & a >>> 16,
- 255 & a >>> 8,
- 255 & a
- ].join('.');
+function getTime(str) {
+ if (!str) return false;
+ str = str.split(':');
+ if (str.length !== 2) return false;
+ var h = parseInt(str[0].replace(/^0/, ''));
+ var m = parseInt(str[1].replace(/^0/, ''));
+ if (h < 0 || h > 23) return false;
+ if (m < 0 || m > 59) return false;
+ return h * 60 + m;
}
-function cidrToRange(cidr) {
- var range = [2];
- cidr = cidr.split('/');
- var cidr_1 = parseInt(cidr[1]);
- if (cidr_1 <= 0 || cidr_1 > 32)
- return false;
- var param = ip2long(cidr[0]);
- if (param === false)
- return false;
- range[0] = long2ip((param) & ((-1 << (32 - cidr_1))));
- var start = ip2long(range[0]);
- range[1] = long2ip(start + Math.pow(2, (32 - cidr_1)) - 1);
- return range;
+const allDays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
+
+/*
+ * Opening times related...
+ */
+
+var slxIdCounter = 0;
+
+/**
+ * Adds a new opening time to the table in expert mode.
+ */
+function newOpeningTime($loc, vals) {
+ var $row = $loc.find('.expert-template div.row').clone();
+ if (vals['days'] && Array.isArray(vals['days'])) {
+ for (var i = 0; i < allDays.length; ++i) {
+ $row.find('.i-' + allDays[i]).prop('checked', vals['days'].indexOf(allDays[i]) !== -1);
+ }
+ }
+ $row.find('input').each(function() {
+ var $inp = $(this);
+ if ($inp.length === 0) return;
+ slxIdCounter++;
+ $inp.prop('id', 'id-inp-' + slxIdCounter);
+ $inp.siblings('label').prop('for', 'id-inp-' + slxIdCounter);
+ });
+ $row.find('.i-openingtime').val(vals['openingtime']);
+ $row.find('.i-closingtime').val(vals['closingtime']);
+ $loc.find('.expert-table').append($row);
+ return $row;
}
/**
- * Add listener to start IP input; when it loses focus, see if we have a
- * CIDR notation and fill out start+end field.
+ * Convert fields from simple mode view to entries in expert mode.
+ * @returns {Array}
*/
-function slxAttachCidr() {
- $('.cidrmagic').each(function () {
- var t = $(this);
- var s = t.find('input.cidrstart');
- var e = t.find('input.cidrend');
- if (!s || !e)
- return;
- t.removeClass('cidrmagic');
- s.focusout(function () {
- var val = s.val();
- if (val.match(/^[0-9]+\.[0-9]+(\.[0-9]+(\.[0-9]+)?)?\/[0-9]{2}$/)) {
- var res = cidrToRange(val);
- if (res === false)
- return;
- s.val(res[0]);
- e.val(res[1]);
- }
+function simpleToExpert($form) {
+ var retval = [], $open, $close;
+ $open = $form.find('.week-open');
+ $close = $form.find('.week-close');
+ if ($open.val() || $close.val()) {
+ retval.push({
+ 'days': ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
+ 'openingtime': $open.val(),
+ 'closingtime': $close.val(),
+ 'tag': 'week'
});
- });
+ }
+ $open = $form.find('.saturday-open');
+ $close = $form.find('.saturday-close');
+ if ($open.val() || $close.val()) {
+ retval.push({
+ 'days': ['Saturday'],
+ 'openingtime': $open.val(),
+ 'closingtime': $close.val(),
+ 'tag': 'saturday'
+ });
+ }
+ $open = $form.find('.sunday-open');
+ $close = $form.find('.sunday-close');
+ if ($open.val() || $close.val()) {
+ retval.push({
+ 'days': ['Sunday'],
+ 'openingtime': $open.val(),
+ 'closingtime': $close.val(),
+ 'tag': 'sunday'
+ });
+ }
+ return retval;
}
-// Attach
-slxAttachCidr(); \ No newline at end of file
+/**
+ * Triggered when the openingtimes/WOL form is submitted
+ */
+function validateOpeningTimes(event) {
+ var schedule, s, e;
+ var badFormat = false;
+ var $form = $(this);
+
+ $form.find('.red-bg').removeClass('red-bg');
+ if ($form.find('.week-open').length > 0) {
+ schedule = simpleToExpert($form);
+ for (var i = 0; i < schedule.length; ++i) {
+ s = getTime(schedule[i].openingtime);
+ e = getTime(schedule[i].closingtime);
+ if (s === false) {
+ $form.find('.' + schedule[i].tag + '-open').addClass('red-bg');
+ badFormat = true;
+ }
+ if (e === false || e <= s) {
+ $form.find('.' + schedule[i].tag + '-close').addClass('red-bg');
+ badFormat = true;
+ }
+ }
+ } else {
+ // Serialize
+ schedule = [];
+ $form.find('.expert-table .expert-row').each(function () {
+ var $t = $(this);
+ if ($t.find('.i-delete').is(':checked')) return; // Skip marked as delete
+ var entry = {
+ 'days': [],
+ 'openingtime': $t.find('.i-openingtime').val(),
+ 'closingtime': $t.find('.i-closingtime').val()
+ };
+ for (var i = 0; i < allDays.length; ++i) {
+ if ($t.find('.i-' + allDays[i]).is(':checked')) {
+ entry['days'].push(allDays[i]);
+ }
+ }
+ if (entry.openingtime.length === 0 && entry.closingtime.length === 0 && entry.days.length === 0) return; // Also ignore empty lines
+ s = getTime(entry.openingtime);
+ e = getTime(entry.closingtime);
+ if (s === false) {
+ $t.find('.i-openingtime').addClass('red-bg');
+ badFormat = true;
+ }
+ if (e === false || e <= s) {
+ $t.find('.i-closingtime').addClass('red-bg');
+ badFormat = true;
+ }
+ if (entry.days.length === 0) {
+ $t.find('.days-box').addClass('red-bg');
+ badFormat = true;
+ }
+ if (badFormat) return;
+ schedule.push(entry);
+ });
+ }
+ if (badFormat) {
+ event.preventDefault();
+ }
+ $form.find('input[name="openingtimes"]').val(JSON.stringify(schedule));
+}
diff --git a/modules-available/locations/config.json b/modules-available/locations/config.json
index 110f8b67..ee2600f2 100644
--- a/modules-available/locations/config.json
+++ b/modules-available/locations/config.json
@@ -1,3 +1,9 @@
{
- "category": "main.content"
+ "category": "main.content",
+ "dependencies": [
+ "bootstrap_timepicker"
+ ],
+ "scripts": [
+ "clientscript.js"
+ ]
} \ No newline at end of file
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 ac866cbc..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) {
@@ -119,9 +115,15 @@ class Location
return $output;
}
- public static function getLocations($selected = 0, $excludeId = 0, $addNoParent = false, $keepArrayKeys = false)
+ /**
+ * @param int|int[] $selected Which locationIDs to mark as selected
+ * @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, int $excludeId = 0, bool $addNoParent = false, bool $keepArrayKeys = false): array
{
- $selected = (int)$selected;
if (self::$flatLocationCache === false) {
$rows = self::getTree();
$rows = self::flattenTree($rows);
@@ -142,7 +144,7 @@ class Location
unset($rows[$key]);
continue;
}
- if ((is_array($selected) && in_array($row['locationid'], $selected)) || (int)$row['locationid'] === $selected) {
+ if ((is_array($selected) && in_array($row['locationid'], $selected)) || $row['locationid'] == $selected) {
$row['selected'] = true;
}
$row['sortIndex'] = $index++;
@@ -151,7 +153,8 @@ class Location
array_unshift($rows, array(
'locationid' => 0,
'locationname' => '-----',
- 'selected' => $selected === 0
+ 'selected' => $selected === 0,
+ 'locationpad' => '',
));
}
if ($keepArrayKeys)
@@ -163,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();
@@ -191,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)) {
@@ -200,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();
@@ -220,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) {
@@ -242,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) {
@@ -265,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)
@@ -285,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
@@ -316,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)) {
@@ -337,8 +341,10 @@ class Location
return $locationId;
}
- public static function isFixedLocationValid($uuidLoc, $ipLoc)
+ public static function isFixedLocationValid($uuidLoc, $ipLoc): bool
{
+ if ($uuidLoc === false)
+ return false;
$uuidLoc = (int)$uuidLoc;
$ipLoc = (int)$ipLoc;
if ($uuidLoc === $ipLoc || $uuidLoc === 0)
@@ -358,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();
}
@@ -375,32 +379,13 @@ class Location
}
/**
- * Used for baseconfig hook
- * @param $locationId
- * @return bool|array ('value' => x, 'display' => y), false if no parent or unknown id
- */
- public static function getBaseconfigParent($locationId)
- {
- $locationId = (int)$locationId;
- if (self::$assocLocationCache === false) {
- self::getLocationsAssoc();
- }
- if (!isset(self::$assocLocationCache[$locationId]))
- return false;
- $locationId = (int)self::$assocLocationCache[$locationId]['parentlocationid'];
- if (!isset(self::$assocLocationCache[$locationId]))
- return false;
- return array('value' => $locationId, 'display' => self::$assocLocationCache[$locationId]['locationname']);
- }
-
- /**
* @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;
}
@@ -408,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();
@@ -444,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();
@@ -475,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) {
@@ -483,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
new file mode 100644
index 00000000..f6ef02da
--- /dev/null
+++ b/modules-available/locations/inc/locationhooks.inc.php
@@ -0,0 +1,29 @@
+<?php
+
+class LocationHooks
+{
+
+ /**
+ * Resolve baseconfig id to locationid -- noop in this case
+ */
+ public static function baseconfigLocationResolver(int $id): int
+ {
+ return $id;
+ }
+
+ /**
+ * Hook to get inheritance tree for all config vars
+ * @param int $id Locationid currently being edited
+ */
+ public static function baseconfigInheritance(int $id): array
+ {
+ $locs = Location::getLocationsAssoc();
+ if ($locs === false || !isset($locs[$id]))
+ return [];
+ BaseConfig::prepareWithOverrides([
+ 'locationid' => $locs[$id]['parentlocationid'] ?? 0
+ ]);
+ return ConfigHolder::getRecursiveConfig(true);
+ }
+
+} \ No newline at end of file
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 d4e1b67b..46a6544c 100644
--- a/modules-available/locations/install.inc.php
+++ b/modules-available/locations/install.inc.php
@@ -15,6 +15,7 @@ $res[] = tableCreate('location', '
`locationid` INT(11) NOT NULL AUTO_INCREMENT,
`parentlocationid` INT(11) NOT NULL,
`locationname` VARCHAR(100) NOT NULL,
+ `openingtime` BLOB DEFAULT NULL,
PRIMARY KEY (`locationid`),
KEY `locationname` (`locationname`),
KEY `parentlocationid` (`parentlocationid`)
@@ -35,5 +36,32 @@ $res[] = tableAddConstraint('subnet', 'locationid', 'location', 'locationid',
$res[] = tableAddConstraint('setting_location', 'locationid', 'location', 'locationid',
'ON UPDATE CASCADE ON DELETE CASCADE');
+// Update
+
+// 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 DEFAULT NULL") === false) {
+ finalResponse(UPDATE_FAILED, 'Could not create openingtime column');
+ }
+ $res[] = UPDATE_DONE;
+}
+if (tableHasColumn('locationinfo_locationconfig', 'openingtime')) {
+ if (Database::exec(
+ "UPDATE location, locationinfo_locationconfig
+ SET location.openingtime = locationinfo_locationconfig.openingtime
+ WHERE location.locationid = locationinfo_locationconfig.locationid
+ AND (Length(location.openingtime) < 5 OR location.openingtime IS NULL)
+ AND Length(locationinfo_locationconfig.openingtime) > 5") === false) {
+ finalResponse(UPDATE_FAILED, 'Could not migrate openingtime data from table to table');
+ }
+ if (Database::exec("ALTER TABLE locationinfo_locationconfig DROP COLUMN openingtime") === false) {
+ finalResponse(UPDATE_FAILED, 'Could not delete old openingtime column');
+ }
+ $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 44855daf..7167f5d3 100644
--- a/modules-available/locations/lang/de/messages.json
+++ b/modules-available/locations/lang/de/messages.json
@@ -1,7 +1,11 @@
{
"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}}, Subnets: {{1}})",
+ "location-deleted": "Location wurde gel\u00f6scht (Locations: {{0}}, IDs: {{1}})",
"location-updated": "Location {{0}} wurde aktualisiert",
"moved-n-machines": "{{0}} Rechner verschoben",
"no-mismatch-location": "Keine widerspr\u00fcchlichen Zuweisungen f\u00fcr diesen Ort",
diff --git a/modules-available/locations/lang/de/module.json b/modules-available/locations/lang/de/module.json
index b38fa89c..5156590c 100644
--- a/modules-available/locations/lang/de/module.json
+++ b/modules-available/locations/lang/de/module.json
@@ -1,4 +1,4 @@
{
- "module_name": "R\u00e4ume\/Orte",
+ "module_name": "R\u00e4ume \/ Orte",
"page_title": "R\u00e4ume und Orte verwalten"
} \ No newline at end of file
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 45464e48..ecd3a647 100644
--- a/modules-available/locations/lang/de/template-tags.json
+++ b/modules-available/locations/lang/de/template-tags.json
@@ -3,15 +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": "Expertenmodus",
"lang_fixMachineAssign": "Zuweisungen anzeigen\/aufheben",
+ "lang_inheritOpeningTimes": "Vom \u00fcbergeordneten Ort \u00fcbernehmen",
"lang_ip": "IP-Adresse",
"lang_listOfSubnets": "Liste der Subnetze",
"lang_location": "Ort",
@@ -25,22 +29,40 @@
"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.",
+ "lang_monTilFr": "Montag - Freitag",
"lang_moveMachines": "In durch Subnet zugeordneten Raum verschieben",
"lang_moveable": "Verschiebbar",
"lang_name": "Name",
- "lang_overrideCount": "Angepasst",
+ "lang_nextEvent": "N\u00e4chstes geplantes Ereignis",
+ "lang_offsetEarly": "Min. vorher",
+ "lang_offsetLate": "Min. danach",
+ "lang_openingTime": "\u00d6ffnungszeit",
"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": "So",
+ "lang_shortThursday": "Do",
+ "lang_shortTuesday": "Di",
+ "lang_shortWednesday": "Mi",
"lang_showRoomplan": "Raumplan anzeigen",
+ "lang_shutdown": "Herunterfahren",
"lang_startAddress": "Startadresse",
"lang_subnet": "IP-Bereich",
- "lang_sysConfig": "Lokalisierung",
+ "lang_sunday": "Sonntag",
"lang_thisListByLocation": "Orte",
"lang_thisListBySubnet": "Subnetze",
- "lang_unassignedMachines": "Rechner, die in keinen definierten Ort fallen"
+ "lang_unassignedMachines": "Rechner, die in keinen definierten Ort fallen",
+ "lang_wakeonlan": "WakeOnLan"
} \ No newline at end of file
diff --git a/modules-available/locations/lang/en/messages.json b/modules-available/locations/lang/en/messages.json
index 80b99ff0..16e0ef76 100644
--- a/modules-available/locations/lang/en/messages.json
+++ b/modules-available/locations/lang/en/messages.json
@@ -1,7 +1,11 @@
{
"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}}, subnets: {{1}})",
+ "location-deleted": "Location has been deleted (locations: {{0}}, IDs: {{1}})",
"location-updated": "Location {{0}} has been updated",
"moved-n-machines": "Moved {{0}} machines",
"no-mismatch-location": "No mismatches for this location",
diff --git a/modules-available/locations/lang/en/module.json b/modules-available/locations/lang/en/module.json
index b2a837b6..bcd13f25 100644
--- a/modules-available/locations/lang/en/module.json
+++ b/modules-available/locations/lang/en/module.json
@@ -1,3 +1,3 @@
{
- "module_name": "Room\/Locations"
+ "module_name": "Rooms \/ Locations"
} \ No newline at end of file
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 abcb7606..55eaec86 100644
--- a/modules-available/locations/lang/en/template-tags.json
+++ b/modules-available/locations/lang/en/template-tags.json
@@ -3,15 +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_deleteChildLocations": "Delete child locations aswell",
+ "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",
@@ -25,22 +29,40 @@
"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).",
+ "lang_monTilFr": "Monday - Friday",
"lang_moveMachines": "Move to room designated by IP address",
"lang_moveable": "Moveable",
"lang_name": "Name",
- "lang_overrideCount": "Overridden",
+ "lang_nextEvent": "Next scheduled event",
+ "lang_offsetEarly": "min. before",
+ "lang_offsetLate": "min. after",
+ "lang_openingTime": "Opening Time",
"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",
+ "lang_shortMonday": "Mon",
+ "lang_shortSaturday": "Sat",
+ "lang_shortSunday": "Sun",
+ "lang_shortThursday": "Thu",
+ "lang_shortTuesday": "Tue",
+ "lang_shortWednesday": "Wed",
"lang_showRoomplan": "Show room plan",
+ "lang_shutdown": "Shutdown",
"lang_startAddress": "Start address",
"lang_subnet": "IP range",
- "lang_sysConfig": "Localization\/Integration",
+ "lang_sunday": "Sunday",
"lang_thisListByLocation": "Locations",
"lang_thisListBySubnet": "Subnets",
- "lang_unassignedMachines": "Machines not matching any location"
+ "lang_unassignedMachines": "Machines not matching any location",
+ "lang_wakeonlan": "WakeOnLan"
} \ No newline at end of file
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 81b58456..279eee44 100644
--- a/modules-available/locations/pages/details.inc.php
+++ b/modules-available/locations/pages/details.inc.php
@@ -3,29 +3,121 @@
class SubPage
{
- public static function doPreprocess($action)
+ public static function doPreprocess($action): bool
{
if ($action === 'updatelocation') {
self::updateLocation();
return true;
}
+ 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();
return true;
+ } elseif ($action === 'getOpeningtimes') {
+ $id = Request::any('locid', 0, 'int');
+ self::ajaxOpeningTimes($id);
+ return true;
}
return false;
}
+ 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');
+ $sd = Request::post('sd', false, 'bool');
+ $sdOffset = Request::post('sd-offset', 0, 'int');
+ $raMode = Request::post('ra-mode', 'ALWAYS', 'string');
+
+ User::assertPermission('location.edit.openingtimes', $locationid);
+
+ // Construct opening-times for database
+ if ($otInherited || $openingTimes === '') {
+ $openingTimes = null;
+ } else {
+ $openingTimes = json_decode($openingTimes, true);
+ if (!is_array($openingTimes)) {
+ $openingTimes = null;
+ } else {
+ $mangled = array();
+ foreach (array_keys($openingTimes) as $key) {
+ $entry = $openingTimes[$key];
+ if (empty($entry['days']) || !is_array($entry['days'])) {
+ Message::addError('ignored-line-no-days');
+ continue;
+ }
+ $start = self::getTime($entry['openingtime']);
+ $end = self::getTime($entry['closingtime']);
+ if ($start === false) {
+ Message::addError('ignored-invalid-start', $entry['openingtime']);
+ continue;
+ }
+ if ($end === false) {
+ Message::addError('ignored-invalid-end', $entry['closingtime']);
+ continue;
+ }
+ if ($end <= $start) {
+ Message::addError('ignored-invalid-range', $entry['openingtime'], $entry['closingtime']);
+ continue;
+ }
+ unset($entry['tag']);
+ $mangled[] = $entry;
+ }
+ if (empty($mangled)) {
+ $openingTimes = null;
+ } else {
+ $openingTimes = json_encode($mangled);
+ }
+ }
+ }
+ // Check if opening-times changed
+ // $res = Database::queryFirst('SELECT openingtime FROM location WHERE locationid = :locationid', compact('locationid'));
+ // $otChanged = $res === false || $res['openingtime'] !== $openingTimes;
+
+ Database::exec('UPDATE location SET openingtime = :openingtime WHERE locationid = :locationid',
+ array('locationid' => $locationid, 'openingtime' => $openingTimes));
+
+ if (Module::isAvailable('rebootcontrol')) {
+ // 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;
+ return $str[0] * 60 + $str[1];
+ }
+
private static function updateLocation()
{
$locationId = Request::post('locationid', false, 'integer');
@@ -81,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');
@@ -125,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;
@@ -151,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');
@@ -167,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))) {
@@ -185,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) {
@@ -241,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;
@@ -251,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)
);
@@ -279,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++;
@@ -303,14 +395,151 @@ 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.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['expertMode'] = !self::isSimpleMode($openingTimes);
+ $data['schedule_data'] = json_encode($openingTimes);
+
+ $rebootcontrol = Module::isAvailable('rebootcontrol');
+ $data['rebootcontrol'] = $rebootcontrol;
+ if ($rebootcontrol) {
+ $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): bool
+ {
+ if (empty($array))
+ return true;
+ // Decompose by day
+ $new = array();
+ foreach ($array as $row) {
+ $s = self::getTime($row['openingtime']);
+ $e = self::getTime($row['closingtime']);
+ if ($s === false || $e === false || $e <= $s)
+ continue;
+ foreach ($row['days'] as $day) {
+ self::addDay($new, $day, $s, $e);
+ }
+ }
+ // Merge by timespan, but always keep saturday and sunday separate
+ $merged = array();
+ foreach ($new as $day => $ranges) {
+ foreach ($ranges as $range) {
+ if ($day === 'Saturday' || $day === 'Sunday') {
+ $add = $day;
+ } else {
+ $add = '';
+ }
+ $key = '#' . $range[0] . '#' . $range[1] . '#' . $add;
+ if (!isset($merged[$key])) {
+ $merged[$key] = array();
+ }
+ $merged[$key][$day] = true;
+ }
+ }
+ // Check if it passes as simple mode
+ if (count($merged) > 3)
+ return false;
+ foreach ($merged as $days) {
+ if (count($days) === 5) {
+ $res = array_keys($days);
+ $res = array_intersect($res, array("Monday", "Tuesday", "Wednesday", "Thursday", "Friday"));
+ if (count($res) !== 5)
+ return false;
+ } elseif (count($days) === 1) {
+ if (!isset($days['Saturday']) && !isset($days['Sunday'])) {
+ return false;
+ }
+ } else {
+ return false;
+ }
+ }
+ // Valid simple mode, finally transform back to what we know
+ $new = array();
+ foreach ($merged as $span => $days) {
+ preg_match('/^#(\d+)#(\d+)#/', $span, $out);
+ $new[] = array(
+ 'days' => array_keys($days),
+ 'openingtime' => floor($out[1] / 60) . ':' . ($out[1] % 60),
+ 'closingtime' => floor($out[2] / 60) . ':' . ($out[2] % 60),
+ );
+ }
+ $array = $new;
+ return true;
+ }
+
+ private static function addDay(&$array, $day, $s, $e)
+ {
+ if (!isset($array[$day])) {
+ $array[$day] = array(array($s, $e));
+ return;
+ }
+ foreach (array_keys($array[$day]) as $key) {
+ $current = $array[$day][$key];
+ if ($s <= $current[0] && $e >= $current[1]) {
+ // Fully dominated
+ unset($array[$day][$key]);
+ continue; // Might partially overlap with additional ranges, keep going
+ }
+ if ($current[0] <= $s && $current[1] >= $s) {
+ // $start lies within existing range
+ if ($current[0] <= $e && $current[1] >= $e)
+ return; // Fully in existing range, do nothing
+ // $end seems to extend range we're checking against but $start lies within this range, update and keep going
+ $s = $current[0];
+ unset($array[$day][$key]);
+ continue;
+ }
+ // Last possibility: $start is before range, $end within range
+ if ($current[0] <= $e && $current[1] >= $e) {
+ // $start must lie before range start, otherwise we'd have hit the case above
+ $e = $current[1];
+ unset($array[$day][$key]);
+ //continue;
+ }
+ }
+ $array[$day][] = array($s, $e);
+ }
} \ No newline at end of file
diff --git a/modules-available/locations/pages/locations.inc.php b/modules-available/locations/pages/locations.inc.php
index b1fd77d4..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;
}
@@ -90,116 +90,46 @@ class SubPage
unset($locationList[0]);
// Statistics: Count machines for each subnet
$unassigned = false;
- $unassignedLoad = 0;
+ $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 locationid, Count(*) AS cnt, Sum(If(state = 'OCCUPIED', 1, 0)) AS used
- FROM machine 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']) . ' %';
- } else {
- $unassigned += $row['cnt'];
- $unassignedLoad += $row['used'];
- }
- }
- 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'] = 0;
- $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);
@@ -211,41 +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) . ' %') : ''),
- '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 042ac4d1..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);
}
@@ -12,3 +12,16 @@ table.locations tbody td:nth-of-type(even) {
pointer-events: none;
opacity: 0.6;
}
+
+.load-col {
+ text-align: right;
+ text-shadow: 1px 1px #fff;
+ 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
new file mode 100644
index 00000000..861bef65
--- /dev/null
+++ b/modules-available/locations/templates/ajax-opening-location.html
@@ -0,0 +1,263 @@
+<div>
+ <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">
+
+ <div align="right" style="margin-bottom: 10px;">
+ <a href="#" class="btn btn-default btn-sm btn-show-expert">{{lang_expertMode}}</a>
+ </div>
+ <div class="clearfix"></div>
+ <table class="table table-condensed" style="margin-bottom:0">
+ <tr>
+ <th>{{lang_day}}</th>
+ <th>{{lang_openingTime}}</th>
+ <th>{{lang_closingTime}}</th>
+ </tr>
+
+ <tr class="tablerow">
+ <td>{{lang_monTilFr}}</td>
+ <td>
+ <div class="input-group bootstrap-timepicker">
+ <span class="input-group-addon">
+ <span class="glyphicon glyphicon-time"></span>
+ </span>
+ <input type="text" class="form-control timepicker2 week-open" pattern="[0-9]{1,2}:[0-9]{2}">
+ </div>
+ </td>
+ <td>
+ <div class="input-group bootstrap-timepicker">
+ <span class="input-group-addon">
+ <span class="glyphicon glyphicon-time"></span>
+ </span>
+ <input type="text" class="form-control timepicker2 week-close" pattern="[0-9]{1,2}:[0-9]{2}">
+ </div>
+ </td>
+ </tr>
+ <tr class="tablerow">
+ <td>{{lang_saturday}}</td>
+ <td>
+ <div class="input-group bootstrap-timepicker">
+ <span class="input-group-addon">
+ <span class="glyphicon glyphicon-time"></span>
+ </span>
+ <input type="text" class="form-control timepicker2 saturday-open" pattern="[0-9]{1,2}:[0-9]{2}">
+ </div>
+ </td>
+ <td>
+ <div class="input-group bootstrap-timepicker">
+ <span class="input-group-addon">
+ <span class="glyphicon glyphicon-time"></span>
+ </span>
+ <input type="text" class="form-control timepicker2 saturday-close" pattern="[0-9]{1,2}:[0-9]{2}">
+ </div>
+ </td>
+ </tr>
+ <tr class="tablerow">
+ <td>{{lang_sunday}}</td>
+ <td>
+ <div class="input-group bootstrap-timepicker">
+ <span class="input-group-addon">
+ <span class="glyphicon glyphicon-time"></span>
+ </span>
+ <input type="text" class="form-control timepicker2 sunday-open" pattern="[0-9]{1,2}:[0-9]{2}">
+ </div>
+ </td>
+ <td>
+ <div class="input-group bootstrap-timepicker">
+ <span class="input-group-addon">
+ <span class="glyphicon glyphicon-time"></span>
+ </span>
+ <input type="text" class="form-control timepicker2 sunday-close" pattern="[0-9]{1,2}:[0-9]{2}">
+ </div>
+ </td>
+ </tr>
+ </table>
+ </div>
+ {{/expertMode}}
+
+ <div class="expert-mode" style="{{^expertMode}}display:none{{/expertMode}}">
+ <div class="clearfix"></div>
+ <div class="expert-table">
+ <div class="row">
+ <div class="col-sm-6">{{lang_openingTime}}</div>
+ <div class="col-sm-4">{{lang_closingTime}}</div>
+ <div class="col-sm-2 text-right">{{lang_delete}}</div>
+ </div>
+ </div>
+ <hr>
+ <div style="text-align: center;">
+ <a class="btn btn-success btn-sm new-openingtime">
+ <span class="glyphicon glyphicon-plus-sign"></span>
+ {{lang_openingTime}}
+ </a>
+ </div>
+ <br>
+ </div>
+</div>
+
+<div class="hidden expert-template">
+ <div class="row expert-row">
+ <hr>
+ <div class="col-xs-12 days-box">
+ <div class="pull-right checkbox checkbox-inline"><input type="checkbox" class="i-delete"><label><span class="glyphicon glyphicon-trash"></span></label></div>
+ <div class="checkbox checkbox-inline"><input type="checkbox" class="i-Monday"><label>{{lang_shortMonday}}</label></div>
+ <div class="checkbox checkbox-inline"><input type="checkbox" class="i-Tuesday"><label>{{lang_shortTuesday}}</label></div>
+ <div class="checkbox checkbox-inline"><input type="checkbox" class="i-Wednesday"><label>{{lang_shortWednesday}}</label></div>
+ <div class="checkbox checkbox-inline"><input type="checkbox" class="i-Thursday"><label>{{lang_shortThursday}}</label></div>
+ <div class="checkbox checkbox-inline"><input type="checkbox" class="i-Friday"><label>{{lang_shortFriday}}</label></div>
+ <div class="checkbox checkbox-inline"><input type="checkbox" class="i-Saturday"><label>{{lang_shortSaturday}}</label></div>
+ <div class="checkbox checkbox-inline"><input type="checkbox" class="i-Sunday"><label>{{lang_shortSunday}}</label></div>
+ </div>
+ <div class="col-sm-6">
+ <div class="input-group bootstrap-timepicker">
+ <span class="input-group-addon"><span class="glyphicon glyphicon-time"></span></span>
+ <input type="text" class="form-control timepicker2 i-openingtime" pattern="[0-9]{1,2}:[0-9]{2}">
+ </div>
+ </div>
+ <div class="col-sm-6">
+ <div class="input-group bootstrap-timepicker">
+ <span class="input-group-addon"><span class="glyphicon glyphicon-time"></span></span>
+ <input type="text" class="form-control timepicker2 i-closingtime" pattern="[0-9]{1,2}:[0-9]{2}">
+ </div>
+ </div>
+ </div>
+</div>
+
+{{#rebootcontrol}}
+<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" {{#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="{{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>
+ </div>
+ </div>
+</div>
+<div class="row shutdown">
+ <div class="col-sm-4">
+ <div class="checkbox checkbox-inline">
+ <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="{{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>
+ (function() {
+
+ var $loc = $('#openingTimesModal{{id}}');
+ var $wol = $loc.find('.wol');
+ var $sd = $loc.find('.shutdown');
+
+ var sync = function($div) {
+ $div.find('input[type="number"]').prop('disabled',
+ !$div.find('input[type="checkbox"]').is(':checked'));
+ };
+ sync($wol);
+ sync($sd);
+ $wol.find('input[type="checkbox"]').on('change', function() { sync($wol); });
+ $sd.find('input[type="checkbox"]').on('change', function() { sync($sd); });
+
+ var scheduleData = {{{schedule_data}}};
+
+ {{#expertMode}}
+ for (var i = 0; i < scheduleData.length; ++i) {
+ newOpeningTime($loc, scheduleData[i]);
+ }
+ {{/expertMode}}
+ {{^expertMode}}
+ for (var i = 0; i < scheduleData.length; ++i) {
+ if (scheduleData[i].days.length === 5) {
+ $loc.find('.week-open').val(scheduleData[i]['openingtime']);
+ $loc.find('.week-close').val(scheduleData[i]['closingtime']);
+ } else if (scheduleData[i].days.length === 1 && scheduleData[i].days[0] === 'Saturday') {
+ $loc.find('.saturday-open').val(scheduleData[i]['openingtime']);
+ $loc.find('.saturday-close').val(scheduleData[i]['closingtime']);
+ } else if (scheduleData[i].days.length === 1 && scheduleData[i].days[0] === 'Sunday') {
+ $loc.find('.sunday-open').val(scheduleData[i]['openingtime']);
+ $loc.find('.sunday-close').val(scheduleData[i]['closingtime']);
+ }
+ }
+ {{/expertMode}}
+
+ setTimepicker($loc.find('.timepicker2'));
+
+ $loc.find('.new-openingtime').click(function (e) {
+ e.preventDefault();
+ setTimepicker(newOpeningTime($loc, {}).find('.timepicker2'));
+ setInputEnabled();
+ });
+
+ $loc.find('.btn-show-expert').click(function (e) {
+ e.preventDefault();
+ scheduleData = simpleToExpert($loc);
+ for (var i = 0; i < scheduleData.length; ++i) {
+ setTimepicker(newOpeningTime($loc, scheduleData[i]).find('.timepicker2'));
+ }
+ $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 6062b559..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-4">
+ <div class="col-md-5">
{{#haveDozmod}}
<div>
<span class="slx-ga2">{{lang_referencingLectures}}:</span> {{lectures}}
@@ -80,8 +80,23 @@
{{/statsLink}}
</div>
{{/haveStatistics}}
+ {{#next_action}}
+ <div>
+ {{lang_nextEvent}}: {{next_action}} – {{next_time}}
+ </div>
+ {{/next_action}}
</div>
- <div class="col-md-4 text-center">
+ <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>
+ {{#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">
@@ -90,8 +105,6 @@
{{#perms.roomplanner.edit.disabled}}{{lang_showRoomplan}}{{/perms.roomplanner.edit.disabled}}
</a>
{{/roomplanner}}
- </div>
- <div class="col-md-4 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>
@@ -130,4 +143,30 @@
</div>
</form>
-</div> \ No newline at end of file
+</div>
+
+<div class="modal fade" id="openingTimesModal{{locationid}}" tabindex="-1" role="dialog">
+ <div class="modal-dialog">
+
+ <div class="modal-content">
+ <form method="post" action="?do=Locations">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="action" value="updateOpeningtimes">
+ <input type="hidden" name="page" value="details">
+ <input type="hidden" name="openingtimes" value="">
+ <input type="hidden" name="locationid" value="{{locationid}}">
+
+ <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>
+ <button type="submit" class="btn btn-primary">
+ <span class="glyphicon glyphicon-floppy-disk"></span>
+ {{lang_save}}
+ </button>
+ </div>
+ </form>
+ </div>
+
+ </div>
+</div>
diff --git a/modules-available/locations/templates/locations.html b/modules-available/locations/templates/locations.html
index a63e3b8c..efd48216 100644
--- a/modules-available/locations/templates/locations.html
+++ b/modules-available/locations/templates/locations.html
@@ -28,24 +28,14 @@
</div>
{{/mismatchMachines}}
- <table class="table table-condensed locations" style="margin-bottom:0">
+ <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>
- <th class="text-nowrap">
- {{#havesysconfig}}{{lang_sysConfig}}{{/havesysconfig}}
- </th>
+ {{#plugins}}
<th class="text-nowrap">
- {{#haveipxe}}{{lang_bootMenu}}{{/haveipxe}}
+ {{header}}
</th>
+ {{/plugins}}
</tr>
{{#list}}
<tr>
@@ -55,75 +45,36 @@
<span>{{locationname}}</span>
{{/show-only}}
{{^show-only}}
- <a href="#" onclick="slxOpenLocation(this, {{locationid}}); return false">
+ <a id="loc-{{locationid}}" href="#" onclick="slxOpenLocation(this, {{locationid}}); return false">
{{locationname}}
<b class="caret"></b>
</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" align="right">
- {{#havestatistics}}
- {{clientLoad}}
- {{/havestatistics}}
- </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}}
- {{lang_overrideCount}}: {{overriddenVars}}&emsp;&emsp;
- {{/overriddenVars}}
- {{/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" align="right">
- {{unassignedLoad}}
+ {{#plugins}}
+ <td class="text-nowrap">
+ {{{propagateDefaultHtml}}}
</td>
- <td></td>
- <td>{{defaultConfig}}</td>
+ {{/plugins}}
</tr>
- {{/unassignedCount}}
</table>
<form method="post" action="?do=Locations">
<input type="hidden" name="token" value="{{token}}">
@@ -151,6 +102,14 @@ var slxLastLocation = false;
var newRowCounter = 0;
+document.addEventListener("DOMContentLoaded", function() {
+ var id = window.location.hash.substring(1);
+ if (id !== "") {
+ var loc_dom = document.getElementById("loc-" + id);
+ slxOpenLocation(loc_dom, id);
+ }
+});
+
function slxAddLocationRow() {
$("#saveLocationRows").show();
var tr = $('#lasttr');
@@ -190,12 +149,12 @@ 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);
td.load('?do=Locations&page=details&action=showlocation&locationid=' + lid, function() {
- slxAttachCidr();
+ if (slxAttachCidr) slxAttachCidr();
scollIntoView(tr);
});
slxLastLocation = tr;
@@ -216,18 +175,14 @@ function scollIntoView(el) {
function slxAddSubnetRow(e, lid) {
var tr = $('#loc-sub-' + lid);
- tr.before('<tr id="row' + slxAddCounter + '" class="cidrmagic">\
+ tr.before('<tr class="cidrmagic">\
<td>#</td>\
<td><input class="form-control cidrstart" type="text" name="newstartaddr[' + slxAddCounter + ']" pattern="\\d{1,3}\.\\d{1,3}\.\\d{1,3}\.\\d{1,3}"></td>\
<td><input class="form-control cidrend" type="text" name="newendaddr[' + slxAddCounter + ']" pattern="\\d{1,3}\.\\d{1,3}\.\\d{1,3}\.\\d{1,3}"></td>\
- <td class="text-center"><button class="btn btn-default btn-sm" type="button" onclick="removeNewSubnetRow(' + slxAddCounter + ')"><span class="glyphicon glyphicon-remove"></span></button></td>\
+ <td class="text-center"><button class="btn btn-default btn-sm" type="button" onclick="$(this).closest(\'tr\').remove()"><span class="glyphicon glyphicon-remove"></span></button></td>\
</tr>');
slxAddCounter++;
- slxAttachCidr();
-}
-
-function removeNewSubnetRow(r) {
- $("#row"+r).remove();
+ if (slxAttachCidr) slxAttachCidr();
}
function deleteSubnetWarning(locid) {
@@ -238,5 +193,12 @@ function deleteSubnetWarning(locid) {
form.submit();
}
}
+
+function loadOpeningTimes(locid) {
+ var $e = $("#openingTimesModal" + locid).find('.modal-body');
+ if (!$e.is(':empty')) return;
+ $e.load("?do=Locations&page=details&action=getOpeningtimes&locid=" + locid)
+}
+
// -->
</script>
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 bab27287..5b20b6d0 100644
--- a/modules-available/main/hooks/cron.inc.php
+++ b/modules-available/main/hooks/cron.inc.php
@@ -8,8 +8,12 @@ case 3:
Database::exec("DELETE FROM property WHERE dateline <> 0 AND dateline < UNIX_TIMESTAMP()");
break;
case 4:
- Database::exec("DELETE FROM callback WHERE (UNIX_TIMESTAMP() - dateline) > 86400");
+ 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 ec8554fd..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` )");
}
// #######################
@@ -75,11 +115,34 @@ if (!tableHasColumn('callback', 'args')) {
// #######################
// ##### 2018-03-19
-// In preparation for LDAP/AD auth: Column to rembember origin server
+// In preparation for LDAP/AD auth: Column to remember origin server
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/categories.json b/modules-available/main/lang/de/categories.json
index 31f5bca0..5e06d259 100644
--- a/modules-available/main/lang/de/categories.json
+++ b/modules-available/main/lang/de/categories.json
@@ -1,5 +1,4 @@
{
- "beta": "Beta",
"content": "Inhalt",
"etc": "Weiteres",
"settings-client": "Einstellungen (Client)",
diff --git a/modules-available/main/lang/de/messages.json b/modules-available/main/lang/de/messages.json
index b6c2a5b3..13f89428 100644
--- a/modules-available/main/lang/de/messages.json
+++ b/modules-available/main/lang/de/messages.json
@@ -12,6 +12,7 @@
"task-error": "Ausf\u00fchrung fehlgeschlagen: {{0}}",
"taskmanager-error": "Verbindung zum Taskmanager fehlgeschlagen",
"taskmanager-format": "Taskmanager hat ung\u00fcltige Daten zur\u00fcckgeliefert",
+ "taskmanager-warning": "Taskmanager: {{0}}",
"token": "Ung\u00fcltiges Token. CSRF Angriff?",
"value-invalid": "Der Wert {{1}} ist ung\u00fcltig f\u00fcr die Option {{0}} und wurde ignoriert"
} \ No newline at end of file
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/messages.json b/modules-available/main/lang/en/messages.json
index 2a9c9c0d..03e436cb 100644
--- a/modules-available/main/lang/en/messages.json
+++ b/modules-available/main/lang/en/messages.json
@@ -12,6 +12,7 @@
"task-error": "Execution failed: {{0}}",
"taskmanager-error": "Failed to connect to the Task Manager",
"taskmanager-format": "Task Manager has returned invalid data",
+ "taskmanager-warning": "Task Manager: {{0}}",
"token": "Invalid token. CSRF attack?",
"value-invalid": "The value {{1}} is invalid for option {{0}} and has been ignored"
} \ No newline at end of file
diff --git a/modules-available/main/lang/en/template-tags.json b/modules-available/main/lang/en/template-tags.json
index 1abd6dbb..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 intallation. 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/hooks/bootup.inc.php b/modules-available/minilinux/hooks/bootup.inc.php
new file mode 100644
index 00000000..e33aaa70
--- /dev/null
+++ b/modules-available/minilinux/hooks/bootup.inc.php
@@ -0,0 +1,3 @@
+<?php
+
+MiniLinux::updateList(); \ No newline at end of file
diff --git a/modules-available/minilinux/hooks/ipxe-bootentry.inc.php b/modules-available/minilinux/hooks/ipxe-bootentry.inc.php
index 944cdfa3..b044ce5d 100644
--- a/modules-available/minilinux/hooks/ipxe-bootentry.inc.php
+++ b/modules-available/minilinux/hooks/ipxe-bootentry.inc.php
@@ -1,145 +1,3 @@
<?php
-class LinuxBootEntryHook extends BootEntryHook
-{
-
- public function name()
- {
- return Dictionary::translateFileModule('minilinux', 'module', 'module_name', true);
- }
-
- public function extraFields()
- {
- /* For translate module:
- * Dictionary::translate('ipxe-kcl-extra');
- * Dictionary::translate('ipxe-debug');
- */
- return [
- new HookExtraField('kcl-extra', 'string', ''),
- new HookExtraField('debug', 'bool', false),
- ];
- }
-
- /**
- * @return HookEntryGroup[]
- */
- protected function groupsInternal()
- {
- /*
- * Dictionary::translate('default_boot_entry');
- * Dictionary::translate('not_installed_hint');
- */
- $array = [];
- $array[] = new HookEntryGroup($this->name(), [
- new HookEntry('default',
- Dictionary::translateFileModule('minilinux', 'module', 'default_boot_entry', true),
- MiniLinux::updateCurrentBootSetting())
- ]);
- $branches = Database::queryAll('SELECT sourceid, branchid, title FROM minilinux_branch ORDER BY title');
- $versions = MiniLinux::queryAllVersionsByBranch();
- // Group by branch for detailed listing
- foreach ($branches as $branch) {
- if (isset($versions[$branch['branchid']])) {
- $group = [];
- foreach ($versions[$branch['branchid']] as $version) {
- $valid = $version['installed'] != 0;
- $title = $version['versionid'] . ' ' . $version['title'];
- if (!$valid) {
- $title .= ' ' . Dictionary::translateFileModule('minilinux', 'module', 'not_installed_hint');
- }
- $group[] = new HookEntry($version['versionid'], $title, $valid);
- }
- $array[] = new HookEntryGroup($branch['title'] ? $branch['title'] : $branch['branchid'], $group);
- }
- }
- return $array;
- }
-
- /**
- * @param $id
- * @return BootEntry the actual boot entry instance for given entry, false if invalid id
- */
- public function getBootEntryInternal($localData)
- {
- $id = $localData['id'];
- if ($id === 'default') { // Special case
- $effectiveId = Property::get(MiniLinux::PROPERTY_DEFAULT_BOOT_EFFECTIVE);
- } else {
- $effectiveId = $id;
- }
- $res = Database::queryFirst('SELECT installed, data FROM minilinux_version WHERE versionid = :id', ['id' => $effectiveId]);
- 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]);
- }
- $remoteData = json_decode($res['data'], true);
- $bios = $efi = false;
- if (!@is_array($remoteData['agnostic']) && !@is_array($remoteData['efi']) && !@is_array($remoteData['bios'])) {
- $remoteData['agnostic'] = []; // We got nothing at all so fake this entry, resulting in a generic default entry
- }
- if (@is_array($remoteData['agnostic'])) {
- $bios = $this->generateExecData($effectiveId, $remoteData['agnostic'], $localData);
- $arch = BootEntry::AGNOSTIC;
- } else {
- if (@is_array($remoteData['efi'])) {
- $efi = $this->generateExecData($effectiveId, $remoteData['efi'], $localData);
- }
- if (@is_array($remoteData['bios'])) {
- $bios = $this->generateExecData($effectiveId, $remoteData['bios'], $localData);
- }
- if ($bios && $efi) {
- $arch = BootEntry::BOTH;
- } elseif ($bios) {
- $arch = BootEntry::BIOS;
- } else {
- $arch = BootEntry::EFI;
- }
- }
- return BootEntry::newStandardBootEntry($bios, $efi, $arch);
- }
-
- private function generateExecData($effectiveId, $remoteData, $localData)
- {
- $exec = new ExecData();
- // Defaults
- $root = '/boot/' . $effectiveId . '/';
- $exec->executable = 'kernel';
- $exec->initRd = ['initramfs-stage31'];
- $exec->imageFree = true;
- $exec->commandLine = 'slxbase=boot/%ID% slxsrv=${serverip} quiet splash ${ipappend1} ${ipappend2}';
- // Overrides
- foreach (['executable', 'commandLine', 'initRd', 'imageFree'] as $key) {
- if (isset($remoteData[$key])) {
- $exec->{$key} = $remoteData[$key];
- }
- }
- // KCL hacks
- if (isset($localData['debug']) && $localData['debug']) {
- $exec->commandLine = IPxe::modifyCommandLine($exec->commandLine,
- isset($remoteData['debugCommandLineModifier'])
- ? $remoteData['debugCommandLineModifier']
- : '-vga -quiet -splash -loglevel loglevel=7'
- );
- }
- if (isset($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) {
- $rd = $root . $rd;
- }
- unset($rd);
- return $exec;
- }
-
- public function isValidId($id)
- {
- $res = Database::queryFirst('SELECT installed FROM minilinux_version WHERE versionid = :id', ['id' => $id]);
- return $res !== false && $res['installed'];
- }
-}
-
return new LinuxBootEntryHook(); \ No newline at end of file
diff --git a/modules-available/minilinux/hooks/main-warning.inc.php b/modules-available/minilinux/hooks/main-warning.inc.php
index 8b052471..1a22468a 100644
--- a/modules-available/minilinux/hooks/main-warning.inc.php
+++ b/modules-available/minilinux/hooks/main-warning.inc.php
@@ -1,6 +1,6 @@
<?php
-if (!is_dir(CONFIG_HTTP_DIR . '/bwlp/default')) {
+if (!is_dir(CONFIG_HTTP_DIR . '/default')) {
Message::addError('minilinux.please-download-minilinux', true);
$needSetup = true;
} else {
diff --git a/modules-available/minilinux/inc/linuxbootentryhook.inc.php b/modules-available/minilinux/inc/linuxbootentryhook.inc.php
new file mode 100644
index 00000000..1424b6b9
--- /dev/null
+++ b/modules-available/minilinux/inc/linuxbootentryhook.inc.php
@@ -0,0 +1,193 @@
+<?php
+
+/**
+ * Class LinuxBootEntryHook.
+ * Only to be used in the ipxe-bootentry hook, as this depends on
+ * the existence of BootEntryHook, a class from serversetup-bwlp-ipxe.
+ * This module is usually not activated when interacting with the
+ * minilinux module.
+ */
+class LinuxBootEntryHook extends BootEntryHook
+{
+
+ public function name(): string
+ {
+ return Dictionary::translateFileModule('minilinux', 'module', 'module_name');
+ }
+
+ 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(): array
+ {
+ /*
+ * Dictionary::translate('default_boot_entry');
+ * Dictionary::translate('not_installed_hint');
+ * Dictionary::translate('latest_of_branch');
+ */
+ $array = [];
+ $array[] = new HookEntryGroup($this->name(), [
+ new HookEntry('default',
+ Dictionary::translateFileModule('minilinux', 'module', 'default_boot_entry'),
+ MiniLinux::updateCurrentBootSetting())
+ ]);
+ $branches = Database::queryAll('SELECT sourceid, branchid, title FROM minilinux_branch ORDER BY title');
+ $versions = MiniLinux::queryAllVersionsByBranch();
+ // Group by branch for detailed listing
+ foreach ($branches as $branch) {
+ if (isset($versions[$branch['branchid']])) {
+ $group = [
+ new HookEntry($branch['branchid'],
+ $branch['branchid'] . ' '
+ . Dictionary::translateFileModule('minilinux', 'module',
+ 'latest_of_branch'),
+ true),
+ ];
+ foreach ($versions[$branch['branchid']] as $version) {
+ $valid = $version['installed'] != MiniLinux::INSTALL_MISSING;
+ $title = $version['versionid'] . ' ' . $version['title'];
+ if (!$valid) {
+ $title .= ' ' . Dictionary::translateFileModule('minilinux', 'module',
+ 'not_installed_hint');
+ }
+ $group[] = new HookEntry($version['versionid'], $title, $valid);
+ }
+ $array[] = new HookEntryGroup($branch['title'] ?: $branch['branchid'], $group);
+ }
+ }
+ return $array;
+ }
+
+ /**
+ * @return ?BootEntry the actual boot entry instance for given entry, false if invalid id
+ */
+ public function getBootEntryInternal(array $localData): ?BootEntry
+ {
+ $id = $localData['id'];
+ if ($id === 'default') { // Special case
+ $effectiveId = Property::get(MiniLinux::PROPERTY_DEFAULT_BOOT_EFFECTIVE);
+ } else {
+ $effectiveId = $id;
+ }
+ $res = Database::queryFirst('SELECT versionid, installed, data FROM minilinux_version WHERE versionid = :id',
+ ['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 AND installed = :ok
+ ORDER BY dateline DESC LIMIT 1',
+ ['id' => $effectiveId, 'ok' => MiniLinux::INSTALL_OK]);
+ }
+ if ($res === false) {
+ 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
+ $remoteData = json_decode($res['data'], true);
+ $bios = $efi = false;
+ if (!@is_array($remoteData['agnostic']) && !@is_array($remoteData['efi']) && !@is_array($remoteData['bios'])) {
+ $remoteData['agnostic'] = []; // We got nothing at all so fake this entry, resulting in a generic default entry
+ }
+ if (@is_array($remoteData['agnostic'])) {
+ $bios = $this->generateExecData($effectiveId, $remoteData['agnostic'], $localData);
+ $arch = BootEntry::AGNOSTIC;
+ } else {
+ if (@is_array($remoteData['efi'])) {
+ $efi = $this->generateExecData($effectiveId, $remoteData['efi'], $localData);
+ }
+ if (@is_array($remoteData['bios'])) {
+ $bios = $this->generateExecData($effectiveId, $remoteData['bios'], $localData);
+ }
+ if ($bios && $efi) {
+ $arch = BootEntry::BOTH;
+ } elseif ($bios) {
+ $arch = BootEntry::BIOS;
+ } else {
+ $arch = BootEntry::EFI;
+ }
+ }
+ return BootEntry::newStandardBootEntry($bios, $efi, $arch, 'ml-' . $id);
+ }
+
+ private function generateExecData($effectiveId, $remoteData, $localData): ExecData
+ {
+ $exec = new ExecData();
+ // Defaults
+ $root = '/boot/' . $effectiveId . '/';
+ $exec->executable = 'kernel';
+ $exec->initRd = ['initramfs-stage31'];
+ $exec->imageFree = true;
+ $exec->commandLine = 'slxbase=boot/%ID% slxsrv=${serverip} quiet splash ${ipappend1} ${ipappend2}'
+ . ' ipv4.ip=${ip} ipv4.router=${gateway} ipv4.dns=${dns} ipv4.hostname=${hostname} ipv4.domain=${domain} ipv4.search=${dnssl}'
+ . ' ipv4.if=${mac} ipv4.ntpsrv=${ntpsrv} ipv4.subnet=${netmask}';
+ // Overrides
+ foreach (['executable', 'commandLine', 'initRd', 'imageFree'] as $key) {
+ if (isset($remoteData[$key])) {
+ $exec->{$key} = $remoteData[$key];
+ }
+ }
+ // KCL hacks
+ if (!empty($localData['debug'])) {
+ // Debug boot enabled
+ $exec->commandLine = IPxe::modifyCommandLine($exec->commandLine,
+ $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 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] !== '/') {
+ $rd = $root . $rd;
+ }
+ }
+ unset($rd);
+ return $exec;
+ }
+
+ 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'] != 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 54536096..cbc797f2 100644
--- a/modules-available/minilinux/inc/minilinux.inc.php
+++ b/modules-available/minilinux/inc/minilinux.inc.php
@@ -11,22 +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);
- error_log('Last: ' . $last);
- 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()');
@@ -34,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(
@@ -49,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()';
@@ -71,11 +79,10 @@ class MiniLinux
WHERE sourceid = :sourceid AND taskid = :taskid",
['sourceid' => $sourceid, 'taskid' => $taskId]);
// Clean up -- delete orphaned versions that are not installed
- $orphaned = Database::queryColumnArray('SELECT versionid FROM minilinux_version WHERE orphan > 4 AND installed = 0');
- if (!empty($orphaned)) {
- Database::exec('DELETE FROM minilinux_version WHERE versionid IN (:list)', ['list' => $orphaned]);
- }
- Database::exec('DELETE FROM minilinux_branch', [], true);
+ 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);
}
private static function addBranches($sourceid, $systems)
@@ -84,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,
+ ]);
}
}
}
@@ -118,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
@@ -151,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;
}
@@ -171,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)))
@@ -182,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)
@@ -198,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) {
@@ -227,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,
@@ -237,22 +259,22 @@ 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) {
// Callback for db column
TaskmanagerCallback::addCallback($task, 'mlGotLinux', $versionid);
+ self::checkStage4($data);
}
+ // Race - someone else wrote a taskid to DB, just call self again to get that one
if ($aff === 0)
return self::downloadVersion($versionid);
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);
}
@@ -262,10 +284,10 @@ class MiniLinux
*/
/**
- * Geenrate messages regarding setup und update availability.
+ * 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.
@@ -308,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)
@@ -321,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;
@@ -332,33 +354,218 @@ 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, $installed)
+ public static function setInstalledState($versionid, int $installed): void
{
- settype($installed, 'int');
- error_log("Setting $versionid to $installed");
Database::exec('UPDATE minilinux_version SET installed = :installed WHERE versionid = :versionid', [
'versionid' => $versionid,
'installed' => $installed,
]);
+ 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;
}
-} \ No newline at end of file
+ public static function setDefaultVersion($versionId)
+ {
+ Property::set(MiniLinux::PROPERTY_DEFAULT_BOOT, $versionId);
+ self::updateCurrentBootSetting();
+ // Legacy PXELINUX boot menu (TODO: Remove this when we get rid of PXELINUX support)
+ $task = Taskmanager::submit('Symlink', [
+ 'target' => $versionId,
+ 'linkname' => CONFIG_HTTP_DIR . '/default',
+ ]);
+ if ($task !== false) {
+ Taskmanager::release($task);
+ }
+ }
+
+ /**
+ * 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(array $data, &$errors = false): bool
+ {
+ $errors = [];
+ $image = false;
+ $rid = 0;
+ foreach (['agnostic', 'efi', 'bios'] as $type) {
+ if (!isset($data[$type]) || !isset($data[$type]['commandLine']))
+ continue;
+ if (!preg_match('/\bslx\.stage4\.path=(\S+)/', $data[$type]['commandLine'], $out))
+ continue;
+ $image = $out[1];
+ if (preg_match('/\bslx\.stage4\.rid=(\d+)/', $data[$type]['commandLine'], $out)) {
+ $rid = (int)$out[1];
+ }
+ break;
+ }
+ if ($image === false)
+ return true; // No stage4
+ if ($rid === 0) {
+ // 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;
+ }
+ // Not found locally -- try to replicate
+ $sock = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
+ if ($sock === false) {
+ $errors[] = 'Error creatring socket to connect to dnbd3-server';
+ return false;
+ }
+ socket_set_option($sock, SOL_SOCKET, SO_SNDTIMEO, array('sec' => 1, 'usec' => 0));
+ socket_set_option($sock, SOL_SOCKET, SO_RCVTIMEO, array('sec' => 5, 'usec' => 0));
+ if (@socket_connect($sock, '127.0.0.1', 5003) === false) {
+ $errors[] = 'Could not connect to local dnbd3-server';
+ socket_close($sock);
+ return false;
+ }
+ // proto-version(16), image name\0, rid(16), flags(8)
+ $payload = pack('vA*xvC', 3, $image, $rid, 0);
+ // magic(16), cmd(16), payload-len(32), offset(64), handle(64) XXX 32Bit compat
+ $packet = pack('A*vVVVVV', 'sr', 2, strlen($payload), 0, 0, 1234, 0) . $payload;
+ if (!socket_send($sock, $packet, strlen($packet), 0)) {
+ $errors[] = 'Cannot send request to dnbd3-server';
+ socket_close($sock);
+ return false;
+ }
+ $len = socket_recv($sock, $reply, 16, MSG_WAITALL);
+ if ($len === 0) {
+ $errors[] = 'Local dnbd3-server cannot replicate required stage4 from master-server';
+ socket_close($sock);
+ return false;
+ }
+ if ($len !== 16) {
+ $errors[] = 'Incomplete reply received from local dnbd3-server. Stage4 might not replicate!';
+ socket_close($sock);
+ return false;
+ }
+ socket_close($sock);
+ // Try to decode header
+ $reply = unpack('A2magic/vcmd/Vsize/Vhandlelow/Vhandlehigh', $reply);
+ if ($reply['magic'] !== 'sr') {
+ $errors[] = 'Reply has wrong magic';
+ }
+ if ($reply['cmd'] !== 2) {
+ $errors[] = 'Reply is not CMD_IMAGE_REPLY';
+ }
+ return empty($errors);
+ }
+
+ /**
+ * Determine by which menus/locations each MiniLinux version is being used.
+ */
+ public static function getBootMenuUsage(): array
+ {
+ if (!Module::isAvailable('serversetup') || !class_exists('BootEntryHook'))
+ return [];
+ $res = Database::simpleQuery("SELECT be.entryid, be.data,
+ GROUP_CONCAT(DISTINCT me.menuid) AS menus,
+ GROUP_CONCAT(DISTINCT ml.locationid) AS locations
+ FROM serversetup_bootentry be
+ LEFT JOIN serversetup_menuentry me USING (entryid)
+ LEFT JOIN serversetup_menu_location ml USING (menuid)
+ WHERE module = 'minilinux'
+ GROUP BY be.data");
+ $return = [];
+ $usedMenuIds = [];
+ foreach ($res as $row) {
+ $data = json_decode($row['data'], true);
+ if (!isset($data['id']))
+ continue;
+ $id = self::resolveEntryId($data['id']);
+ $new = [
+ 'entryids' => [$row['entryid']],
+ '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;
+ }
+ }
+ // Build id => title map for menus
+ $res = Database::simpleQuery("SELECT menuid, title FROM serversetup_menu m
+ WHERE menuid IN (:menuid)", ['menuid' => array_unique($usedMenuIds)]);
+ $menus = [];
+ foreach ($res as $row) {
+ $menus[$row['menuid']] = $row['title'];
+ }
+ // Build output array
+ foreach ($return as &$item) {
+ $item['locations'] = array_map(function ($i) {
+ return ['locationid' => $i, 'locationname' => Location::getName($i)];
+ }, array_unique(array_filter($item['locations'], 'is_numeric')));
+ $item['menus'] = array_map(function ($i) use ($menus) {
+ return ['menuid' => $i, 'menuname' => $menus[$i]];
+ }, array_unique(array_filter($item['menus'], 'is_numeric')));
+ $item['locationCount'] = count($item['locations']);
+ $item['menuCount'] = count($item['menus']);
+ $item['entryCount'] = count($item['entryids']);
+ }
+ return $return;
+ }
+
+ /**
+ * 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(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 = :ok
+ ORDER BY dateline DESC LIMIT 1',
+ ['id' => $id, 'ok' => self::INSTALL_OK]);
+ if ($res !== false) {
+ $id = $res['versionid'];
+ }
+ }
+ return $id;
+ }
+
+}
diff --git a/modules-available/minilinux/install.inc.php b/modules-available/minilinux/install.inc.php
index b859671a..7ef82d74 100644
--- a/modules-available/minilinux/install.inc.php
+++ b/modules-available/minilinux/install.inc.php
@@ -2,9 +2,9 @@
$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,
+ `lastupdate` int(10) UNSIGNED NOT NULL DEFAULT '0',
`taskid` char(36) CHARACTER SET ascii DEFAULT NULL,
`pubkey` blob NOT NULL,
PRIMARY KEY (`sourceid`),
@@ -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 687b4a71..f47249d5 100644
--- a/modules-available/minilinux/lang/de/module.json
+++ b/modules-available/minilinux/lang/de/module.json
@@ -6,8 +6,13 @@
"file-ok": "OK",
"file-size-mismatch": "Dateigr\u00f6\u00dfe stimmt nicht",
"ipxe-debug": "Debug-Ausgaben statt Bootlogo",
+ "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",
- "module_name": "Netboot Grundsystem",
+ "latest_of_branch": "(Neueste lokal vorhandene Version)",
+ "menu-sources": "Update-Quellen",
+ "menu-versions": "Verf\u00fcgbare Versionen",
+ "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 29012620..4773611a 100644
--- a/modules-available/minilinux/lang/de/permissions.json
+++ b/modules-available/minilinux/lang/de/permissions.json
@@ -1,4 +1,5 @@
{
- "view": "Zeige Komponenten des Minilinux. Wird nicht benötigt, wenn Nutzer eine der anderen Rechte hat.",
- "update": "Aktualisieren von Komponenten des Minilinux."
+ "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 2054896c..894b864b 100644
--- a/modules-available/minilinux/lang/de/template-tags.json
+++ b/modules-available/minilinux/lang/de/template-tags.json
@@ -2,21 +2,29 @@
"lang_branchesHeading": "Verf\u00fcgbare Varianten und Versionen",
"lang_changelog": "Changelog",
"lang_confirmDeleteVersion": "Diese Version wirklich l\u00f6schen?",
+ "lang_default": "Standard",
"lang_download": "Herunterladen",
"lang_id": "ID",
"lang_installed": "Installiert",
- "lang_introText": "Hier gibts MiniLinux.",
+ "lang_isGlobalDefault": "Ist globaler Standard",
"lang_key": "GPG-Key",
"lang_lastUpdate": "Zuletzt \u00fcberpr\u00fcft",
- "lang_minilinuxHeading": "Netboot Linux verwalten",
- "lang_orphanedVersion": "Verwaiste Version",
+ "lang_locations": "R\u00e4ume \/ Orte",
+ "lang_maybeMissingStage4": "Stage 4 m\u00f6glicherweise nicht verf\u00fcgbar",
+ "lang_menuEntries": "Men\u00fceintr\u00e4ge",
+ "lang_menus": "Men\u00fcs",
+ "lang_minilinuxHeading": "Netboot-Grundsystem verwalten",
+ "lang_orphanedVersion": "Verwaist",
+ "lang_orphanedVersionToolTip": "Diese Version wird vom Update-Server nicht mehr angeboten",
"lang_releaseDate": "Ver\u00f6ffentlichungsdatum",
"lang_selectedDefaultIs": "Gew\u00e4hltes Standardsystem ist",
+ "lang_setGlobalDefault": "Als globalen Standard festlegen",
"lang_sources": "Quellen",
"lang_sourcesIntro": "Liste der Quellen, aus denen Updates bezogen werden k\u00f6nnen.",
"lang_title": "Titel",
"lang_updateSourcesButton": "Nach neuen Updates suchen",
"lang_url": "URL",
+ "lang_usedBy": "Verwendet",
"lang_verify": "Integrit\u00e4t \u00fcberpr\u00fcfen",
"lang_verifyToolTip": "Dateiintegrit\u00e4t anhand von Pr\u00fcfsummen verifizieren",
"lang_version": "Version"
diff --git a/modules-available/minilinux/lang/en/messages.json b/modules-available/minilinux/lang/en/messages.json
index 6dc736a4..193b18fa 100644
--- a/modules-available/minilinux/lang/en/messages.json
+++ b/modules-available/minilinux/lang/en/messages.json
@@ -1,6 +1,10 @@
{
+ "default-is-invalid": "Currently selected default is invalid",
+ "default-not-installed": "Currently selected default of {{0}} is not locally installed (any more).",
+ "default-update-available": "You selected default system {{0}} can be updated to {{1}}",
"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 b1526869..ff5c7a49 100644
--- a/modules-available/minilinux/lang/en/module.json
+++ b/modules-available/minilinux/lang/en/module.json
@@ -1,9 +1,18 @@
{
+ "default_boot_entry": "(Use global default)",
"file-checksum-bad": "Bad checksum",
"file-missing": "File missing",
"file-not-readable": "File not readable",
"file-ok": "OK",
"file-size-mismatch": "File size mismatch",
- "module_name": "Minilinux",
+ "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)",
+ "menu-sources": "Sources for updates",
+ "menu-versions": "Available versions",
+ "module_name": "Net-boot OS",
+ "not_installed_hint": "(not installed)",
"page_title": "Manage Netboot Linux flavors"
} \ No newline at end of file
diff --git a/modules-available/minilinux/lang/en/permissions.json b/modules-available/minilinux/lang/en/permissions.json
index b8389e62..9d97ad00 100644
--- a/modules-available/minilinux/lang/en/permissions.json
+++ b/modules-available/minilinux/lang/en/permissions.json
@@ -1,4 +1,5 @@
{
- "view": "Show list of minilinux components. Not needed if User has any of the other permissions.",
- "update": "Update minilinux components."
+ "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/lang/en/template-tags.json b/modules-available/minilinux/lang/en/template-tags.json
index 48ba0c15..5b3c77e4 100644
--- a/modules-available/minilinux/lang/en/template-tags.json
+++ b/modules-available/minilinux/lang/en/template-tags.json
@@ -1,15 +1,31 @@
{
- "lang_canUpdate1": "At least one component of",
- "lang_canUpdate2": "Can be updated. For a smooth operation, it is recommended to keep all components up to date.",
- "lang_configurationPackageNotFound": "Configuration package not found!",
- "lang_desiredVersion": "Desired version",
- "lang_errorGetting": "Error while downloading list!",
- "lang_filesInVersion": "Files for version",
- "lang_listObtained": "Downloading list...",
- "lang_outdated": "Outdated",
- "lang_redownload": "Download again",
- "lang_systemUpdated": "The system is up to date.",
- "lang_update": "Update",
- "lang_updateAll": "Update all modules",
- "lang_uptodate": "Up to date"
+ "lang_branchesHeading": "Available branches and versions",
+ "lang_changelog": "Change log",
+ "lang_confirmDeleteVersion": "Do you want to delete this version?",
+ "lang_default": "Default",
+ "lang_download": "Download",
+ "lang_id": "ID",
+ "lang_installed": "Installed",
+ "lang_isGlobalDefault": "Global default",
+ "lang_key": "GPG key",
+ "lang_lastUpdate": "Last updated",
+ "lang_locations": "Rooms \/ Locations",
+ "lang_maybeMissingStage4": "Stage 4 might be missing",
+ "lang_menuEntries": "Menu entries",
+ "lang_menus": "Menus",
+ "lang_minilinuxHeading": "Manage netboot base system",
+ "lang_orphanedVersion": "Orphaned",
+ "lang_orphanedVersionToolTip": "This version is not offered by the update server any more",
+ "lang_releaseDate": "Release date",
+ "lang_selectedDefaultIs": "Current default is",
+ "lang_setGlobalDefault": "Set as global default",
+ "lang_sources": "Sources",
+ "lang_sourcesIntro": "List of update sources that will be checked for available branches and versions.",
+ "lang_title": "Title",
+ "lang_updateSourcesButton": "Check for new updates",
+ "lang_url": "URL",
+ "lang_usedBy": "Used",
+ "lang_verify": "Check file integrity",
+ "lang_verifyToolTip": "Check all files against known checksums",
+ "lang_version": "Version"
} \ No newline at end of file
diff --git a/modules-available/minilinux/page.inc.php b/modules-available/minilinux/page.inc.php
index 7c7e3d36..8004f1ab 100644
--- a/modules-available/minilinux/page.inc.php
+++ b/modules-available/minilinux/page.inc.php
@@ -25,42 +25,75 @@ class Page_MiniLinux extends Page
}
User::assertPermission('view');
+ 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)]);
+ $show = Request::get('show', 'list', 'string');
+ if ($show === 'list') {
+ // List branches and versions
+ $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) {
+ // 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);
+ $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');
+ $sourceViewData = ['list' => [], 'show_refresh' => true];
+ $tooOld = strtotime('-7 days');
+ $showRefresh = strtotime('-5 minutes');
+ 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) {
+ $sourceViewData['show_refresh'] = false;
+ }
+ $sourceViewData['list'][] = $row;
+ }
+ }
+ // 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));
}
- // List branches and versions
- $branches = Database::queryAll('SELECT sourceid, branchid, title FROM minilinux_branch ORDER BY title ASC');
- $versions = MiniLinux::queryAllVersionsByBranch();
- // Group by branch for detailed listing
- foreach ($branches as &$branch) {
- if (isset($versions[$branch['branchid']])) {
- $branch['versionlist'] = $this->renderVersionList($versions[$branch['branchid']]);
- }
- }
- unset($branch);
- Render::addTemplate('branches', ['branches' => $branches]);
- // List sources
- $res = Database::simpleQuery('SELECT sourceid, title, url, lastupdate, pubkey FROM minilinux_source ORDER BY title, sourceid');
- $data = ['list' => [], 'show_refresh' => true];
- $tooOld = strtotime('-7 days');
- $showRefresh = strtotime('-10 minutes');
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- $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;
- }
- $data['list'][] = $row;
+ if (isset($branches)) {
+ Render::addTemplate('branches', ['branches' => $branches]);
+ } elseif (isset($sourceViewData)) {
+ Render::addTemplate('sources', $sourceViewData);
+ } else {
+ Message::addError('main.invalid-action', $show);
}
- Render::addTemplate('sources', $data);
}
protected function doAjax()
@@ -74,23 +107,26 @@ class Page_MiniLinux extends Page
}
}
- private function renderVersionList($versions)
+ private function renderVersionList(array $versions, array $usage): string
{
$def = Property::get(MiniLinux::PROPERTY_DEFAULT_BOOT);
- $eff = Property::get(MiniLinux::PROPERTY_DEFAULT_BOOT_EFFECTIVE);
+ //$eff = Property::get(MiniLinux::PROPERTY_DEFAULT_BOOT_EFFECTIVE);
foreach ($versions as &$version) {
$version['dateline_s'] = Util::prettyTime($version['dateline']);
- $version['orphan'] = ($version['orphan'] > 2);
+ $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';
}
}
+ if (isset($usage[$version['versionid']])) {
+ $version['usage'] = $usage[$version['versionid']];
+ }
}
return Render::parse('versionlist', ['versions' => array_values($versions)]);
}
@@ -103,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');
@@ -115,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 = [];
@@ -139,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']);
}
}
@@ -147,14 +184,17 @@ class Page_MiniLinux extends Page
array_multisort($sort, SORT_ASC, $data['files']);
if (!$valid) {
$data['verify_button'] = false;
- $data['download_button'] = !$data['dltask'];
- 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'] !== null || $ver['installed'] != MiniLinux::INSTALL_MISSING) {
+ MiniLinux::checkStage4($data, $data['s4_errors']);
+ }
+ $data['changelog'] = Util::markup($ver['description'] ?? '');
echo Render::parse('filelist', $data);
}
@@ -164,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))
@@ -191,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 '???';
}
@@ -212,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 {
@@ -234,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,
@@ -243,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);
}
}
@@ -252,15 +293,20 @@ class Page_MiniLinux extends Page
private function updateSources()
{
+ User::assertPermission('view'); // As it doesn't really change anything, accept view permission
$ret = MiniLinux::updateList();
if ($ret > 0) {
- sleep(2);
- Trigger::checkCallbacks();
+ for ($i = 0; $i < 6; ++$i) {
+ sleep(1);
+ if (!Trigger::checkCallbacks())
+ break;
+ }
}
}
private function setDefault()
{
+ User::assertPermission('update');
$versionid = Request::post('version', false, 'string');
if ($versionid === false) {
Message::addError('main.parameter-missing', 'versionid');
@@ -272,7 +318,7 @@ class Page_MiniLinux extends Page
Message::addError('no-such-version');
return;
}
- Property::set(MiniLinux::PROPERTY_DEFAULT_BOOT, $version['versionid']);
+ MiniLinux::setDefaultVersion($version['versionid']);
}
}
diff --git a/modules-available/minilinux/templates/branches.html b/modules-available/minilinux/templates/branches.html
index 5f3c4e50..372321e2 100644
--- a/modules-available/minilinux/templates/branches.html
+++ b/modules-available/minilinux/templates/branches.html
@@ -1,23 +1,48 @@
<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">
- {{sourceid}} {{branchid}}
+ <div class="pull-right slx-pointer" data-toggle="collapse" data-target="#{{bid}}">
+ {{sourceid}} {{branchid}} <b class="caret"></b>
</div>
<b>{{title}}</b>
</div>
+ <div class="collapse in branch-item" id="{{bid}}">
<div class="panel-body">
{{description}}
</div>
{{{versionlist}}}
+ </div>
</div>
{{/branches}}
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
+ // Remember collapsed state
+ var c = localStorage.getItem('ml-collapse');
+ if (c) {
+ c = JSON.parse(c);
+ for (var e in c) {
+ if (c.hasOwnProperty(e)) {
+ $('#' + e).collapse('hide');
+ }
+ }
+ } else {
+ c = {};
+ }
+ $('.branch-item').on('hide.bs.collapse', function() {
+ c[this.id] = true;
+ localStorage.setItem('ml-collapse', JSON.stringify(c));
+ }).on('show.bs.collapse', function() {
+ delete c[this.id];
+ localStorage.setItem('ml-collapse', JSON.stringify(c));
+ });
+ // Button magic
var addHandlers = function(parent) {
parent.find('.btn-verify').click(function() {
loadDetails($(this).data('version'), { show: "version", verify: 1 });
@@ -63,9 +88,16 @@
});
var taskDone = {};
var wasUnfinished = false;
+ var errors = {};
function dlTmCb(task) {
if (!task.data || !task.data.files)
return;
+ if (task.data.error) {
+ if (errors[task.id] !== task.data.error) {
+ errors[task.id] = task.data.error;
+ $('#error-' + task.id).text(errors[task.id]).show();
+ }
+ }
for (var i = 0; i < task.data.files.length; ++i) {
var f = task.data.files[i];
var id = task.id + f.id;
diff --git a/modules-available/minilinux/templates/filelist.html b/modules-available/minilinux/templates/filelist.html
index 9aa175bd..241d1264 100644
--- a/modules-available/minilinux/templates/filelist.html
+++ b/modules-available/minilinux/templates/filelist.html
@@ -8,12 +8,6 @@
{{lang_verify}}
</button>
{{/verify_button}}
- {{#download_button}}
- <button type="button" class="btn btn-xs btn-success btn-download" data-version="{{versionid}}">
- <span class="glyphicon glyphicon-download"></span>
- {{lang_download}}
- </button>
- {{/download_button}}
{{#delete_button}}
<button type="submit" name="show" value="delete" class="btn btn-xs btn-danger"
data-confirm="{{lang_confirmDeleteVersion}}">
@@ -47,11 +41,18 @@
</tr>
{{/files}}
</table>
+{{#s4_errors}}
+<div class="alert alert-warning">{{lang_maybeMissingStage4}}: {{.}}</div>
+{{/s4_errors}}
{{#dltask}}
<div class="hidden" data-tm-id="{{dltask}}" data-tm-callback="dlTmCb"></div>
+<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 3059e827..c66de597 100644
--- a/modules-available/minilinux/templates/page-minilinux.html
+++ b/modules-available/minilinux/templates/page-minilinux.html
@@ -1,5 +1,18 @@
-<h1>{{lang_minilinuxHeading}}</h1>
+{{#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}}
-<p>{{lang_introText}}</p>
+<h1>{{lang_minilinuxHeading}}</h1>
{{lang_selectedDefaultIs}}: <b>{{default}}</b> \ No newline at end of file
diff --git a/modules-available/minilinux/templates/sources.html b/modules-available/minilinux/templates/sources.html
index dabc7f4d..50ad7c6f 100644
--- a/modules-available/minilinux/templates/sources.html
+++ b/modules-available/minilinux/templates/sources.html
@@ -20,10 +20,10 @@
<td class="small">{{url}}</td>
<td class="{{update_class}}">{{lastupdate_s}}</td>
<td class="text-center">
- <button type="button" class="btn btn-default btn-xs" data-confirm="#confirm-{{source}}" data-close="{{lang_close}}">
+ <button type="button" class="btn btn-default btn-xs" data-confirm="#confirm-{{sourceid}}" data-close="{{lang_close}}">
<span class="glyphicon glyphicon-eye-open"></span>
</button>
- <pre id="confirm-{{source}}" class="hidden">{{pubkey}}</pre>
+ <pre id="confirm-{{sourceid}}" class="hidden">{{pubkey}}</pre>
</td>
</tr>
{{/list}}
diff --git a/modules-available/minilinux/templates/versionlist.html b/modules-available/minilinux/templates/versionlist.html
index 4ef4e631..e66960b2 100644
--- a/modules-available/minilinux/templates/versionlist.html
+++ b/modules-available/minilinux/templates/versionlist.html
@@ -3,9 +3,10 @@
<th class="slx-smallcol">{{lang_version}}</th>
<th class="slx-smallcol">{{lang_releaseDate}}</th>
<th>{{lang_title}}</th>
+ <th class="slx-smallcol">{{lang_usedBy}}</th>
<th class="slx-smallcol"></th>
- <th class="slx-smallcol"></th>
- <th class="slx-smallcol"></th>
+ <th class="slx-smallcol" style="width:100px">{{lang_default}}</th>
+ <th class="slx-smallcol" style="width:150px">{{lang_download}}</th>
</tr>
{{#versions}}
<tr>
@@ -15,11 +16,46 @@
<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}}
+ <div class="dropdown">
+ <button class="btn btn-default btn-xs dropdown-toggle" type="button" data-toggle="dropdown">
+ {{usage.entryCount}} / {{usage.menuCount}} / {{usage.locationCount}}
+ <span class="caret"></span>
+ </button>
+ <ul class="dropdown-menu">
+ <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 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 slx-bold">{{lang_locations}}</li>
+ {{/usage.locations.0}}
+ {{#usage.locations}}
+ <li class="disabled"><a href="#">{{locationname}}</a></li>
+ {{/usage.locations}}
+ </ul>
+ </div>
+ {{/usage.entryids.0}}
+ </td>
+ <td class="text-nowrap">
{{#orphan}}
- {{lang_orphanedVersion}}
+ <span class="label label-danger" title="{{lang_orphanedVersionToolTip}}">{{lang_orphanedVersion}}</span>
{{/orphan}}
</td>
<td class="text-nowrap text-center {{default_class}}">
@@ -27,31 +63,33 @@
<form method="post" action="?do=minilinux" style="margin:0;padding:0;display:inline">
<input type="hidden" name="token" value="{{token}}">
<input type="hidden" name="version" value="{{versionid}}">
- <button type="submit" name="show" value="setdefault" class="btn btn-xs btn-info">
+ <button type="submit" name="show" value="setdefault" class="btn btn-xs btn-info" title="{{lang_setGlobalDefault}}">
<span class="glyphicon glyphicon-flag"></span>
</button>
</form>
{{/showsetdefault}}
{{#isdefault}}
- <span class="glyphicon glyphicon-ok"></span>
+ <span class="glyphicon glyphicon-ok" title="{{lang_isGlobalDefault}}"></span>
{{/isdefault}}
</td>
- <td class="text-nowrap text-center">
+ <td class="text-nowrap text-right">
{{#installed}}
- <span class="btn btn-default btn-xs disabled">{{lang_installed}}</span>
+ <span class="label label-info">{{lang_installed}}</span>
{{/installed}}
{{^installed}}
+ {{^orphan}}
{{^downloading}}
<button type="button" class="btn btn-xs btn-success btn-download" data-version="{{versionid}}">
<span class="glyphicon glyphicon-download"></span>
{{lang_download}}
</button>
{{/downloading}}
+ {{/orphan}}
{{/installed}}
</td>
</tr>
<tr>
- <td colspan="6" class="version-container collapse" data-version="{{versionid}}"></td>
+ <td colspan="7" class="version-container collapse" data-version="{{versionid}}"></td>
</tr>
{{/versions}}
</table> \ No newline at end of file
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 660c94ae..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) {
@@ -84,7 +84,7 @@ class GetPermissionData
if (!empty($joins)) {
$joins .= ' GROUP BY r.roleid';
}
- return Database::queryAll("SELECT r.roleid, r.rolename, r.roledescription $cols FROM role r
+ return Database::queryAll("SELECT r.roleid, r.rolename, r.builtin, r.roledescription $cols FROM role r
$joins
ORDER BY rolename ASC");
}
@@ -93,25 +93,32 @@ class GetPermissionData
* Get permissions and locations for a given role.
*
* @param string $roleid id of the role
- * @return array array containing an array of permissions and an array of locations
+ * @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
{
- $query = "SELECT roleid, rolename, roledescription FROM role WHERE roleid = :roleid";
- $data = Database::queryFirst($query, array("roleid" => $roleid));
- $query = "SELECT roleid, locationid FROM role_x_location WHERE roleid = :roleid";
- $res = Database::simpleQuery($query, array("roleid" => $roleid));
+ $data = self::getRole($roleid);
+ $res = Database::simpleQuery("SELECT roleid, locationid FROM role_x_location WHERE roleid = :roleid",
+ array("roleid" => $roleid));
+ if ($res === false)
+ return null;
$data["locations"] = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ foreach ($res as $row) {
$data["locations"][] = $row['locationid'];
}
- $query = "SELECT roleid, permissionid FROM role_x_permission WHERE roleid = :roleid";
- $res = Database::simpleQuery($query, array("roleid" => $roleid));
+ $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;
}
+ public static function getRole($roleId)
+ {
+ return Database::queryFirst("SELECT roleid, rolename, builtin, roledescription FROM role
+ WHERE roleid = :roleid", ['roleid' => $roleId]);
+ }
+
} \ No newline at end of file
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 46b8c065..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);
@@ -83,14 +83,12 @@ class PermissionUtil
$locations = [0];
} else {
$locations = Location::getLocationRootChain($locationid);
- if (empty($locations)) { // Non-existent location, still continue as user might have global perms
- $locations = [0];
- }
+ $locations[] = 0;
}
$cache =& self::$permissionCacheLoc;
if (array_key_exists($key, $cache)) {
if (array_key_exists($locationid, $cache[$key]))
- return $cache[$key][$locationid]; // Exact match - return immedtiately
+ return $cache[$key][$locationid]; // Exact match - return immediately
foreach ($locations as $lid) {
if (array_key_exists($lid, $cache[$key]) && $cache[$key][$lid]) {
$cache[$key][$locationid] = true;
@@ -123,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) {
@@ -134,29 +134,19 @@ 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) {
$cache[$key] = $retval;
} else {
$cache[$key][$locationid] = $retval;
- if ($cacheAll) {
- $locations = array_keys($allLocs);
- }
- foreach ($locations as $lid) {
- if (!array_key_exists($lid, $cache[$key])) {
- $cache[$key][$lid] = false;
- }
- }
}
return $retval;
}
@@ -164,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);
@@ -188,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;
@@ -211,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) {
@@ -236,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;
@@ -265,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) {
@@ -278,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);
@@ -287,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)) {
@@ -301,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');
@@ -328,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 d0f8cdeb..ae6c9b03 100644
--- a/modules-available/permissionmanager/install.inc.php
+++ b/modules-available/permissionmanager/install.inc.php
@@ -5,6 +5,7 @@ $res = array();
$res[] = tableCreate('role', "
roleid int(10) unsigned NOT NULL AUTO_INCREMENT,
rolename varchar(200) NOT NULL,
+ builtin bool NOT NULL DEFAULT '0',
roledescription TEXT,
PRIMARY KEY (roleid)
");
@@ -30,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)
");
@@ -100,52 +101,72 @@ if (!tableHasColumn('role', 'roledescription')) {
$res[] = UPDATE_DONE;
}
-if (!tableHasColumn('role', 'roledescription')) {
- finalResponse(UPDATE_RETRY, 'Try again later');
+// 2020-01-09 flag for builtin roles that can't be edited
+if (!tableHasColumn('role', 'builtin')) {
+ $alter = Database::exec("ALTER TABLE role ADD builtin bool NOT NULL DEFAULT '0' AFTER rolename");
+ if ($alter === false)
+ finalResponse(UPDATE_FAILED, 'Cannot add builtin field to table role: ' . Database::lastError());
+ $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` VALUES
- (1,'Super-Admin', 'Hat keinerlei Zugriffsbeschränkungen'),
- (2,'Admin', 'Alles bis auf Rechte-/Nutzerverwaltung'),
- (3,'Prüfungsadmin', 'Kann E-Prüfungen verwalten, Prüfungsmodus einschalten, etc.'),
- (4,'Lesezugriff', 'Kann auf die meisten Seiten zugreifen, jedoch keine Änderungen vornehmen')") !== false) {
- // Success, there probably were no roles before, keep going
+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) {
// 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)");
+ // In case user fiddled around before
+ Database::exec("DELETE FROM role_x_permission WHERE roleid IN (1,2,3,4)");
// Assign permissions to roles
- Database::exec("INSERT INTO `role_x_permission` VALUES
+ 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'),
(4,'locationinfo.panel.list'),
(4,'locations.location.view'),
(4,'minilinux.view'),
- (4,'news.*'),
+ (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.*'),
@@ -159,22 +180,26 @@ if (Database::exec("INSERT INTO `role` VALUES
(4,'systemstatus.show.overview.*'),
(4,'systemstatus.tab.*'),
(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.*'),
@@ -183,11 +208,14 @@ if (Database::exec("INSERT INTO `role` VALUES
(2,'sysconfig.*'),
(2,'syslog.*'),
(2,'systemstatus.*'),
- (2,'vmstore.edit'),
+ (2,'vmstore.*'),
(2,'webinterface.*')");
- // Asign the first user to the superadmin role (if one exists)
- Database::exec("INSERT INTO `role_x_user` VALUES (1,1)");
- $res[] = UPDATE_DONE;
+ Database::exec("OPTIMIZE TABLE role_x_permission");
+ // Assign the first user to the superadmin role (if one exists)
+ $num = Database::exec("INSERT IGNORE INTO `role_x_user` VALUES (1,1)");
+ if ($num > 0) {
+ $res[] = UPDATE_DONE;
+ }
}
//
diff --git a/modules-available/permissionmanager/lang/de/messages.json b/modules-available/permissionmanager/lang/de/messages.json
new file mode 100644
index 00000000..86c432b5
--- /dev/null
+++ b/modules-available/permissionmanager/lang/de/messages.json
@@ -0,0 +1,4 @@
+{
+ "builtin-role": "Vorgegebene Rolle {{0}} kann nicht bearbeitet werden",
+ "invalid-role-id": "Ung\u00fcltige Rollen-ID: {{0}}"
+} \ No newline at end of file
diff --git a/modules-available/permissionmanager/lang/de/template-tags.json b/modules-available/permissionmanager/lang/de/template-tags.json
index 504ef6d2..e8f44c82 100644
--- a/modules-available/permissionmanager/lang/de/template-tags.json
+++ b/modules-available/permissionmanager/lang/de/template-tags.json
@@ -1,6 +1,7 @@
{
"lang_addRole": "Rollen erteilen",
"lang_addRoleHeading": "Neue Rolle hinzuf\u00fcgen",
+ "lang_copyRoleHeading": "Rolle kopieren",
"lang_description": "Beschreibung",
"lang_editRoleHeading": "Rolle bearbeiten",
"lang_locationAwareDesc": "Berechtigungen mit diesem Symbol k\u00f6nnen auf bestimmte R\u00e4ume\/Orte beschr\u00e4nkt werden. Alle anderen Berechtigungen sind unabh\u00e4ngig von den f\u00fcr diese Rolle ausgew\u00e4hlten Orten.",
diff --git a/modules-available/permissionmanager/lang/en/messages.json b/modules-available/permissionmanager/lang/en/messages.json
new file mode 100644
index 00000000..18091c83
--- /dev/null
+++ b/modules-available/permissionmanager/lang/en/messages.json
@@ -0,0 +1,4 @@
+{
+ "builtin-role": "Builtin role {{0}} cannot be modified",
+ "invalid-role-id": "Invalid role ID: {{0}}"
+} \ No newline at end of file
diff --git a/modules-available/permissionmanager/lang/en/template-tags.json b/modules-available/permissionmanager/lang/en/template-tags.json
index 6f1fa614..d13397e8 100644
--- a/modules-available/permissionmanager/lang/en/template-tags.json
+++ b/modules-available/permissionmanager/lang/en/template-tags.json
@@ -1,6 +1,7 @@
{
"lang_addRole": "Grant Roles",
"lang_addRoleHeading": "Add new role",
+ "lang_copyRoleHeading": "Copy role",
"lang_description": "Description",
"lang_editRoleHeading": "Edit role",
"lang_locationAwareDesc": "Permissions with this symbol can be restricted to certain locations. All other permissions are independent of the locations selected for this role.",
diff --git a/modules-available/permissionmanager/page.inc.php b/modules-available/permissionmanager/page.inc.php
index 462d3163..7e9f17e4 100644
--- a/modules-available/permissionmanager/page.inc.php
+++ b/modules-available/permissionmanager/page.inc.php
@@ -18,25 +18,23 @@ 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');
$id = Request::post('deleteId', false, 'int');
+ $this->denyActionIfBuiltin($id);
PermissionDbUpdate::deleteRole($id);
} elseif ($action === 'saveRole') {
User::assertPermission('roles.edit');
- $roleID = Request::post("roleid", false, 'int');
- if ($roleID === false) {
- Message::addError('main.parameter-missing', 'roleid');
- Util::redirect('?do=permissionmanager');
- }
+ $roleID = Request::post("roleid", Request::REQUIRED_EMPTY, 'int');
+ $this->denyActionIfBuiltin($roleID);
$roleName = Request::post("rolename", '', 'string');
if (empty($roleName)) {
Message::addError('main.parameter-empty', 'rolename');
@@ -116,7 +114,17 @@ class Page_PermissionManager extends Page
$selectedLocations = array();
$roleid = Request::get("roleid", false, 'int');
if ($roleid !== false) {
- $data += GetPermissionData::getRoleData($roleid);
+ $role = GetPermissionData::getRoleData($roleid);
+ if ($role === null) {
+ Message::addError('invalid-role-id', $roleid);
+ Util::redirect('?do=permissionmanager');
+ }
+ if ($role['builtin']) {
+ // Copy the role, as it's builtin
+ $role['roleid'] = '';
+ $role['rolename'] .= ' (2)';
+ }
+ $data += $role;
$selectedPermissions = $data["permissions"];
$selectedLocations = $data["locations"];
}
@@ -139,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 == "";
@@ -195,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)) {
@@ -234,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);
@@ -259,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("*");
@@ -279,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) {
@@ -298,4 +308,19 @@ class Page_PermissionManager extends Page
return $result;
}
+ private function denyActionIfBuiltin(string $roleID): void
+ {
+ if ($roleID) {
+ $existing = GetPermissionData::getRole($roleID);
+ if ($existing === false) {
+ Message::addError('invalid-role-id', $roleID);
+ Util::redirect('?do=permissionmanager');
+ }
+ if ($existing['builtin']) {
+ Message::addError('builtin-role', $existing['rolename']);
+ Util::redirect('?do=permissionmanager');
+ }
+ }
+ }
+
}
diff --git a/modules-available/permissionmanager/templates/roleeditor.html b/modules-available/permissionmanager/templates/roleeditor.html
index c464c1fc..37d0d5fe 100644
--- a/modules-available/permissionmanager/templates/roleeditor.html
+++ b/modules-available/permissionmanager/templates/roleeditor.html
@@ -3,7 +3,12 @@
{{lang_editRoleHeading}}
{{/roleid}}
{{^roleid}}
- {{lang_addRoleHeading}}
+ {{#builtin}}
+ {{lang_copyRoleHeading}}
+ {{/builtin}}
+ {{^builtin}}
+ {{lang_addRoleHeading}}
+ {{/builtin}}
{{/roleid}}
</h2>
diff --git a/modules-available/permissionmanager/templates/rolestable.html b/modules-available/permissionmanager/templates/rolestable.html
index e50dffc7..170dde88 100644
--- a/modules-available/permissionmanager/templates/rolestable.html
+++ b/modules-available/permissionmanager/templates/rolestable.html
@@ -31,9 +31,17 @@
<td class="rolename">{{rolename}}</td>
<td class="text-muted"><table class="slx-ellipsis"><tr><td>{{roledescription}}</td></tr></table></td>
<td class="text-center">
- <a class="btn btn-xs btn-primary" href="?do=permissionmanager&amp;show=roleEditor&amp;roleid={{roleid}}"><span class="glyphicon glyphicon-edit"></span></a>
+ <a class="btn btn-xs btn-primary" href="?do=permissionmanager&amp;show=roleEditor&amp;roleid={{roleid}}">
+ {{#builtin}}
+ <span class="glyphicon glyphicon-duplicate"></span>
+ {{/builtin}}
+ {{^builtin}}
+ <span class="glyphicon glyphicon-edit"></span>
+ {{/builtin}}
+ </a>
</td>
<td class="text-center">
+ {{^builtin}}
<button type="submit" name="deleteId" value="{{roleid}}" class="btn btn-xs btn-danger" {{perms.roles.edit.disabled}}
data-confirm="#confirm-role-{{roleid}}" data-title="{{rolename}}">
<span class="glyphicon glyphicon-trash"></span>
@@ -42,6 +50,7 @@
<p>{{lang_roleDeleteConfirm}}</p>
{{lang_numAssignedUsers}}: {{users}}
</div>
+ {{/builtin}}
</td>
</tr>
{{/roles}}
@@ -52,7 +61,10 @@
</form>
<div class="text-right">
- <a href="?do=permissionmanager&amp;show=roleEditor" class="btn btn-success {{perms.roles.edit.disabled}}"><span class="glyphicon glyphicon-plus"></span> {{lang_newRole}}</a>
+ <a href="?do=permissionmanager&amp;show=roleEditor" class="btn btn-success {{perms.roles.edit.disabled}}">
+ <span class="glyphicon glyphicon-plus"></span>
+ {{lang_newRole}}
+ </a>
</div>
<script>
diff --git a/modules-available/rebootcontrol/api.inc.php b/modules-available/rebootcontrol/api.inc.php
index 6ebc8399..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 authentification
- '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/clientscript.js b/modules-available/rebootcontrol/clientscript.js
new file mode 100644
index 00000000..447072a0
--- /dev/null
+++ b/modules-available/rebootcontrol/clientscript.js
@@ -0,0 +1,27 @@
+var stillActive = 10;
+document.addEventListener('DOMContentLoaded', function() {
+ var clients = [];
+ $('.machineuuid').each(function() { clients.push($(this).data('uuid')); });
+ if (clients.length === 0)
+ return;
+ function updateClientStatus() {
+ if (stillActive <= 0) return;
+ stillActive--;
+ setTimeout(updateClientStatus, Math.max(1, 30 - stillActive) * 1000);
+ $.ajax({
+ url: "?do=rebootcontrol",
+ method: "POST",
+ dataType: 'json',
+ data: { token: TOKEN, action: "clientstatus", clients: clients }
+ }).done(function(data) {
+ console.log(data);
+ if (!data)
+ return;
+ for (var e in data) {
+ $('#status-' + e).prop('class', 'glyphicon ' + data[e]);
+ if (stillActive <= 0) $('#spinner-' + e).remove();
+ }
+ });
+ }
+ setTimeout(updateClientStatus, 10);
+}); \ No newline at end of file
diff --git a/modules-available/rebootcontrol/config.json b/modules-available/rebootcontrol/config.json
index f823e2eb..4608665b 100644
--- a/modules-available/rebootcontrol/config.json
+++ b/modules-available/rebootcontrol/config.json
@@ -3,6 +3,7 @@
"collapse": true,
"dependencies": [
"locations",
- "js_stupidtable"
+ "js_stupidtable",
+ "statistics"
]
} \ 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
new file mode 100644
index 00000000..e934988d
--- /dev/null
+++ b/modules-available/rebootcontrol/hooks/client-update.inc.php
@@ -0,0 +1,25 @@
+<?php
+
+if ($type === '~poweron') {
+ $NOW = time();
+ $subnet = Request::post('subnet', false, 'string');
+ if ($subnet !== false && ($subnet = explode('/', $subnet)) !== false && count($subnet) === 2
+ && $subnet[0] === $ip && $subnet[1] >= 8 && $subnet[1] < 32) {
+ $start = ip2long($ip);
+ if ($start !== false) {
+ $maskHost = (int)(2 ** (32 - $subnet[1]) - 1);
+ $maskNet = ~$maskHost & 0xffffffff;
+ $end = $start | $maskHost;
+ $start &= $maskNet;
+ $netparams = ['start' => sprintf('%u', $start), 'end' => sprintf('%u', $end), 'now' => $NOW];
+ $affected = Database::exec('UPDATE reboot_subnet
+ SET lastseen = :now, seencount = seencount + 1
+ WHERE start = :start AND end = :end', $netparams);
+ if ($affected === 0) {
+ // New entry
+ Database::exec('INSERT INTO reboot_subnet (start, end, fixed, isdirect, lastseen, seencount)
+ VALUES (:start, :end, 0, 0, :now, 1)', $netparams);
+ }
+ }
+ }
+}
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
new file mode 100644
index 00000000..289426c7
--- /dev/null
+++ b/modules-available/rebootcontrol/hooks/cron.inc.php
@@ -0,0 +1,253 @@
+<?php
+
+/*
+ * JumpHost availability test, 5 times a day...
+ */
+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');
+ foreach ($res as $row) {
+ RebootControl::wakeViaJumpHost($row, '255.255.255.255', [['macaddr' => '00:11:22:33:44:55']]);
+ }
+}
+
+// CRON for Scheduler
+Scheduler::cron();
+
+/*
+ * Client reachability test -- can be disabled
+ */
+if (Property::get(RebootControl::KEY_AUTOSCAN_DISABLED))
+ return;
+
+class Stuff
+{
+ public static $subnets;
+}
+
+function destSawPw(array $destTask, array $destMachine, string $passwd): bool
+{
+ return strpos($destTask['data']['result'][$destMachine['machineuuid']]['stdout'], "passwd=$passwd") !== false;
+}
+
+function spawnDestinationListener($dstid, &$destMachine, &$destTask, &$destDeadline)
+{
+ $destMachines = Stuff::$subnets[$dstid];
+ cron_log(count($destMachines) . ' potential destination machines for subnet ' . $dstid);
+ shuffle($destMachines);
+ $destMachines = array_slice($destMachines, 0, 3);
+ $destTask = $destMachine = false;
+ $destDeadline = 0;
+ foreach ($destMachines as $machine) {
+ cron_log("Trying to use {$machine['clientip']} as listener for " . long2ip($machine['bcast']));
+ $destTask = RebootControl::runScript([$machine], "echo 'Running-MARK'\nbusybox timeout 8 jawol -v -l -l", 10);
+ Taskmanager::release($destTask);
+ $destDeadline = time() + 10;
+ if (!Taskmanager::isRunning($destTask))
+ continue;
+ sleep(2); // Wait a bit and re-check job is running; only then proceed with this host
+ $destTask = Taskmanager::status($destTask);
+ cron_log("....is {$destTask['statusCode']} {$machine['machineuuid']}");
+ if (Taskmanager::isRunning($destTask)
+ && strpos($destTask['data']['result'][$machine['machineuuid']]['stdout'], 'Running-MARK') !== false) {
+ $destMachine = $machine;
+ break; // GOOD TO GO
+ }
+ cron_log(print_r($destTask, true));
+ cron_log("Dest isn't running or didn't have MARK in output, trying another one...");
+ }
+}
+
+function testClientToClient($srcid, $dstid)
+{
+ $sourceMachines = Stuff::$subnets[$srcid];
+ // Start listener on destination
+ spawnDestinationListener($dstid, $destMachine, $destTask, $destDeadline);
+ if ($destMachine === false || !Taskmanager::isRunning($destTask))
+ return false; // No suitable dest-host found
+ // Find a source host
+ $passwd = sprintf('%02x:%02x:%02x:%02x:%02x:%02x', mt_rand(0, 255), mt_rand(0, 255),
+ mt_rand(0, 255), mt_rand(0, 255), mt_rand(0, 255), mt_rand(0, 255));
+ shuffle($sourceMachines);
+ $sourceMachines = array_slice($sourceMachines, 0, 3);
+ cron_log("Running sending task on "
+ . implode(', ', array_map(function($item) { return $item['clientip']; }, $sourceMachines)));
+ $sourceTask = RebootControl::wakeViaClient($sourceMachines, $destMachine['macaddr'], $destMachine['bcast'], $passwd);
+ Taskmanager::release($sourceTask);
+ if (!Taskmanager::isRunning($sourceTask)) {
+ cron_log('Failed to launch task for source hosts...');
+ return false;
+ }
+ cron_log('Waiting for testing tasks to finish...');
+ // Loop as long as destination task and source task is running and we didn't see the pw at destination yet
+ while (Taskmanager::isRunning($destTask) && Taskmanager::isRunning($sourceTask)
+ && !destSawPw($destTask, $destMachine, $passwd) && $destDeadline > time()) {
+ $sourceTask = Taskmanager::status($sourceTask);
+ usleep(250000);
+ $destTask = Taskmanager::status($destTask);
+ }
+ // Wait for destination listener task to finish; we might want to reuse that client,
+ // and trying to spawn a new listener while the old one is running will fail
+ for ($to = 0; $to < 30 && Taskmanager::isRunning($destTask); ++$to) {
+ usleep(250000);
+ }
+ cron_log($destTask['data']['result'][$destMachine['machineuuid']]['stdout']);
+ // Final moment: did dest see the packets from src? Determine this by looking for the generated password
+ if (destSawPw($destTask, $destMachine, $passwd))
+ return 1; // Found pw
+ return 0; // Nothing :-(
+}
+
+function testServerToClient($dstid)
+{
+ spawnDestinationListener($dstid, $destMachine, $destTask, $destDeadline);
+ if ($destMachine === false || !Taskmanager::isRunning($destTask))
+ return false; // No suitable dest-host found
+ $passwd = sprintf('%02x:%02x:%02x:%02x:%02x:%02x', mt_rand(0, 255), mt_rand(0, 255),
+ mt_rand(0, 255), mt_rand(0, 255), mt_rand(0, 255), mt_rand(0, 255));
+ cron_log('Sending WOL packets from Sat Server...');
+ $task = RebootControl::wakeDirectly($destMachine['macaddr'], $destMachine['bcast'], $passwd);
+ usleep(200000);
+ $destTask = Taskmanager::status($destTask);
+ if (!destSawPw($destTask, $destMachine, $passwd) && !Taskmanager::isTask($task))
+ return false;
+ cron_log('Waiting for receive on destination...');
+ $task = Taskmanager::status($task);
+ if (!destSawPw($destTask, $destMachine, $passwd)) {
+ $task = Taskmanager::waitComplete($task, 2000);
+ $destTask = Taskmanager::status($destTask);
+ }
+ // Wait for destination listener task to finish; we might want to reuse that client,
+ // and trying to spawn a new listener while the old one is running will fail
+ for ($to = 0; $to < 30 && Taskmanager::isRunning($destTask); ++$to) {
+ usleep(250000);
+ }
+ cron_log($destTask['data']['result'][$destMachine['machineuuid']]['stdout']);
+ if (destSawPw($destTask, $destMachine, $passwd))
+ return 1;
+ return 0;
+}
+
+/**
+ * Take test result, turn into "next check" timestamp
+ */
+function resultToTime($result)
+{
+ if ($result === false) {
+ // Temporary failure -- couldn't run at least one destination and one source task
+ $next = 7200; // 2 hours
+ } elseif ($result === 0) {
+ // Test finished, subnet not reachable
+ $next = 86400 * 7; // a week
+ } else {
+ // Test finished, reachable
+ $next = 86400 * 14; // two weeks
+ }
+ return time() + round($next * mt_rand(90, 133) / 100);
+}
+
+/*
+ *
+ */
+
+// First, cleanup: delete orphaned subnets that don't exist anymore, or don't have any clients using our server
+$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, m.locationid
+ FROM reboot_subnet s
+ INNER JOIN machine m ON (
+ (m.state = 'IDLE' OR m.state = 'OCCUPIED')
+ AND
+ (m.lastseen >= $cutoff)
+ AND
+ (INET_ATON(m.clientip) BETWEEN s.start AND s.end)
+ )");
+
+//cron_log('Machine: ' . $res->rowCount());
+
+if ($res->rowCount() === 0)
+ return;
+
+Stuff::$subnets = [];
+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);
+if (!Taskmanager::isFinished($task)) {
+ cron_log('Task manager down. Doing nothing.');
+ return; // No :-(
+}
+unset($task);
+
+/*
+ * Try server to client
+ */
+
+$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)) . ')');
+foreach ($res as $row) {
+ $dst = (int)$row['subnetid'];
+ cron_log('Direct check for subnetid ' . $dst);
+ $result = testServerToClient($dst);
+ $next = resultToTime($result);
+ if ($result === false) {
+ Database::exec('UPDATE reboot_subnet
+ SET nextdirectcheck = :nextcheck
+ WHERE subnetid = :dst', ['nextcheck' => $next, 'dst' => $dst]);
+ } else {
+ Database::exec('UPDATE reboot_subnet
+ SET nextdirectcheck = :nextcheck, isdirect = :isdirect
+ WHERE subnetid = :dst', ['nextcheck' => $next, 'isdirect' => $result, 'dst' => $dst]);
+ }
+}
+
+/*
+ * Try client to client
+ */
+
+if (!Property::get(RebootControl::KEY_SCAN_CLIENT_TO_CLIENT))
+ return;
+
+// Query all possible combos
+$combos = [];
+foreach (Stuff::$subnets as $src => $_) {
+ $src = (int)$src;
+ foreach (Stuff::$subnets as $dst => $_) {
+ $dst = (int)$dst;
+ if ($src !== $dst) {
+ $combos[] = [$src, $dst];
+ }
+ }
+}
+
+// Check subnet to subnet
+if (count($combos) > 0) {
+ $res = Database::simpleQuery("SELECT ss.subnetid AS srcid, sd.subnetid AS dstid
+ FROM reboot_subnet ss
+ INNER JOIN reboot_subnet sd ON ((ss.subnetid, sd.subnetid) IN (:combos) AND sd.fixed = 0)
+ LEFT JOIN reboot_subnet_x_subnet sxs ON (ss.subnetid = sxs.srcid AND sd.subnetid = sxs.dstid)
+ WHERE sxs.nextcheck < UNIX_TIMESTAMP() OR sxs.nextcheck IS NULL
+ ORDER BY sxs.nextcheck ASC
+ LIMIT 10", ['combos' => $combos]);
+ cron_log('C2C checks: ' . $res->rowCount());
+ foreach ($res as $row) {
+ $src = (int)$row['srcid'];
+ $dst = (int)$row['dstid'];
+ $result = testClientToClient($src, $dst);
+ $next = resultToTime($result);
+ Database::exec('INSERT INTO reboot_subnet_x_subnet (srcid, dstid, reachable, nextcheck)
+ VALUES (:srcid, :dstid, :reachable, :nextcheck)
+ ON DUPLICATE KEY UPDATE ' . ($result === false ? '' : 'reachable = VALUES(reachable),') . ' nextcheck = VALUES(nextcheck)',
+ ['srcid' => $src, 'dstid' => $dst, 'reachable' => (int)$result, 'nextcheck' => $next]);
+ }
+}
diff --git a/modules-available/rebootcontrol/inc/rebootcontrol.inc.php b/modules-available/rebootcontrol/inc/rebootcontrol.inc.php
index ec4b84ed..107c2a50 100644
--- a/modules-available/rebootcontrol/inc/rebootcontrol.inc.php
+++ b/modules-available/rebootcontrol/inc/rebootcontrol.inc.php
@@ -5,81 +5,542 @@ class RebootControl
const KEY_TASKLIST = 'rebootcontrol.tasklist';
+ const KEY_AUTOSCAN_DISABLED = 'rebootcontrol.disable.scan';
+
+ const KEY_SCAN_CLIENT_TO_CLIENT = 'rebootcontrol.scan.c2c';
+
+ const KEY_UDP_PORT = 'rebootcontrol.port';
+
+ const KEY_BROADCAST_ADDRESS = 'rebootcontrol.broadcast-addr';
+
const REBOOT = 'REBOOT';
const KEXEC_REBOOT = 'KEXEC_REBOOT';
const SHUTDOWN = 'SHUTDOWN';
+ const TASK_REBOOTCTL = 'TASK_REBOOTCTL';
+ const TASK_WOL = 'WAKE_ON_LAN';
+ const TASK_EXEC = 'REMOTE_EXEC';
/**
* @param string[] $uuids List of machineuuids to reboot
* @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 = RebootQueries::getMachinesByUuid($uuids);
+ $list = RebootUtils::getMachinesByUuid($uuids);
if (empty($list))
return false;
- return self::execute($list, $kexec ? RebootControl::KEXEC_REBOOT : RebootControl::REBOOT, 0, 0);
+ return self::execute($list, $kexec ? RebootControl::KEXEC_REBOOT : RebootControl::REBOOT, 0);
}
/**
- * @param array $list list of clients containing each keys 'machineuuid' and 'clientip'
+ * @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, $locationId)
+ public static function execute(array $list, string $mode, int $minutes)
{
$task = Taskmanager::submit("RemoteReboot", array(
"clients" => $list,
"mode" => $mode,
"minutes" => $minutes,
- "locationId" => $locationId,
"sshkey" => SSHKey::getPrivateKey(),
"port" => 9922, // Hard-coded, must match mgmt-sshd module
));
if (!Taskmanager::isFailed($task)) {
- Property::addToList(RebootControl::KEY_TASKLIST, $locationId . '/' . $task["id"], 60 * 24);
+ 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;
}
/**
+ * 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);
+ $newClients = [];
+ foreach ($clients as $c) {
+ $d = ['clientip' => $c['clientip']];
+ if (isset($c['machineuuid'])) {
+ $d['machineuuid'] = $c['machineuuid'];
+ }
+ $newClients[] = $d;
+ }
+ $data = [
+ 'id' => $taskId,
+ 'type' => $type,
+ 'locations' => $lids,
+ 'clients' => $newClients,
+ '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;
+ }
+ Property::addToList(RebootControl::KEY_TASKLIST, json_encode($data), 20);
+ }
+
+ /**
* @param int[]|null $locations filter by these locations
- * @return array list of active tasks for reboots/shutdowns.
+ * @param ?string $id only with this TaskID
+ * @return array|false list of active tasks for reboots/shutdowns.
*/
- public static function getActiveTasks($locations = null)
+ public static function getActiveTasks(array $locations = null, string $id = null)
{
- if (is_array($locations) && in_array(0,$locations)) {
+ if (is_array($locations) && in_array(0, $locations)) {
$locations = null;
}
$list = Property::getList(RebootControl::KEY_TASKLIST);
$return = [];
- foreach ($list as $entry) {
- $p = explode('/', $entry, 2);
- if (count($p) !== 2) {
- Property::removeFromList(RebootControl::KEY_TASKLIST, $entry);
+ foreach ($list as $subkey => $entry) {
+ $p = json_decode($entry, true);
+ if (!is_array($p) || !isset($p['id'])) {
+ Property::removeFromListByKey(RebootControl::KEY_TASKLIST, $subkey);
continue;
}
- if (is_array($locations) && !in_array($p[0], $locations)) // Ignore
+ if (is_array($locations) && is_array($p['locations']) && array_diff($p['locations'], $locations) !== [])
+ continue; // Not allowed
+ if ($id !== null) {
+ if ($p['id'] === $id)
+ return $p;
continue;
- $id = $p[1];
- $task = Taskmanager::status($id);
- if (!Taskmanager::isTask($task)) {
- Property::removeFromList(RebootControl::KEY_TASKLIST, $entry);
+ }
+ $valid = empty($p['tasks']);
+ if (!$valid) {
+ // Validate at least one task is still valid
+ foreach ($p['tasks'] as $task) {
+ $task = Taskmanager::status($task);
+ if (Taskmanager::isTask($task)) {
+ $p['status'] = $task['statusCode'];
+ $valid = true;
+ break;
+ }
+ }
+ }
+ if (!$valid) {
+ Property::removeFromListByKey(RebootControl::KEY_TASKLIST, $subkey);
continue;
}
- $return[] = [
- 'taskId' => $task['id'],
- 'locationId' => $task['data']['locationId'],
- 'time' => $task['data']['time'],
- 'mode' => $task['data']['mode'],
- 'clientCount' => count($task['data']['clients']),
- 'status' => $task['statusCode'],
- ];
+ $return[] = $p;
}
+ if ($id !== null)
+ return false;
return $return;
}
-} \ No newline at end of file
+ /**
+ * Execute given command or script on a list of hosts. The list of hosts is an array of structs containing
+ * each a known machine-uuid and/or hostname, and optionally a port to use, which would otherwise default to 9922,
+ * and optionally a username to use, which would default to root.
+ * The command should be compatible with the remote user's default shell (most likely bash).
+ *
+ * @param array $clients [ { clientip: <host>, machineuuid: <uuid>, port: <port>, username: <username> }, ... ]
+ * @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 task struct, false on error
+ */
+ 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);
+ }
+ return $task;
+ }
+
+ private static function runScriptInternal(array &$clients, string $command, int $timeout = 5, $privkey = false)
+ {
+ $valid = [];
+ $invalid = [];
+ foreach ($clients as $client) {
+ if (is_string($client)) {
+ $invalid[strtoupper($client)] = []; // Assume machineuuid
+ } elseif (!isset($client['clientip']) && !isset($client['machineuuid'])) {
+ error_log('RebootControl::runScript called with list entry that has neither IP nor UUID');
+ } elseif (!isset($client['clientip'])) {
+ $invalid[$client['machineuuid']] = $client;
+ } else {
+ $valid[] = $client;
+ }
+ }
+ if (!empty($invalid)) {
+ $res = Database::simpleQuery('SELECT machineuuid, clientip, locationid FROM machine WHERE machineuuid IN (:uuids)',
+ ['uuids' => array_keys($invalid)]);
+ foreach ($res as $row) {
+ if (isset($invalid[$row['machineuuid']])) {
+ $valid[] = $row + $invalid[$row['machineuuid']];
+ } else {
+ $valid[] = $row;
+ }
+ }
+ }
+ $clients = $valid;
+ if (empty($clients)) {
+ error_log('RebootControl::runScript called without any clients');
+ return false;
+ }
+ if ($privkey === false) {
+ $privkey = SSHKey::getPrivateKey();
+ }
+ return Taskmanager::submit('RemoteExec', [
+ 'clients' => $clients,
+ 'command' => $command,
+ 'timeoutSeconds' => $timeout,
+ 'sshkey' => $privkey,
+ 'port' => 9922, // Fallback if no port given in client struct
+ ]);
+ }
+
+ public static function connectionCheckCallback($task, $hostId)
+ {
+ $reachable = 0;
+ if (isset($task['data']['result'])) {
+ foreach ($task['data']['result'] as $res) {
+ if ($res['exitCode'] == 0) {
+ $reachable = 1;
+ }
+ }
+ }
+ Database::exec('UPDATE reboot_jumphost SET reachable = :reachable WHERE hostid = :id',
+ ['id' => $hostId, 'reachable' => $reachable]);
+ }
+
+ /**
+ * Wake clients given by MAC address(es) via jawol util.
+ * Multiple MAC addresses can be passed as a space separated list.
+ */
+ private static function buildClientWakeCommand(string $macs, string $bcast = null, string $passwd = null): string
+ {
+ $command = 'jawol';
+ if (!empty($bcast)) {
+ $command .= " -d '$bcast'";
+ } else {
+ $command .= ' -i br0';
+ }
+ if (!empty($passwd)) {
+ $command .= " -p '$passwd'";
+ }
+ $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
+ * @return array|false task struct, false on error
+ */
+ public static function wakeDirectly($macaddr, string $bcast = null, string $passwd = null)
+ {
+ if (!is_array($macaddr)) {
+ $macaddr = [$macaddr];
+ }
+ $port = (int)Property::get(RebootControl::KEY_UDP_PORT);
+ if ($port < 1 || $port > 65535) {
+ $port = 9;
+ }
+ $arg = [];
+ foreach ($macaddr as $mac) {
+ $arg[] = [
+ 'ip' => $bcast,
+ 'mac' => $mac,
+ 'methods' => ['DIRECT'],
+ 'password' => $passwd,
+ ];
+ }
+ return Taskmanager::submit('WakeOnLan', ['clients' => $arg]);
+ }
+
+ /**
+ * 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');
+ if (empty($macs)) {
+ error_log('Called wakeViaJumpHost without clients');
+ return false;
+ }
+ $macs = "'" . implode("' '", $macs) . "'";
+ $macs = str_replace('-', ':', $macs);
+ $script = str_replace(['%IP%', '%MACS%'], [$bcast, $macs], $jumphost['script']);
+ $arg = [[
+ 'clientip' => $jumphost['host'],
+ 'port' => $jumphost['port'],
+ 'username' => $jumphost['username'],
+ ]];
+ $task = RebootControl::runScriptInternal($arg, $script, 6, $jumphost['sshkey']);
+ if ($task !== false && isset($task['id'])) {
+ TaskmanagerCallback::addCallback($task, 'rbcConnCheck', $hostid);
+ }
+ return $task;
+ }
+
+ /**
+ * @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(array $clientList, array &$failed = null): ?string
+ {
+ $errors = '';
+ $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');
+ foreach ($res as $row) {
+ $row += [
+ 'djumphosts' => [],
+ 'ijumphosts' => [],
+ ];
+ $subnets[$row['subnetid']] = $row;
+ }
+ // Get all jump hosts
+ 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;
+ }
+ foreach ($clientList as $dbClient) {
+ $ip = sprintf('%u', ip2long($dbClient['clientip'])); // 32Bit snprintf
+ unset($subnet);
+ $subnet = false;
+ foreach ($subnets as &$sn) {
+ if ($sn['start'] <= $ip && $sn['end'] >= $ip) {
+ $subnet =& $sn;
+ break;
+ }
+ }
+ if ($subnet === false) {
+ $unknown[] = $dbClient;
+ continue;
+ }
+ $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';
+ }
+ 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';
+ }
+ // 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
+ }
+ // "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;
+ }
+ }
+ 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;
+ }
+ }
+ 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;
+ }
+ }
+ $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 null;
+ }
+
+ private static function findMachinesForSubnet(&$subnet)
+ {
+ if (isset($subnet['dclients']))
+ return;
+ $cutoff = time() - 320;
+ // Get clients from same subnet first
+ $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]);
+ // If none, get clients from other subnets known to be able to reach this one
+ $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]);
+ shuffle($subnet['iclients']);
+ $subnet['iclients'] = array_slice($subnet['iclients'], 0, 3);
+ }
+
+ public static function prepareExec()
+ {
+ User::assertPermission('.rebootcontrol.action.exec');
+ $uuids = array_values(Request::post('uuid', Request::REQUIRED, 'array'));
+ $machines = RebootUtils::getFilteredMachineList($uuids, '.rebootcontrol.action.exec');
+ if ($machines === false)
+ return;
+ $id = mt_rand();
+ Session::set('exec-' . $id, $machines, 60);
+ 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/rebootqueries.inc.php b/modules-available/rebootcontrol/inc/rebootqueries.inc.php
deleted file mode 100644
index 063b36e4..00000000
--- a/modules-available/rebootcontrol/inc/rebootqueries.inc.php
+++ /dev/null
@@ -1,58 +0,0 @@
-<?php
-
-class RebootQueries
-{
-
- // Get Client+IP+CurrentVM+CurrentUser+Location to fill the table
- public static function getMachineTable($locationId) {
- $queryArgs = array('cutoff' => strtotime('-30 days'));
- if ($locationId === 0) {
- $where = 'machine.locationid IS NULL';
- } else {
- $where = 'machine.locationid = :locationid';
- $queryArgs['locationid'] = $locationId;
- }
- $leftJoin = '';
- $sessionField = 'machine.currentsession';
- if (Module::get('dozmod') !== false) {
- // SELECT lectureid, displayname FROM sat.lecture WHERE lectureid = :lectureid
- $leftJoin = 'LEFT JOIN sat.lecture ON (lecture.lectureid = machine.currentsession)';
- $sessionField = 'IFNULL(lecture.displayname, machine.currentsession) AS currentsession';
- }
- $res = Database::simpleQuery("
- SELECT machine.machineuuid, machine.hostname, machine.clientip,
- machine.lastboot, machine.lastseen, machine.logintime, machine.state,
- $sessionField, machine.currentuser, machine.locationid
- FROM machine
- $leftJoin
- WHERE $where AND machine.lastseen > :cutoff", $queryArgs);
- $ret = $res->fetchAll(PDO::FETCH_ASSOC);
- foreach ($ret as &$row) {
- if ($row['state'] === 'IDLE' || $row['state'] === 'OCCUPIED') {
- $row['status'] = 1;
- } else {
- $row['status'] = 0;
- }
- if ($row['state'] !== 'OCCUPIED') {
- $row['currentuser'] = '';
- $row['currentsession'] = '';
- }
- }
- return $ret;
- }
-
- /**
- * Get machines by list of UUIDs
- * @param string[] $list list of system UUIDs
- * @return array list of machines with machineuuid, hostname, clientip, state and locationid
- */
- public static function getMachinesByUuid($list)
- {
- if (empty($list))
- return array();
- $res = Database::simpleQuery("SELECT machineuuid, hostname, clientip, state, locationid FROM machine
- WHERE machineuuid IN (:list)", compact('list'));
- return $res->fetchAll(PDO::FETCH_ASSOC);
- }
-
-} \ No newline at end of file
diff --git a/modules-available/rebootcontrol/inc/rebootutils.inc.php b/modules-available/rebootcontrol/inc/rebootutils.inc.php
new file mode 100644
index 00000000..e05d90dc
--- /dev/null
+++ b/modules-available/rebootcontrol/inc/rebootutils.inc.php
@@ -0,0 +1,74 @@
+<?php
+
+class RebootUtils
+{
+
+ /**
+ * Get machines by list of UUIDs
+ * @param string[] $list list of system UUIDs
+ * @return array list of machines with machineuuid, hostname, clientip, state and locationid
+ */
+ public static function getMachinesByUuid(array $list, bool $assoc = false,
+ array $columns = ['machineuuid', 'hostname', 'clientip', 'state', 'locationid']): array
+ {
+ if (empty($list))
+ return array();
+ $columns = implode(',', $columns);
+ $res = Database::simpleQuery("SELECT $columns FROM machine
+ WHERE machineuuid IN (:list)", compact('list'));
+ if (!$assoc)
+ return $res->fetchAll();
+ $ret = [];
+ foreach ($res as $row) {
+ $ret[$row['machineuuid']] = $row;
+ }
+ return $ret;
+ }
+
+ /**
+ * Sort list of clients so that machines that are up and running come first.
+ * Requires the array elements to have key "state" from machine table.
+ * @param array $clients list of clients
+ */
+ public static function sortRunningFirst(array &$clients): void
+ {
+ usort($clients, function($a, $b) {
+ $a = ($a['state'] === 'IDLE' || $a['state'] === 'OCCUPIED');
+ $b = ($b['state'] === 'IDLE' || $b['state'] === 'OCCUPIED');
+ if ($a === $b)
+ return 0;
+ return $a ? -1 : 1;
+ });
+ }
+
+ /**
+ * Query list of clients (by uuid), taking user context into account, by filtering
+ * by given $permission.
+ * @param array $requestedClients list of uuids
+ * @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(array $requestedClients, string $permission)
+ {
+ $actualClients = RebootUtils::getMachinesByUuid($requestedClients);
+ if (count($actualClients) !== count($requestedClients)) {
+ // We could go ahead an see which ones were not found in DB but this should not happen anyways unless the
+ // user manipulated the request
+ Message::addWarning('some-machine-not-found');
+ }
+ // Filter ones with no permission
+ foreach (array_keys($actualClients) as $idx) {
+ if (!User::hasPermission($permission, $actualClients[$idx]['locationid'])) {
+ Message::addWarning('locations.no-permission-location', $actualClients[$idx]['locationid']);
+ unset($actualClients[$idx]);
+ }
+ }
+ // See if anything is left
+ if (!is_array($actualClients) || empty($actualClients)) {
+ Message::addError('no-clients-selected');
+ return false;
+ }
+ return $actualClients;
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/rebootcontrol/inc/scheduler.inc.php b/modules-available/rebootcontrol/inc/scheduler.inc.php
new file mode 100644
index 00000000..19a01beb
--- /dev/null
+++ b/modules-available/rebootcontrol/inc/scheduler.inc.php
@@ -0,0 +1,330 @@
+<?php
+
+class Scheduler
+{
+
+ 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];
+
+ /**
+ * @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", ['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;
+ }
+
+ /**
+ * 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) {
+ foreach ($row['days'] as $day) {
+ 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'])];
+ }
+ }
+ }
+ $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;
+ }
+ 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;
+ }
+
+ /**
+ * 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 {
+ // 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
new file mode 100644
index 00000000..d45a2443
--- /dev/null
+++ b/modules-available/rebootcontrol/install.inc.php
@@ -0,0 +1,77 @@
+<?php
+
+$output = array();
+
+$output[] = tableCreate('reboot_subnet', "
+ `subnetid` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+ `start` INT(10) UNSIGNED NOT NULL,
+ `end` INT(10) UNSIGNED NOT NULL,
+ `fixed` BOOL NOT NULL,
+ `isdirect` BOOL NOT NULL,
+ `nextdirectcheck` INT(10) UNSIGNED NOT NULL DEFAULT '0',
+ `lastseen` INT(10) UNSIGNED NOT NULL DEFAULT '0',
+ `seencount` INT(10) UNSIGNED NOT NULL DEFAULT '0',
+ PRIMARY KEY (`subnetid`),
+ UNIQUE KEY `range` (`start`, `end`)");
+
+$output[] = tableCreate('reboot_jumphost', "
+ `hostid` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+ `host` VARCHAR(100) NOT NULL,
+ `port` SMALLINT(10) UNSIGNED NOT NULL,
+ `username` VARCHAR(30) NOT NULL,
+ `reachable` BOOL NOT NULL,
+ `sshkey` BLOB NOT NULL,
+ `script` BLOB NOT NULL,
+ PRIMARY KEY (`hostid`)");
+
+$output[] = tableCreate('reboot_jumphost_x_subnet', "
+ `hostid` INT(10) UNSIGNED NOT NULL,
+ `subnetid` INT(10) UNSIGNED NOT NULL,
+ PRIMARY KEY (`hostid`, `subnetid`)");
+
+$output[] = tableCreate('reboot_subnet_x_subnet', "
+ `srcid` INT(10) UNSIGNED NOT NULL,
+ `dstid` INT(10) UNSIGNED NOT NULL,
+ `reachable` BOOL NOT NULL,
+ `nextcheck` INT(10) UNSIGNED NOT NULL DEFAULT '0',
+ PRIMARY KEY (`srcid`, `dstid`),
+ KEY `nextcheck` (`nextcheck`)");
+
+$output[] = tableCreate('reboot_scheduler', "
+ `locationid` INT(11) NOT NULL,
+ `action` ENUM('WOL', 'SHUTDOWN', 'REBOOT'),
+ `nextexecution` INT(10) UNSIGNED NOT NULL DEFAULT 0,
+ `options` BLOB,
+ PRIMARY KEY (`locationid`)");
+
+$output[] = tableAddConstraint('reboot_jumphost_x_subnet', 'hostid', 'reboot_jumphost', 'hostid',
+ 'ON UPDATE CASCADE ON DELETE CASCADE');
+$output[] = tableAddConstraint('reboot_jumphost_x_subnet', 'subnetid', 'reboot_subnet', 'subnetid',
+ 'ON UPDATE CASCADE ON DELETE CASCADE');
+$output[] = tableAddConstraint('reboot_subnet_x_subnet', 'srcid', 'reboot_subnet', 'subnetid',
+ 'ON UPDATE CASCADE ON DELETE CASCADE');
+$output[] = tableAddConstraint('reboot_subnet_x_subnet', 'dstid', 'reboot_subnet', 'subnetid',
+ 'ON UPDATE CASCADE ON DELETE CASCADE');
+$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/messages.json b/modules-available/rebootcontrol/lang/de/messages.json
index 2a7e1299..b481d64a 100644
--- a/modules-available/rebootcontrol/lang/de/messages.json
+++ b/modules-available/rebootcontrol/lang/de/messages.json
@@ -1,4 +1,20 @@
{
+ "invalid-cidr": "Ung\u00fcltige CIDR-Angabe: {{0}}",
+ "invalid-port": "Ung\u00fcltiger Port: {{0}}",
+ "invalid-subnet": "Ung\u00fcltiges Subnetz: {{0}}",
+ "jumphost-deleted": "Sprung-Host {{0}} gel\u00f6scht",
+ "jumphost-saved": "Sprung-Host {{0}} gespeichert",
"no-clients-selected": "Keine Clients ausgew\u00e4hlt",
- "some-machine-not-found": "Einige Clients aus dem POST request wurden nicht gefunden"
+ "no-current-tasks": "Keine aktuellen oder k\u00fcrzlich abgeschlossenen Aufgaben",
+ "no-such-jumphost": "Sprung-Host {{0}} existiert nicht",
+ "no-such-task": "Task {{0}} existiert nicht",
+ "some-machine-not-found": "Einige Clients aus dem POST request wurden nicht gefunden",
+ "subnet-already-exists": "Subnetz existiert bereits",
+ "subnet-created": "Subnetz angelegt",
+ "subnet-deleted": "Subnetz gel\u00f6scht",
+ "subnet-updated": "Subnetz aktualisiert",
+ "unknown-exec-job": "Unbekannte Job-ID: {{0}}",
+ "unknown-task-type": "Unbekannter Task-Typ",
+ "woldiscover-disabled": "Automatische WOL-Ermittlung deaktiviert",
+ "woldiscover-enabled": "Automatische WOL-Ermittlung aktiviert"
} \ No newline at end of file
diff --git a/modules-available/rebootcontrol/lang/de/module.json b/modules-available/rebootcontrol/lang/de/module.json
index 1f325354..2488fc81 100644
--- a/modules-available/rebootcontrol/lang/de/module.json
+++ b/modules-available/rebootcontrol/lang/de/module.json
@@ -1,4 +1,6 @@
{
- "module_name": "Reboot Control",
- "page_title": "Reboot Control"
+ "jumphosts": "Sprung-Hosts",
+ "module_name": "Fernsteuerung \/ WakeOnLAN",
+ "page_title": "WakeOnLAN",
+ "subnets": "Subnetze"
} \ 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 12ec4c83..589db5b9 100644
--- a/modules-available/rebootcontrol/lang/de/permissions.json
+++ b/modules-available/rebootcontrol/lang/de/permissions.json
@@ -1,5 +1,15 @@
{
- "action.shutdown": "Client herunterfahren.",
- "action.reboot": "Client neustarten.",
- "newkeypair": "Neues Schlüsselpaar generieren."
+ "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.",
+ "jumphost.view": "Liste der Sprung-Hosts sehen.",
+ "newkeypair": "Neues Schl\u00fcsselpaar generieren.",
+ "subnet.edit": "Subnetze hinzuf\u00fcgen\/entfernen.",
+ "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 c678ef88..b54adbcd 100644
--- a/modules-available/rebootcontrol/lang/de/template-tags.json
+++ b/modules-available/rebootcontrol/lang/de/template-tags.json
@@ -1,39 +1,85 @@
{
+ "lang_aWolJob": "WakeOnLAN-Job",
"lang_activeTasks": "Laufende Jobs",
+ "lang_add": "Hinzuf\u00fcgen",
+ "lang_addNewSubnet": "Ein Subnetz manuell hinzuf\u00fcgen",
+ "lang_assignedJumpHosts": "Zugewiesene Sprung-Hosts",
+ "lang_assignedSubnets": "Zugewiesene Subnetze",
"lang_authFail": "Authentifizierung fehlgeschlagen",
+ "lang_check": "Testen",
+ "lang_checkOutputLabel": "Ausgabe",
+ "lang_checkingJumpHost": "Teste Sprung-Host",
"lang_client": "Client",
"lang_clientCount": "# Clients",
- "lang_confirmNewKeypair": "Wirklich neues Schl\u00fcsselpaar erzeugen?",
+ "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",
"lang_error": "Nicht erreichbar",
+ "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 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",
+ "lang_host": "Host",
+ "lang_hostDeleteConfirm": "Diesen Sprung-Host l\u00f6schen?",
+ "lang_hostNonZeroExit": "Das hinterlegte Script hat einen Exit-Code ungleich 0 zur\u00fcckgeliefert",
+ "lang_hostNotReachable": "Host nicht erreichbar",
+ "lang_hostReachable": "Host erreichbar",
"lang_ip": "IP",
- "lang_kexecRebootCheck": "Schneller Reboot direkt in bwLehrpool",
+ "lang_isDirect": "Direkt erreichbar",
+ "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",
+ "lang_jumpHosts": "Sprung-Hosts",
+ "lang_keypairConfirmCheck": "Ich bin sicher",
+ "lang_lastseen": "Zuletzt gesehen",
"lang_location": "Standort",
- "lang_minutes": " Minuten",
- "lang_mode": "Modus",
+ "lang_moduleHeading": "Fernsteuerung \/ WakeOnLAN",
+ "lang_new": "Neu",
"lang_newKeypairExplanation": "Sie k\u00f6nnen ein neues Schl\u00fcsselpaar erzeugen lassen. In diesem Fall wird das alte Schl\u00fcsselpaar verworfen, sodass alle zum jetzigen Zeitpunkt bereits gestarteten Rechner nicht mehr aus der Ferne bedient werden k\u00f6nnen, bis diese manuell neugestartet wurden.",
- "lang_off": "Aus",
- "lang_on": "An",
+ "lang_noTasksForJob": "Keine Tasks f\u00fcr diesen Job",
+ "lang_numAssignedSubnets": "# Netze",
"lang_online": "Online",
+ "lang_port": "Port",
+ "lang_privkey": "Geheimer Schl\u00fcssel",
"lang_pubKey": "SSH Public Key:",
- "lang_reboot": "Neustarten",
+ "lang_reachable": "Erreichbar",
+ "lang_reachableFrom": "Erreichbar von",
+ "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_rebootButton": "Neustarten",
- "lang_rebootCheck": "Wollen Sie die ausgew\u00e4hlten Rechner wirklich neustarten?",
- "lang_rebootControl": "Reboot Control",
- "lang_rebootIn": "Neustart in:",
"lang_rebooting": "Neustart...",
- "lang_selectall": "Alle ausw\u00e4hlen",
- "lang_selected": "Ausgew\u00e4hlt",
- "lang_session": "Sitzung",
+ "lang_remoteExec": "Ausf\u00fchren",
+ "lang_scriptOrCommand": "Befehl \/ Script",
"lang_settings": "Einstellungen",
"lang_shutdown": "Herunterfahren",
"lang_shutdownAt": "Herunterfahren um: ",
- "lang_shutdownButton": "Herunterfahren",
- "lang_shutdownCheck": "Wollen Sie die ausgew\u00e4hlten Rechner wirklich herunterfahren?",
- "lang_shutdownIn": "Herunterfahren in: ",
"lang_status": "Status",
- "lang_time": "Zeit",
- "lang_unselectall": "Alle abw\u00e4hlen"
+ "lang_stderr": "Standard-Error Ausgabe",
+ "lang_stdout": "Standard-Output Ausgabe",
+ "lang_subnet": "Subnetz",
+ "lang_subnets": "Subnetze",
+ "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 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",
+ "lang_wolReachabilityHelp": "Die erste Zahl ist die Anzahl von Sprung-Hosts, die dieses Subnetz erreichen k\u00f6nnen. Die zweite Zahl ist die Anzahl anderer Subnetze, von denen aus WOL-Pakete dieses Subnetz erreichen k\u00f6nnen."
} \ No newline at end of file
diff --git a/modules-available/rebootcontrol/lang/en/messages.json b/modules-available/rebootcontrol/lang/en/messages.json
index 50bdd7fe..f125e944 100644
--- a/modules-available/rebootcontrol/lang/en/messages.json
+++ b/modules-available/rebootcontrol/lang/en/messages.json
@@ -1,4 +1,20 @@
{
+ "invalid-cidr": "Invalid CIDR notion: {{0}}",
+ "invalid-port": "Invalid port: {{0}}",
+ "invalid-subnet": "Invalid subnet: {{0}}",
+ "jumphost-deleted": "Delete jump host {{0}}",
+ "jumphost-saved": "Saved jump host {{0}}",
"no-clients-selected": "No clients selected",
- "some-machine-not-found": "Some machines from your POST request don't exist"
+ "no-current-tasks": "No recent tasks",
+ "no-such-jumphost": "No such jump host {{0}}",
+ "no-such-task": "No such task: {{0}}",
+ "some-machine-not-found": "Some machines from your POST request don't exist",
+ "subnet-already-exists": "Subnet already exists",
+ "subnet-created": "Created subnet",
+ "subnet-deleted": "Deleted subnet",
+ "subnet-updated": "Updated subnet",
+ "unknown-exec-job": "Invalid job ID: {{0}}",
+ "unknown-task-type": "Invalid task type",
+ "woldiscover-disabled": "Automatic WOL-detection disabled",
+ "woldiscover-enabled": "Automatic WOL-detection enabled"
} \ No newline at end of file
diff --git a/modules-available/rebootcontrol/lang/en/module.json b/modules-available/rebootcontrol/lang/en/module.json
index 1f325354..1308976b 100644
--- a/modules-available/rebootcontrol/lang/en/module.json
+++ b/modules-available/rebootcontrol/lang/en/module.json
@@ -1,4 +1,6 @@
{
- "module_name": "Reboot Control",
- "page_title": "Reboot Control"
+ "jumphosts": "Jump hosts",
+ "module_name": "Remote \/ WakeOnLAN",
+ "page_title": "WakeOnLAN",
+ "subnets": "Subnets"
} \ No newline at end of file
diff --git a/modules-available/rebootcontrol/lang/en/permissions.json b/modules-available/rebootcontrol/lang/en/permissions.json
index 34badbaf..b925c2b2 100644
--- a/modules-available/rebootcontrol/lang/en/permissions.json
+++ b/modules-available/rebootcontrol/lang/en/permissions.json
@@ -1,5 +1,15 @@
{
- "action.shutdown": "Shutdown Client.",
- "action.reboot": "Reboot Client.",
- "newkeypair": "Generate new Keypair."
+ "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.",
+ "jumphost.view": "See list of jump hosts.",
+ "newkeypair": "Generate new Key pair.",
+ "subnet.edit": "Add\/remove subnets.",
+ "subnet.flag": "Edit subnet properties.",
+ "subnet.view": "See list of subnets.",
+ "woldiscover": "Toggle automatic determination of WOL-reachability."
} \ No newline at end of file
diff --git a/modules-available/rebootcontrol/lang/en/template-tags.json b/modules-available/rebootcontrol/lang/en/template-tags.json
index c64014ff..5740b208 100644
--- a/modules-available/rebootcontrol/lang/en/template-tags.json
+++ b/modules-available/rebootcontrol/lang/en/template-tags.json
@@ -1,39 +1,85 @@
{
+ "lang_aWolJob": "WakeOnLAN job",
"lang_activeTasks": "Active tasks",
+ "lang_add": "Add",
+ "lang_addNewSubnet": "Manually add new subnet",
+ "lang_assignedJumpHosts": "Assigned jump hosts",
+ "lang_assignedSubnets": "Assigned subnets",
"lang_authFail": "Authentication failed",
+ "lang_check": "Check",
+ "lang_checkOutputLabel": "Output",
+ "lang_checkingJumpHost": "Check jump host",
"lang_client": "Client",
"lang_clientCount": "# clients",
- "lang_confirmNewKeypair": "Really create new key pair?",
+ "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",
"lang_error": "Not available",
- "lang_genNew": "Generate new keypair",
+ "lang_execRemoteCommand": "Execute command on clients",
+ "lang_executingRemotely": "Executing on selected clients...",
+ "lang_exitCode": "Exit code",
+ "lang_fixSubnetDesc": "If enabled, reachability of this subnet will not be determined automatically any more. You can then set manually whether the subnet is reachable from this server. Additionally, the subnet will not be purged from the database anymore if there was no client activity for six months.",
+ "lang_fixSubnetSettings": "Manually configure this subnet",
+ "lang_genNew": "Generate new key pair",
+ "lang_help": "Help",
+ "lang_host": "Host",
+ "lang_hostDeleteConfirm": "Delete this jump host",
+ "lang_hostNonZeroExit": "The assigned script exited with a return code other than 0",
+ "lang_hostNotReachable": "Host not reachable",
+ "lang_hostReachable": "Host reachable",
"lang_ip": "IP",
- "lang_kexecRebootCheck": "Quick reboot straight to bwLehrpool (kexec)",
+ "lang_isDirect": "Directly reachable",
+ "lang_isDirectHelp": "This subnet can directly receive WOL packets from the server. No jump host etc. required.",
+ "lang_isFixed": "Manually configured",
+ "lang_isFixedHelp": "Reachability from the satellite server is not determined automatically, but set in the web UI.",
+ "lang_jumpHost": "Jump host",
+ "lang_jumpHosts": "Jump hosts",
+ "lang_keypairConfirmCheck": "I'm sure",
+ "lang_lastseen": "Last seen",
"lang_location": "Location",
- "lang_minutes": " Minutes",
- "lang_mode": "Mode",
- "lang_newKeypairExplanation": "You can create a new keypair, which will replace the old one. Please note that after doing so, you cannot poweroff or reboot clients that are already running, since they still use the old key. They have to be rebooted manually first.",
- "lang_off": "Off",
- "lang_on": "On",
+ "lang_moduleHeading": "WakeOnLAN",
+ "lang_new": "New",
+ "lang_newKeypairExplanation": "You can create a new key pair, which will replace the old one. Please note that after doing so, you cannot poweroff or reboot clients that are already running, since they still use the old key. They have to be rebooted manually first.",
+ "lang_noTasksForJob": "No tasks for this job",
+ "lang_numAssignedSubnets": "# Subnets",
"lang_online": "Online",
+ "lang_port": "Port",
+ "lang_privkey": "Private key",
"lang_pubKey": "SSH Public Key:",
- "lang_reboot": "Reboot",
+ "lang_reachable": "Reachable",
+ "lang_reachableFrom": "Reachable via",
+ "lang_reachableFromServer": "Reachable from server",
+ "lang_reachableFromServerDesc": "If checked it will be assumed that the server can send WOL packets to clients in this subnet. This requires the router of the destination subnet to forward directed broadcasts.",
"lang_rebootAt": "Reboot at:",
- "lang_rebootButton": "Reboot",
- "lang_rebootCheck": "Do you really want to reboot the selected clients?",
- "lang_rebootControl": "Reboot Control",
- "lang_rebootIn": "Reboot in:",
"lang_rebooting": "Rebooting...",
- "lang_selectall": "Select all",
- "lang_selected": "Selected",
- "lang_session": "Session",
+ "lang_remoteExec": "Execute",
+ "lang_scriptOrCommand": "Command \/ Script",
"lang_settings": "Settings",
"lang_shutdown": "Shut Down",
"lang_shutdownAt": "Shutdown at: ",
- "lang_shutdownButton": "Shutdown",
- "lang_shutdownCheck": "Do you really want to shut down the selected clients?",
- "lang_shutdownIn": "Shutdown in: ",
"lang_status": "Status",
- "lang_time": "Time",
- "lang_unselectall": "Unselect all"
+ "lang_stderr": "Standard error",
+ "lang_stdout": "Standard output",
+ "lang_subnet": "Subnet",
+ "lang_subnets": "Subnets",
+ "lang_subnetsDescription": "These are subnets known to the server. For WOL to work across subnets the server needs to know which subnets are reachable via directed broadcasts. This list will be populated automatically as new clients boot up. For subnets that are not reachable via directed broadcasts, you can set up \"jump hosts\", which is any kind of host that can reach the desired destination subnet. Also, the satellite server will automatically use other bwLehrpool-Clients to reach selected machines, if applicable. If you don't want this to be detected automatically, you can disable this feature in the Settings above.",
+ "lang_task": "Task",
+ "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_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",
+ "lang_wolReachabilityHelp": "The first figure is the number of jump hosts that are configured for this subnet. The second figure is the number of client subnets that can reach this subnet."
} \ No newline at end of file
diff --git a/modules-available/rebootcontrol/page.inc.php b/modules-available/rebootcontrol/page.inc.php
index 3a438504..80eff842 100644
--- a/modules-available/rebootcontrol/page.inc.php
+++ b/modules-available/rebootcontrol/page.inc.php
@@ -3,7 +3,10 @@
class Page_RebootControl extends Page
{
- private $action = false;
+ /**
+ * @var bool whether we have a SubPage from the pages/ subdir
+ */
+ private $haveSubpage = false;
/**
* Called before any page rendering happens - early hook to check parameters etc.
@@ -17,62 +20,87 @@ class Page_RebootControl extends Page
Util::redirect('?do=Main'); // does not return
}
- $this->action = Request::any('action', 'show', 'string');
-
-
- if ($this->action === 'reboot' || $this->action === 'shutdown') {
-
- $requestedClients = Request::post('clients', false, 'array');
- if (!is_array($requestedClients) || empty($requestedClients)) {
- Message::addError('no-clients-selected');
- Util::redirect();
- }
+ if (User::hasPermission('jumphost.*')) {
+ Dashboard::addSubmenu('?do=rebootcontrol&show=jumphost', Dictionary::translate('jumphosts'));
+ }
+ if (User::hasPermission('subnet.*')) {
+ Dashboard::addSubmenu('?do=rebootcontrol&show=subnet', Dictionary::translate('subnets'));
+ }
- $actualClients = RebootQueries::getMachinesByUuid($requestedClients);
- if (count($actualClients) !== count($requestedClients)) {
- // We could go ahead an see which ones were not found in DB but this should not happen anyways unless the
- // user manipulated the request
- Message::addWarning('some-machine-not-found');
+ $section = Request::any('show', false, 'string');
+ if ($section !== false) {
+ $section = preg_replace('/[^a-z]/', '', $section);
+ if (file_exists('modules/rebootcontrol/pages/' . $section . '.inc.php')) {
+ require_once 'modules/rebootcontrol/pages/' . $section . '.inc.php';
+ $this->haveSubpage = true;
+ SubPage::doPreprocess();
+ } else {
+ Message::addError('main.invalid-action', $section);
+ return;
}
- // Filter ones with no permission
- foreach (array_keys($actualClients) as $idx) {
- if (!User::hasPermission('action.' . $this->action, $actualClients[$idx]['locationid'])) {
- Message::addWarning('locations.no-permission-location', $actualClients[$idx]['locationid']);
- unset($actualClients[$idx]);
+ } else {
+ $action = Request::post('action', 'show', 'string');
+
+ if ($action === 'reboot' || $action === 'shutdown') {
+ $this->execRebootShutdown($action);
+ } elseif ($action === 'toggle-wol') {
+ User::assertPermission('woldiscover');
+ $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 {
- $locationId = $actualClients[$idx]['locationid'];
+ Message::addInfo('woldiscover-disabled');
}
+ $section = 'subnet'; // For redirect below
}
- // See if anything is left
- if (!is_array($actualClients) || empty($actualClients)) {
- Message::addError('no-clients-selected');
- Util::redirect();
- }
- usort($actualClients, function($a, $b) {
- $a = ($a['state'] === 'IDLE' || $a['state'] === 'OCCUPIED');
- $b = ($b['state'] === 'IDLE' || $b['state'] === 'OCCUPIED');
- if ($a === $b)
- return 0;
- return $a ? -1 : 1;
- });
- if ($this->action === 'shutdown') {
- $mode = 'SHUTDOWN';
- $minutes = Request::post('s-minutes', 0, 'int');
- } elseif (Request::any('quick', false, 'string') === 'on') {
- $mode = 'KEXEC_REBOOT';
- $minutes = Request::post('r-minutes', 0, 'int');
- } else {
- $mode = 'REBOOT';
- $minutes = Request::post('r-minutes', 0, 'int');
- }
- $task = RebootControl::execute($actualClients, $mode, $minutes, $locationId);
- if (Taskmanager::isTask($task)) {
- Util::redirect("?do=rebootcontrol&taskid=" . $task["id"]);
+ }
+
+ if (Request::isPost()) {
+ Util::redirect('?do=rebootcontrol' . ($section ? '&show=' . $section : ''));
+ } elseif ($section === false) {
+ 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");
+ Util::redirect('?do=rebootcontrol&show=subnet');
}
}
+ }
+ private function execRebootShutdown($action)
+ {
+ $requestedClients = Request::post('clients', false, 'array');
+ if (!is_array($requestedClients) || empty($requestedClients)) {
+ Message::addError('no-clients-selected');
+ return;
+ }
+
+ $actualClients = RebootUtils::getFilteredMachineList($requestedClients, 'action.' . $action);
+ if ($actualClients === false)
+ return;
+ RebootUtils::sortRunningFirst($actualClients);
+ if ($action === 'shutdown') {
+ $mode = 'SHUTDOWN';
+ $minutes = Request::post('s-minutes', 0, 'int');
+ } elseif (Request::any('quick', false, 'string') === 'on') {
+ $mode = 'KEXEC_REBOOT';
+ $minutes = Request::post('r-minutes', 0, 'int');
+ } else {
+ $mode = 'REBOOT';
+ $minutes = Request::post('r-minutes', 0, 'int');
+ }
+ $task = RebootControl::execute($actualClients, $mode, $minutes);
+ if (Taskmanager::isTask($task)) {
+ Util::redirect("?do=rebootcontrol&show=task&what=task&taskid=" . $task["id"]);
+ }
}
/**
@@ -81,94 +109,55 @@ class Page_RebootControl extends Page
protected function doRender()
{
- if ($this->action === 'show') {
-
- $data = [];
- $task = Request::get("taskid", false, 'string');
- if ($task !== false) {
- $task = Taskmanager::status($task);
- }
-
- if (Taskmanager::isTask($task)) {
-
- $data['taskId'] = $task['id'];
- $data['locationId'] = $task['data']['locationId'];
- $data['locationName'] = Location::getName($task['data']['locationId']);
- $uuids = array_map(function($entry) {
- return $entry['machineuuid'];
- }, $task['data']['clients']);
- $data['clients'] = RebootQueries::getMachinesByUuid($uuids);
- Render::addTemplate('status', $data);
-
- } else {
-
- //location you want to see, default are "not assigned" clients
- $requestedLocation = Request::get('location', false, 'int');
- $allowedLocs = User::getAllowedLocations("action.*");
- if (empty($allowedLocs)) {
- User::assertPermission('action.*');
- }
-
- if ($requestedLocation === false) {
- if (in_array(0, $allowedLocs)) {
- $requestedLocation = 0;
- } else {
- $requestedLocation = reset($allowedLocs);
- }
- }
-
- $data['locations'] = Location::getLocations($requestedLocation, 0, true);
-
- // disable each location user has no permission for
- foreach ($data['locations'] as &$loc) {
- if (!in_array($loc["locationid"], $allowedLocs)) {
- $loc["disabled"] = "disabled";
- } elseif ($loc["locationid"] == $requestedLocation) {
- $data['location'] = $loc['locationname'];
- }
- }
- // Always show public key (it's public, isn't it?)
- $data['pubKey'] = SSHKey::getPublicKey();
-
- // Only enable shutdown/reboot-button if user has permission for the location
- Permission::addGlobalTags($data['perms'], $requestedLocation, ['newkeypair', 'action.shutdown', 'action.reboot']);
-
- Render::addTemplate('header', $data);
-
- // only fill table if user has at least one permission for the location
- if (!in_array($requestedLocation, $allowedLocs)) {
- Message::addError('locations.no-permission-location', $requestedLocation);
- } else {
- $data['data'] = RebootQueries::getMachineTable($requestedLocation);
- Render::addTemplate('_page', $data);
- }
-
- // Append list of active reboot/shutdown tasks
- $active = RebootControl::getActiveTasks($allowedLocs);
- if (!empty($active)) {
- foreach ($active as &$entry) {
- $entry['locationName'] = Location::getName($entry['locationId']);
- }
- unset($entry);
- Render::addTemplate('task-list', ['list' => $active]);
- }
-
- }
+ $port = (int)Property::get(RebootControl::KEY_UDP_PORT);
+ if ($port < 1 || $port > 65535) {
+ $port = 9;
+ }
+ // Always show public key (it's public, isn't it?)
+ $data = [
+ 'pubkey' => SSHKey::getPublicKey(),
+ '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();
}
}
- function doAjax()
+ protected function doAjax()
{
- $this->action = Request::post('action', false, 'string');
- if ($this->action === 'generateNewKeypair') {
+ $action = Request::post('action', false, 'string');
+ if ($action === 'generateNewKeypair') {
User::assertPermission("newkeypair");
Property::set("rebootcontrol-private-key", false);
echo SSHKey::getPublicKey();
+ } elseif ($action === 'clientstatus') {
+ $clients = Request::post('clients');
+ if (is_array($clients)) {
+ // XXX No permission check here, should we consider this as leaking sensitive information?
+ $machines = RebootUtils::getMachinesByUuid(array_values($clients), false, ['machineuuid', 'state']);
+ $ret = [];
+ foreach ($machines as $machine) {
+ switch ($machine['state']) {
+ case 'OFFLINE': $val = 'glyphicon-off'; break;
+ case 'IDLE': $val = 'glyphicon-ok green'; break;
+ case 'OCCUPIED': $val = 'glyphicon-user red'; break;
+ case 'STANDBY': $val = 'glyphicon-off green'; break;
+ default: $val = 'glyphicon-question-sign'; break;
+ }
+ $ret[$machine['machineuuid']] = $val;
+ }
+ Header('Content-Type: application/json; charset=utf-8');
+ echo json_encode($ret);
+ }
} else {
echo 'Invalid action.';
}
}
-
-
}
diff --git a/modules-available/rebootcontrol/pages/exec.inc.php b/modules-available/rebootcontrol/pages/exec.inc.php
new file mode 100644
index 00000000..6b5ea407
--- /dev/null
+++ b/modules-available/rebootcontrol/pages/exec.inc.php
@@ -0,0 +1,57 @@
+<?php
+
+class SubPage
+{
+
+ public static function doPreprocess()
+ {
+ $action = Request::post('action', false, 'string');
+ if ($action === 'exec') {
+ self::execExec();
+ }
+ }
+
+ private static function execExec()
+ {
+ $uuids = array_values(Request::post('uuid', Request::REQUIRED, 'array'));
+ $machines = RebootUtils::getFilteredMachineList($uuids, 'action.exec');
+ if (empty($machines))
+ return;
+ RebootUtils::sortRunningFirst($machines);
+ $script = preg_replace('/\r\n?/', "\n", Request::post('script', Request::REQUIRED, 'string'));
+ $task = RebootControl::runScript($machines, $script);
+ if (Taskmanager::isTask($task)) {
+ Util::redirect("?do=rebootcontrol&show=task&what=task&taskid=" . $task["id"]);
+ }
+ }
+
+ /*
+ * Render
+ */
+
+ public static function doRender()
+ {
+ $what = Request::get('what', 'list', 'string');
+ if ($what === 'prepare') {
+ self::showPrepare();
+ }
+ }
+
+ private static function showPrepare()
+ {
+ $id = Request::get('id', Request::REQUIRED, 'int');
+ $machines = Session::get('exec-' . $id);
+ if (!is_array($machines)) {
+ Message::addError('unknown-exec-job', $id);
+ return;
+ }
+ Session::set('exec-' . $id, false);
+ Render::addTemplate('exec-enter-command', ['clients' => $machines, 'id' => $id]);
+ }
+
+ public static function doAjax()
+ {
+
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/rebootcontrol/pages/jumphost.inc.php b/modules-available/rebootcontrol/pages/jumphost.inc.php
new file mode 100644
index 00000000..d9aae234
--- /dev/null
+++ b/modules-available/rebootcontrol/pages/jumphost.inc.php
@@ -0,0 +1,222 @@
+<?php
+
+class SubPage
+{
+
+ public static function doPreprocess()
+ {
+ $action = Request::post('action', false, 'string');
+ if ($action === 'save') {
+ self::saveJumpHost();
+ } elseif ($action === 'assign') {
+ self::saveSubnetAssignment();
+ } elseif ($action === 'list') {
+ self::listAction();
+ }
+ }
+
+ /*
+ * POST
+ */
+
+ private static function listAction()
+ {
+ $id = Request::post('checkid', false, 'int');
+ if ($id !== false) {
+ // Check connectivity
+ User::assertPermission('jumphost.edit');
+ self::execCheckConnection($id);
+ return;
+ }
+ $id = Request::post('deleteid', false, 'int');
+ if ($id !== false) {
+ User::assertPermission('jumphost.edit');
+ self::deleteJumphost($id);
+ }
+ }
+
+ private static function execCheckConnection($hostid)
+ {
+ // Permcheck in caller
+ $host = self::getJumpHost($hostid);
+ $task = RebootControl::wakeViaJumpHost($host, '255.255.255.255', [['macaddr' => '00:11:22:33:44:55']]);
+ if (!Taskmanager::isTask($task))
+ return;
+ Util::redirect('?do=rebootcontrol&show=task&type=checkhost&what=task&taskid=' . $task['id']);
+ }
+
+ private static function deleteJumphost($hostid)
+ {
+ // Permcheck in caller
+ $host = self::getJumpHost($hostid);
+ Database::exec('DELETE FROM reboot_jumphost WHERE hostid = :hostid LIMIT 1', ['hostid' => $hostid]);
+ Message::addSuccess('jumphost-deleted', $host['host']);
+ }
+
+ private static function saveJumpHost()
+ {
+ User::assertPermission('jumphost.edit');
+ $id = Request::post('hostid', Request::REQUIRED, 'string');
+ $host = Request::post('host', Request::REQUIRED, 'string');
+ $port = Request::post('port', Request::REQUIRED, 'int');
+ if ($port < 1 || $port > 65535) {
+ Message::addError('invalid-port', $port);
+ return;
+ }
+ $username = Request::post('username', Request::REQUIRED, 'string');
+ $sshkey = Request::post('sshkey', Request::REQUIRED, 'string');
+ $script = preg_replace('/\r\n?/', "\n", Request::post('script', Request::REQUIRED, 'string'));
+ if ($id === 'new') {
+ $ret = Database::exec('INSERT INTO reboot_jumphost (host, port, username, sshkey, script, reachable)
+ VALUE (:host, :port, :username, :sshkey, :script, 0)', compact('host', 'port', 'username', 'sshkey', 'script'));
+ $id = Database::lastInsertId();
+ } else {
+ $ret = Database::exec('UPDATE reboot_jumphost SET
+ host = :host, port = :port, username = :username, sshkey = :sshkey, script = :script, reachable = 0
+ WHERE hostid = :id', compact('host', 'port', 'username', 'sshkey', 'script', 'id'));
+ if ($ret === 0) {
+ $ret = Database::queryFirst('SELECT hostid FROM reboot_jumphost WHERE hostid = :id', ['id' => $id]);
+ if ($ret !== false) {
+ $ret = 1;
+ }
+ }
+ }
+ if ($ret > 0) {
+ Message::addSuccess('jumphost-saved', $host);
+ self::execCheckConnection($id);
+ } else {
+ Message::addError('no-such-jumphost', $id);
+ }
+ }
+
+ private static function saveSubnetAssignment()
+ {
+ User::assertPermission('jumphost.edit');
+ $id = Request::post('hostid', Request::REQUIRED, 'string');
+ $host = self::getJumpHost($id);
+ $nets = Request::post('subnet', [], 'array');
+ if (empty($nets)) {
+ Database::exec('DELETE FROM reboot_jumphost_x_subnet WHERE hostid = :id', ['id' => $id]);
+ } else {
+ $nets = array_keys($nets);
+ Database::exec('DELETE FROM reboot_jumphost_x_subnet WHERE hostid = :id AND subnetid NOT IN (:nets)',
+ ['id' => $id, 'nets' => $nets]);
+ $nets = array_map(function($item) use ($id) {
+ return [$id, $item];
+ }, $nets);
+ Database::exec('INSERT IGNORE INTO reboot_jumphost_x_subnet (hostid, subnetid) VALUES :nets', ['nets' => $nets]);
+ }
+ Message::addSuccess('jumphost-saved', $host['host']);
+ }
+
+ /*
+ * Render
+ */
+
+ public static function doRender()
+ {
+ $what = Request::get('what', 'list', 'string');
+ if ($what === 'edit') {
+ self::showJumpHost();
+ } elseif ($what === 'assign') {
+ self::showAssignSubnets();
+ } else {
+ self::showJumpHosts();
+ }
+ }
+
+ private static function showJumpHosts()
+ {
+ User::assertPermission('jumphost.*');
+ $hosts = [];
+ $res = Database::simpleQuery('SELECT hostid, host, port, Count(jxs.subnetid) AS subnetCount, reachable
+ FROM reboot_jumphost jh
+ LEFT JOIN reboot_jumphost_x_subnet jxs USING (hostid)
+ GROUP BY hostid
+ ORDER BY hostid');
+ foreach ($res as $row) {
+ $hosts[] = $row;
+ }
+ $data = [
+ 'jumpHosts' => $hosts
+ ];
+ Permission::addGlobalTags($data['perms'], null, ['jumphost.edit', 'jumphost.assign-subnet']);
+ Render::addTemplate('jumphost-list', $data);
+ }
+
+ private static function showJumpHost()
+ {
+ User::assertPermission('jumphost.edit');
+ $id = Request::get('id', Request::REQUIRED, 'string');
+ if ($id === 'new') {
+ $host = ['hostid' => 'new', 'port' => 22, 'script' => "# Assume bash\n"
+ . "MACS='%MACS%'\n"
+ . "IP='%IP%'\n"
+ . "EW=false\n"
+ . "WOL=false\n"
+ . "command -v etherwake > /dev/null && ( [ \"\$(id -u)\" = 0 ] || [ -u \"\$(which etherwake)\" ] ) && EW=true\n"
+ . "command -v wakeonlan > /dev/null && WOL=true\n"
+ . "if \$EW && ( ! \$WOL || [ \"\$IP\" = '255.255.255.255' ] ); then\n"
+ . "\tifaces=\"\$(ls -1 /sys/class/net/)\"\n"
+ . "\t[ -z \"\$ifaces\" ] && ifaces=eth0\n"
+ . "\tfor ifc in \$ifaces; do\n"
+ . "\t\t[ \"\$ifc\" = 'lo' ] && continue\n"
+ . "\t\tfor mac in \$MACS; do\n"
+ . "\t\t\tetherwake -i \"\$ifc\" \"\$mac\"\n"
+ . "\t\tdone\n"
+ . "\tdone\n"
+ . "elif \$WOL; then\n"
+ . "\twakeonlan -i \"\$IP\" \$MACS\n"
+ . "else\n"
+ . "\techo 'No suitable WOL tool found' >&2\n"
+ . "\texit 1\n"
+ . "fi\n"];
+ } else {
+ $host = self::getJumpHost($id);
+ }
+ Render::addTemplate('jumphost-edit', $host);
+ }
+
+ private static function showAssignSubnets()
+ {
+ User::assertPermission('jumphost.assign-subnet');
+ $id = Request::get('id', Request::REQUIRED, 'int');
+ $host = self::getJumpHost($id);
+ $res = Database::simpleQuery('SELECT s.subnetid, s.start, s.end, jxs.hostid FROM reboot_subnet s
+ LEFT JOIN reboot_jumphost_x_subnet jxs ON (s.subnetid = jxs.subnetid AND jxs.hostid = :id)
+ ORDER BY start ASC',
+ ['id' => $id]);
+ $list = [];
+ foreach ($res as $row) {
+ $row['cidr'] = IpUtil::rangeToCidr($row['start'], $row['end']);
+ if ($row['hostid'] !== null) {
+ $row['checked'] = 'checked';
+ }
+ $list[] = $row;
+ }
+ $host['list'] = $list;
+ Render::addTemplate('jumphost-subnets', $host);
+ }
+
+ public static function doAjax()
+ {
+
+ }
+
+ /*
+ * MISC
+ */
+
+ private static function getJumpHost($hostid)
+ {
+ $host = Database::queryFirst('SELECT hostid, host, port, username, sshkey, script
+ FROM reboot_jumphost
+ WHERE hostid = :id', ['id' => $hostid]);
+ if ($host === false) {
+ Message::addError('no-such-jumphost', $hostid);
+ Util::redirect('?do=rebootcontrol');
+ }
+ return $host;
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/rebootcontrol/pages/subnet.inc.php b/modules-available/rebootcontrol/pages/subnet.inc.php
new file mode 100644
index 00000000..a6d8d837
--- /dev/null
+++ b/modules-available/rebootcontrol/pages/subnet.inc.php
@@ -0,0 +1,179 @@
+<?php
+
+class SubPage
+{
+
+ public static function doPreprocess()
+ {
+ $action = Request::post('action', false, 'string');
+ if ($action === 'add') {
+ self::addSubnet();
+ } elseif ($action === 'edit') {
+ self::editSubnet();
+ } elseif ($action === 'delete') {
+ self::deleteSubnet();
+ }
+ }
+
+ /*
+ * POST
+ */
+
+ private static function addSubnet()
+ {
+ User::assertPermission('subnet.edit');
+ $cidr = Request::post('cidr', Request::REQUIRED, 'string');
+ $range = IpUtil::parseCidr($cidr);
+ if ($range === null) {
+ Message::addError('invalid-cidr', $cidr);
+ return;
+ }
+ $ret = Database::exec('INSERT INTO reboot_subnet (start, end, fixed, isdirect)
+ VALUES (:start, :end, 1, 0)', [
+ 'start' => $range['start'],
+ 'end' => $range['end'],
+ ], true);
+ if ($ret === false) {
+ Message::addError('subnet-already-exists');
+ } else {
+ Message::addSuccess('subnet-created');
+ Util::redirect('?do=rebootcontrol&show=subnet&what=subnet&id=' . Database::lastInsertId());
+ }
+ }
+
+ private static function editSubnet()
+ {
+ User::assertPermission('subnet.flag');
+ $id = Request::post('id', Request::REQUIRED, 'int');
+ $subnet = Database::queryFirst('SELECT subnetid
+ FROM reboot_subnet WHERE subnetid = :id', ['id' => $id]);
+ if ($subnet === false) {
+ Message::addError('invalid-subnet', $id);
+ return;
+ }
+ $params = [
+ 'id' => $id,
+ 'fixed' => !empty(Request::post('fixed', false, 'string')),
+ 'isdirect' => !empty(Request::post('isdirect', false, 'string')),
+ ];
+ Database::exec('UPDATE reboot_subnet SET fixed = :fixed, isdirect = If(:fixed, :isdirect, isdirect)
+ WHERE subnetid = :id', $params);
+ if (User::hasPermission('jumphost.assign-subnet')) {
+ $hosts = Request::post('jumphost', [], 'array');
+ if (empty($hosts)) {
+ Database::exec('DELETE FROM reboot_jumphost_x_subnet WHERE subnetid = :id', ['id' => $id]);
+ } else {
+ $hosts = array_keys($hosts);
+ Database::exec('DELETE FROM reboot_jumphost_x_subnet WHERE subnetid = :id AND hostid NOT IN (:hosts)',
+ ['id' => $id, 'hosts' => $hosts]);
+ $hosts = array_map(function($item) use ($id) {
+ return [$item, $id];
+ }, $hosts);
+ Database::exec('INSERT IGNORE INTO reboot_jumphost_x_subnet (hostid, subnetid) VALUES :hosts', ['hosts' => $hosts]);
+ }
+ }
+ Message::addSuccess('subnet-updated');
+ }
+
+ private static function deleteSubnet()
+ {
+ User::assertPermission('subnet.edit');
+ User::assertPermission('subnet.flag');
+ $id = Request::post('id', Request::REQUIRED, 'int');
+ $num = Database::exec('DELETE FROM reboot_subnet WHERE subnetid = :id', ['id' => $id]);
+ if ($num < 1) {
+ Message::addError('invalid-subnet', $id);
+ return;
+ }
+ Message::addSuccess('subnet-deleted');
+ }
+
+ /*
+ * Render
+ */
+
+ public static function doRender()
+ {
+ $what = Request::get('what', 'list', 'string');
+ if ($what === 'list') {
+ self::showSubnets();
+ } elseif ($what === 'subnet') {
+ self::showSubnet();
+ }
+ }
+
+ private static function showSubnets()
+ {
+ 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
+ LEFT JOIN reboot_jumphost_x_subnet hxs USING (subnetid)
+ LEFT JOIN reboot_subnet_x_subnet sxs ON (s.subnetid = sxs.dstid AND sxs.reachable <> 0)
+ GROUP BY subnetid, start, end
+ ORDER BY start ASC, end DESC');
+ $deadline = strtotime('-60 days');
+ 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];
+ Render::addTemplate('subnet-list', $data);
+ }
+
+ private static function showSubnet()
+ {
+ User::assertPermission('subnet.*');
+ $id = Request::get('id', Request::REQUIRED, 'int');
+ $subnet = Database::queryFirst('SELECT subnetid, start, end, fixed, isdirect
+ FROM reboot_subnet WHERE subnetid = :id', ['id' => $id]);
+ if ($subnet === false) {
+ Message::addError('invalid-subnet', $id);
+ return;
+ }
+ $subnet['cidr'] = IpUtil::rangeToCidr($subnet['start'], $subnet['end']);
+ $subnet['start_s'] = long2ip($subnet['start']);
+ $subnet['end_s'] = long2ip($subnet['end']);
+ // Get list of jump hosts
+ $res = Database::simpleQuery('SELECT h.hostid, h.host, h.port, hxs.subnetid FROM reboot_jumphost h
+ LEFT JOIN reboot_jumphost_x_subnet hxs ON (h.hostid = hxs.hostid AND hxs.subnetid = :id)
+ ORDER BY h.host ASC', ['id' => $id]);
+ // Mark those assigned to the current subnet
+ $jh = [];
+ foreach ($res as $row) {
+ $row['checked'] = $row['subnetid'] === null ? '' : 'checked';
+ $jh[] = $row;
+ }
+ $subnet['jumpHosts'] = $jh;
+ $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 = [];
+ foreach ($res as $row) {
+ $sn[] = ['cidr' => IpUtil::rangeToCidr($row['start'], $row['end'])];
+ }
+ $subnet['sourceNets'] = $sn;
+ $subnet['showC2C'] = true;
+ }
+ Permission::addGlobalTags($subnet['perms'], null, ['subnet.flag', 'jumphost.view', 'jumphost.assign-subnet']);
+ Render::addTemplate('subnet-edit', $subnet);
+ }
+
+ public static function doAjax()
+ {
+
+ }
+
+}
diff --git a/modules-available/rebootcontrol/pages/task.inc.php b/modules-available/rebootcontrol/pages/task.inc.php
new file mode 100644
index 00000000..7db2a90b
--- /dev/null
+++ b/modules-available/rebootcontrol/pages/task.inc.php
@@ -0,0 +1,147 @@
+<?php
+
+class SubPage
+{
+
+ public static function doPreprocess()
+ {
+
+ }
+
+ public static function doRender()
+ {
+ $xxx = Request::get('tasks');
+ if (is_array($xxx)) {
+ $data = array_map(function($item) { return ['id' => $item]; }, $xxx);
+ Render::addTemplate('status-wol', ['tasks' => $data]);
+ return;
+ }
+ $show = Request::get('what', 'tasklist', 'string');
+ if ($show === 'tasklist') {
+ self::showTaskList();
+ } elseif ($show === 'task') {
+ self::showTask();
+ }
+ }
+
+ 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') {
+ // Override
+ $task = Taskmanager::status($taskid);
+ if (!Taskmanager::isTask($task) || !isset($task['data'])) {
+ Message::addError('no-such-task', $taskid);
+ return;
+ }
+ $td =& $task['data'];
+ $ip = array_key_first($td['result']);
+ $data = [
+ 'taskId' => $task['id'],
+ 'host' => $ip,
+ ];
+ Render::addTemplate('status-checkconnection', $data);
+ return;
+ }
+ if ($type !== false) {
+ Message::addError('unknown-task-type');
+ }
+
+ $job = RebootControl::getActiveTasks(null, $taskid);
+ if ($job === false) {
+ Message::addError('no-such-task', $taskid);
+ return;
+ }
+ if (isset($job['type'])) {
+ $type = $job['type'];
+ }
+ if ($type === RebootControl::TASK_EXEC) {
+ $template = $perm = 'exec';
+ } elseif ($type === RebootControl::TASK_REBOOTCTL) {
+ $template = 'reboot';
+ if ($job['action'] === RebootControl::SHUTDOWN) {
+ $perm = 'shutdown';
+ } else {
+ $perm = 'reboot';
+ }
+ } elseif ($type == RebootControl::TASK_WOL) {
+ $template = $perm = 'wol';
+ } else {
+ Message::addError('unknown-task-type', $type);
+ return;
+ }
+ if (!empty($job['locations'])) {
+ $allowedLocs = User::getAllowedLocations("action.$perm");
+ if (!in_array(0, $allowedLocs) && array_diff($job['locations'], $allowedLocs) !== []) {
+ Message::addError('main.no-permission');
+ return;
+ }
+ self::expandLocationIds($job['locations']);
+ }
+
+ // Output
+ if ($type === RebootControl::TASK_REBOOTCTL) {
+ $job['clients'] = RebootUtils::getMachinesByUuid(ArrayUtil::flattenByKey($job['clients'], 'machineuuid'));
+ } elseif ($type === RebootControl::TASK_EXEC) {
+ $details = RebootUtils::getMachinesByUuid(ArrayUtil::flattenByKey($job['clients'], 'machineuuid'), true);
+ foreach ($job['clients'] as &$client) {
+ if (isset($client['machineuuid']) && isset($details[$client['machineuuid']])) {
+ $client += $details[$client['machineuuid']];
+ }
+ }
+ } elseif ($type === RebootControl::TASK_WOL) {
+ // Nothing (yet)
+ } else {
+ ErrorHandler::traceError('oopsie');
+ }
+ $job['timestamp_s'] = Util::prettyTime($job['timestamp']);
+ Render::addTemplate('status-' . $template, $job);
+ }
+
+ private static function showTaskList()
+ {
+ 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');
+ } else {
+ foreach ($active as &$entry) {
+ self::expandLocationIds($entry['locations']);
+ 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]);
+ }
+ }
+
+ private static function expandLocationIds(&$lids)
+ {
+ foreach ($lids as &$locid) {
+ if ($locid === null || $locid === 0) {
+ $name = '-';
+ } else {
+ $name = Location::getName($locid);
+ }
+ $locid = ['id' => $locid, 'name' => $name];
+ }
+ $lids = array_values($lids);
+ }
+
+ public static function doAjax()
+ {
+
+ }
+
+}
diff --git a/modules-available/rebootcontrol/permissions/permissions.json b/modules-available/rebootcontrol/permissions/permissions.json
index a058ffbf..a508f8b6 100644
--- a/modules-available/rebootcontrol/permissions/permissions.json
+++ b/modules-available/rebootcontrol/permissions/permissions.json
@@ -2,10 +2,40 @@
"newkeypair": {
"location-aware": false
},
+ "woldiscover": {
+ "location-aware": false
+ },
+ "subnet.view": {
+ "location-aware": false
+ },
+ "subnet.edit": {
+ "location-aware": false
+ },
+ "subnet.flag": {
+ "location-aware": false
+ },
+ "jumphost.view": {
+ "location-aware": false
+ },
+ "jumphost.edit": {
+ "location-aware": false
+ },
+ "jumphost.assign-subnet": {
+ "location-aware": false
+ },
"action.reboot": {
"location-aware": true
},
"action.shutdown": {
"location-aware": true
+ },
+ "action.wol": {
+ "location-aware": true
+ },
+ "action.exec": {
+ "location-aware": true
+ },
+ "action.view": {
+ "location-aware": true
}
} \ No newline at end of file
diff --git a/modules-available/rebootcontrol/style.css b/modules-available/rebootcontrol/style.css
index e35bce29..0f96c09f 100644
--- a/modules-available/rebootcontrol/style.css
+++ b/modules-available/rebootcontrol/style.css
@@ -1,25 +1,3 @@
-.rebootTimerForm {
- margin-top: 20px;
-}
-
-.statusColumn {
- text-align: center;
-}
-
-.table > tbody > tr > td {
- vertical-align: middle;
- height: 50px;
-}
-
-.checkbox {
- margin-top: 0;
- margin-bottom: 0;
-}
-
-.select-button {
- min-width: 150px;
-}
-
#dataTable {
margin-top: 20px;
}
@@ -33,6 +11,10 @@ pre {
white-space: pre-wrap;
}
-th[data-sort] {
- cursor: pointer;
+div.loc {
+ margin: 1px 2px;
+ border-radius: 2px;
+ padding: 1px 3px;
+ background: #eee;
+ float: left;
} \ No newline at end of file
diff --git a/modules-available/rebootcontrol/templates/_page.html b/modules-available/rebootcontrol/templates/_page.html
deleted file mode 100644
index a124e165..00000000
--- a/modules-available/rebootcontrol/templates/_page.html
+++ /dev/null
@@ -1,184 +0,0 @@
-<h3>{{location}}</h3>
-
-<form method="post" action="?do=rebootcontrol" class="form-inline">
- <input type="hidden" name="token" value="{{token}}">
- <div class="row">
- <div class="col-md-12">
- <table class="table table-condensed table-hover stupidtable" id="dataTable">
- <thead>
- <tr>
- <th data-sort="string">{{lang_client}}</th>
- <th data-sort="ipv4">{{lang_ip}}</th>
- <th data-sort="string">{{lang_status}}</th>
- <th data-sort="string">{{lang_session}}</th>
- <th data-sort="string">{{lang_user}}</th>
- <th data-sort="int" data-sort-default="desc">{{lang_selected}}</th>
- </tr>
- </thead>
-
- <tbody>
- {{#data}}
- <tr>
- <td>
- {{hostname}}
- {{^hostname}}{{clientip}}{{/hostname}}
- </td>
- <td>{{clientip}}</td>
- <td class="statusColumn">
- {{#status}}
- <span class="text-success">{{lang_on}}</span>
- {{/status}}
- {{^status}}
- <span class="text-danger">{{lang_off}}</span>
- {{/status}}
- </td>
- <td>{{#status}}{{currentsession}}{{/status}}</td>
- <td>{{#status}}{{currentuser}}{{/status}}</td>
- <td data-sort-value="0" class="checkboxColumn slx-smallcol">
- <div class="checkbox">
- <input id="m-{{machineuuid}}" type="checkbox" name="clients[]" value='{{machineuuid}}'>
- <label for="m-{{machineuuid}}"></label>
- </div>
- </td>
- </tr>
- {{/data}}
- </tbody>
- </table>
- </div>
- </div>
-
- <!-- Modals -->
- <div class ="modal fade" id="rebootModal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel">
- <div class="modal-dialog" role="document">
- <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>
- <h4 class="modal-title" id="myModalLabel">{{lang_rebootButton}}</h4>
- </div>
- <div class="modal-body">
- <div>{{lang_rebootCheck}}</div>
- <div>{{lang_rebootIn}} <input name="r-minutes" title="{{lang_shutdownIn}}" type="number" value="0" min="0" pattern="\d+"> {{lang_minutes}}</div>
- <div>
- <div class="checkbox checkbox-inline">
- <input name="quick" type="checkbox" value="on" id="rb-quick">
- <label for="rb-quick">{{lang_kexecRebootCheck}}</label>
- </div>
- </div>
- </div>
- <div class="modal-footer">
- <button type="button" class="btn btn-default" data-dismiss="modal">{{lang_cancel}}</button>
- <button type="submit" {{perms.action.reboot.disabled}} name="action" value="reboot" class="btn btn-warning">
- <span class="glyphicon glyphicon-repeat"></span>
- {{lang_reboot}}
- </button>
- </div>
- </div>
- </div>
- </div>
-
- <div class ="modal fade" id="shutdownModal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel2">
- <div class="modal-dialog" role="document">
- <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>
- <h4 class="modal-title" id="myModalLabel2">{{lang_shutdownButton}}</h4>
- </div>
- <div class="modal-body">
- <div>{{lang_shutdownCheck}}</div>
- {{lang_shutdownIn}} <input name="s-minutes" title="{{lang_shutdownIn}}" type="number" value="0" min="0" pattern="\d+"> {{lang_minutes}}
- </div>
- <div class="modal-footer">
- <button type="button" class="btn btn-default" data-dismiss="modal">{{lang_cancel}}</button>
- <button type="submit" {{perms.action.shutdown.disabled}} name="action" value="shutdown" class="btn btn-danger">
- <span class="glyphicon glyphicon-off"></span>
- {{lang_shutdownButton}}
- </button>
- </div>
- </div>
- </div>
- </div>
-</form>
-
-
-<script type="application/javascript">
- var $dataTable;
-
- document.addEventListener("DOMContentLoaded", function() {
-
- $dataTable = $("#dataTable");
- markCheckedRows();
- // Handle change of checkboxes in table
- $('input:checkbox').change(function() {
- var $this = $(this);
- //give each checkbox the function to mark the row (in green)
- if ($this.is(':checked')) {
- markRows($this.closest("tr"), true);
- $this.closest("td").data("sort-value", 1);
- } else {
- markRows($this.closest("tr"), false);
- $this.closest("td").data("sort-value", 0);
- }
-
- //if all are checked, change the selectAll-Button to unselectAll. if one is not checked, change unselectAll to selectAll
- var unchecked = $dataTable.find("input:checkbox:not(:checked)").length;
- var checked = $dataTable.find("input:checkbox:checked").length;
- if (unchecked === 0) {
- $('#selectAllButton').hide();
- $('#unselectAllButton').show();
- } else if (checked === 0) {
- $('#selectAllButton').show();
- $('#unselectAllButton').hide();
- }
-
- //if no client is selected, disable the shutdown/reboot button, and enable them if a client is selected
- $('#rebootButton').prop('disabled', checked === 0 || '{{perms.action.reboot.disabled}}' === 'disabled');
- $('#shutdownButton').prop('disabled', checked === 0 || '{{perms.action.shutdown.disabled}}' === 'disabled');
- });
- // Propagate click on column with checkbox to checkbox
- $('.checkboxColumn').click(function(e) {
- if (e.target === this) {
- $(this).find('input:checkbox').click();
- }
- });
- // Arm the (de)select all buttons
- $('#selectAllButton').click(function() { selectAllRows(true); });
- $('#unselectAllButton').click(function() { selectAllRows(false); });
- });
-
- // Check all checkboxes, change selectAll button, make shutdown/reboot button enabled as clients will certainly be selected
- function selectAllRows(selected) {
- var $box = $dataTable.find('input:checkbox');
- if ($box.length === 0) return;
- if (selected) {
- $box = $box.filter(':not(:checked)');
- } else {
- $box = $box.filter(':checked');
- }
- if ($box.length === 0) return;
- $box.prop('checked', !!selected).trigger('change');
- }
-
- // mark all previous checked rows (used when loading site), enable (de)select all if list is not empty
- function markCheckedRows() {
- var $checked = $dataTable.find("input:checkbox:checked");
- markRows($checked.closest("tr"), true);
- var $unchecked = $dataTable.find("input:checkbox:not(:checked)");
- markRows($unchecked.closest("tr"), false);
- if($unchecked.length === 0) {
- $('#selectAllButton').hide();
- $('#unselectAllButton').show();
- }
- if ($unchecked.length > 0 || $checked.length > 0) {
- $('.select-button').prop('disabled', false);
- }
- }
-
- function markRows($rows, marked) {
- if (marked) {
- $rows.addClass('active');
- } else {
- $rows.removeClass('active');
- }
- }
-
-</script> \ No newline at end of file
diff --git a/modules-available/rebootcontrol/templates/exec-enter-command.html b/modules-available/rebootcontrol/templates/exec-enter-command.html
new file mode 100644
index 00000000..8bf81605
--- /dev/null
+++ b/modules-available/rebootcontrol/templates/exec-enter-command.html
@@ -0,0 +1,43 @@
+<h2>{{lang_execRemoteCommand}}</h2>
+
+<form method="post" action="?do=rebootcontrol" id="list-form">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="show" value="exec">
+ <table class="table table-hover stupidtable" id="dataTable">
+ <thead>
+ <tr>
+ <th data-sort="string">{{lang_client}}</th>
+ <th data-sort="ipv4">{{lang_ip}}</th>
+ <th data-sort="string">
+ {{lang_status}}
+ </th>
+ </tr>
+ </thead>
+
+ <tbody>
+ {{#clients}}
+ <tr>
+ <td>
+ {{hostname}}{{^hostname}}{{machineuuid}}{{/hostname}}
+ <input type="hidden" name="uuid[]" value="{{machineuuid}}">
+ </td>
+ <td>{{clientip}}</td>
+ <td>{{state}}</td>
+ </tr>
+ {{/clients}}
+ </tbody>
+ </table>
+
+ <h3>{{lang_enterCommand}}</h3>
+
+ <div>
+ <label for="script-text">{{lang_scriptOrCommand}}</label>
+ <textarea id="script-text" class="form-control" name="script" rows="10"></textarea>
+ </div>
+ <div class="text-right slx-space">
+ <button type="submit" class="btn btn-primary" name="action" value="exec">
+ <span class="glyphicon glyphicon-play"></span>
+ {{lang_remoteExec}}
+ </button>
+ </div>
+</form> \ No newline at end of file
diff --git a/modules-available/rebootcontrol/templates/header.html b/modules-available/rebootcontrol/templates/header.html
index e171ccd6..47d97714 100644
--- a/modules-available/rebootcontrol/templates/header.html
+++ b/modules-available/rebootcontrol/templates/header.html
@@ -1,34 +1,9 @@
<div class="page-header">
- <button type="button" id="settingsButton" class="btn btn-default pull-right" data-toggle="modal" data-target="#settingsModal"><span class="glyphicon glyphicon-cog"></span> {{lang_settings}}</button>
- <h1>{{lang_rebootControl}}</h1>
-</div>
-
-<div>
- <label>{{lang_location}}:
- <select id="locationDropdown" class="form-control" onchange="selectLocation()">
- {{#locations}}
- <option value="{{locationid}}" {{disabled}} {{#selected}}selected{{/selected}}>{{locationpad}} {{locationname}}</option>
- {{/locations}}
- </select>
- </label>
- <div class="pull-right">
- <button type="button" id="shutdownButton" class="btn btn-danger action-button" data-toggle="modal" data-target="#shutdownModal" disabled>
- <span class="glyphicon glyphicon-off"></span>
- {{lang_shutdownButton}}
- </button>
- <button type="button" id="rebootButton" class="btn btn-warning action-button" data-toggle="modal" data-target="#rebootModal" disabled>
- <span class="glyphicon glyphicon-repeat"></span>
- {{lang_rebootButton}}
- </button>
- <button type="button" id="selectAllButton" class="btn btn-primary select-button" disabled>
- <span class="glyphicon glyphicon-check"></span>
- {{lang_selectall}}
- </button>
- <button type="button" id="unselectAllButton" class="btn btn-default select-button collapse" disabled>
- <span class="glyphicon glyphicon-unchecked"></span>
- {{lang_unselectall}}
- </button>
- </div>
+ <button type="button" id="settingsButton" class="btn btn-default pull-right" data-toggle="modal" data-target="#settingsModal">
+ <span class="glyphicon glyphicon-cog"></span>
+ {{lang_settings}}
+ </button>
+ <h1>{{lang_moduleHeading}}</h1>
</div>
<div id="settingsModal" class="modal fade" role="dialog">
@@ -40,40 +15,96 @@
<h4 class="modal-title"><b>{{lang_settings}}</b></h4>
</div>
<div class="modal-body">
- <p>{{lang_pubKey}}</p>
- <pre id="pubkey">{{pubKey}}</pre>
+ <label for="pubkey">{{lang_pubKey}}</label>
+ <pre id="pubkey">{{pubkey}}</pre>
<p>{{lang_newKeypairExplanation}}</p>
- </div>
- <div class="modal-footer">
- <button {{perms.newkeypair.disabled}} class="btn btn-danger pull-right" onclick="generateNewKeypair()" type="button">
+ <div class="checkbox">
+ <input {{perms.newkeypair.disabled}} type="checkbox" id="keypair-confirm">
+ <label for="keypair-confirm">{{lang_keypairConfirmCheck}}</label>
+ </div>
+ <button {{perms.newkeypair.disabled}} class="btn btn-danger pull-right" id="keypair-button"
+ onclick="generateNewKeypair()" type="button">
<span class="glyphicon glyphicon-refresh"></span>
{{lang_genNew}}
</button>
+ <div class="clearfix slx-space"></div>
</div>
+ <form method="post" action="?do=rebootcontrol">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="action" value="toggle-wol">
+ <div class="modal-body">
+ <label for="port-input">{{lang_wol}}</label>
+ <div class="input-group">
+ <span class="input-group-addon">{{lang_wolDestPort}}</span>
+ <input {{perms.woldiscover.disabled}} type="number" min="1" max="65535"
+ class="form-control" name="port" value="{{port}}" id="port-input">
+ </div>
+ </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>
+ </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>
</div>
</div>
<script type="application/javascript">
-
- // Change Location when selected in Dropdown Menu
- function selectLocation() {
- var dropdown = $("#locationDropdown");
- var location = dropdown.val();
- window.location.replace("?do=rebootcontrol&location="+location);
- }
-
- function generateNewKeypair() {
- if (!confirm('{{lang_confirmNewKeypair}}'))
+document.addEventListener('DOMContentLoaded', function() {
+ var $btn = $('#keypair-button');
+ var $chk = $('#keypair-confirm');
+ $chk.prop('checked', false); // Firefox helpfully keeping state on F5
+ $btn.click(function() {
+ if (!$chk.is(':checked')) {
+ var $p = $chk.parent();
+ $p.fadeOut(100, function () {
+ $p.fadeIn(75);
+ });
return;
+ }
+ $btn.prop('disabled', true);
$.ajax({
url: '?do=rebootcontrol',
type: 'POST',
data: { action: "generateNewKeypair", token: TOKEN },
success: function(value) {
$('#pubkey').text(value);
+ },
+ fail: function() {
+ $('#pubkey').text('Error');
+ $btn.prop('disabled', false);
}
});
- }
+ });
+});
+
-</script> \ No newline at end of file
+</script>
diff --git a/modules-available/rebootcontrol/templates/jumphost-edit.html b/modules-available/rebootcontrol/templates/jumphost-edit.html
new file mode 100644
index 00000000..7a79dc86
--- /dev/null
+++ b/modules-available/rebootcontrol/templates/jumphost-edit.html
@@ -0,0 +1,42 @@
+<h2>{{lang_editJumpHost}}</h2>
+
+<form method="post" action="?do=rebootcontrol">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="show" value="jumphost">
+ <input type="hidden" name="hostid" value="{{hostid}}">
+ <div class="list-group">
+ <div class="list-group-item">
+ <div class="row">
+ <div class="col-md-9 col-sm-7">
+ <label for="host">{{lang_host}}</label>
+ <input required id="host" class="form-control" type="text" name="host" value="{{host}}">
+ </div>
+ <div class="col-md-3 col-sm-5">
+ <label for="port">{{lang_port}}</label>
+ <input required id="port" class="form-control" type="number" name="port" value="{{port}}" min="1" max="65535">
+ </div>
+ </div>
+ </div>
+ <div class="list-group-item">
+ <label for="username">{{lang_username}}</label>
+ <input required id="username" class="form-control" type="text" name="username" value="{{username}}">
+ </div>
+ <div class="list-group-item">
+ <label for="sshkey">{{lang_privkey}}</label>
+ <textarea required id="sshkey" class="form-control" name="sshkey" rows="8">{{sshkey}}</textarea>
+ </div>
+ <div class="list-group-item">
+ <label for="script">{{lang_wakeupScript}}</label>
+ <textarea required id="script" class="form-control" name="script" rows="8">{{script}}</textarea>
+ <div class="slx-smallspace"></div>
+ <p>{{lang_wakeScriptHelp}}</p>
+ </div>
+ </div>
+ <div class="buttonbar text-right">
+ <button type="reset" class="btn btn-default">{{lang_reset}}</button>
+ <button type="submit" class="btn btn-primary" name="action" value="save">
+ <span class="glyphicon glyphicon-floppy-disk"></span>
+ {{lang_save}}
+ </button>
+ </div>
+</form> \ No newline at end of file
diff --git a/modules-available/rebootcontrol/templates/jumphost-list.html b/modules-available/rebootcontrol/templates/jumphost-list.html
new file mode 100644
index 00000000..083480b8
--- /dev/null
+++ b/modules-available/rebootcontrol/templates/jumphost-list.html
@@ -0,0 +1,63 @@
+<h2>{{lang_wolReachability}}</h2>
+
+<h3>{{lang_jumpHosts}}</h3>
+
+<form method="post" action="?do=rebootcontrol">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="show" value="jumphost">
+ <input type="hidden" name="action" value="list">
+ <table class="table">
+ <thead>
+ <tr>
+ <th>{{lang_host}}</th>
+ <th class="slx-smallcol">{{lang_numAssignedSubnets}}</th>
+ <th class="slx-smallcol">{{lang_reachable}}</th>
+ <th class="slx-smallcol"></th>
+ </tr>
+ </thead>
+ <tbody>
+ {{#jumpHosts}}
+ <tr>
+ <td>{{host}}:{{port}}</td>
+ <td class="text-nowrap text-right">
+ <a class="btn btn-xs btn-default {{perms.jumphost.assign-subnet.disabled}}"
+ href="?do=rebootcontrol&amp;show=jumphost&amp;what=assign&amp;id={{hostid}}">
+ <span class="glyphicon glyphicon-tasks"></span>
+ </a>
+ <span class="badge">{{subnetCount}}</span>
+ </td>
+ <td class="text-nowrap text-center">
+ {{#reachable}}
+ <span class="glyphicon glyphicon-ok text-success"></span>
+ {{/reachable}}
+ {{^reachable}}
+ <span class="glyphicon glyphicon-remove text-danger"></span>
+ {{/reachable}}
+ <button class="btn btn-xs btn-default btn-check-jumphost" type="submit" name="checkid" value="{{hostid}}"
+ {{perms.jumphost.edit.disabled}}>
+ {{lang_check}}
+ </button>
+ </td>
+ <td class="text-nowrap text-center">
+ <a class="btn btn-xs btn-default {{perms.jumphost.edit.disabled}}"
+ href="?do=rebootcontrol&amp;show=jumphost&amp;what=edit&amp;id={{hostid}}">
+ <span class="glyphicon glyphicon-edit"></span>
+ </a>
+ <button type="submit" name="deleteid" value="{{hostid}}" class="btn btn-xs btn-danger"
+ data-confirm="#confirm-delete-host" data-title="{{host}}" {{perms.jumphost.edit.disabled}}>
+ <span class="glyphicon glyphicon-trash"></span>
+ </button>
+ </td>
+ </tr>
+ {{/jumpHosts}}
+ </tbody>
+ </table>
+</form>
+<div class="buttonbar text-right">
+ <a class="btn btn-success" href="?do=rebootcontrol&amp;show=jumphost&amp;what=edit&amp;id=new">
+ <span class="glyphicon glyphicon-plus"></span>
+ {{lang_new}}
+ </a>
+</div>
+
+<div class="hidden" id="confirm-delete-host">{{lang_hostDeleteConfirm}}</div> \ No newline at end of file
diff --git a/modules-available/rebootcontrol/templates/jumphost-subnets.html b/modules-available/rebootcontrol/templates/jumphost-subnets.html
new file mode 100644
index 00000000..9b418667
--- /dev/null
+++ b/modules-available/rebootcontrol/templates/jumphost-subnets.html
@@ -0,0 +1,28 @@
+<h2>{{lang_jumpHost}} {{host}} - {{lang_assignedSubnets}}</h2>
+
+<form method="post" action="?do=rebootcontrol">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="show" value="jumphost">
+ <input type="hidden" name="action" value="assign">
+ <input type="hidden" name="hostid" value="{{hostid}}">
+ <div class="list-group">
+ <div class="list-group-item">
+ {{#list}}
+ <div class="row">
+ <div class="col-md-12">
+ <div class="checkbox">
+ <input id="check-{{subnetid}}" type="checkbox" name="subnet[{{subnetid}}]" {{checked}}>
+ <label for="check-{{subnetid}}">{{cidr}}</label>
+ </div>
+ </div>
+ </div>
+ {{/list}}
+ <div class="text-right">
+ <button type="submit" class="btn btn-primary">
+ <span class="glyphicon glyphicon-floppy-disk"></span>
+ {{lang_save}}
+ </button>
+ </div>
+ </div>
+ </div>
+</form> \ No newline at end of file
diff --git a/modules-available/rebootcontrol/templates/status-checkconnection.html b/modules-available/rebootcontrol/templates/status-checkconnection.html
new file mode 100644
index 00000000..da1177e7
--- /dev/null
+++ b/modules-available/rebootcontrol/templates/status-checkconnection.html
@@ -0,0 +1,47 @@
+<h3>{{lang_checkingJumpHost}}: {{host}} – {{timestamp_s}}</h3>
+
+<div class="clearfix"></div>
+<div class="collapse alert alert-success" id="result-ok">
+ <span class="glyphicon glyphicon-check"></span>
+ {{lang_hostReachable}}
+</div>
+<div class="collapse alert alert-warning" id="result-error">
+ <span class="glyphicon glyphicon-remove"></span>
+ {{lang_hostNonZeroExit}}
+</div>
+<div class="collapse alert alert-danger" id="result-unreach">
+ <span class="glyphicon glyphicon-remove"></span>
+ {{lang_hostNotReachable}}
+</div>
+
+<div class="collapse" id="log-wrapper">
+ <label for="log-output">{{lang_checkOutputLabel}}</label>
+ <pre id="log-output"></pre>
+</div>
+
+<div data-tm-id="{{taskId}}" data-tm-log="error" data-tm-callback="updateStatus">{{lang_checkingJumpHost}}</div>
+<script type="application/javascript">
+ function updateStatus(task) {
+ if (!task || !task.data || !task.data.result || !task.data.result['{{host}}'])
+ return;
+ var status = task.data.result['{{host}}'];
+ var log = '';
+ if (status.stderr) log += status.stderr + "\n";
+ if (status.stdout) log += status.stdout + "\n";
+ showErrorLog(log);
+ if (task.statusCode === 'TASK_FINISHED' || task.statusCode === 'TASK_ERROR') {
+ if (status.exitCode === 0) {
+ $('#result-ok').show();
+ } else if (status.exitCode > 0) {
+ $('#result-error').show();
+ } else {
+ $('#result-unreach').show();
+ }
+ }
+ }
+ function showErrorLog(log) {
+ if (!log) return;
+ $('#log-output').text(log);
+ $('#log-wrapper').show();
+ }
+</script>
diff --git a/modules-available/rebootcontrol/templates/status-exec.html b/modules-available/rebootcontrol/templates/status-exec.html
new file mode 100644
index 00000000..a3efef5f
--- /dev/null
+++ b/modules-available/rebootcontrol/templates/status-exec.html
@@ -0,0 +1,76 @@
+<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>
+
+<div class="list-group">
+ <div class="list-group-item">
+<div class="row">
+ <div class="col-md-6 col-sm-8 col-xs-12 slx-bold">{{lang_host}}</div>
+ <div class="col-md-4 col-sm-2 col-xs-6 slx-bold">{{lang_status}}</div>
+ <div class="col-md-2 col-sm-2 col-xs-6 slx-bold text-right">{{lang_exitCode}}</div>
+</div>
+ </div>
+
+{{#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">
+ <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>
+ <div class="stdout collapse">
+ <i>{{lang_stdout}}</i>
+ <pre></pre>
+ </div>
+ <div class="stderr collapse">
+ <i>{{lang_stderr}}</i>
+ <pre></pre>
+ </div>
+</div>
+{{/clients}}
+</div>
+
+<script><!--
+
+var ignoreHosts = {};
+
+function updateStatus(task) {
+ if (!task || !task.data || !task.data.result)
+ return;
+ for (var host in task.data.result) {
+ if (!task.data.result.hasOwnProperty(host) || ignoreHosts[host])
+ continue;
+ updateStatusClient(host, task.data.result[host]);
+ }
+}
+
+function updateStatusClient(id, status) {
+ var $p = $('#client-' + id);
+ if ($p.length === 0)
+ return;
+ $p.find('.state').text(status.state);
+ if (status.stdout) $p.find('.stdout').show().find('pre').text(status.stdout);
+ if (status.stderr) $p.find('.stderr').show().find('pre').text(status.stderr);
+ if (status.state === 'DONE' || status.state === 'ERROR' || status.state === 'TIMEOUT') {
+ $p.find('.state').addClass((status.state === 'DONE') ? 'text-success' : 'text-danger');
+ if (status.exitCode >= 0) {
+ $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);
+ }
+ }
+}
+
+//--></script> \ No newline at end of file
diff --git a/modules-available/rebootcontrol/templates/status.html b/modules-available/rebootcontrol/templates/status-reboot.html
index c05b2fad..34971845 100644
--- a/modules-available/rebootcontrol/templates/status.html
+++ b/modules-available/rebootcontrol/templates/status-reboot.html
@@ -1,37 +1,36 @@
-<div>
- <form class="form-inline">
- <b>{{lang_location}}: {{locationName}}</b>
- <input type="hidden" name="do" value="rebootcontrol">
- <input type="hidden" name="location" value="{{locationId}}">
- <button type="submit" class="btn btn-primary pull-right"><span class="glyphicon glyphicon-arrow-left"></span> {{lang_back}}</button>
- </form>
-</div>
+<h3>{{action}} – {{timestamp_s}}</h3>
-<div>
- <table class="table table-hover stupidtable" id="dataTable">
- <thead>
- <tr>
- <th data-sort="string">{{lang_client}}</th>
- <th data-sort="ipv4">{{lang_ip}}</th>
- <th data-sort="string">
- {{lang_status}}
- </th>
- </tr>
- </thead>
+{{#locations}}
+<div class="loc">{{name}}</div>
+{{/locations}}
+<div class="clearfix slx-space"></div>
- <tbody>
- {{#clients}}
- <tr>
- <td>{{hostname}}{{^hostname}}{{machineuuid}}{{/hostname}}</td>
- <td>{{clientip}}</td>
- <td id="status-{{machineuuid}}"></td>
- </tr>
- {{/clients}}
- </tbody>
- </table>
-</div>
+<table class="table table-hover stupidtable" id="dataTable">
+ <thead>
+ <tr>
+ <th data-sort="string">{{lang_client}}</th>
+ <th data-sort="ipv4">{{lang_ip}}</th>
+ <th data-sort="string">
+ {{lang_status}}
+ </th>
+ </tr>
+ </thead>
-<div data-tm-id="{{taskId}}" data-tm-log="error" data-tm-callback="updateStatus"></div>
+ <tbody>
+ {{#clients}}
+ <tr>
+ <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>
+ <span id="text-{{machineuuid}}"></span>
+ </td>
+ </tr>
+ {{/clients}}
+ </tbody>
+</table>
+
+<div data-tm-id="{{id}}" data-tm-log="error" data-tm-callback="updateStatus"></div>
<script type="application/javascript">
statusStrings = {
@@ -48,11 +47,12 @@
function updateStatus(task) {
if (!task || !task.data || !task.data.clientStatus)
return;
+ stillActive = 25;
var clientStatus = task.data.clientStatus;
for (var uuid in clientStatus) {
if (!clientStatus.hasOwnProperty(uuid))
continue;
- var $s = $("#status-" + uuid);
+ var $s = $("#text-" + uuid);
var status = clientStatus[uuid];
if ($s.data('state') === status)
continue;
diff --git a/modules-available/rebootcontrol/templates/status-wol.html b/modules-available/rebootcontrol/templates/status-wol.html
new file mode 100644
index 00000000..70517f84
--- /dev/null
+++ b/modules-available/rebootcontrol/templates/status-wol.html
@@ -0,0 +1,82 @@
+<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">{{lang_aWolJob}}</div>
+{{/tasks}}
+{{^tasks}}
+<div class="alert alert-warning">
+ <span class="glyphicon glyphicon-exclamation-sign"></span>
+ {{lang_noTasksForJob}}
+</div>
+{{/tasks}}
+
+<pre>{{log}}</pre>
+
+<table class="table table-hover stupidtable" id="dataTable">
+ <thead>
+ <tr>
+ <th data-sort="string">{{lang_client}}</th>
+ <th data-sort="ipv4">{{lang_ip}}</th>
+ <th data-sort="string">
+ {{lang_status}}
+ </th>
+ </tr>
+ </thead>
+
+ <tbody>
+ {{#clients}}
+ <tr>
+ <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>
+ {{/machineuuid}}
+ </td>
+ </tr>
+ {{/clients}}
+ </tbody>
+</table>
+
+<a class="text-muted" href="#debug-out" data-toggle="collapse">Debug</a>
+<pre id="debug-out" class="collapse"></pre>
+
+<script>
+function wolCallback(task) {
+ if (task.statusCode === 'TASK_WAITING' || task.statusCode === 'TASK_PROCESSING') {
+ stillActive = 25;
+ } else if (task.data && task.data.result) {
+ var $do = $('#debug-out');
+ var txt = $do.text();
+ var res = task.data.result;
+ for (var k in res) {
+ if (res.hasOwnProperty(k)) {
+ txt += k + ":\n";
+ if (res[k].stdout && res[k].stdout.trimEnd && res[k].stdout.trimEnd()) {
+ txt += res[k].stdout.trimEnd() + "\n";
+ }
+ if (res[k].stderr && res[k].stderr.trimEnd && res[k].stderr.trimEnd()) {
+ txt += res[k].stderr.trimEnd() + "\n";
+ }
+ txt += "Exit " + res[k].exitCode + "\n\n";
+ }
+ }
+ $do.text(txt);
+ }
+}
+</script>
diff --git a/modules-available/rebootcontrol/templates/subnet-edit.html b/modules-available/rebootcontrol/templates/subnet-edit.html
new file mode 100644
index 00000000..570865c7
--- /dev/null
+++ b/modules-available/rebootcontrol/templates/subnet-edit.html
@@ -0,0 +1,78 @@
+<!-- subnetid, start, end, fixed, isdirect, lastdirectcheck, lastseen, seencount -->
+
+<form method="post" action="?do=rebootcontrol">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="show" value="subnet">
+ <input type="hidden" name="id" value="{{subnetid}}">
+ <div class="panel panel-default">
+ <div class="panel-heading">
+ {{lang_editSubnet}}: <b>{{cidr}}</b> ({{start_s}}&thinsp;-&thinsp;{{end_s}})
+ </div>
+ <div class="list-group">
+ <div class="list-group-item">
+ <div class="checkbox">
+ <input id="fixed_cb" type="checkbox" name="fixed" {{#fixed}}checked{{/fixed}} {{perms.subnet.flag.disabled}}>
+ <label for="fixed_cb">{{lang_fixSubnetSettings}}</label>
+ </div>
+ <div class="slx-space"></div>
+ <p>{{lang_fixSubnetDesc}}</p>
+ </div>
+ <div class="list-group-item {{^fixed}}collapse{{/fixed}} subnet-option">
+ <div class="checkbox">
+ <input id="direct_cb" type="checkbox" name="isdirect" {{#isdirect}}checked{{/isdirect}} {{perms.subnet.flag.disabled}}>
+ <label for="direct_cb">{{lang_reachableFromServer}}</label>
+ </div>
+ <div class="slx-space"></div>
+ <p>{{lang_reachableFromServerDesc}}</p>
+ </div>
+ <div class="list-group-item {{perms.jumphost.view.hidden}}">
+ <label>{{lang_assignedJumpHosts}}</label>
+ {{#jumpHosts}}
+ <div class="row">
+ <div class="col-md-12">
+ <div class="checkbox">
+ <input id="jhb{{hostid}}" type="checkbox" name="jumphost[{{hostid}}]" {{checked}}
+ {{perms.jumphost.assign-subnet.disabled}}>
+ <label for="jhb{{hostid}}">{{host}}:{{port}}</label>
+ </div>
+ </div>
+ </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"
+ data-confirm="{{lang_confirmDeleteSubnet}}"
+ {{perms.subnet.edit.disabled}} {{perms.subnet.flag.disabled}}>
+ <span class="glyphicon glyphicon-trash"></span>
+ {{lang_delete}}
+ </button>
+ <button type="submit" class="btn btn-primary" name="action" value="edit" {{perms.subnet.flag.disabled}}>
+ <span class="glyphicon glyphicon-floppy-disk"></span>
+ {{lang_save}}
+ </button>
+ </div>
+ </div>
+</form>
+<script><!--
+document.addEventListener('DOMContentLoaded', function() {
+ var $overrides = $('.subnet-option');
+ var $cb = $('#fixed_cb');
+ $cb.change(function() {
+ if ($cb.is(':checked')) {
+ $overrides.show();
+ } else {
+ $overrides.hide();
+ }
+ }).change();
+
+});
+//--></script>
diff --git a/modules-available/rebootcontrol/templates/subnet-list.html b/modules-available/rebootcontrol/templates/subnet-list.html
new file mode 100644
index 00000000..2bc9208f
--- /dev/null
+++ b/modules-available/rebootcontrol/templates/subnet-list.html
@@ -0,0 +1,78 @@
+<h2>{{lang_wolReachability}}</h2>
+
+<h3>{{lang_subnets}}</h3>
+
+<p>{{lang_subnetsDescription}}</p>
+
+<p>{{lang_wolMachineSupportText}}</p>
+
+<table class="table">
+ <thead>
+ <tr>
+ <th>{{lang_subnet}}</th>
+ <th class="slx-smallcol">{{lang_isFixed}}</th>
+ <th class="slx-smallcol">{{lang_isDirect}}</th>
+ <th class="slx-smallcol">{{lang_wolReachability}}</th>
+ <th class="slx-smallcol">{{lang_lastseen}}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{#subnets}}
+ <tr>
+ <td>
+ <div style="display: inline-block; min-width: 10em">
+ <a href="?do=rebootcontrol&amp;show=subnet&amp;what=subnet&amp;id={{subnetid}}">{{cidr}}</a>
+ </div>
+ <a href="?do=statistics&amp;show=list&amp;filters=clientip={{cidr}}" class="btn btn-default btn-xs">
+ <span class="glyphicon glyphicon-eye-open"></span>
+ </a>
+ </td>
+ <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}} text-nowrap">{{lastseen_s}}</td>
+ </tr>
+ {{/subnets}}
+ </tbody>
+</table>
+
+<div class="panel panel-default">
+ <div class="panel-heading">
+ {{lang_help}}
+ </div>
+ <div class="panel-body">
+ <p>
+ <b>{{lang_isFixed}}</b>:
+ {{lang_isFixedHelp}}
+ </p>
+ <p>
+ <b>{{lang_isDirect}}</b>:
+ {{lang_isDirectHelp}}
+ </p>
+ <p>
+ <b>{{lang_wolReachability}}</b>:
+ {{lang_wolReachabilityHelp}}
+ </p>
+ </div>
+</div>
+
+<form method="post" action="?do=rebootcontrol">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="show" value="subnet">
+ <div class="list-group">
+ <div class="list-group-item">
+ <label>{{lang_addNewSubnet}}</label>
+ <div class="row">
+ <div class="col-md-4 col-sm-6">
+ <input class="form-control" type="text" name="cidr" placeholder="1.2.3.0/24">
+ </div>
+ <div class="col-md-4 col-sm-6">
+ <button class="btn btn-primary" name="action" value="add">
+ <span class="glyphicon glyphicon-floppy-disk"></span>
+ {{lang_add}}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+</form>
diff --git a/modules-available/rebootcontrol/templates/task-header.html b/modules-available/rebootcontrol/templates/task-header.html
new file mode 100644
index 00000000..211c16e5
--- /dev/null
+++ b/modules-available/rebootcontrol/templates/task-header.html
@@ -0,0 +1,4 @@
+<p>
+ {{lang_taskListIntro}}
+</p>
+<div class="slx-space"></div> \ No newline at end of file
diff --git a/modules-available/rebootcontrol/templates/task-list.html b/modules-available/rebootcontrol/templates/task-list.html
index 063ba949..dcb04450 100644
--- a/modules-available/rebootcontrol/templates/task-list.html
+++ b/modules-available/rebootcontrol/templates/task-list.html
@@ -2,9 +2,9 @@
<table class="table">
<thead>
<tr>
- <th>{{lang_mode}}</th>
+ <th>{{lang_when}}</th>
+ <th>{{lang_task}}</th>
<th>{{lang_location}}</th>
- <th>{{lang_time}}</th>
<th>{{lang_clientCount}}</th>
<th>{{lang_status}}</th>
</tr>
@@ -12,19 +12,23 @@
<tbody>
{{#list}}
<tr>
- <td>
- <a href="?do=rebootcontrol&amp;taskid={{taskId}}">{{mode}}</a>
+ <td class="text-nowrap">
+ {{timestamp_s}}
</td>
- <td>
- {{locationName}}
+ <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>
<td>
- {{time}}
+ {{#locations}}
+ <div class="loc">{{name}}</div>
+ {{/locations}}
+ <div class="clearfix"></div>
</td>
- <td>
- {{clientCount}}
+ <td class="text-nowrap">
+ {{clients}}
</td>
- <td>
+ <td class="text-nowrap">
{{status}}
</td>
</tr>
diff --git a/modules-available/remoteaccess/api.inc.php b/modules-available/remoteaccess/api.inc.php
new file mode 100644
index 00000000..c558d126
--- /dev/null
+++ b/modules-available/remoteaccess/api.inc.php
@@ -0,0 +1,98 @@
+<?php
+
+$ip = $_SERVER['REMOTE_ADDR'];
+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
+ ORDER BY lastseen DESC
+ LIMIT 1", ['ip' => $ip]);
+ if ($c !== false) {
+ $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 === null) {
+ die('No allowed IP defined');
+}
+$iplong = ip2long($ip);
+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();
+
+if (empty($remoteLocations)) {
+ $rows = [];
+} 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.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)
+ AND r.machineuuid IS NULL",
+ ['locs' => $remoteLocations]);
+
+ $wolCut = time() - 90;
+ foreach ($rows as &$row) {
+ if (($row['state'] === 'OFFLINE' || $row['state'] === 'STANDBY') && $row['woltime'] > $wolCut) {
+ $row['wol_in_progress'] = true;
+ }
+ settype($row['locationid'], 'int');
+ settype($row['vncport'], 'int');
+ unset($row['woltime']);
+ }
+}
+
+$groups = Database::queryAll("SELECT g.groupid AS id, g.groupname AS name,
+ GROUP_CONCAT(l.locationid) AS locationids, g.passwd AS password
+ FROM remoteaccess_group g INNER JOIN remoteaccess_x_location l USING (groupid)
+ WHERE g.active = 1
+ GROUP BY g.groupid");
+foreach ($groups as &$group) {
+ $group['locationids'] = explode(',', $group['locationids']);
+ if (empty($group['password'])) {
+ unset($group['password']);
+ }
+ settype($group['id'], 'int');
+ foreach ($group['locationids'] as &$lid) {
+ settype($lid, 'int');
+ }
+}
+
+$fakeid = 100000;
+echo json_encode(['clients' => $rows, 'locations' => $groups]);
+
+// WTF, this makes the server return a 500 -.-
+//fastcgi_finish_request();
+
+RemoteAccess::ensureMachinesRunning();
diff --git a/modules-available/remoteaccess/baseconfig/getconfig.inc.php b/modules-available/remoteaccess/baseconfig/getconfig.inc.php
new file mode 100644
index 00000000..182daef1
--- /dev/null
+++ b/modules-available/remoteaccess/baseconfig/getconfig.inc.php
@@ -0,0 +1,50 @@
+<?php
+
+/** @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',
+ ['uuid' => $uuid], true);
+ if (is_array($res))
+ return;
+
+ // Locations from closest to furthest (order)
+ $locationId = ConfigHolder::get('SLX_LOCATIONS');
+ 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);
+ }
+ 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/config.json b/modules-available/remoteaccess/config.json
new file mode 100644
index 00000000..1530df87
--- /dev/null
+++ b/modules-available/remoteaccess/config.json
@@ -0,0 +1,7 @@
+{
+ "category": "main.settings-client",
+ "dependencies" : [
+ "locations",
+ "rebootcontrol"
+ ]
+} \ No newline at end of file
diff --git a/modules-available/remoteaccess/hooks/client-update.inc.php b/modules-available/remoteaccess/hooks/client-update.inc.php
new file mode 100644
index 00000000..ecf5d91c
--- /dev/null
+++ b/modules-available/remoteaccess/hooks/client-update.inc.php
@@ -0,0 +1,9 @@
+<?php
+
+if ($type === '~poweron') {
+ Database::exec("UPDATE remoteaccess_machine SET password = NULL WHERE machineuuid = :uuid",
+ ['uuid' => $uuid]);
+} elseif ($type === '~poweroff') {
+ Database::exec("UPDATE remoteaccess_machine SET woltime = 0 WHERE machineuuid = :uuid",
+ ['uuid' => $uuid]);
+}
diff --git a/modules-available/remoteaccess/hooks/cron.inc.php b/modules-available/remoteaccess/hooks/cron.inc.php
new file mode 100644
index 00000000..2ee6e375
--- /dev/null
+++ b/modules-available/remoteaccess/hooks/cron.inc.php
@@ -0,0 +1,3 @@
+<?php
+
+RemoteAccess::ensureMachinesRunning();
diff --git a/modules-available/remoteaccess/inc/remoteaccess.inc.php b/modules-available/remoteaccess/inc/remoteaccess.inc.php
new file mode 100644
index 00000000..95ca3821
--- /dev/null
+++ b/modules-available/remoteaccess/inc/remoteaccess.inc.php
@@ -0,0 +1,117 @@
+<?php
+
+class RemoteAccess
+{
+
+ const PROP_ALLOWED_VNC_NET = 'remoteaccess.allowedvncaccess';
+
+ const PROP_TRY_VIRT_HANDOVER = 'remoteaccess.virthandover';
+
+ 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) {
+ $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)");
+ } 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()
+ {
+ if (!Module::isAvailable('rebootcontrol')) {
+ error_log("Not waking remote access machines: rebootcontrol missing");
+ return;
+ }
+
+ $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;
+ 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)) {
+ // 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 = ($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(string $locs, int $num): int
+ {
+ 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()); ++$i) {
+ $list[] = $row;
+ Database::exec("INSERT INTO remoteaccess_machine (machineuuid, password, woltime)
+ VALUES (:uuid, NULL, :now)
+ ON DUPLICATE KEY UPDATE woltime = VALUES(woltime)",
+ ['uuid' => $row['machineuuid'], 'now' => $NOW]);
+ }
+ if (empty($list))
+ break; // No more clients in this location
+ RebootControl::wakeMachines($list, $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
+ Database::exec('UPDATE remoteaccess_machine SET woltime = :faketime WHERE machineuuid IN (:fails)',
+ ['faketime' => $NOW - 95, 'fails' => $failIds]);
+ }
+ }
+ return $num;
+ }
+
+}
diff --git a/modules-available/remoteaccess/install.inc.php b/modules-available/remoteaccess/install.inc.php
new file mode 100644
index 00000000..2a6fec36
--- /dev/null
+++ b/modules-available/remoteaccess/install.inc.php
@@ -0,0 +1,80 @@
+<?php
+
+$dbret = [];
+
+$dbret[] = tableCreate('remoteaccess_group', "
+ `groupid` int(11) NOT NULL AUTO_INCREMENT,
+ `groupname` varchar(100) NOT NULL,
+ `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`)
+");
+
+$dbret[] = tableCreate('remoteaccess_x_location', "
+ `groupid` int(11) NOT NULL,
+ `locationid` int(11) NOT NULL,
+ PRIMARY KEY (`groupid`, `locationid`)
+");
+
+$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`)
+");
+
+$dbret[] = tableAddConstraint('remoteaccess_x_location', 'locationid', 'location', 'locationid',
+ 'ON UPDATE CASCADE ON DELETE CASCADE');
+
+$dbret[] = tableAddConstraint('remoteaccess_x_location', 'groupid', 'remoteaccess_group', 'groupid',
+ 'ON UPDATE CASCADE ON DELETE CASCADE');
+
+$dbret[] = tableAddConstraint('remoteaccess_machine', 'machineuuid', 'machine', 'machineuuid',
+ 'ON UPDATE CASCADE ON DELETE CASCADE');
+
+if (tableExists('remoteaccess_location')
+ && tableExists('remoteaccess_x_location')
+ && tableExists('remoteaccess_group')) {
+ // Migrate old version
+ $wantedIdleCount = (int)Property::get('remoteaccess.wantedclients', 0);
+ $ret = Database::exec("INSERT IGNORE INTO remoteaccess_group (groupid, groupname, wolcount, passwd)
+ SELECT l.locationid, l.locationname, $wantedIdleCount AS blu, '' AS bla FROM location l
+ INNER JOIN remoteaccess_location rl USING (locationid)");
+ if ($ret === false) {
+ finalResponse(UPDATE_FAILED, Database::lastError());
+ }
+ Property::set('remoteaccess.wantedclients', 0, 1);
+ $max = Database::queryFirst("SELECT groupid FROM remoteaccess_group ORDER BY groupid DESC LIMIT 1");
+ if ($max !== false) {
+ Database::exec("ALTER TABLE remoteaccess_group AUTO_INCREMENT = :next", ['next' => $max['groupid'] + 1]);
+ }
+ $ret = Database::exec("INSERT IGNORE INTO remoteaccess_x_location (groupid, locationid)
+ SELECT locationid, locationid FROM remoteaccess_location");
+ if ($ret === false) {
+ finalResponse(UPDATE_FAILED, Database::lastError());
+ }
+ 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/messages.json b/modules-available/remoteaccess/lang/de/messages.json
new file mode 100644
index 00000000..a7b26240
--- /dev/null
+++ b/modules-available/remoteaccess/lang/de/messages.json
@@ -0,0 +1,8 @@
+{
+ "group-added": "Gruppe hinzugef\u00fcgt",
+ "group-deleted": "Gruppe {{0}} gel\u00f6scht",
+ "group-not-found": "Gruppe {{0}} existiert nicht",
+ "group-updated": "Gruppe {{0}} wurde aktualisiert",
+ "locations-not-allowed": "Gruppe {{0}} hat Orte zugewiesen, f\u00fcr die Sie keine Berechtigung haben",
+ "settings-saved": "Einstellungen gespeichert"
+} \ No newline at end of file
diff --git a/modules-available/remoteaccess/lang/de/module.json b/modules-available/remoteaccess/lang/de/module.json
new file mode 100644
index 00000000..21d8ca69
--- /dev/null
+++ b/modules-available/remoteaccess/lang/de/module.json
@@ -0,0 +1,4 @@
+{
+ "module_name": "Fernzugriff",
+ "page_title": "Fernzugriff"
+} \ No newline at end of file
diff --git a/modules-available/remoteaccess/lang/de/permissions.json b/modules-available/remoteaccess/lang/de/permissions.json
new file mode 100644
index 00000000..ef402eed
--- /dev/null
+++ b/modules-available/remoteaccess/lang/de/permissions.json
@@ -0,0 +1,7 @@
+{
+ "group.add": "Neue Gruppe anlegen",
+ "group.edit": "Einstellungen einer Gruppe bearbeiten, Gruppe l\u00f6schen",
+ "group.locations": "Zugewiesene R\u00e4ume einer Gruppe \u00e4ndern",
+ "set-proxy-ip": "F\u00fcr Zugriff freigegebene IP-Adresse\/Bereich \u00e4ndern",
+ "view": "Seite sehen"
+} \ No newline at end of file
diff --git a/modules-available/remoteaccess/lang/de/template-tags.json b/modules-available/remoteaccess/lang/de/template-tags.json
new file mode 100644
index 00000000..1a502a6b
--- /dev/null
+++ b/modules-available/remoteaccess/lang/de/template-tags.json
@@ -0,0 +1,27 @@
+{
+ "lang_add": "Hinzuf\u00fcgen",
+ "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_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/messages.json b/modules-available/remoteaccess/lang/en/messages.json
new file mode 100644
index 00000000..15b7e06c
--- /dev/null
+++ b/modules-available/remoteaccess/lang/en/messages.json
@@ -0,0 +1,8 @@
+{
+ "group-added": "Group added",
+ "group-deleted": "Group {{0}} deleted",
+ "group-not-found": "Group {{0}} does not exist",
+ "group-updated": "Group {{0}} updated",
+ "locations-not-allowed": "You don't have permission to view some locations in group {{0}} ",
+ "settings-saved": "Settings saved"
+} \ No newline at end of file
diff --git a/modules-available/remoteaccess/lang/en/module.json b/modules-available/remoteaccess/lang/en/module.json
new file mode 100644
index 00000000..308aeb15
--- /dev/null
+++ b/modules-available/remoteaccess/lang/en/module.json
@@ -0,0 +1,4 @@
+{
+ "module_name": "Remoteaccess",
+ "page_title": "Remoteaccess"
+} \ No newline at end of file
diff --git a/modules-available/remoteaccess/lang/en/permissions.json b/modules-available/remoteaccess/lang/en/permissions.json
new file mode 100644
index 00000000..e90ce398
--- /dev/null
+++ b/modules-available/remoteaccess/lang/en/permissions.json
@@ -0,0 +1,7 @@
+{
+ "group.add": "Add new group",
+ "group.edit": "Edit or delete a group",
+ "group.locations": "Assign rooms to a group",
+ "set-proxy-ip": "Set allowed proxy IP(-Range)",
+ "view": "View page"
+} \ 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
new file mode 100644
index 00000000..037550e4
--- /dev/null
+++ b/modules-available/remoteaccess/lang/en/template-tags.json
@@ -0,0 +1,27 @@
+{
+ "lang_add": "Add",
+ "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_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
new file mode 100644
index 00000000..ba248b4d
--- /dev/null
+++ b/modules-available/remoteaccess/page.inc.php
@@ -0,0 +1,196 @@
+<?php
+
+class Page_RemoteAccess extends Page
+{
+
+ protected function doPreprocess()
+ {
+ User::load();
+ if (!User::isLoggedIn()) {
+ Message::addError('main.no-permission');
+ Util::redirect('?do=Main');
+ }
+ User::assertPermission('view');
+ $action = Request::post('action', false, 'string');
+ // Add group adds a DB row and then falls through to regular saving
+ if ($action === 'add-group') {
+ User::assertPermission('group.add');
+ Database::exec("INSERT INTO remoteaccess_group (groupname, wolcount, passwd, active)
+ VALUES ('.new', 0, '', 0)");
+ Message::addSuccess('group-added');
+ if (User::hasPermission('group.edit')) {
+ $action = 'save-groups';
+ }
+ }
+ if ($action === 'save-groups') {
+ User::assertPermission('group.edit');
+ $groups = Request::post('group', [], 'array');
+ foreach ($groups as $id => $group) {
+ Database::exec("UPDATE remoteaccess_group SET groupname = :name, wolcount = :wol,
+ passwd = :passwd, active = :active WHERE groupid = :id", [
+ 'id' => $id,
+ 'name' => $group['groupname'] ?? $id,
+ 'wol' => $group['wolcount'] ?? 0,
+ 'passwd' => $group['passwd'] ?? 0,
+ 'active' => (int)($group['active'] ?? 0),
+ ]);
+ }
+ Message::addSuccess('settings-saved');
+ } elseif ($action === 'save-settings') {
+ 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');
+ $groupid = Request::post('groupid', Request::REQUIRED, 'int');
+ $group = $this->groupNameOrFail($groupid);
+ if (!$this->checkGroupLocations($groupid)) {
+ Message::addError('locations-not-allowed', $group);
+ } else {
+ Database::exec("DELETE FROM remoteaccess_group WHERE groupid = :id", ['id' => $groupid]);
+ Message::addSuccess('group-deleted', $group);
+ }
+ } elseif ($action === 'set-locations') {
+ User::assertPermission('group.locations');
+ $groupid = Request::post('groupid', Request::REQUIRED, 'int');
+ $group = $this->groupNameOrFail($groupid);
+ $locations = array_values(Request::post('location', [], 'array'));
+ // Merge what's already set where we don't have permission
+ $locations = Permission::mergeWithDisallowed($locations, 'group.locations',
+ "SELECT locationid FROM remoteaccess_x_location WHERE groupid = :id", ['id' => $groupid]);
+ if (empty($locations)) {
+ Database::exec("DELETE FROM remoteaccess_x_location WHERE groupid = :id", ['id' => $groupid]);
+ } else {
+ Database::exec("INSERT IGNORE INTO remoteaccess_x_location (groupid, locationid)
+ VALUES :values", ['values' => array_map(function($item) use ($groupid) { return [$groupid, $item]; }, $locations)]);
+ Database::exec("DELETE FROM remoteaccess_x_location WHERE groupid = :id AND locationid NOT IN (:locations)",
+ ['id' => $groupid, 'locations' => $locations]);
+ }
+ Message::addSuccess('group-updated', $group);
+ }
+ if (Request::isPost()) {
+ Util::redirect('?do=remoteaccess');
+ }
+ }
+
+ private function groupNameOrFail($groupid)
+ {
+ $group = Database::queryFirst("SELECT groupname FROM remoteaccess_group WHERE groupid = :id",
+ ['id' => $groupid]);
+ if ($group === false) {
+ Message::addError('group-not-found', $groupid);
+ Util::redirect('?do=remoteaccess');
+ }
+ return $group['groupname'];
+ }
+
+ protected function doRender()
+ {
+ $groupid = Request::get('groupid', false, 'int');
+ 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, 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, 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';
+ }
+ }
+ $data = [
+ 'groupid' => $groupid,
+ 'groupname' => $group,
+ 'locations' => array_values($locationList),
+ 'disabled' => empty($allowed) ? 'disabled' : '',
+ ];
+ Permission::addGlobalTags($data['perms'], null, ['group.locations', 'group.edit']);
+ Render::addTemplate('edit-group', $data);
+ }
+ }
+
+ /**
+ * @param int $groupid group to check
+ * @return bool if we have permission for all the locations assigned to group
+ */
+ private function checkGroupLocations(int $groupid): bool
+ {
+ $allowed = User::getAllowedLocations('group.locations');
+ if (in_array(0, $allowed))
+ return true;
+ $hasLocs = Database::queryColumnArray("SELECT locationid FROM remoteaccess_x_location WHERE groupid = :id",
+ ['id' => $groupid]);
+ $diff = array_diff($hasLocs, $allowed);
+ 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/permissions/permissions.json b/modules-available/remoteaccess/permissions/permissions.json
new file mode 100644
index 00000000..c91ce7ae
--- /dev/null
+++ b/modules-available/remoteaccess/permissions/permissions.json
@@ -0,0 +1,17 @@
+{
+ "view": {
+ "location-aware": false
+ },
+ "group.locations": {
+ "location-aware": true
+ },
+ "group.add": {
+ "location-aware": false
+ },
+ "group.edit": {
+ "location-aware": false
+ },
+ "set-proxy-ip": {
+ "location-aware": false
+ }
+} \ No newline at end of file
diff --git a/modules-available/remoteaccess/templates/edit-group.html b/modules-available/remoteaccess/templates/edit-group.html
new file mode 100644
index 00000000..93fa66be
--- /dev/null
+++ b/modules-available/remoteaccess/templates/edit-group.html
@@ -0,0 +1,56 @@
+<h2>{{lang_assignLocations}}</h2>
+<h3>{{groupname}}</h3>
+
+<form method="post" action="?do=remoteaccess">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="groupid" value="{{groupid}}">
+
+ <div class="buttonbar pull-right">
+ <button type="submit" class="btn btn-danger" name="action" value="delete-group" data-confirm="{{lang_reallyDelete}}"
+ {{perms.group.locations.disabled}} {{perms.group.edit.disabled}}>
+ <span class="glyphicon glyphicon-remove"></span>
+ {{lang_delete}}
+ </button>
+ <button type="submit" class="btn btn-primary" name="action" value="set-locations" {{perms.group.locations.disabled}}>
+ <span class="glyphicon glyphicon-floppy-disk"></span>
+ {{lang_save}}
+ </button>
+ </div>
+ <div class="clearfix"></div>
+
+ <div class="form-group">
+ <p>{{lang_locationSelectionText}}</p>
+ <table class="table table-condensed table-hover">
+ {{#locations}}
+ <tr>
+ <td class="slx-smallcol">
+ <div class="checkbox checkbox-inline">
+ <input type="checkbox" name="location[]" value="{{locationid}}" id="loc-check-{{locationid}}"
+ {{checked}} {{disabled}}>
+ <label></label>
+ </div>
+ </td>
+ <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}}
+ </table>
+ </div>
+ <div class="buttonbar pull-right">
+ <button type="submit" class="btn btn-primary" name="action" value="set-locations" {{perms.group.locations.disabled}}>
+ <span class="glyphicon glyphicon-floppy-disk"></span>
+ {{lang_save}}
+ </button>
+ </div>
+ <div class="clearfix"></div>
+</form>
diff --git a/modules-available/remoteaccess/templates/edit-settings.html b/modules-available/remoteaccess/templates/edit-settings.html
new file mode 100644
index 00000000..4c4c011a
--- /dev/null
+++ b/modules-available/remoteaccess/templates/edit-settings.html
@@ -0,0 +1,142 @@
+<h2>{{lang_remoteAccessSettings}}</h2>
+
+<h3>{{lang_general}}</h3>
+
+<form method="post" action="?do=remoteaccess">
+ <input type="hidden" name="token" value="{{token}}">
+ <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">
+ <input type="checkbox" name="virt-handover" value="1"
+ id="virt-handover" {{virt-handover_checked}} {{perms.set-proxy-ip.disabled}}>
+ <label for="virt-handover">{{lang_tryVirtualizerHandover}}</label>
+ </div>
+ <p>{{lang_tryVirtualizerText}}</p>
+ </div>
+ <div class="buttonbar pull-right">
+ <button type="submit" class="btn btn-primary" name="action" value="save-settings" {{perms.set-proxy-ip.disabled}}>
+ <span class="glyphicon glyphicon-floppy-disk"></span>
+ {{lang_save}}
+ </button>
+ </div>
+ <div class="clearfix"></div>
+</form>
+
+<h3>{{lang_groups}}</h3>
+
+<form method="post" action="?do=remoteaccess">
+ <input type="hidden" name="token" value="{{token}}">
+ <div class="form-group">
+ <p>{{lang_groupListText}}</p>
+ <table class="table table-condensed table-hover">
+ <thead>
+ <tr>
+ <th></th>
+ <th>{{lang_group}}</th>
+ <th class="text-nowrap" width="10%">{{lang_numLocs}}</th>
+ <th class="text-nowrap" width="10%">{{lang_keepAvailableWol}}</th>
+ <th class="text-nowrap" width="13%">{{lang_password}}</th>
+ </tr>
+ </thead>
+ {{#groups}}
+ <tr>
+ <td class="slx-smallcol">
+ <div class="checkbox checkbox-inline">
+ <input type="checkbox" name="group[{{groupid}}][active]" value="1" id="group-check-{{groupid}}"
+ {{checked}} {{perms.group.edit.disabled}}>
+ <label for="group-check-{{groupid}}"></label>
+ </div>
+ </td>
+ <td class="text-nowrap">
+ <input type="text" class="form-control" name="group[{{groupid}}][groupname]" value="{{groupname}}"
+ {{perms.group.edit.disabled}}>
+ </td>
+ <td class="text-right text-nowrap">
+ <span class="badge">{{locs}}</span>
+ <a href="?do=remoteaccess&amp;groupid={{groupid}}" class="btn btn-xs btn-default">
+ <span class="glyphicon glyphicon-edit"></span>
+ </a>
+ </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}}"
+ {{perms.group.edit.disabled}}>
+ </td>
+ </tr>
+ {{/groups}}
+ </table>
+ </div>
+ <div class="buttonbar pull-right">
+ <button type="submit" class="btn btn-success" name="action" value="add-group" {{perms.group.add.disabled}}>
+ <span class="glyphicon glyphicon-plus"></span>
+ {{lang_add}}
+ </button>
+ <button type="submit" class="btn btn-primary" name="action" value="save-groups" {{perms.group.edit.disabled}}>
+ <span class="glyphicon glyphicon-floppy-disk"></span>
+ {{lang_save}}
+ </button>
+ </div>
+ <div class="clearfix"></div>
+</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/clientscript.js b/modules-available/roomplanner/clientscript.js
index 0ad14a25..45bbaa18 100644
--- a/modules-available/roomplanner/clientscript.js
+++ b/modules-available/roomplanner/clientscript.js
@@ -48,9 +48,9 @@ function renderMachineEntry(item, escape) {
+ ' <div class="machine-body">'
+ ' <div class="machine-entry-header"> ' + escape(item.hostname) + extraText + '</div>'
+ ' <table>'
- + '<tr><td>UUID:</td> <td>' + escape(item.machineuuid) + '</td></tr>'
- + '<tr><td>MAC: </td> <td>' + escape(item.macaddr) + '</td></tr>'
+ '<tr><td>IP: </td> <td>' + escape(item.clientip) + '</td></tr>'
+ + '<tr><td>MAC: </td> <td>' + escape(item.macaddr) + '</td></tr>'
+ + '<tr><td>UUID:</td> <td>' + escape(item.machineuuid) + '</td></tr>'
+ ' </table>'
+ ' </div>'
+ '</div>';
diff --git a/modules-available/roomplanner/hooks/client-update.inc.php b/modules-available/roomplanner/hooks/client-update.inc.php
new file mode 100644
index 00000000..a8ffd555
--- /dev/null
+++ b/modules-available/roomplanner/hooks/client-update.inc.php
@@ -0,0 +1,19 @@
+<?php
+
+// Either poweron or user logged in
+if ($type === '~poweron' || ($type === '~runstate' && $used === 1 && $old['state'] !== 'OCCUPIED')) {
+ // Get all the clients that are managers for rooms this client is a tutor for.
+ // Start in the machine table aliased tut, map $ip to client uuid, then look this
+ // up in the location_roomplan table as tutoruuid, get the according managerip,
+ // look this one up again in machine table aliased mgr to finally get the according
+ // mac address of that manager. We need that for WOL.
+ $managers = Database::queryAll('SELECT mgr.machineuuid, mgr.clientip, mgr.macaddr
+ FROM machine tut
+ INNER JOIN location_roomplan lr ON (tut.machineuuid = lr.tutoruuid)
+ INNER JOIN machine mgr ON (lr.managerip = mgr.clientip)
+ WHERE tut.clientip = :tutorip', ['tutorip' => $ip]);
+ if (!empty($managers) && Module::isAvailable('rebootcontrol')) {
+ // Create WOL job
+ RebootControl::wakeMachines($managers);
+ }
+} \ No newline at end of file
diff --git a/modules-available/roomplanner/images/electricalDevices_wIP/copier-east.png b/modules-available/roomplanner/images/electricalDevices_wIP/copier-east.png
new file mode 100644
index 00000000..e5b6d562
--- /dev/null
+++ b/modules-available/roomplanner/images/electricalDevices_wIP/copier-east.png
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_wIP/copier-north.png b/modules-available/roomplanner/images/electricalDevices_wIP/copier-north.png
new file mode 100644
index 00000000..9d12ff3c
--- /dev/null
+++ b/modules-available/roomplanner/images/electricalDevices_wIP/copier-north.png
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_wIP/copier.png b/modules-available/roomplanner/images/electricalDevices_wIP/copier-south.png
index 24cdc2ae..24cdc2ae 100644
--- a/modules-available/roomplanner/images/electricalDevices_wIP/copier.png
+++ b/modules-available/roomplanner/images/electricalDevices_wIP/copier-south.png
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_wIP/copier-west.png b/modules-available/roomplanner/images/electricalDevices_wIP/copier-west.png
new file mode 100644
index 00000000..3d4dd6ad
--- /dev/null
+++ b/modules-available/roomplanner/images/electricalDevices_wIP/copier-west.png
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_wIP/pc.png b/modules-available/roomplanner/images/electricalDevices_wIP/pc.png
deleted file mode 100644
index d1c9417d..00000000
--- a/modules-available/roomplanner/images/electricalDevices_wIP/pc.png
+++ /dev/null
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_wIP/printer-east.png b/modules-available/roomplanner/images/electricalDevices_wIP/printer-east.png
new file mode 100644
index 00000000..5ebe9866
--- /dev/null
+++ b/modules-available/roomplanner/images/electricalDevices_wIP/printer-east.png
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_wIP/printer-north.png b/modules-available/roomplanner/images/electricalDevices_wIP/printer-north.png
new file mode 100644
index 00000000..675e1788
--- /dev/null
+++ b/modules-available/roomplanner/images/electricalDevices_wIP/printer-north.png
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_wIP/printer.png b/modules-available/roomplanner/images/electricalDevices_wIP/printer-south.png
index ec851e04..ec851e04 100644
--- a/modules-available/roomplanner/images/electricalDevices_wIP/printer.png
+++ b/modules-available/roomplanner/images/electricalDevices_wIP/printer-south.png
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_wIP/printer-west.png b/modules-available/roomplanner/images/electricalDevices_wIP/printer-west.png
new file mode 100644
index 00000000..99033b92
--- /dev/null
+++ b/modules-available/roomplanner/images/electricalDevices_wIP/printer-west.png
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_wIP/telephone-east.png b/modules-available/roomplanner/images/electricalDevices_wIP/telephone-east.png
new file mode 100644
index 00000000..3581ff67
--- /dev/null
+++ b/modules-available/roomplanner/images/electricalDevices_wIP/telephone-east.png
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_wIP/telephone-north.png b/modules-available/roomplanner/images/electricalDevices_wIP/telephone-north.png
new file mode 100644
index 00000000..8c6fb85d
--- /dev/null
+++ b/modules-available/roomplanner/images/electricalDevices_wIP/telephone-north.png
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_wIP/telephone.png b/modules-available/roomplanner/images/electricalDevices_wIP/telephone-south.png
index 207bfe56..207bfe56 100644
--- a/modules-available/roomplanner/images/electricalDevices_wIP/telephone.png
+++ b/modules-available/roomplanner/images/electricalDevices_wIP/telephone-south.png
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_wIP/telephone-west.png b/modules-available/roomplanner/images/electricalDevices_wIP/telephone-west.png
new file mode 100644
index 00000000..89780d44
--- /dev/null
+++ b/modules-available/roomplanner/images/electricalDevices_wIP/telephone-west.png
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_woIP/flatscreen.png b/modules-available/roomplanner/images/electricalDevices_woIP/flatscreen-east.png
index de05797c..de05797c 100644
--- a/modules-available/roomplanner/images/electricalDevices_woIP/flatscreen.png
+++ b/modules-available/roomplanner/images/electricalDevices_woIP/flatscreen-east.png
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_woIP/flatscreen-north.png b/modules-available/roomplanner/images/electricalDevices_woIP/flatscreen-north.png
new file mode 100644
index 00000000..52b650a0
--- /dev/null
+++ b/modules-available/roomplanner/images/electricalDevices_woIP/flatscreen-north.png
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_woIP/flatscreen-south.png b/modules-available/roomplanner/images/electricalDevices_woIP/flatscreen-south.png
new file mode 100644
index 00000000..8c4bfb7f
--- /dev/null
+++ b/modules-available/roomplanner/images/electricalDevices_woIP/flatscreen-south.png
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_woIP/flatscreen-west.png b/modules-available/roomplanner/images/electricalDevices_woIP/flatscreen-west.png
new file mode 100644
index 00000000..39708ba5
--- /dev/null
+++ b/modules-available/roomplanner/images/electricalDevices_woIP/flatscreen-west.png
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_woIP/lamp.png b/modules-available/roomplanner/images/electricalDevices_woIP/lamp-east.png
index 584ea1d9..584ea1d9 100644
--- a/modules-available/roomplanner/images/electricalDevices_woIP/lamp.png
+++ b/modules-available/roomplanner/images/electricalDevices_woIP/lamp-east.png
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_woIP/lamp-north.png b/modules-available/roomplanner/images/electricalDevices_woIP/lamp-north.png
new file mode 100644
index 00000000..e9805690
--- /dev/null
+++ b/modules-available/roomplanner/images/electricalDevices_woIP/lamp-north.png
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_woIP/lamp-south.png b/modules-available/roomplanner/images/electricalDevices_woIP/lamp-south.png
new file mode 100644
index 00000000..96b8bbc1
--- /dev/null
+++ b/modules-available/roomplanner/images/electricalDevices_woIP/lamp-south.png
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_woIP/lamp-west.png b/modules-available/roomplanner/images/electricalDevices_woIP/lamp-west.png
new file mode 100644
index 00000000..1c6203a0
--- /dev/null
+++ b/modules-available/roomplanner/images/electricalDevices_woIP/lamp-west.png
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_woIP/tvcamera.png b/modules-available/roomplanner/images/electricalDevices_woIP/tvcamera-east.png
index c57833b2..c57833b2 100644
--- a/modules-available/roomplanner/images/electricalDevices_woIP/tvcamera.png
+++ b/modules-available/roomplanner/images/electricalDevices_woIP/tvcamera-east.png
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_woIP/tvcamera-north.png b/modules-available/roomplanner/images/electricalDevices_woIP/tvcamera-north.png
new file mode 100644
index 00000000..0ce942e5
--- /dev/null
+++ b/modules-available/roomplanner/images/electricalDevices_woIP/tvcamera-north.png
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_woIP/tvcamera-south.png b/modules-available/roomplanner/images/electricalDevices_woIP/tvcamera-south.png
new file mode 100644
index 00000000..9914e553
--- /dev/null
+++ b/modules-available/roomplanner/images/electricalDevices_woIP/tvcamera-south.png
Binary files differ
diff --git a/modules-available/roomplanner/images/electricalDevices_woIP/tvcamera-west.png b/modules-available/roomplanner/images/electricalDevices_woIP/tvcamera-west.png
new file mode 100644
index 00000000..175502fd
--- /dev/null
+++ b/modules-available/roomplanner/images/electricalDevices_woIP/tvcamera-west.png
Binary files differ
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 3646ae6a..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
}
@@ -164,6 +167,7 @@ class PvsGenerator
ConfigHolder::add("SLX_ADDONS", false, 100000);
ConfigHolder::add("SLX_PVS_DEDICATED", 'yes');
ConfigHolder::add("SLX_AUTOLOGIN", 'ON', 100000);
+ ConfigHolder::add("SLX_LOGOUT_TIMEOUT", false, 100000);
} else {
ConfigHolder::add("SLX_PVS_HYBRID", 'yes');
}
@@ -172,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/js/grid.js b/modules-available/roomplanner/js/grid.js
index 697d7c3f..9157faa5 100644
--- a/modules-available/roomplanner/js/grid.js
+++ b/modules-available/roomplanner/js/grid.js
@@ -123,12 +123,20 @@ if (!roomplanner) var roomplanner = {
});
}
},
- initTooltip: function(el) {
+ initTooltip: function (el) {
if ($(el).attr('itemtype') === 'pc') {
- var tip = "<b>Rechnerdaten</b><br>";
- $(roomplanner.computerAttributes).each(function(i,key){
- tip += __(key)+": "+$(el).attr(key)+"<br>";
+ var tip = '<table>';
+ $(roomplanner.computerAttributes).each(function (i, key) {
+ tip += '<tr>';
+ if (key === 'hostname') {
+ tip += '<td colspan="2">' + $(el).attr(key) + '</td>';
+ } else {
+ tip += '<td>' + __(key) + ": " + '</td>';
+ tip += '<td>' + $(el).attr(key) + '</td>';
+ }
+ tip += '</tr>';
});
+ tip += '</table>';
$(el).attr('title', tip).attr('data-toggle', 'tooltip');
$(el).tooltip({html: true, container: 'body'});
diff --git a/modules-available/roomplanner/js/init.js b/modules-available/roomplanner/js/init.js
index 7cada0dd..79f8e17e 100644
--- a/modules-available/roomplanner/js/init.js
+++ b/modules-available/roomplanner/js/init.js
@@ -6,10 +6,10 @@ function initRoomplanner() {
$('#drawarea').css('left',(-roomplanner.settings.scale*10)+'px');
roomplanner.computerAttributes = [
- "muuid",
- "mac_address",
- "ip",
- "hostname"
+ "hostname",
+ "ip",
+ "mac_address",
+ "muuid"
];
$("#loadButton").click(function() {
@@ -66,11 +66,11 @@ function initRoomplanner() {
}
var translation = {
- "muuid" : "Machine UUID",
- "mac_address" : "MAC Adresse",
- "ip" : "IP Adresse",
- "hostname": "Rechnername",
-
+ "muuid" : "UUID",
+ "mac_address" : "MAC",
+ "ip" : "IP",
+ "hostname": "Hostname",
+
"wall-horizontal" : "Wand (horizontal)",
"wall-vertical" : "Mauer (vertikal)",
"window-horizontal" : "Fenster",
diff --git a/modules-available/roomplanner/js/lib/jquery-ui-draggable-collision.js b/modules-available/roomplanner/js/lib/jquery-ui-draggable-collision.js
index 3ef553b1..8aa351b9 100644
--- a/modules-available/roomplanner/js/lib/jquery-ui-draggable-collision.js
+++ b/modules-available/roomplanner/js/lib/jquery-ui-draggable-collision.js
@@ -377,7 +377,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
// that the jquery-collision plugin takes into account during the calculations
// * Also, the Coords() values get populated with these offsets at various times, so that they reflect "intended position"
//
- // Note also that the collider, obstacle, and direction data fields are temporarily overriden (because we need them here,
+ // Note also that the collider, obstacle, and direction data fields are temporarily overridden (because we need them here,
// and the user may not have asked for them), and then erased and placed where the user wants them, right before
// sending out the collision events
//
@@ -385,7 +385,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
// something relative, it has to get translated right before sending out the events...
function handleCollide( event, ui )
{
- // Note that $(this) is the draggable that's moving - it has a ui.position that moves acording to where
+ // Note that $(this) is the draggable that's moving - it has a ui.position that moves according to where
// the draggable is "about to move". However, our "collidable" objects might not be the same as $(this) -
// they might be child elements. So we need to keep track of recent and present position so we can apply the
// "intended" dx and dy to all the moving elements:
diff --git a/modules-available/roomplanner/lang/de/messages.json b/modules-available/roomplanner/lang/de/messages.json
index 3943c2c3..dca2c770 100644
--- a/modules-available/roomplanner/lang/de/messages.json
+++ b/modules-available/roomplanner/lang/de/messages.json
@@ -1,5 +1,7 @@
{
+ "db-error": "Datenbankfehler",
"invalid-tutor-uuid": "Ung\u00fcltige ID f\u00fcr den Tutor festgelegt",
"json-data-invalid": "\u00dcbermittelte Daten sind kein g\u00fcltiges JSON",
+ "leaf-mode-mismatch": "Falscher Raumtyp: Versuche zusammengesetzten Raum als normalen Raum zu speichern, oder umgekehrt!",
"need-locationid": "Keine locationid angegeben"
} \ No newline at end of file
diff --git a/modules-available/roomplanner/lang/de/template-tags.json b/modules-available/roomplanner/lang/de/template-tags.json
index 3d9db40c..967322e6 100644
--- a/modules-available/roomplanner/lang/de/template-tags.json
+++ b/modules-available/roomplanner/lang/de/template-tags.json
@@ -17,16 +17,22 @@
"lang_classroomdeskchair": "Klassenzimmertisch mit Stuhl",
"lang_classroomtable": "Klassenzimmertisch",
"lang_coatrack": "Garderobe",
+ "lang_composedLayout": "Zusammengesetztes Layout",
"lang_conferencetable": "Konferenztisch",
"lang_confirmDiscardChanges": "Wollen Sie alle \u00c4nderungen verwerfen?",
+ "lang_controlRoomDesc": "Dies ist der Raum, dessen PVS-Manager bei logischer Zusammenlegung der Unterr\u00e4ume der kontrollierende PVS-Manager sein wird.",
+ "lang_controllingRoom": "Kontrollierender Raum",
"lang_couch": "Couch",
"lang_dedicatedManager": "Exklusiv",
"lang_descriptionBySearch": "Hier k\u00f6nnen aus der Liste aller bekannter Rechner suchen.",
"lang_descriptionBySubnet": "Hier sehen Sie Computer, die sich in den zum Raum geh\u00f6renden Subnetzen befinden.",
"lang_deskLamp": "Tischlampe",
"lang_door": "T\u00fcr",
+ "lang_editComposedRoom": "Zusammengesetzten Raum bearbeiten",
+ "lang_exposeAsComposedRoom": "Als zusammengesetzten Raum im PVS-Manager anbieten",
"lang_flatscreen": "Flatscreeen",
"lang_greenchair": "Gr\u00fcner Stuhl",
+ "lang_horizontal": "Horizontal",
"lang_labelBySearch": "Alle Rechner",
"lang_labelBySubnet": "Rechner im Subnetz",
"lang_lecturetheaterrow": "Stuhlreihe",
@@ -53,5 +59,6 @@
"lang_titleAddMachine": "Rechner hinzuf\u00fcgen",
"lang_titleBySearch": "Suche",
"lang_titleBySubnet": "Subnetz",
+ "lang_vertical": "Vertikal",
"lang_wastecan": "M\u00fclleimer"
} \ No newline at end of file
diff --git a/modules-available/roomplanner/lang/en/messages.json b/modules-available/roomplanner/lang/en/messages.json
index b43fc951..0b90ec1d 100644
--- a/modules-available/roomplanner/lang/en/messages.json
+++ b/modules-available/roomplanner/lang/en/messages.json
@@ -1,5 +1,7 @@
{
+ "db-error": "Database error",
"invalid-tutor-uuid": "Invalid ID for tutor",
"json-data-invalid": "Submitted data is no valid JSON",
+ "leaf-mode-mismatch": "Wrong room type: Trying to save composed room as normal room, or vice versa!",
"need-locationid": "No locationid given"
} \ No newline at end of file
diff --git a/modules-available/roomplanner/lang/en/template-tags.json b/modules-available/roomplanner/lang/en/template-tags.json
index dd055d0a..6e5bdfee 100644
--- a/modules-available/roomplanner/lang/en/template-tags.json
+++ b/modules-available/roomplanner/lang/en/template-tags.json
@@ -17,16 +17,22 @@
"lang_classroomdeskchair": "classroom desk with chair",
"lang_classroomtable": "classroom table",
"lang_coatrack": "coatrack",
+ "lang_composedLayout": "Composed layout",
"lang_conferencetable": "conference table",
"lang_confirmDiscardChanges": "Do you want to discard all changes?",
+ "lang_controlRoomDesc": "The selected room will be the one where the PVS manager will be picked from. The other room's PVS manager will *not* be usable in the composed setup.",
+ "lang_controllingRoom": "Comtrolling room",
"lang_couch": "couch",
"lang_dedicatedManager": "Dedicated",
"lang_descriptionBySearch": "Select a computer from a list of all known computers here.",
"lang_descriptionBySubnet": "Select a computer from a related subnet.",
"lang_deskLamp": "desk lamp",
"lang_door": "door",
+ "lang_editComposedRoom": "Edit composed room",
+ "lang_exposeAsComposedRoom": "Expose as composed room (in PVS manger's list of rooms)",
"lang_flatscreen": "flatscreen",
"lang_greenchair": "green chair",
+ "lang_horizontal": "Horizontally",
"lang_labelBySearch": "all computers",
"lang_labelBySubnet": "computers in subnet",
"lang_lecturetheaterrow": "lecture theater row",
@@ -53,5 +59,6 @@
"lang_titleAddMachine": "Add Machine",
"lang_titleBySearch": "Search",
"lang_titleBySubnet": "Subnet",
+ "lang_vertical": "Vertically",
"lang_wastecan": "waste can"
} \ No newline at end of file
diff --git a/modules-available/roomplanner/page.inc.php b/modules-available/roomplanner/page.inc.php
index 52ad34f3..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');
}
@@ -79,7 +79,12 @@ class Page_Roomplanner extends Page
private function showLeafEditor()
{
- $config = Database::queryFirst('SELECT roomplan, managerip, tutoruuid FROM location_roomplan WHERE locationid = :locationid', ['locationid' => $this->locationid]);
+ $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;
@@ -90,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']);
@@ -103,13 +102,15 @@ 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),
'edit_disabled' => $canEdit ? '' : 'disabled',
- 'statistics_disabled' => Module::get('statistics') !== false && User::hasPermission('.statistics.machine.view-details') ? '' : 'disabled',
+ 'statistics_disabled' =>
+ (Module::get('statistics') !== false && User::hasPermission('.statistics.machine.view-details'))
+ ? '' : 'disabled',
];
Render::addTemplate('header', $params);
if ($canEdit) {
@@ -122,9 +123,10 @@ class Page_Roomplanner extends Page
private function showComposedEditor()
{
// Load settings
- $row = Database::queryFirst("SELECT locationid, roomplan FROM location_roomplan WHERE locationid = :lid", [
- 'lid' => $this->locationid,
- ]);
+ $row = Database::queryFirst("SELECT locationid, roomplan
+ FROM location_roomplan
+ WHERE locationid = :lid",
+ ['lid' => $this->locationid]);
$room = new ComposedRoom($row);
$params = [
'location' => $this->location,
@@ -161,7 +163,7 @@ class Page_Roomplanner extends Page
$this->action = Request::any('action', false, 'string');
if ($this->action === 'getmachines') {
-
+ // Load suggestions when typing in the search box of the "add machine" pop-up
User::load();
$locations = User::getAllowedLocations('edit');
if (empty($locations)) {
@@ -190,8 +192,7 @@ class Page_Roomplanner extends Page
$returnObject = ['machines' => []];
- while ($row = $result->fetch(PDO::FETCH_ASSOC)) {
- error_log("$roomLocationId, {$row['subnetlocationid']}");
+ foreach ($result as $row) {
if (!Location::isFixedLocationValid($roomLocationId, $row['subnetlocationid']))
continue;
if (empty($row['hostname'])) {
@@ -203,11 +204,12 @@ class Page_Roomplanner extends Page
}
echo json_encode($returnObject);
} 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);
@@ -224,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);
@@ -245,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)) {
@@ -258,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);
@@ -277,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");
}
}
@@ -293,11 +290,15 @@ class Page_Roomplanner extends Page
}
}
- protected function saveComputerConfig($computers, $oldComputers)
+ /**
+ * @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(array $computers, array $oldComputers)
{
$oldUuids = [];
- /* collect all uuids from the old computers */
+ /* collect all uuids from the old roomplan */
foreach ($oldComputers['computers'] as $c) {
$oldUuids[] = $c['muuid'];
}
@@ -314,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'],
@@ -330,6 +331,7 @@ class Page_Roomplanner extends Page
['locationid' => $this->locationid, 'muuid' => $computer['muuid'], 'position' => $position]);
}
+ // Get all computers that were removed from the roomplan and reset their data in DB
$toDelete = array_diff($oldUuids, $newUuids);
foreach ($toDelete as $d) {
@@ -337,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');
@@ -348,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');
@@ -366,22 +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',
+ $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'])) {
@@ -408,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
@@ -417,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/style.css b/modules-available/roomplanner/style.css
index 8f516465..0ee6d923 100644
--- a/modules-available/roomplanner/style.css
+++ b/modules-available/roomplanner/style.css
@@ -112,11 +112,6 @@ div.vertical div.img {
[itemlook="door-wn"] { background: url('images/wall/door-wn.png') no-repeat;}
[itemlook="door-ws"] { background: url('images/wall/door-ws.png') no-repeat;}
-[itemlook="copier"] {
- background: url('images/electricalDevices_wIP/copier.png') no-repeat;
- }
-
-
[itemlook|="pc"] {
border: 1px solid #999;
border-radius: 4px;
@@ -139,30 +134,102 @@ div.vertical div.img {
}
-[itemlook="printer"] {
- background: url('images/electricalDevices_wIP/printer.png') no-repeat;
+[itemlook="copier-east"] {
+ background: url('images/electricalDevices_wIP/copier-east.png') no-repeat;
+}
- }
+[itemlook="copier-south"] {
+ background: url('images/electricalDevices_wIP/copier-south.png') no-repeat;
+}
-[itemlook="telephone"] {
- background: url('images/electricalDevices_wIP/telephone.png') no-repeat;
+[itemlook="copier-west"] {
+ background: url('images/electricalDevices_wIP/copier-west.png') no-repeat;
+}
- }
+[itemlook="copier-north"] {
+ background: url('images/electricalDevices_wIP/copier-north.png') no-repeat;
+}
-[itemlook="flatscreen"] {
- background: url('images/electricalDevices_woIP/flatscreen.png') no-repeat;
+[itemlook="printer-east"] {
+ background: url('images/electricalDevices_wIP/printer-east.png') no-repeat;
+}
- }
+[itemlook="printer-south"] {
+ background: url('images/electricalDevices_wIP/printer-south.png') no-repeat;
+}
-[itemlook="lamp"] {
- background: url('images/electricalDevices_woIP/lamp.png') no-repeat;
+[itemlook="printer-west"] {
+ background: url('images/electricalDevices_wIP/printer-west.png') no-repeat;
+}
- }
+[itemlook="printer-north"] {
+ background: url('images/electricalDevices_wIP/printer-north.png') no-repeat;
+}
-[itemlook="tvcamera"] {
- background: url('images/electricalDevices_woIP/tvcamera.png') no-repeat;
+[itemlook="telephone-east"] {
+ background: url('images/electricalDevices_wIP/telephone-east.png') no-repeat;
+}
+
+[itemlook="telephone-south"] {
+ background: url('images/electricalDevices_wIP/telephone-south.png') no-repeat;
+}
+
+[itemlook="telephone-west"] {
+ background: url('images/electricalDevices_wIP/telephone-west.png') no-repeat;
+}
+
+[itemlook="telephone-north"] {
+ background: url('images/electricalDevices_wIP/telephone-north.png') no-repeat;
+}
- }
+
+[itemlook="flatscreen-east"] {
+ background: url('images/electricalDevices_woIP/flatscreen-east.png') no-repeat;
+}
+
+[itemlook="flatscreen-south"] {
+ background: url('images/electricalDevices_woIP/flatscreen-south.png') no-repeat;
+}
+
+[itemlook="flatscreen-west"] {
+ background: url('images/electricalDevices_woIP/flatscreen-west.png') no-repeat;
+}
+
+[itemlook="flatscreen-north"] {
+ background: url('images/electricalDevices_woIP/flatscreen-north.png') no-repeat;
+}
+
+[itemlook="lamp-east"] {
+ background: url('images/electricalDevices_woIP/lamp-east.png') no-repeat;
+}
+
+[itemlook="lamp-south"] {
+ background: url('images/electricalDevices_woIP/lamp-south.png') no-repeat;
+}
+
+[itemlook="lamp-west"] {
+ background: url('images/electricalDevices_woIP/lamp-west.png') no-repeat;
+}
+
+[itemlook="lamp-north"] {
+ background: url('images/electricalDevices_woIP/lamp-north.png') no-repeat;
+}
+
+[itemlook="tvcamera-east"] {
+ background: url('images/electricalDevices_woIP/tvcamera-east.png') no-repeat;
+}
+
+[itemlook="tvcamera-south"] {
+ background: url('images/electricalDevices_woIP/tvcamera-south.png') no-repeat;
+}
+
+[itemlook="tvcamera-west"] {
+ background: url('images/electricalDevices_woIP/tvcamera-west.png') no-repeat;
+}
+
+[itemlook="tvcamera-north"] {
+ background: url('images/electricalDevices_woIP/tvcamera-north.png') no-repeat;
+}
[itemlook="4chairs1squaretable"] {
background: url('images/furniture/4chairs1squaretable.png') no-repeat;
@@ -795,7 +862,7 @@ div.ui-draggable:hover > .pcHandle {
border-top: 1px solid #bbb;
}
-.machine-entry-header {
+div.machine-entry-header {
font-weight: bolder;
font-size: 18px;
}
@@ -804,3 +871,31 @@ div.ui-draggable:hover > .pcHandle {
max-height : 600px;
}
+.tooltip-inner {
+ min-width: 300px;
+ max-width: 900px;
+}
+
+.tooltip-inner table {
+ border-collapse:collapse;
+ text-align: left;
+ margin-bottom: 10px;
+}
+
+.tooltip-inner tr {
+ border-top: 1px solid #bbb;
+}
+
+.tooltip-inner td {
+ display:table-cell;
+ padding: 2px;
+}
+
+.tooltip-inner tr:first-child {
+ font-weight: bold;
+ font-size: 16px;
+ padding-bottom: 5px;
+ white-space: nowrap;
+ border: none;
+ text-align: center;
+}
diff --git a/modules-available/roomplanner/templates/item-selector.html b/modules-available/roomplanner/templates/item-selector.html
index 72607e7c..f4680cf1 100644
--- a/modules-available/roomplanner/templates/item-selector.html
+++ b/modules-available/roomplanner/templates/item-selector.html
@@ -34,7 +34,7 @@
</li>
<li>
<div itemtype="furniture" scalable="v" itemlook="wall-vertical" class="draggable"
- style="width:25px; height:100px;" data-height="100" data-width="25" title="Wand (vertial)"></div>
+ style="width:25px; height:100px;" data-height="100" data-width="25" title="Wand (vertikal)"></div>
</li>
<li>
<div itemtype="furniture" scalable="h" itemlook="window-horizontal" class="draggable"
@@ -90,15 +90,15 @@
data-height="100" data-width="100" title="PC" noresize=1></div>
</li>
<li>
- <div itemtype="pc" itemlook="copier" class="draggable" style="width:100px; height:100px;"
+ <div itemtype="pc" itemlook="copier-south" class="draggable" style="width:100px; height:100px;"
data-height="100" data-width="100" title="{{lang_photocopier}}" noresize=1></div>
</li>
<li>
- <div itemtype="pc" itemlook="printer" class="draggable" style="width:100px; height:100px;"
+ <div itemtype="pc" itemlook="printer-south" class="draggable" style="width:100px; height:100px;"
data-height="100" data-width="100" title="{{lang_printer}}" noresize=1></div>
</li>
<li>
- <div itemtype="pc" itemlook="telephone" class="draggable" style="width:100px; height:100px;"
+ <div itemtype="pc" itemlook="telephone-south" class="draggable" style="width:100px; height:100px;"
data-height="100" data-width="100" title="{{lang_telephone}}" noresize=1></div>
</li>
</ul>
@@ -107,15 +107,15 @@
<div role="tabpanel" class="tab-pane" id="electricaldevices">
<ul class="toollist">
<li>
- <div itemtype="furniture" itemlook="flatscreen" class="draggable" style="width:75px; height:100px;"
+ <div itemtype="furniture" itemlook="flatscreen-east" class="draggable" style="width:75px; height:100px;"
data-height="100" data-width="75" title="{{lang_flatscreen}}"></div>
</li>
<li>
- <div itemtype="furniture" itemlook="lamp" class="draggable" style="width:125px; height:50px;"
+ <div itemtype="furniture" itemlook="lamp-east" class="draggable" style="width:125px; height:50px;"
data-height="50" data-width="125" title="{{lang_deskLamp}}"></div>
</li>
<li>
- <div itemtype="furniture" itemlook="tvcamera" class="draggable" style="width:125px; height:50px;"
+ <div itemtype="furniture" itemlook="tvcamera-east" class="draggable" style="width:125px; height:50px;"
data-height="50" data-width="125" title="{{lang_projector}}"></div>
</li>
</ul>
@@ -311,4 +311,4 @@
</div>
</div>
-</div> \ No newline at end of file
+</div>
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 8ea2b2a6..a5de1053 100644
--- a/modules-available/runmode/baseconfig/getconfig.inc.php
+++ b/modules-available/runmode/baseconfig/getconfig.inc.php
@@ -1,22 +1,23 @@
<?php
-$foofoo = 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']);
-};
-
-$foofoo($uuid); \ No newline at end of file
+}
diff --git a/modules-available/runmode/inc/runmode.inc.php b/modules-available/runmode/inc/runmode.inc.php
index 174fb675..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) {
@@ -69,16 +69,24 @@ class RunMode
}
/**
- * @param string $machineuuid
+ * 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 should this machine be considered a normal client?
+ */
+ 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 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';
@@ -115,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();
@@ -127,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();
@@ -141,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();
@@ -165,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'];
}
@@ -180,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) {
@@ -192,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;
@@ -206,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();
@@ -224,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();
@@ -272,10 +281,6 @@ class RunModeModuleConfig
*/
public $isClient = false;
/**
- * @var bool If true, config.tgz should not be downloaded by the client
- */
- public $noSysconfig = false;
- /**
* @var bool Allow adding and removing machines to this mode via the generic form
*/
public $allowGenericEditor = true;
@@ -289,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))
@@ -300,25 +305,21 @@ class RunModeModuleConfig
$this->loadType($data, 'getModeName', 'string');
$this->loadType($data, 'configHook', 'string');
$this->loadType($data, 'isClient', 'boolean');
- $this->loadType($data, 'noSysconfig', 'boolean');
$this->loadType($data, 'allowGenericEditor', 'boolean');
$this->loadType($data, 'deleteUrlSnippet', 'string');
$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 f8b48152..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,17 +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
- if ($rows['config']->userHasPermission(null)) {
- $disabled = '';
- } elseif (User::hasPermission('list-all')) {
+ // in $this->renderModule()
+ /** @var array{config: RunModeModuleConfig} $rows */
+ if (!$rows['config']->userHasPermission(null)) {
+ if (!User::hasPermission('list-all'))
+ continue;
$disabled = 'disabled';
- } else {
- continue;
} // </Permissions>
}
+ $anythingOk = true;
$module = Module::get($moduleId);
if ($module === false)
continue;
@@ -239,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);
@@ -275,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) {
@@ -309,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
@@ -333,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 c3804a03..dc78f481 100644
--- a/modules-available/serversetup-bwlp-ipxe/api.inc.php
+++ b/modules-available/serversetup-bwlp-ipxe/api.inc.php
@@ -1,273 +1,58 @@
<?php
-// Menu mode
-
-$serverIp = Property::getServerIp();
-
-// Check if required arguments are given; if not, spit out according script and chain to self
-$uuid = Request::any('uuid', false, 'string');
-// Get platform - EFI or PCBIOS
-$platform = Request::any('platform', false, 'string');
-$manuf = Request::any('manuf', false, 'string');
-$product = Request::any('product', false, 'string');
-$slxExtensions = Request::any('slx-extensions', false, 'int');
-
-if ($platform === false || ($uuid === false && $product === false) || $slxExtensions === false) {
- // Redirect to self with added parameters
- $url = parse_url($_SERVER['REQUEST_URI']);
- if (isset($_SERVER['SCRIPT_URI']) && preg_match('#^(\w+://[^/]+)#', $_SERVER['SCRIPT_URI'], $out)) {
- $urlbase = $out[1];
- } elseif (isset($_SERVER['REQUEST_SCHEME']) && isset($_SERVER['SERVER_NAME'])) {
- $urlbase = $_SERVER['REQUEST_SCHEME'] . '://' . $_SERVER['SERVER_NAME'];
- } elseif (isset($_SERVER['REQUEST_SCHEME']) && isset($_SERVER['SERVER_ADDR'])) {
- $urlbase = $_SERVER['REQUEST_SCHEME'] . '://' . $_SERVER['SERVER_ADDR'];
- } else {
- $urlbase = 'http://' . $serverIp;
- }
- $urlbase .= $url['path'];
- if (empty($url['query'])) {
- $arr = [];
- } else {
- parse_str($url['query'], $arr);
- foreach ($arr as &$v) {
- $v = urlencode($v);
+(function() {
+ $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';
}
- unset($v);
- }
- $arr['uuid'] = '${uuid}';
- $arr['mac'] = '${mac}';
- $arr['manuf'] = '${manufacturer:uristring}';
- $arr['product'] = '${product:uristring}';
- $arr['platform'] = '${platform:uristring}';
- $query = '?';
- foreach ($arr as $k => $v) {
- $query .= $k . '=' . $v . '&';
- }
- //$query = substr($query, 0, -1);
- echo <<<HERE
-#!ipxe
-set slxtest:string something ||
-iseq \${slxtest:md5} \${} && set slxext 0 || set slxext 1 ||
-clear slxtest ||
-set self {$urlbase}{$query}slx-extensions=\${slxext}
-:retry
-echo Chaining to \${self}
-chain -ar \${self} ||
-echo Chaining to self failed with \${errno}, retrying in a bit...
-sleep 5
-goto retry
-HERE;
- exit;
-}
-// ipxe has it lowercase, but we use uppercase
-$platform = strtoupper($platform);
-if ($platform !== 'PCBIOS' && $platform !== 'EFI') {
- $platform = 'PCBIOS'; // Just hope for the best?
-}
-
-$BOOT_METHODS = Localboot::BOOT_METHODS[$platform];
-
-$ip = $_SERVER['REMOTE_ADDR'];
-if (substr($ip, 0, 7) === '::ffff:') {
- $ip = substr($ip, 7);
-}
-$menu = Request::get('menuid', false, 'int');
-if ($menu !== false) {
- $menu = new IPxeMenu($menu);
- $initLabel = 'slx_menu';
-} else {
- $menu = IPxeMenu::forClient($ip, $uuid);
- $initLabel = 'init';
-}
-// 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->getDefaultScriptLabel()) !== false) {
- $directBoot = "goto $tmp ||";
- $initLabel = 'init';
-} else {
- $directBoot = '';
-}
-
-// Get preferred localboot method, depending on system model
-$localboot = false;
-$model = false;
-if ($uuid !== false && Module::get('statistics') !== false) {
- // If we have the machine table, we rather try to look up the system model from there, using the UUID
- $row = Database::queryFirst('SELECT systemmodel FROM machine WHERE machineuuid = :uuid', ['uuid' => $uuid]);
- if ($row !== false && !empty($row['systemmodel'])) {
- $model = $row['systemmodel'];
- }
-}
-if ($model === false) {
- // Otherwise use what iPXE sent us
- function modfilt($str)
- {
- if (empty($str) || preg_match('/product\s+name|be\s+filled|unknown|default\s+string|system\s+model|manufacturer/i', $str))
- return false;
- return trim(preg_replace('/\s+/', ' ', $str));
}
- $manuf = modfilt($manuf);
- $product = modfilt($product);
- if (!empty($product)) {
- $model = $product;
- if (!empty($manuf)) {
- $model .= " ($manuf)";
+ if ($type === 'bash') {
+ $builder = new ScriptBuilderBash();
+ } elseif ($type === 'grub') {
+ $builder = new ScriptBuilderGrub();
+ } else {
+ $builder = new ScriptBuilderIpxe();
+ }
+ $bootEntryId = Request::get('beid', false, 'string');
+ if ($bootEntryId !== false) {
+ $bootEntryId = Util::cleanUtf8($bootEntryId);
+ }
+ $entryId = Request::get('entryid', false, 'int');
+ if ($bootEntryId !== false) {
+ $entry = BootEntry::fromDatabaseId($bootEntryId);
+ $data = $builder->getBootEntry($entry);
+ } elseif ($entryId !== false) {
+ $entry = MenuEntry::get($entryId);
+ $data = $builder->getMenuEntry($entry);
+ } else {
+ // Get bootstrap code if required...
+ $data = $builder->bootstrapLive();
+ if ($data === false) {
+ // ...otherwise, generate normal code
+ $menuId = Request::get('menuid', false, 'int');
+ if ($menuId !== false) {
+ $menu = IPxeMenu::get($menuId, true);
+ } else {
+ $menu = IPxeMenu::forClient($builder->clientIp(), $builder->uuid());
+ }
+ $data = null;
+ if ($menu->itemCount() === 1 && $menu->timeoutMs() === 0) {
+ // One entry, no timeout -- direct boot
+ $entry = $menu->defaultEntry();
+ if ($entry !== null) {
+ $data = $builder->getMenuEntry($entry);
+ }
+ }
+ if ($data === null) {
+ // Show menu
+ $data = $builder->getMenu($menu, true);
+ }
}
}
-}
-// Query
-if ($model !== false) {
- $e = strtolower($platform); // We made sure $platform is either PCBIOS or EFI, so no injection possible
- $row = Database::queryFirst("SELECT $e AS bootmethod FROM serversetup_localboot WHERE systemmodel = :model LIMIT 1",
- ['model' => $model]);
- if ($row !== false) {
- $localboot = $row['bootmethod'];
- }
-}
-if ($localboot === false || !isset($BOOT_METHODS[$localboot])) {
- $localboot = Localboot::getDefault()[$platform];
- if (!isset($BOOT_METHODS[$localboot])) {
- $localboot = array_keys($BOOT_METHODS)[0];
- }
-}
-// Convert to actual ipxe code
-if (isset($BOOT_METHODS[$localboot])) {
- $localboot = $BOOT_METHODS[$localboot];
-} else {
- $localboot = 'prompt Localboot not possible';
-}
-
-if ($slxExtensions) {
- $slxConsoleUpdate = '--update';
- $slxPasswordOnly = '--nouser';
-} else {
- $slxConsoleUpdate = '';
- $slxPasswordOnly = '';
-}
-
-$output = <<<HERE
-#!ipxe
-
-goto $initLabel || goto fail ||
-
-# functions
-
-# password check with gotos
-# set slx_hash to the expected hash
-# slx_salt to the salt to use
-# slx_pw_ok to the label to jump on success
-# slx_pw_fail to label for wrong pw
-:slx_pass_check
-login $slxPasswordOnly ||
-set slxtmp_pw \${password:md5}-\${slx_salt} || goto fail
-set slxtmp_pw \${slxtmp_pw:md5} || goto fail
-clear password ||
-iseq \${slxtmp_pw} \${slx_hash} || prompt Wrong password. Press a key. ||
-iseq \${slxtmp_pw} \${slx_hash} || goto \${slx_pw_fail} ||
-iseq \${slxtmp_pw} \${slx_hash} && goto \${slx_pw_ok} ||
-goto fail
-
-:slx_localboot
-imgfree ||
-console ||
-$localboot || goto fail
-
-# start
-:init
-
-set ipappend1 ip=\${ip}:{$serverIp}:\${gateway}:\${netmask}
-set ipappend2 BOOTIF=01-\${mac:hexhyp}
-set serverip $serverIp ||
-iseq \${idx} \${} && set idx:string X ||
-
-# Clean up in case we've been chained to
-imgfree ||
-
-$directBoot
-
-imgfetch --name bg-menu /tftp/pxe-menu.png ||
-
-:start
-
-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
-
-:slx_menu
-
-iseq \${serverip} \${} || goto ip_check_ok
-goto init
-:ip_check_ok
-
-console --left 55 --top 88 --right 63 --bottom 64 $slxConsoleUpdate --keep --picture bg-menu ||
-
-HERE;
-
-$output .= $menu->getMenuDefinition('target', $platform, $slxExtensions);
-
-$output .= <<<HERE
-
-console --left 60 --top 130 --right 67 --bottom 86 $slxConsoleUpdate ||
-goto \${target} ||
-echo Could not find menu entry in script.
-prompt Press any key to continue.
-goto start
-
-HERE;
-
-$output .= $menu->getItemsCode($platform);
-
-/*
-
-: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
-
-*/
-
-$output .= <<<HERE
-:fail
-prompt Boot failed. Press any key to start.
-goto init
-HERE;
-
-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 ($platform === 'EFI') {
- $cs = 'ASCII';
-} else {
- $cs = 'IBM437';
-}
-Header('Content-Type: text/plain; charset=' . $cs);
-
-echo iconv('UTF-8', $cs . '//TRANSLIT//IGNORE', $output);
+ $builder->output($data);
+})();
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 dec70528..5812c0cd 100644
--- a/modules-available/serversetup-bwlp-ipxe/inc/bootentry.inc.php
+++ b/modules-available/serversetup-bwlp-ipxe/inc/bootentry.inc.php
@@ -12,13 +12,28 @@ abstract class BootEntry
/** Supports both via distinct entry */
const BOTH = 'PCBIOS-EFI';
- public abstract function supportsMode($mode);
+ /**
+ * @var string Internal ID - set to your liking, e.g. the MiniLinux version identifier
+ */
+ protected $internalId;
+
+ public function __construct(string $internalId)
+ {
+ $this->internalId = $internalId;
+ }
+
+ public abstract function supportsMode(string $mode): bool;
- public abstract function toScript($failLabel, $mode);
+ public abstract function toScript(ScriptBuilderBase $builder): string;
- public abstract function toArray();
+ public abstract function toArray(): array;
- public abstract function addFormFields(&$array);
+ public abstract function addFormFields(array &$array): void;
+
+ public function internalId(): string
+ {
+ return $this->internalId;
+ }
/*
*
@@ -28,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;
}
@@ -45,26 +60,29 @@ 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);
}
if ($module === '.exec') {
return new StandardBootEntry($data);
}
+ if ($module === '.special') {
+ return new SpecialBootEntry($data);
+ }
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;
@@ -82,9 +100,9 @@ abstract class BootEntry
return $ret;
}
- public static function newCustomBootEntry($initData)
+ public static function newCustomBootEntry($initData): ?CustomBootEntry
{
- if (empty($initData['script']))
+ if (!is_array($initData) || empty($initData))
return null;
return new CustomBootEntry($initData);
}
@@ -92,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']);
}
@@ -110,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;
@@ -135,13 +152,16 @@ class StandardBootEntry extends BootEntry
* @var ExecData same for EFI
*/
protected $efi;
-
- protected $arch; // Constants below
+ /**
+ * @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) {
@@ -214,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;
@@ -241,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;
@@ -257,73 +274,16 @@ class StandardBootEntry extends BootEntry
return false;
}
- public function toScript($failLabel, $mode)
+ public function toScript(ScriptBuilderBase $builder): string
{
- if (!$this->supportsMode($mode)) {
- return "prompt Entry doesn't have an executable for mode $mode\n";
- }
- if ($this->arch === BootEntry::AGNOSTIC || $mode == BootEntry::BIOS) {
- $entry = $this->pcbios;
- } else {
- $entry = $this->efi;
- }
- $entry->sanitize();
-
- $script = '';
- if ($entry->resetConsole) {
- $script .= "console ||\n";
- }
- if ($entry->imageFree) {
- $script .= "imgfree ||\n";
- }
- foreach ($entry->dhcpOptions as $opt) {
- if (empty($opt['value'])) {
- $val = '${}';
- } else {
- if (empty($opt['hex'])) {
- $val = bin2hex($opt['value']);
- } else {
- $val = $opt['value'];
- }
- preg_match_all('/[0-9a-f]{2}/', $val, $out);
- $val = implode(':', $out[0]);
- }
- $script .= 'set net${idx}/' . $opt['opt'] . ':hex ' . $val
- . ' || prompt Cannot override DHCP server option ' . $opt['opt'] . ". Press any key to continue anyways.\n";
- }
- $initrds = [];
- if (!empty($entry->initRd)) {
- foreach (array_values($entry->initRd) as $i => $initrd) {
- if (empty($initrd))
- continue;
- $script .= "initrd --name initrd$i $initrd || goto $failLabel\n";
- $initrds[] = "initrd$i";
- }
- }
- $script .= "boot ";
- if ($entry->autoUnload) {
- $script .= "-a ";
- }
- if ($entry->replace) {
- $script .= "-r ";
- }
- $script .= $entry->executable;
- if (empty($initrds)) {
- $rdBase = '';
- } else {
- $rdBase = " initrd=" . implode(',', $initrds);
- }
- if (!empty($entry->commandLine)) {
- $script .= "$rdBase {$entry->commandLine}";
- }
- $script .= " || goto $failLabel\n";
- if ($entry->resetConsole) {
- $script .= "goto start ||\n";
- }
- return $script;
+ if ($this->arch === BootEntry::AGNOSTIC) // Same as below, could construct fall-through but this is more clear
+ return $builder->execDataToScript($this->pcbios, null, null);
+ return $builder->execDataToScript(null,
+ $this->supportsMode(BootEntry::BIOS) ? $this->pcbios : null,
+ $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);
@@ -331,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(),
@@ -343,65 +306,118 @@ class StandardBootEntry extends BootEntry
class CustomBootEntry extends BootEntry
{
- protected $script;
+ /**
+ * @var string iPXE
+ */
+ protected $ipxe = '';
+
+ protected $bash;
+
+ protected $grub;
public function __construct($data)
{
- if (is_array($data) && isset($data['script'])) {
- $this->script = $data['script'];
+ parent::__construct('custom');
+ if (is_array($data)) {
+ $this->ipxe = $data['script'] ?? ''; // LEGACY
+ foreach (['bash', 'grub'] as $key) {
+ $this->{$key} = $data[$key] ?? '';
+ }
}
}
- public function supportsMode($mode)
+ public function supportsMode(string $mode): bool
{
return true;
}
- public function toScript($failLabel, $mode)
+ public function toScript(ScriptBuilderBase $builder): string
{
- return str_replace('%fail%', $failLabel, $this->script) . "\n";
+ // 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->script,
+ 'script' => $this->ipxe,
];
$array['script_checked'] = 'checked';
}
- public function toArray()
+ /**
+ * @return array{script: string}
+ */
+ public function toArray(): array
{
- return ['script' => $this->script];
+ return ['script' => $this->ipxe];
}
}
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($failLabel, $mode)
+ public function toScript(ScriptBuilderBase $builder): string
{
- return 'chain -ar ${self}&menuid=' . $this->menuId . ' || goto ' . $failLabel . "\n";
+ $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
{
}
}
+class SpecialBootEntry extends BootEntry
+{
+
+ private $type;
+
+ public function __construct($type)
+ {
+ $this->type = $type['type'] ?? $type;
+ parent::__construct('special-' . $this->type);
+ }
+
+ public function supportsMode(string $mode): bool
+ {
+ return true;
+ }
+
+ public function toScript(ScriptBuilderBase $builder): string
+ {
+ return $builder->getSpecial($this->type);
+ }
+
+ public function toArray(): array
+ {
+ return [];
+ }
+
+ public function addFormFields(array &$array): void { }
+
+}
diff --git a/modules-available/serversetup-bwlp-ipxe/inc/bootentryhook.inc.php b/modules-available/serversetup-bwlp-ipxe/inc/bootentryhook.inc.php
index cf180006..ab55c888 100644
--- a/modules-available/serversetup-bwlp-ipxe/inc/bootentryhook.inc.php
+++ b/modules-available/serversetup-bwlp-ipxe/inc/bootentryhook.inc.php
@@ -6,37 +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;
- 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) {
@@ -50,34 +50,48 @@ 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);
}
- public function setSelected($id)
+ /**
+ * @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(string $mixed): void
{
- $json = @json_decode($id, true);
+ $json = @json_decode($mixed, true);
if (is_array($json)) {
$id = $json['id'];
$this->data = $json;
+ } else {
+ $id = $mixed;
}
$this->selectedId = $id;
}
- public function renderExtraFields()
+ /**
+ * @return string ID of entry that was marked as selected by setSelected()
+ */
+ public function getSelected(): string
+ {
+ return $this->selectedId;
+ }
+
+ /**
+ * @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;
@@ -126,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;
@@ -164,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;
@@ -185,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 97f98b94..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 = [];
@@ -159,4 +162,4 @@ class ExecData
];
}
-} \ No newline at end of file
+}
diff --git a/modules-available/serversetup-bwlp-ipxe/inc/ipxe.inc.php b/modules-available/serversetup-bwlp-ipxe/inc/ipxe.inc.php
index 4c2a7678..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 {
@@ -318,9 +316,10 @@ class IPxe
if (empty($title)) {
$title = $label;
}
- Database::exec('INSERT IGNORE INTO serversetup_bootentry (entryid, hotkey, title, builtin, data)
- VALUES (:label, :hotkey, :title, 0, :data)', [
+ 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',
'hotkey' => $hotkey,
'title' => $title,
'data' => json_encode($data),
@@ -332,52 +331,43 @@ class IPxe
public static function createDefaultEntries()
{
- Database::exec( 'INSERT IGNORE INTO serversetup_bootentry (entryid, hotkey, title, builtin, data)
- VALUES (:entryid, :hotkey, :title, 1, :data) ON DUPLICATE KEY UPDATE data = VALUES(data)',
+ $query = 'INSERT IGNORE INTO serversetup_bootentry (entryid, hotkey, title, builtin, module, data)
+ VALUES (:entryid, :hotkey, :title, 1, :module, :data)
+ ON DUPLICATE KEY UPDATE builtin = 1, module = VALUES(module), data = VALUES(data)';
+ Database::exec($query,
[
'entryid' => 'bwlp-default',
'hotkey' => 'B',
'title' => 'bwLehrpool-Umgebung starten',
+ 'module' => 'minilinux',
'data' => json_encode([
- 'script' => '
-imgfree ||
-set slxextra initrd=logo ||
-initrd /boot/default/initramfs-stage31 || goto fail
-initrd --name logo /tftp/bwlp.cpio || clear slxextra
-boot -a -r /boot/default/kernel initrd=initramfs-stage31 ${slxextra} slxbase=boot/default quiet splash loglevel=5 rd.systemd.show_status=auto intel_iommu=igfx_off ${ipappend1} ${ipappend2} || goto fail
-',
+ 'id' => 'default',
+ 'kcl-extra' => '',
+ 'debug' => false,
]),
]);
- $query = 'INSERT IGNORE INTO serversetup_bootentry (entryid, hotkey, title, builtin, data)
- VALUES (:entryid, :hotkey, :title, 1, :data) ON DUPLICATE KEY UPDATE data = VALUES(data)';
Database::exec($query,
[
'entryid' => 'bwlp-default-dbg',
'hotkey' => 'D',
'title' => 'bwLehrpool-Umgebung starten (nosplash, debug output)',
+ 'module' => 'minilinux',
'data' => json_encode([
- 'executable' => ['PCBIOS' => '/boot/default/kernel'],
- 'initRd' => ['PCBIOS' => ['/boot/default/initramfs-stage31']],
- 'commandLine' => ['PCBIOS' => 'slxbase=boot/default loglevel=7 intel_iommu=igfx_off ${ipappend1} ${ipappend2}'],
- 'replace' => true,
- 'autoUnload' => true,
- 'resetConsole' => true,
- 'arch' => 'agnostic',
+ 'id' => 'default',
+ 'kcl-extra' => '',
+ 'debug' => true,
]),
]);
Database::exec($query,
[
'entryid' => 'bwlp-default-sh',
- 'hotkey' => 'D',
+ 'hotkey' => 'S',
'title' => 'bwLehrpool-Umgebung starten (nosplash, !!! debug shell !!!)',
+ 'module' => 'minilinux',
'data' => json_encode([
- 'executable' => ['PCBIOS' => '/boot/default/kernel'],
- 'initRd' => ['PCBIOS' => ['/boot/default/initramfs-stage31']],
- 'commandLine' => ['PCBIOS' => 'slxbase=boot/default loglevel=7 debug=1 intel_iommu=igfx_off ${ipappend1} ${ipappend2}'],
- 'replace' => true,
- 'autoUnload' => true,
- 'resetConsole' => true,
- 'arch' => 'agnostic',
+ 'id' => 'default',
+ 'kcl-extra' => 'debug=1',
+ 'debug' => true,
]),
]);
Database::exec($query,
@@ -385,17 +375,17 @@ boot -a -r /boot/default/kernel initrd=initramfs-stage31 ${slxextra} slxbase=boo
'entryid' => 'localboot',
'hotkey' => 'L',
'title' => 'Lokales System starten',
- 'data' => json_encode([
- 'script' => 'goto slx_localboot || goto %fail% ||',
- ]),
+ 'module' => '.special',
+ 'data' => json_encode(['type' => 'localboot']),
]);
Database::exec($query,
[
'entryid' => 'poweroff',
'hotkey' => 'P',
'title' => 'Power off',
+ 'module' => '.script',
'data' => json_encode([
- 'script' => 'poweroff || goto %fail% ||',
+ 'script' => 'poweroff || goto fail ||',
]),
]);
Database::exec($query,
@@ -403,8 +393,9 @@ boot -a -r /boot/default/kernel initrd=initramfs-stage31 ${slxextra} slxbase=boo
'entryid' => 'reboot',
'hotkey' => 'R',
'title' => 'Reboot',
+ 'module' => '.script',
'data' => json_encode([
- 'script' => 'reboot || goto %fail% ||',
+ 'script' => 'reboot || goto fail ||',
]),
]);
}
@@ -415,10 +406,9 @@ boot -a -r /boot/default/kernel initrd=initramfs-stage31 ${slxextra} slxbase=boo
* 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
@@ -441,10 +431,9 @@ boot -a -r /boot/default/kernel initrd=initramfs-stage31 ${slxextra} slxbase=boo
}
/**
- * @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
@@ -473,7 +462,7 @@ boot -a -r /boot/default/kernel initrd=initramfs-stage31 ${slxextra} slxbase=boo
$script .= "set netX/{$opt}:{$type} {$args[$i]} || goto %fail%\n";
}
}
- } elseif ($arg{0} === '-') {
+ } elseif ($arg[0] === '-') {
continue;
} elseif ($file === false) {
$file = self::parseFile($arg);
@@ -503,11 +492,8 @@ boot -a -r /boot/default/kernel initrd=initramfs-stage31 ${slxextra} slxbase=boo
/**
* 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];
@@ -515,12 +501,12 @@ boot -a -r /boot/default/kernel initrd=initramfs-stage31 ${slxextra} slxbase=boo
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 '';
@@ -535,15 +521,16 @@ boot -a -r /boot/default/kernel initrd=initramfs-stage31 ${slxextra} slxbase=boo
* 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 f87d15c2..3ffecba1 100644
--- a/modules-available/serversetup-bwlp-ipxe/inc/ipxemenu.inc.php
+++ b/modules-available/serversetup-bwlp-ipxe/inc/ipxemenu.inc.php
@@ -3,84 +3,71 @@
class IPxeMenu
{
+ /**
+ * @var int ID of this menu, from DB
+ */
protected $menuid;
- protected $timeoutMs;
- protected $title;
- protected $defaultEntryId;
+ /**
+ * @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[]
*/
- protected $items = [];
+ public $items = [];
- public function __construct($menu)
+ 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]);
+ if ($menu !== false)
+ return new IPxeMenu($menu);
+ if (!$emptyFallback)
+ return null;
+ return new EmptyIPxeMenu();
+ }
+
+ /**
+ * IPxeMenu constructor.
+ *
+ * @param array $menu array for according menu row
+ */
+ public function __construct(array $menu)
{
- if (!is_array($menu)) {
- $menu = Database::queryFirst("SELECT menuid, timeoutms, title, defaultentryid FROM serversetup_menu
- WHERE menuid = :menuid LIMIT 1", ['menuid' => $menu]);
- if (!is_array($menu)) {
- $menu = ['menuid' => 'foo', 'title' => 'Invalid Menu ID: ' . (int)$menu];
- }
- }
$this->menuid = (int)$menu['menuid'];
$this->timeoutMs = (int)$menu['timeoutms'];
- $this->title = $menu['title'];
- $this->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
+ $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();
- }
- }
-
- public function getMenuDefinition($targetVar, $mode, $slxExtensions)
- {
- $str = "menu -- {$this->title}\n";
- foreach ($this->items as $item) {
- $str .= $item->getMenuItemScript("m_{$this->menuid}", $this->defaultEntryId, $mode, $slxExtensions);
- }
- if ($this->defaultEntryId === null) {
- $defaultLabel = "mx_{$this->menuid}_poweroff";
- } else {
- $defaultLabel = "m_{$this->menuid}_{$this->defaultEntryId}";
- }
- $str .= "choose";
- if ($this->timeoutMs > 0) {
- $str .= " --timeout {$this->timeoutMs}";
+ if ($defaultEntryId === null && !empty($this->items)) {
+ $defaultEntryId = $this->items[0]->menuEntryId();
}
- $str .= " $targetVar || goto $defaultLabel || goto fail\n";
- if ($this->defaultEntryId === null) {
- $str .= "goto skip_{$defaultLabel}\n"
- . ":{$defaultLabel}\n"
- . "poweroff || goto fail\n"
- . ":skip_{$defaultLabel}\n";
- }
- return $str;
- }
-
- public function getItemsCode($mode)
- {
- $str = '';
- foreach ($this->items as $item) {
- $str .= $item->getBootEntryScript("m_{$this->menuid}", 'fail', $mode);
- $str .= "goto slx_menu\n";
- }
- return $str;
+ $this->defaultEntryId = (int)$defaultEntryId;
}
- public function title()
+ public function title(): string
{
return $this->title;
}
- public function timeoutMs()
+ public function timeoutMs(): int
{
return $this->timeoutMs;
}
@@ -88,52 +75,89 @@ class IPxeMenu
/**
* @return int Number of items in this menu
*/
- public function itemCount()
+ public function itemCount(): int
{
return count($this->items);
}
/**
- * @return string|false Return script label of default entry, false if not set
+ * @return MenuEntry|null Return preselected menu entry
*/
- public function getDefaultScriptLabel()
+ public function defaultEntry(): ?MenuEntry
{
- if ($this->defaultEntryId !== null)
- return "m_{$this->menuid}_{$this->defaultEntryId}";
- return false;
+ 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)
+ public static function forLocation(int $locationId): IPxeMenu
{
$chain = null;
if (Module::isAvailable('locations')) {
$chain = Location::getLocationRootChain($locationId);
}
if (!empty($chain)) {
- $res = Database::simpleQuery("SELECT m.menuid, m.timeoutms, m.title, IFNULL(ml.defaultentryid, m.defaultentryid) AS defaultentryid, ml.locationid
- FROM serversetup_menu m
- INNER JOIN serversetup_menu_location ml USING (menuid)
- WHERE ml.locationid IN (:chain)", ['chain' => $chain]);
+ $res = Database::simpleQuery("SELECT m.menuid, m.timeoutms, m.title,
+ IFNULL(ml.defaultentryid, m.defaultentryid) AS defaultentryid, ml.locationid
+ FROM serversetup_menu m
+ INNER JOIN serversetup_menu_location ml USING (menuid)
+ WHERE ml.locationid IN (:chain)", ['chain' => $chain]);
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;
}
@@ -156,13 +180,19 @@ class IPxeMenu
return new IPxeMenu($menu);
}
- public static function forClient($ip, $uuid)
+ 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;
}
}
@@ -170,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 a65e9f98..da94a16b 100644
--- a/modules-available/serversetup-bwlp-ipxe/inc/menuentry.inc.php
+++ b/modules-available/serversetup-bwlp-ipxe/inc/menuentry.inc.php
@@ -5,114 +5,98 @@ class MenuEntry
/**
* @var int id of entry, used for pw
*/
- private $menuentryid;
+ public $menuentryid;
/**
* @var false|string key code as expected by iPXE
*/
- private $hotkey;
+ public $hotkey;
/**
* @var string
*/
- private $title;
+ public $title;
/**
* @var bool
*/
- private $hidden;
+ public $hidden;
/**
* @var bool
*/
- private $gap;
+ public $gap;
/**
* @var int
*/
- private $sortval;
+ public $sortval;
/**
- * @var BootEntry
+ * @var ?BootEntry
*/
- private $bootEntry = null;
+ public $bootEntry = null;
- private $md5pass = null;
+ public $plainpass = null;
+
+ public $md5pass = null;
+
+ 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
+ FROM serversetup_menuentry e
+ LEFT JOIN serversetup_bootentry b USING (entryid)
+ WHERE e.menuentryid = :id", ['id' => $menuEntryId]);
+ if ($row === false)
+ return null;
+ return new MenuEntry($row);
+ }
/**
* MenuEntry constructor.
*
* @param array $row row from database
*/
- public function __construct($row)
+ public function __construct(array $row)
{
- if (is_array($row)) {
- 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 ($row['refmenuid'] !== null) {
- $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 getMenuItemScript($lblPrefix, $requestedDefaultId, $mode, $slxExtensions)
+ public function getBootEntryScript(ScriptBuilderBase $builder): string
{
- if ($this->bootEntry !== null && !$this->bootEntry->supportsMode($mode))
+ if ($this->bootEntry === null)
return '';
- $str = 'item ';
- if ($this->gap) {
- $str .= '--gap -- ';
- } else {
- if ($this->hidden && $slxExtensions) {
- if ($this->hotkey === false)
- return ''; // Hidden entries without hotkey are illegal
- $str .= '--hidden ';
- }
- if ($this->hotkey !== false) {
- $str .= '--key ' . $this->hotkey . ' ';
- }
- if ($this->menuentryid == $requestedDefaultId) {
- $str .= '--default ';
- }
- $str .= "-- {$lblPrefix}_{$this->menuentryid} ";
- }
- if (empty($this->title)) {
- $str .= '${}';
- } else {
- $str .= $this->title;
- }
- return $str . " || prompt Could not create menu item for {$lblPrefix}_{$this->menuentryid}\n";
+ return $this->bootEntry->toScript($builder);
}
- public function getBootEntryScript($lblPrefix, $failLabel, $mode)
+ public function menuEntryId(): int
{
- if ($this->bootEntry === null || !$this->bootEntry->supportsMode($mode))
- return '';
- $str = ":{$lblPrefix}_{$this->menuentryid}\n";
- if (!empty($this->md5pass)) {
- $str .= "set slx_hash {$this->md5pass} || goto $failLabel\n"
- . "set slx_salt {$this->menuentryid} || goto $failLabel\n"
- . "set slx_pw_ok {$lblPrefix}_ok || goto $failLabel\n"
- . "set slx_pw_fail slx_menu || goto $failLabel\n"
- . "goto slx_pass_check || goto $failLabel\n"
- . ":{$lblPrefix}_ok\n";
- }
- return $str . $this->bootEntry->toScript($failLabel, $mode);
+ return $this->menuentryid;
}
- public function menuEntryId()
+ public function title(): string
{
- return $this->menuentryid;
+ return $this->title;
}
- public function title()
+ public function internalId(): string
{
- return $this->title;
+ if ($this->bootEntry === null)
+ return '';
+ return $this->bootEntry->internalId();
}
/*
@@ -154,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());
}
@@ -163,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]))
@@ -178,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 ff548c4c..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 crypted 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
new file mode 100644
index 00000000..9cd07388
--- /dev/null
+++ b/modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderbase.inc.php
@@ -0,0 +1,102 @@
+<?php
+
+abstract class ScriptBuilderBase
+{
+
+ private $lblId = 0;
+
+ protected $serverIp;
+
+ protected $platform = '';
+
+ /** @var string */
+ protected $clientIp;
+
+ /** @var ?string */
+ protected $uuid;
+
+ /**
+ * @var bool Running iPXE has slx-extensions
+ */
+ protected $hasExtension = false;
+
+ public function hasExtensions(): bool
+ {
+ return $this->hasExtension;
+ }
+
+ public function platform(): string
+ {
+ return $this->platform;
+ }
+
+ public function uuid(): ?string
+ {
+ return $this->uuid;
+ }
+
+ public function clientIp(): string
+ {
+ return $this->clientIp;
+ }
+
+ public function getLabel(): string
+ {
+ return 'b' . mt_rand(100, 999) . 'x' . (++$this->lblId);
+ }
+
+ public function __construct(?string $platform = null, ?string $serverIp = null, ?bool $slxExtensions = null)
+ {
+ $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', 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');
+ $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.
+ */
+ public abstract function output(string $string): void;
+
+ public abstract function bootstrapLive();
+
+ public abstract function getMenu(IPxeMenu $menu, bool $bootstrap);
+
+ /**
+ * @param MenuEntry|null $menuEntry The according menu entry, or null if invalid.
+ * @param bool $honorPassword Whether we should generate a password dialog if protected, or skip
+ * @return string generated script/code/...
+ */
+ public abstract function getMenuEntry(?MenuEntry $entry, bool $honorPassword = true): string;
+
+ /**
+ * @param BootEntry|null|false $bootEntry
+ */
+ public abstract function getBootEntry(?BootEntry $entry): string;
+
+ public abstract function getSpecial(string $special);
+
+ 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.
+ */
+ 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
new file mode 100644
index 00000000..d6b542ec
--- /dev/null
+++ b/modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderbash.inc.php
@@ -0,0 +1,97 @@
+<?php
+
+class ScriptBuilderBash extends ScriptBuilderBase
+{
+
+ public function output(string $string): void
+ {
+ echo $string;
+ }
+
+ public function bootstrapLive(): bool { return false; }
+
+ public function getMenu(IPxeMenu $menu, bool $bootstrap): string
+ {
+ return $this->menuToScript($menu);
+ }
+
+ public function getBootEntry(?BootEntry $entry): string
+ {
+ if ($entry === null) {
+ return "echo 'Invalid boot entry id'\nread -n1 -r _\n";
+ }
+ return $entry->toScript($this);
+ }
+
+ 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(string $special): string
+ {
+ return ''; // We can't really do localboot here I guess
+ }
+
+ 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) {
+ $id = $entry->menuentryid;
+ if ($entry->bootEntry === null || (!empty($this->platform) && !$entry->bootEntry->supportsMode($this->platform)))
+ continue;
+ if (!$entry->hidden) {
+ $output .= 'items_name[' . $id . ']=' . $this->bashString($entry->title) . "\n";
+ if ($entry->gap) {
+ $output .= 'items_gap[' . $id . "]=1\n";
+ }
+ }
+ if ($entry->hotkey !== false) {
+ $output .= 'hotkey_item[' . $entry->hotkey . ']=' . $id . "\n";
+ }
+ if ($id == $menu->defaultEntryId) {
+ $output .= "menu_default={$id}\n";
+ }
+ }
+ return $output . "menu_timeout=" . $menu->timeoutMs
+ . "\nmenu_title=" . $this->bashString($menu->title) . "\n";
+ }
+
+ public function execDataToScript(?ExecData $agnostic, ?ExecData $bios, ?ExecData $efi): string
+ {
+ if ($agnostic !== null)
+ return $this->execDataToScriptInternal($agnostic);
+ if ($bios !== null && $this->platform === BootEntry::BIOS)
+ return $this->execDataToScriptInternal($bios);
+ if ($efi !== null && $this->platform === BootEntry::EFI)
+ return $this->execDataToScriptInternal($efi);
+ return $this->execDataToScriptInternal($bios ?? $efi ?? new ExecData());
+ }
+
+ private function execDataToScriptInternal(ExecData $entry) : string
+ {
+ $entry->sanitize();
+ $script = "declare -a initrd\ndeclare kernel kcl\n";
+ if (!empty($entry->initRd)) {
+ foreach ($entry->initRd as $initrd) {
+ if (empty($initrd))
+ continue;
+ $script .= 'initrd+=( ' . $this->bashString($initrd) . " )\n";
+ }
+ }
+ $script .= 'kernel=' . $this->bashString($entry->executable) . "\n";
+ $script .= 'kcl="' . str_replace('"', '"\\""', $entry->commandLine) . "\"\n"; // Allow expansion
+ return $script;
+ }
+
+ private function bashString(string $string): string
+ {
+ if (strpos($string, "'") === false) {
+ return "'$string'";
+ }
+ return "'" . str_replace("'", "'\\''", $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
new file mode 100644
index 00000000..9421684f
--- /dev/null
+++ b/modules-available/serversetup-bwlp-ipxe/inc/scriptbuilderipxe.inc.php
@@ -0,0 +1,478 @@
+<?php
+
+class ScriptBuilderIpxe extends ScriptBuilderBase
+{
+
+ private function getUrlBase(): string
+ {
+ if (isset($_SERVER['REQUEST_URI'])) {
+ $url = parse_url($_SERVER['REQUEST_URI']);
+ if (isset($_SERVER['SCRIPT_URI']) && preg_match('#^(\w+://[^/]+)#', $_SERVER['SCRIPT_URI'], $out)) {
+ $urlbase = $out[1];
+ } elseif (isset($_SERVER['REQUEST_SCHEME']) && isset($_SERVER['SERVER_NAME'])) {
+ $urlbase = $_SERVER['REQUEST_SCHEME'] . '://' . $_SERVER['SERVER_NAME'];
+ } elseif (isset($_SERVER['REQUEST_SCHEME']) && isset($_SERVER['SERVER_ADDR'])) {
+ $urlbase = $_SERVER['REQUEST_SCHEME'] . '://' . $_SERVER['SERVER_ADDR'];
+ } else {
+ $urlbase = 'http://' . $this->serverIp;
+ }
+ return $urlbase . $url['path'];
+ }
+ // Static fallback
+ return 'http://' . $this->serverIp . '/boot/ipxe';
+
+ }
+
+ private function getUrlFull(?bool &$hasExt = null, ?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']);
+ if ($key !== null) {
+ $fromQuery[$key] = $value;
+ }
+ $hasExt = isset($fromQuery['slx-extensions']);
+ $required = [
+ 'uuid' => '${uuid}',
+ 'mac' => '${mac}',
+ 'manuf' => '${manufacturer:uristring}',
+ 'product' => '${product:uristring}',
+ 'platform' => '${platform:uristring}',
+ ];
+ $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($hasExt, $key, $value);
+ if ($hasExt) {
+ $output = "#!ipxe\nset self {$urlfull} ||\n";
+ } else {
+ $output = <<<HERE
+#!ipxe
+set slxtest:string something ||
+iseq \${slxtest:md5} \${} && set slxext 0 || set slxext 1 ||
+clear slxtest ||
+set self {$urlfull}slx-extensions=\${slxext} ||
+
+HERE;
+ }
+ $output .= <<<HERE
+:retry
+echo Chaining to \${self}
+chain -ar \${self} ||
+echo Chaining to self failed with \${errno}, retrying in a bit...
+sleep 5
+goto retry
+
+HERE;
+ return $output;
+ }
+
+ /**
+ * 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 === false || $this->platform === '') {
+ // REQUIRED so we can hide incompatible entries
+ return $this->redirect();
+ }
+ return false;
+ }
+
+ public function getBootEntry(?BootEntry $entry): string
+ {
+ if ($entry === null) {
+ return "#!ipxe\nprompt --timeout 5000 Invalid boot entry id\n";
+ }
+ return $entry->toScript($this);
+ }
+
+ public function getMenu(IPxeMenu $menu, bool $bootstrap): string
+ {
+ if ($bootstrap) {
+ return "#!ipxe\nimgfree ||\n" . $this->menuToScript($menu);
+ }
+ $base = $this->getUrlFull();
+ return "#!ipxe\nset self {$base} ||\n" . $this->menuToScript($menu);
+ }
+
+ public function menuToScript(IPxeMenu $menu): string
+ {
+ if ($this->hasExtension) {
+ $slxConsoleUpdate = '--update';
+ } else {
+ $slxConsoleUpdate = '';
+ }
+
+ $output = <<<HERE
+:start
+
+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 ||
+
+:slx_menu
+
+console --left 55 --top 88 --right 63 --bottom 64 $slxConsoleUpdate --keep --picture bg-menu ||
+
+menu -- {$menu->title} || prompt --timeout 5000 Error creating menu ||
+
+HERE;
+ foreach ($menu->items as $item) {
+ $output .= $this->getMenuItemScript($menu->defaultEntryId, $item);
+ }
+ if ($menu->defaultEntryId === null) {
+ $default = "poweroff || exit 1 ||";
+ } else {
+ $default = "chain -a \${self}&entryid={$menu->defaultEntryId} ||";
+ }
+ $output .= "choose";
+ if ($menu->timeoutMs > 0) {
+ $output .= " --timeout {$menu->timeoutMs}";
+ }
+ $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.
+prompt Press any key to continue.
+goto start
+:default
+$default
+:fail
+prompt Boot failed. Press any key to start.
+goto start
+
+HERE;
+ return $output;
+ }
+
+ private function getMenuItemScript(int $requestedDefaultId, MenuEntry $entry): string
+ {
+ $str = 'item ';
+ if ($entry->gap) {
+ $str .= '--gap -- ';
+ } else {
+ if ($entry->bootEntry === null || (!empty($this->platform) && !$entry->bootEntry->supportsMode($this->platform)))
+ return '';
+ if ($entry->hidden && $this->hasExtension) {
+ if ($entry->hotkey === false)
+ return ''; // Hidden entries without hotkey are illegal
+ $str .= '--hidden ';
+ }
+ if ($entry->hotkey !== false) {
+ $str .= '--key ' . $entry->hotkey . ' ';
+ }
+ if ($entry->menuentryid == $requestedDefaultId) {
+ $str .= '--default ';
+ }
+ $str .= "-- {$entry->menuentryid} ";
+ }
+ if (empty($entry->title)) {
+ $str .= '${}';
+ } else {
+ $str .= $entry->title;
+ }
+ return $str . " || prompt Could not create menu item for {$entry->menuentryid}\n";
+ }
+
+ public function getSpecial(string $special): string
+ {
+ if ($special === 'localboot') {
+ // Get preferred localboot method, depending on system model
+ // Check if required arguments are given; if not, spit out according script and chain to self
+ // Get platform - EFI or PCBIOS
+ $manuf = Request::any('manuf', false, 'string');
+ $product = Request::any('product', false, 'string');
+ if ($this->uuid === false && $manuf === false && $product === false) {
+ return $this->redirect('special', 'localboot');
+ }
+ $BOOT_METHODS = Localboot::BOOT_METHODS[$this->platform];
+ $localboot = false;
+ $model = false;
+ if ($this->uuid !== false && Module::get('statistics') !== false) {
+ // If we have the machine table, we rather try to look up the system model from there, using the UUID
+ $row = Database::queryFirst('SELECT systemmodel FROM machine WHERE machineuuid = :uuid', ['uuid' => $this->uuid]);
+ if ($row !== false && !empty($row['systemmodel'])) {
+ $model = $row['systemmodel'];
+ }
+ }
+ if ($model === false) {
+ // Otherwise use what iPXE sent us
+ $manuf = $this->modfilt($manuf);
+ $product = $this->modfilt($product);
+ if (!empty($product)) {
+ $model = $product;
+ if (!empty($manuf)) {
+ $model .= " ($manuf)";
+ }
+ $model = Util::ansiToUtf8($model);
+ }
+ }
+ // Query
+ if ($model !== false) {
+ $e = strtolower($this->platform); // We made sure $this->platform is either PCBIOS or EFI, so no injection possible
+ $row = Database::queryFirst("SELECT $e AS bootmethod FROM serversetup_localboot WHERE systemmodel = :model LIMIT 1",
+ ['model' => $model]);
+ if ($row !== false) {
+ $localboot = $row['bootmethod'];
+ }
+ }
+ if ($localboot === false || !isset($BOOT_METHODS[$localboot])) {
+ $localboot = Localboot::getDefault()[$this->platform];
+ if (!isset($BOOT_METHODS[$localboot])) {
+ $localboot = array_keys($BOOT_METHODS)[0];
+ }
+ }
+ // Convert to actual ipxe code
+ $localboot = $BOOT_METHODS[$localboot] ?? 'prompt Localboot not possible';
+ $output = <<<BLA
+imgfree ||
+console ||
+$localboot || goto fail
+
+BLA;
+ //
+ } else {
+ $output = "prompt --timeout 5000 Unknown special command '$special' ||\nchain -ar \${self}\n";
+ }
+ return $output;
+ }
+
+ public function output(string $string): void
+ {
+ // 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 {
+ if ($this->platform === 'EFI') {
+ $cs = 'ASCII';
+ } else {
+ $cs = 'IBM437';
+ }
+ Header('Content-Type: text/plain; charset=' . $cs);
+
+ 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)
+ {
+ if (empty($str) || preg_match('/product\s+name|be\s+filled|unknown|default\s+string|system\s+model|manufacturer/i', $str))
+ return false;
+ return trim(preg_replace('/\s+/', ' ', $str));
+ }
+
+ const PROP_PW_SALT = 'ipxe.salt.';
+
+ private function passwordDialog(MenuEntry $entry): string
+ {
+ if ($this->hasExtension) {
+ $salt = dechex(mt_rand(0x100000, 0xFFFFFF));
+ Property::addToList(self::PROP_PW_SALT . $this->clientIp, $salt, 5);
+ return <<<HERE
+set password \${} ||
+login --nouser ||
+set password \${password:md5}-{$entry->menuentryid}
+set password \${password:md5}$salt
+params
+param pwhash \${password:md5}
+chain -a \${self}&entryid={$entry->menuentryid}##params || goto fail ||
+
+HERE;
+ }
+ return <<<HERE
+set username PASSWORD ONLY ||
+login ||
+params
+param pwplain \${password}
+chain -a \${self}&entryid={$entry->menuentryid}##params || goto fail ||
+
+HERE;
+ }
+
+ public function getMenuEntry(?MenuEntry $entry, bool $honorPassword = true): string
+ {
+ if ($entry === null)
+ return "#!ipxe\nprompt --timeout 10000 Invalid menu entry id\n";
+ $base = $this->getUrlBase();
+ $meid = $entry->menuEntryId();
+ // Make sure legacy variables are set; they might get used
+ $output = <<<HERE
+#!ipxe
+set ipappend1 ip=\${ip}:{$this->serverIp}:\${gateway}:\${netmask}
+set ipappend2 BOOTIF=01-\${mac:hexhyp}
+set serverip {$this->serverIp} ||
+iseq \${idx} \${} && set idx:string X ||
+iseq \${self} \${} && set self {$base}? ||
+set menuentryid $meid ||
+
+HERE;
+ // Check for password
+ if ($honorPassword && !empty($entry->md5pass)) {
+ $pwh = Request::post('pwhash', false, 'string');
+ $pwp = Request::post('pwplain', false, 'string');
+ if ($pwh === false && $pwp === false) {
+ return $output . $this->passwordDialog($entry);
+ }
+ $ok = false;
+ if ($pwh !== false) {
+ $list = Property::getList(self::PROP_PW_SALT . $this->clientIp);
+ foreach ($list as $salt) {
+ if ($pwh === md5($entry->md5pass . $salt)) {
+ $ok = true;
+ break;
+ }
+ }
+ }
+ if (!$ok && $pwp !== false && !empty($entry->plainpass)) {
+ $ok = ($pwp === $entry->plainpass);
+ }
+ if (!$ok) {
+ return $output . "prompt --timeout 10000 Wrong password ||\n";
+ }
+ }
+ // Output actual entry
+ $output .= str_replace('%fail%', 'fail', $entry->getBootEntryScript($this));
+ $output .= <<<HERE
+
+goto end
+:fail
+prompt --timeout 5000 Error launching selected boot entry ||
+:end
+
+HERE;
+ return $output;
+ }
+
+ public function execDataToScript(?ExecData $agnostic, ?ExecData $bios, ?ExecData $efi) : string
+ {
+ if ($agnostic !== null)
+ return $this->execDataToScriptInternal($agnostic) . "\ngoto fail\n";
+
+ if (empty($this->platform)) {
+ // output dynamic code that decides client-side
+ $biosLabel = $this->getLabel();
+ $output = 'iseq ${platform} efi || goto ' . $biosLabel . "\n";
+ // EFI
+ if ($efi !== null) {
+ $output .= $this->execDataToScriptInternal($efi) . "\n";
+ } else {
+ $output .= "echo EFI not supported\n";
+ }
+ $output .= "goto fail\n"
+ . ':' . $biosLabel . "\n";
+ if ($bios !== null) {
+ $output .= $this->execDataToScriptInternal($bios) . "\n";
+ } else {
+ $output .= "echo BIOS not supported\n";
+ }
+ return $output . "goto fail\n";
+ }
+ // static, we know in advance
+ if ($efi !== null && $this->platform === BootEntry::EFI)
+ return $this->execDataToScriptInternal($efi) . "\ngoto fail\n";
+ // Should be BIOS at this point
+ return $this->execDataToScriptInternal($bios ?? $efi ?? new ExecData()) . "\ngoto fail\n";
+ }
+
+ private function execDataToScriptInternal(ExecData $entry) : string
+ {
+ $entry->sanitize();
+ $script = '';
+ if ($entry->resetConsole) {
+ $script .= "console ||\n";
+ }
+ if ($entry->imageFree) {
+ $script .= "imgfree ||\n";
+ }
+ foreach ($entry->dhcpOptions as $opt) {
+ if (empty($opt['value'])) {
+ $val = '${}';
+ } else {
+ if (empty($opt['hex'])) {
+ $val = bin2hex($opt['value']);
+ } else {
+ $val = $opt['value'];
+ }
+ preg_match_all('/[0-9a-f]{2}/', $val, $out);
+ $val = implode(':', $out[0]);
+ }
+ $script .= 'set net${idx}/' . $opt['opt'] . ':hex ' . $val
+ . ' || prompt Cannot override DHCP server option ' . $opt['opt'] . ". Press any key to continue anyways.\n";
+ }
+ $initrds = [];
+ if (!empty($entry->initRd)) {
+ foreach (array_values($entry->initRd) as $i => $initrd) {
+ if (empty($initrd))
+ continue;
+ $script .= "initrd --name initrd$i $initrd || goto fail\n";
+ $initrds[] = "initrd$i";
+ }
+ }
+ $script .= "boot ";
+ if ($entry->autoUnload) {
+ $script .= "-a ";
+ }
+ if ($entry->replace) {
+ $script .= "-r ";
+ }
+ $script .= $entry->executable;
+ if (!empty($initrds)) {
+ foreach ($initrds as $initrd) {
+ $script .= " initrd=$initrd";
+ }
+ }
+ if (!empty($entry->commandLine)) {
+ $script .= ' ' . $entry->commandLine . ' slx.ipxe.id=${menuentryid}';
+ }
+ $script .= " || goto fail\n";
+ if ($entry->resetConsole) {
+ $script .= "goto start ||\n";
+ }
+ return $script;
+ }
+
+}
diff --git a/modules-available/serversetup-bwlp-ipxe/install.inc.php b/modules-available/serversetup-bwlp-ipxe/install.inc.php
index 37cfc085..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 {
@@ -111,13 +111,13 @@ if (Module::get('location') !== false) {
}
}
-// 2019-09-21 Add modue column to bootentry
+// 2019-09-21 Add module column to bootentry
if (!tableHasColumn('serversetup_bootentry', 'module')) {
if (Database::exec("ALTER TABLE serversetup_bootentry
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 aeffb3c2..eba15cbe 100644
--- a/modules-available/serversetup-bwlp-ipxe/lang/de/template-tags.json
+++ b/modules-available/serversetup-bwlp-ipxe/lang/de/template-tags.json
@@ -34,10 +34,12 @@
"lang_entryTitle": "Bezeichnung",
"lang_execAutoUnload": "Nach Ausf\u00fchrung entladen (--autofree)",
"lang_execImageFree": "Andere geladene Images vor dem Ausf\u00fchren entladen (imgfree)",
- "lang_execReplace": "Aktuellen iPXE-Stack erstzen (--replace)",
+ "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 29d99bf6..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",
@@ -11,6 +11,7 @@
"lang_biosOnly": "BIOS only",
"lang_bootAddress": "Boot Address of the Server",
"lang_bootEntryData": "Menu item data",
+ "lang_bootEntryDetailsHeading": "Type Specific Configuration",
"lang_bootentryDeleteConfirm": "Are you sure you want to delete this menu item?",
"lang_bootentryHead": "Menu items",
"lang_bootentryIntro": "This is where you can add, edit and remove menu items, which can be added to menus. A menu item is either a combination of a kernel\/image to load (and an optional initrd), or a custom iPXE-script.",
@@ -35,17 +36,22 @@
"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",
"lang_hotkey": "Hotkey",
"lang_idFormatHint": "(16 chars max, a-z 0-9 - _)",
"lang_imageToLoad": "Image to load (e.g. kernel)",
"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",
@@ -59,6 +65,7 @@
"lang_menuTimeout": "Timeout",
"lang_menuTitle": "Menu",
"lang_moduleHeading": "iPXE \/ Boot Menu",
+ "lang_moduleSpecificId": "Module specific ID",
"lang_newBootEntryHead": "New menu item",
"lang_newMenu": "New menu",
"lang_none": "(none)",
@@ -67,9 +74,13 @@
"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",
"lang_set": "Set",
@@ -82,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 cc5fdbe5..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 (Property::getServerIp() === 'invalid' && $addr) {
- Util::redirect('?do=serversetup&show=address');
+ } elseif ($addr && !preg_match('/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/', Property::getServerIp())) {
+ 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) {
@@ -266,16 +289,14 @@ class Page_ServerSetup extends Page
$default = Localboot::getDefault();
$optionList = $this->makeSelectArray(Localboot::BOOT_METHODS, $default);
// Exceptions
- $cutoff = strtotime('-90 days');
$models = [];
$res = Database::simpleQuery('SELECT m.systemmodel, cnt, sl.pcbios AS PCBIOS, sl.efi AS EFI FROM (
SELECT m2.systemmodel, Count(*) AS cnt FROM machine m2
- WHERE m2.lastseen > :cutoff
GROUP BY systemmodel
) m
LEFT JOIN serversetup_localboot sl USING (systemmodel)
- ORDER BY systemmodel', ['cutoff' => $cutoff]);
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ ORDER BY systemmodel');
+ foreach ($res as $row) {
$row['modelesc'] = urlencode($row['systemmodel']);
$row['options'] = $this->makeSelectArray(Localboot::BOOT_METHODS, $row);
$models[] = $row;
@@ -298,14 +319,16 @@ class Page_ServerSetup extends Page
{
$allowEdit = User::hasPermission('ipxe.bootentry.edit');
- $bootentryTable = Database::queryAll("SELECT be.entryid, be.hotkey, be.title, be.builtin, be.module, Count(sm.menuid) AS refs
+ $bootentryTable = Database::queryAll("SELECT be.entryid, be.hotkey, be.title, be.builtin, be.module, Count(sme.menuid) AS refs,
+ GROUP_CONCAT(sm.title SEPARATOR ', ') as menuname
FROM serversetup_bootentry be
- LEFT JOIN serversetup_menuentry sm USING (entryid)
- GROUP BY be.entryid, be.title
- ORDER BY be.title ASC");
+ LEFT JOIN serversetup_menuentry sme USING (entryid)
+ LEFT JOIN serversetup_menu sm USING (menuid)
+ WHERE be.module <> '.special'
+ 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');
}
@@ -319,6 +342,7 @@ class Page_ServerSetup extends Page
Util::redirect('?do=serversetup&show=bootentry');
}
+ Module::isAvailable('js_stupidtable');
Render::addTemplate('bootentry-list', array(
'bootentryTable' => $bootentryTable,
'allowEdit' => $allowEdit,
@@ -339,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);
@@ -357,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);
@@ -378,7 +402,8 @@ class Page_ServerSetup extends Page
// if = edit, else = add new
if ($id !== 0) {
$menu = Database::queryFirst("SELECT menuid, timeoutms, title, defaultentryid, isdefault
- FROM serversetup_menu WHERE menuid = :id", compact('id'));
+ FROM serversetup_menu m
+ WHERE menuid = :id", compact('id'));
} else {
$menu = [];
$menu['menuid'] = 0;
@@ -404,9 +429,13 @@ class Page_ServerSetup extends Page
$menu['timeout'] = round($menu['timeoutms'] / 1000);
$menu['entries'] = [];
- $res = Database::simpleQuery("SELECT menuentryid, entryid, refmenuid, hotkey, title, hidden, sortval, plainpass FROM
- serversetup_menuentry WHERE menuid = :id ORDER BY sortval ASC", compact('id'));
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
+ $res = Database::simpleQuery("SELECT me.menuentryid, me.entryid, me.refmenuid, me.hotkey, me.title,
+ me.hidden, me.sortval, me.plainpass, be.title AS parenttitle
+ FROM serversetup_menuentry me
+ LEFT JOIN serversetup_bootentry be USING (entryid)
+ WHERE menuid = :id
+ ORDER BY sortval ASC", compact('id'));
+ foreach ($res as $row) {
if ($row['entryid'] === null && $row['refmenuid'] !== null) {
$row['entryid'] = 'menu:' . $row['refmenuid'];
}
@@ -419,12 +448,12 @@ class Page_ServerSetup extends Page
$menu['entrylist'] = array_merge(
Database::queryAll("SELECT entryid, title, hotkey, module, data FROM serversetup_bootentry ORDER BY title ASC"),
// Add all menus, so we can link
- Database::queryAll("SELECT Concat('menu:', menuid) AS entryid, title FROM serversetup_menu ORDER BY title ASC")
+ Database::queryAll("SELECT Concat('menu:', menuid) AS entryid, title, 1 AS no_edit FROM serversetup_menu ORDER BY title ASC")
);
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']) {
@@ -439,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,
];
}
@@ -456,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'])) {
@@ -504,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');
@@ -527,7 +555,7 @@ class Page_ServerSetup extends Page
if ($he->moduleId === $row['module']) {
$he->setSelected($row['data']);
$he->checked = 'checked';
- if ($he->getBootEntry($row['data']) === null) {
+ if (!$he->isValidId($he->getSelected())) {
Message::addError('invalid-custom-entry-id', $row['module'], $row['data']);
}
break;
@@ -554,10 +582,10 @@ class Page_ServerSetup extends Page
}
}
if (!in_array('PCBIOS', $f)) {
- $params['entries'][] = ['mode' => 'PCBIOS'];
+ $params['entries'][] = (new ExecData)->toFormFields('PCBIOS');
}
if (!in_array('EFI', $f)) {
- $params['entries'][] = ['mode' => 'EFI'];
+ $params['entries'][] = (new ExecData)->toFormFields('EFI');
}
$params['disabled'] = User::hasPermission('ipxe.bootentry.edit') ? '' : 'disabled';
@@ -566,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
@@ -590,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;
@@ -608,7 +656,6 @@ class Page_ServerSetup extends Page
}
unset($item);
array_multisort($sortIp, SORT_STRING, $this->addrListTask['data']['addresses']);
- return true;
}
private function deleteBootEntry()
@@ -665,7 +712,9 @@ class Page_ServerSetup extends Page
'timeoutms' => abs(Request::post('timeout', 0, 'int') * 1000),
];
if ($id === 0) {
- Database::exec("INSERT INTO serversetup_menu (title, timeoutms, isdefault) VALUES (:title, :timeoutms, 0)", $insertParams);
+ $num = Database::queryFirst("SELECT Count(*) AS cnt FROM serversetup_menu");
+ $insertParams['def'] = ($num['cnt'] == 0) ? 1 : 0;
+ Database::exec("INSERT INTO serversetup_menu (title, timeoutms, isdefault) VALUES (:title, :timeoutms, :def)", $insertParams);
$menu['menuid'] = $id = Database::lastInsertId();
} else {
$menu = Database::queryFirst("SELECT m.menuid
@@ -749,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),
@@ -783,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;
@@ -824,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');
@@ -841,15 +891,14 @@ 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;
}
/** @var BootEntryHook $module */
$module = $hook->run();
$id = Request::post('selection-' . $type, false, 'string');
- $entry = $module->isValidId($id);
- if ($entry === null) {
+ if (!$module->isValidId($id)) {
Message::addError('invalid-custom-entry-id', $type, $id);
return;
}
@@ -870,16 +919,21 @@ class Page_ServerSetup extends Page
// New or update?
if (empty($oldEntryId)) {
// New entry
- Database::exec('INSERT INTO serversetup_bootentry (entryid, title, builtin, module, data)
- VALUES (:entryid, :title, 0, :module, :data)', $params);
+ Database::exec("INSERT INTO serversetup_bootentry (entryid, title, hotkey, builtin, module, data)
+ VALUES (:entryid, :title, '', 0, :module, :data)", $params);
Message::addSuccess('boot-entry-created', $newId);
} 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);
@@ -897,7 +951,9 @@ class Page_ServerSetup extends Page
}
User::assertPermission('ipxe.menu.assign', $locationId);
// List of menu entries
- $res = Database::simpleQuery('SELECT menuentryid, title FROM serversetup_menuentry');
+ $res = Database::simpleQuery('SELECT me.menuentryid, If(Length(me.title) = 0, be.title, me.title)
+ FROM serversetup_menuentry me
+ INNER JOIN serversetup_bootentry be USING (entryid)');
$menuEntries = $res->fetchAll(PDO::FETCH_KEY_PAIR);
// List of menus
$data = [
@@ -906,15 +962,18 @@ class Page_ServerSetup extends Page
];
$menu = IPxeMenu::forLocation($loc['parentlocationid']);
$data['defaultMenu'] = $menu;
- $res = Database::simpleQuery('SELECT m.menuid, m.title, ml.locationid, ml.defaultentryid, GROUP_CONCAT(me.menuentryid) AS entries FROM serversetup_menu m
+ $res = Database::simpleQuery('SELECT m.defaultentryid AS menu_default, m.menuid, m.title, ml.locationid,
+ ml.defaultentryid, GROUP_CONCAT(me.menuentryid) AS entries
+ FROM serversetup_menu m
LEFT JOIN serversetup_menu_location ml ON (m.menuid = ml.menuid AND ml.locationid = :locationid)
INNER JOIN serversetup_menuentry me ON (m.menuid = me.menuid AND me.entryid IS NOT NULL)
- GROUP BY menuid
+ GROUP BY m.menuid, m.title
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'] = [];
foreach ($eids as $eid) {
$row['entries'][] = [
@@ -1011,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');
}
@@ -1020,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/bootentry-list.html b/modules-available/serversetup-bwlp-ipxe/templates/bootentry-list.html
index 9eecc6f5..e9b5be18 100644
--- a/modules-available/serversetup-bwlp-ipxe/templates/bootentry-list.html
+++ b/modules-available/serversetup-bwlp-ipxe/templates/bootentry-list.html
@@ -7,12 +7,12 @@
<form method="post" action="?do=serversetup">
<input type="hidden" name="token" value="{{token}}">
<input type="hidden" name="action" value="deleteBootentry">
- <table class="table">
+ <table class="table stupidtable">
<thead>
<tr>
- <th>{{lang_entryId}}</th>
- <th>{{lang_bootentryType}}</th>
- <th>{{lang_bootentryTitle}}</th>
+ <th data-sort="string">{{lang_entryId}}</th>
+ <th data-sort="string">{{lang_bootentryType}}</th>
+ <th data-sort="string" data-sort-onload="yes">{{lang_bootentryTitle}}</th>
<th class="small">{{lang_hotkey}}</th>
<th class="small slx-smallcol">{{lang_refCount}}</th>
<th class="small slx-smallcol">{{lang_edit}}</th>
@@ -37,7 +37,7 @@
</td>
<td align="right">
{{#refs}}
- <span class="badge">{{refs}}</span>
+ <span class="badge" data-toggle="tooltip" data-placement="top" title="{{menuname}}">{{refs}}</span>
{{/refs}}
</td>
<td align="center">
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-new-boot-entry.html b/modules-available/serversetup-bwlp-ipxe/templates/ipxe-new-boot-entry.html
index fc78d0ed..97f1d59c 100644
--- a/modules-available/serversetup-bwlp-ipxe/templates/ipxe-new-boot-entry.html
+++ b/modules-available/serversetup-bwlp-ipxe/templates/ipxe-new-boot-entry.html
@@ -95,7 +95,7 @@
<div class="form-group">
<div class="checkbox checkbox-inline">
<input id="exec-imgfree-{{mode}}" class="form-control" type="checkbox"
- name="entry[{{mode}}][imgfree]" {{imageFree_checked}} {{disabled}}>
+ name="entry[{{mode}}][imageFree]" {{imageFree_checked}} {{disabled}}>
<label for="exec-imgfree-{{mode}}">{{lang_execImageFree}}</label>
</div>
</div>
@@ -224,10 +224,20 @@ document.addEventListener('DOMContentLoaded', function () {
$('.type-form').hide();
var name = $(this).val().replace('.', '');
$('#form-' + name).show();
+ if (name === 'script') {
+ $('[id^=input-ex-]').prop('required', false);
+ $('#script-ta').prop('required', true);
+ } else if (name === 'exec') {
+ $('#arch-selector').change();
+ $('#script-ta').prop('required', false);
+ } else {
+ $('[id^=input-ex-]').prop('required', false);
+ $('#script-ta').prop('required', false);
+ }
});
- $('.type-radio[checked]').click();
var $as = $('#arch-selector');
$as.change(function() {
+ $('[id^=input-ex-]').prop('required', false);
var v = $as.val();
if (v === 'agnostic') {
v = 'PCBIOS';
@@ -239,9 +249,12 @@ document.addEventListener('DOMContentLoaded', function () {
var cols = 12 / vs.length;
$('.mode-class').hide();
for (var i = 0; i < vs.length; ++i) {
- $('#col-' + vs[i]).attr('class', 'mode-class col-md-' + cols).show();
+ const col = $('#col-' + vs[i]);
+ col.attr('class', 'mode-class col-md-' + cols).show();
+ col.find('[id^=input-ex-]').prop('required',true);
}
}).change();
+ $('.type-radio[checked]').click();
var colorize = function() {
var $t = $(this);
$t.css('color', ($t.data('hex') && !$t.val().match(/^[a-f0-9]*$/i)) ? 'red' : '');
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-ipxe/templates/menu-assign-location.html b/modules-available/serversetup-bwlp-ipxe/templates/menu-assign-location.html
index 4e08a346..9e128166 100644
--- a/modules-available/serversetup-bwlp-ipxe/templates/menu-assign-location.html
+++ b/modules-available/serversetup-bwlp-ipxe/templates/menu-assign-location.html
@@ -18,12 +18,12 @@
<tr>
<td>
<div class="radio radio-inline">
- <input type="radio" name="menuid" value="0" {{default_selected}}>
+ <input id="m-default" type="radio" name="menuid" value="0" {{default_selected}}>
<label></label>
</div>
</td>
<td>
- <i>{{lang_useDefaultMenu}}</i>
+ <label style="font-weight:normal" for="m-default"><i>{{lang_useDefaultMenu}}</i></label>
</td>
<td>
{{defaultMenu.title}}
@@ -34,12 +34,13 @@
<tr>
<td>
<div class="radio radio-inline">
- <input type="radio" name="menuid" value="{{menuid}}" {{menu_selected}}>
+ <input id="m-{{menuid}}" type="radio" name="menuid" value="{{menuid}}" {{menu_selected}}>
<label></label>
</div>
</td>
<td>
- {{title}}
+ <label style="font-weight:normal;margin-bottom:0" for="m-{{menuid}}">{{title}}</label>
+ <div class="small text-muted">{{default_entry_title}}</div>
</td>
<td class="text-right">
<select name="defaultentryid-{{menuid}}" class="form-control">
diff --git a/modules-available/serversetup-bwlp-ipxe/templates/menu-edit.html b/modules-available/serversetup-bwlp-ipxe/templates/menu-edit.html
index efff704f..6f119515 100644
--- a/modules-available/serversetup-bwlp-ipxe/templates/menu-edit.html
+++ b/modules-available/serversetup-bwlp-ipxe/templates/menu-edit.html
@@ -80,11 +80,12 @@
</td>
<td>
<input class="form-control title" name="entry[{{menuentryid}}][title]" value="{{title}}"
- maxlength="100" {{readonly}}>
+ placeholder="{{parenttitle}}" maxlength="100" {{readonly}}>
</td>
<td>
- <select class="form-control key-list no-spacer" {{^entryid}}style="display: none;"{{/entryid}} name="entry[{{menuentryid}}][hotkey]" {{readonly}} data-default="{{hotkey}}">
+ <select class="form-control key-list no-spacer" {{^entryid}}style="display: none;"{{/entryid}}
+ name="entry[{{menuentryid}}][hotkey]" {{readonly}} data-default="{{hotkey}}">
</select>
</td>
@@ -145,7 +146,7 @@
{{#entrylist}}
<div id="entrydata-{{entryid}}" class="entrydata">
<div>
- {{lang_entryTitle}}: <b>{{title}}</b>
+ {{lang_entryTitle}}: <b class="name">{{title}}</b>
</div>
{{#data}}
{{#script}}
@@ -206,6 +207,11 @@
{{/otherFields}}
</table>
{{/ishook}}
+ {{^no_edit}}
+ <div class="text-right">
+ <a href="?do=serversetup&amp;show=editbootentry&amp;id={{entryid}}">{{lang_edit}}</a>
+ </div>
+ {{/no_edit}}
</div>
{{/entrylist}}
</div>
@@ -246,7 +252,7 @@
</button>
</td>
<td>
- <input class="form-control title" data-old="#new#" name="entry[%new%][title]" maxlength="100">
+ <input class="form-control title" name="entry[%new%][title]" maxlength="100">
</td>
<td>
<select class="form-control key-list no-spacer" style="display: none;" name="entry[%new%][hotkey]">
@@ -351,15 +357,8 @@
tableRow.find('.no-spacer').show();
}
var $title = tableRow.find('.title');
- var oldval = $title.data('old');
- if (oldval === '#stop#')
- return;
- if (oldval !== '#new#' && oldval !== $title.val()) {
- $title.data('old', '#stop#');
- return;
- }
- var text = $('#' + entryId.replace(':', '\\:') + '-name').text();
- $title.val(text).data('old', text);
+ var text = $('#entrydata-' + entryId.replace(':', '\\:') + ' .name').text();
+ $title.prop('placeholder', text);
});
});
</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 19ae3cb6..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');
@@ -45,14 +46,14 @@ if ($type{0} === '~') {
if ($macaddr === false) die("No/Invalid MAC address.\n");
if ($uptime < 0 || $uptime > 4000000) die("Implausible uptime.\n");
$realcores = Request::post('realcores', 0, 'integer');
- if ($realcores < 0 || $realcores > 512) $realcores = 0;
+ if ($realcores < 0 || $realcores > 1024) $realcores = 0;
$mbram = Request::post('mbram', 0, 'integer');
- if ($mbram < 0 || $mbram > 2048000) $mbram = 0;
+ if ($mbram < 0 || $mbram > 4096000) $mbram = 0;
$kvmstate = Request::post('kvmstate', 'UNKNOWN', 'string');
$valid = array('UNKNOWN', 'UNSUPPORTED', 'DISABLED', 'ENABLED');
if (!in_array($kvmstate, $valid)) $kvmstate = 'UNKNOWN';
- $cpumodel = Request::post('cpumodel', '', 'string');
- $systemmodel = Request::post('systemmodel', '', 'string');
+ $cpumodel = Util::cleanUtf8(Request::post('cpumodel', '', 'string'));
+ $systemmodel = Util::cleanUtf8(Request::post('systemmodel', '', 'string'));
$id44mb = Request::post('id44mb', 0, 'integer');
if ($id44mb < 0 || $id44mb > 10240000) $id44mb = 0;
$badsectors = Request::post('badsectors', 0, 'integer');
@@ -61,10 +62,26 @@ if ($type{0} === '~') {
if (!is_string($hostname) || $hostname === $ip) {
$hostname = '';
}
- $data = 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");
@@ -98,6 +115,10 @@ if ($type{0} === '~') {
$new['hostname'] = $hostname;
$moresql .= ' hostname = :hostname,';
}
+ if (($runmode = Request::post('runmode', false, 'string')) !== false) {
+ $new['currentrunmode'] = Util::cleanUtf8($runmode);
+ $moresql .= ' currentrunmode = :currentrunmode,';
+ }
$new['oldstate'] = $old['state'];
$new['oldlastseen'] = $old['lastseen'];
$res = Database::exec('UPDATE machine SET '
@@ -112,11 +133,11 @@ if ($type{0} === '~') {
. ' cpumodel = :cpumodel,'
. ' systemmodel = :systemmodel,'
. ' id44mb = :id44mb,'
- . ' live_tmpsize = 0, live_swapsize = 0, live_memsize = 0,'
+ . ' 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");
}
@@ -142,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
@@ -151,30 +182,48 @@ 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']
- . '. RAM: ' . Util::readableFileSize($old['live_memfree'], -1, 2)
- . ', Swap: ' . Util::readableFileSize($old['live_swapfree'], -1, 2)
- . ', 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 = '';
if ($old === false) die("Unknown machine.\n");
if ($old['clientip'] !== $ip) {
- EventLog::warning("[runstate] IP address of client $uuid seems to have changed ({$old['clientip']} -> $ip)");
- die("Address changed.\n");
+ updateIp('runstate', $uuid, $old, $ip);
}
$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
@@ -192,9 +241,27 @@ if ($type{0} === '~') {
}
}
}
- foreach (['memsize', 'tmpsize', 'swapsize', 'memfree', 'tmpfree', 'swapfree'] as $item) {
- $strUpdateBoottime .= ' live_' . $item . ' = :_' . $item . ', ';
- $params['_' . $item] = ceil(Request::post($item, 0, 'int') / 1024);
+ foreach (['memsize', 'tmpsize', 'swapsize', 'id45size',
+ 'memfree', 'tmpfree', 'swapfree', 'id45free',
+ 'cpuload', 'cputemp'] as $item) {
+ $liveVal = Request::post($item, false, 'int');
+ if ($liveVal !== false && $liveVal >= 0) {
+ $strUpdateBoottime .= ' live_' . $item . ' = :live_' . $item . ', ';
+ if ($item === 'cpuload' || $item === 'cputemp') {
+ $liveVal = round($liveVal);
+ } else {
+ $liveVal = ceil($liveVal / 1024);
+ }
+ $max = ($item === 'cpuload') ? 100 : (2 ** 31);
+ if ($liveVal > $max) {
+ $liveVal = $max;
+ }
+ $params['live_' . $item] = $liveVal;
+ }
+ }
+ if (($runmode = Request::post('runmode', false, 'string')) !== false) {
+ $params['currentrunmode'] = Util::cleanUtf8($runmode);
+ $strUpdateBoottime .= ' currentrunmode = :currentrunmode, ';
}
// Figure out what's happening - state changes
if ($used === 0 && $old['state'] !== 'IDLE') {
@@ -205,17 +272,20 @@ 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
if ($sessionLength !== 0 || $old['logintime'] === 0) {
// This event is a start of a new session, rather than an update
$params['user'] = Request::post('user', null, 'string');
+ if (is_string($params['user'])) {
+ $params['user'] = Util::cleanUtf8($params['user']);
+ }
$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;
}
@@ -223,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) {
@@ -233,11 +303,15 @@ 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) {
- EventLog::warning("[poweroff] IP address of client $uuid seems to have changed ({$old['clientip']} -> $ip)");
- die("Address changed.\n");
+ updateIp('poweroff', $uuid, $old, $ip);
}
if ($mode === false && $old['state'] === 'OCCUPIED' && $old['logintime'] !== 0) {
$sessionLength = $old['lastseen'] - $old['logintime'];
@@ -248,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)) {
@@ -260,23 +338,24 @@ if ($type{0} === '~') {
if (!array_key_exists('name', $screen))
continue;
// Filter bogus data
- $screen['name'] = iconv('UTF-8', 'UTF-8//IGNORE', $screen['name']);
+ $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.)
@@ -291,7 +370,7 @@ if ($type{0} === '~') {
. " VALUES (:id, :key, :value) ON DUPLICATE KEY UPDATE value = VALUES(value)", array(
'id' => $machinehwid,
'key' => $key,
- 'value' => $value,
+ 'value' => Util::cleanUtf8($value),
));
}
}
@@ -313,41 +392,52 @@ 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) {
- EventLog::warning("[suspend] IP address of client $uuid seems to have changed ({$old['clientip']} -> $ip)");
- die("Address changed.\n");
+ updateIp('suspend', $uuid, $old, $ip);
}
if ($NOW - $old['lastseen'] < 610 && $old['state'] !== 'OFFLINE') {
- Database::exec("UPDATE machine SET lastseen = UNIX_TIMESTAMP(), state = 'STANDBY'
+ Database::exec("UPDATE machine SET lastseen = UNIX_TIMESTAMP(), state = 'STANDBY',
+ 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) {
- EventLog::info("[resume] IP address of client $uuid seems to have changed ({$old['clientip']} -> $ip), allowed on resume.");
+ updateIp('resume', $uuid, $old, $ip);
}
if ($old['state'] === 'STANDBY') {
- $res = Database::exec("UPDATE machine SET state = 'IDLE', clientip = :ip, lastseen = UNIX_TIMESTAMP()
+ $res = Database::exec("UPDATE machine SET state = 'IDLE', clientip = :ip, lastseen = UNIX_TIMESTAMP(),
+ standbysem = If(standbysem > 1, standbysem - 2, 0)
WHERE machineuuid = :uuid AND state = :oldstate AND lastseen = :oldlastseen",
array('uuid' => $uuid, 'ip' => $ip, 'oldlastseen' => $old['lastseen'], 'oldstate' => $old['state']));
// Write standby period length to statistic table
@@ -358,12 +448,16 @@ 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']);
}
} else {
die("INVALID ACTION '$type'\n");
}
+ foreach (Hook::load('client-update') as $hook) {
+ include_once $hook->file;
+ }
die("OK. (RESULT=0)\n");
}
@@ -389,45 +483,47 @@ 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 = Request::post('user', 'unknown', 'string');
+ $user = Util::cleanUtf8(Request::post('user', 'unknown', 'string'));
$loguser = Request::post('loguser', 0, 'int') !== 0;
- $sessionName = Request::post('name', 'unknown', 'string');
- $sessionUuid = Request::post('uuid', '', 'string');
+ $sessionName = Util::cleanUtf8(Request::post('name', 'unknown', 'string'));
+ $sessionUuid = Util::cleanUtf8(Request::post('uuid', '', 'string'));
$session = strlen($sessionUuid) === 36 ? $sessionUuid : $sessionName;
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) {
@@ -439,12 +535,20 @@ 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']}'");
}
}
}
+function updateIp($type, $uuid, $old, $newIp)
+{
+ EventLog::warning("[$type] IP address of client $uuid seems to have changed ({$old['clientip']} -> $newIp)");
+ Database::exec("UPDATE machine SET clientip = :ip
+ WHERE machineuuid = :uuid AND state = :oldstate AND lastseen = :oldlastseen",
+ ['uuid' => $uuid, 'oldlastseen' => $old['lastseen'], 'oldstate' => $old['state'], 'ip' => $newIp]);
+}
+
echo "OK.\n";
diff --git a/modules-available/statistics/baseconfig/getconfig.inc.php b/modules-available/statistics/baseconfig/getconfig.inc.php
new file mode 100644
index 00000000..f90cd49d
--- /dev/null
+++ b/modules-available/statistics/baseconfig/getconfig.inc.php
@@ -0,0 +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', null, 'string');
+ }
+}
+
+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]);
+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)
+ 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)
+ VALUES (UNIX_TIMESTAMP(), :type, :ip, :uuid, '', :data)",
+ ['type' => 'boot-runmode', 'ip' => $ip, 'uuid' => $uuid, 'data' => $mode]);
+ }
+ });
+} \ No newline at end of file
diff --git a/modules-available/statistics/baseconfig/hook.json b/modules-available/statistics/baseconfig/hook.json
new file mode 100644
index 00000000..4a406653
--- /dev/null
+++ b/modules-available/statistics/baseconfig/hook.json
@@ -0,0 +1,7 @@
+{
+ "table": "setting_machine",
+ "field": "machineuuid",
+ "locationResolver": "StatisticsHooks::baseconfigLocationResolver",
+ "tostring": "StatisticsHooks::getBaseconfigName",
+ "getInheritance": "StatisticsHooks::baseconfigInheritance"
+} \ 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 412dc3cb..a683ab6a 100644
--- a/modules-available/statistics/config.json
+++ b/modules-available/statistics/config.json
@@ -1,8 +1,6 @@
{
"category": "main.status",
"dependencies": [
- "js_chart",
- "js_selectize",
"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 6393b2c6..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']
- . '. RAM: ' . Util::readableFileSize($row['live_memfree'], -1, 2)
- . ', Swap: ' . Util::readableFileSize($row['live_swapfree'], -1, 2)
- . ', 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,13 +83,13 @@ 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");
}
}
if (mt_rand(1, 10) === 1) {
- Database::exec("DELETE FROM machine WHERE (UNIX_TIMESTAMP() - 86400 * 365) > lastseen");
+ Database::exec("DELETE FROM machine WHERE (UNIX_TIMESTAMP() - 86400 * 365 * 2) > lastseen");
if (mt_rand(1, 100) === 1) {
Database::exec("OPTIMIZE TABLE machine");
}
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
new file mode 100644
index 00000000..f7a50b0d
--- /dev/null
+++ b/modules-available/statistics/hooks/translation.inc.php
@@ -0,0 +1,28 @@
+<?php
+
+$HANDLER = array();
+
+/**
+ * List of valid subsections
+ */
+$HANDLER['subsections'] = array(
+ 'filters'
+);
+
+/*
+ * Handlers for the subsections that will return an array of expected tags.
+ * This is optional, if you don't want to define expected tags, don't create a function.
+ */
+
+/**
+ * Configuration categories.
+ */
+$HANDLER['grep_filters'] = function (Module $module): array {
+ if (!$module->activate(1, false))
+ return array();
+ $want = StatisticsFilter::$columns;
+ foreach ($want as &$entry) {
+ $entry = true;
+ }
+ return $want;
+};
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/filter.inc.php b/modules-available/statistics/inc/filter.inc.php
deleted file mode 100644
index 46de467b..00000000
--- a/modules-available/statistics/inc/filter.inc.php
+++ /dev/null
@@ -1,308 +0,0 @@
-<?php
-
-/* base class with rudimentary SQL generation abilities.
- * WARNING: argument is escaped, but $column and $operator are passed unfiltered into SQL */
-
-class Filter
-{
- /**
- * Delimiter for js_selectize filters
- */
- const DELIMITER = '~,~';
-
- public $column;
- public $operator;
- public $argument;
-
- private static $keyCounter = 0;
-
- public static function getNewKey($colname)
- {
- return $colname . '_' . (self::$keyCounter++);
- }
-
- public function __construct($column, $operator, $argument = null)
- {
- $this->column = trim($column);
- $this->operator = trim($operator);
- $this->argument = is_array($argument) ? $argument : trim($argument);
- }
-
- /* returns a where clause and adds needed operators to the passed array */
- public function whereClause(&$args, &$joins)
- {
- $key = Filter::getNewKey($this->column);
- $addendum = '';
-
- /* check if we have to do some parsing*/
- if (Page_Statistics::$columns[$this->column]['type'] === 'date') {
- $args[$key] = strtotime($this->argument);
- } else {
- $args[$key] = $this->argument;
- if ($this->operator === '~' || $this->operator === '!~') {
- $args[$key] = str_replace(array('=', '_', '%', '*', '?'), array('==', '=_', '=%', '%', '_'), $args[$key]);
- $addendum = " ESCAPE '='";
- }
- }
-
- $op = $this->operator;
- if ($this->operator == '~') {
- $op = 'LIKE';
- } elseif ($this->operator == '!~') {
- $op = 'NOT LIKE';
- }
-
- return $this->column . ' ' . $op . ' :' . $key . $addendum;
- }
-
- /* parse a query into an array of filters */
- public static function parseQuery($query)
- {
- $operators = ['<=', '>=', '!=', '!~', '=', '~', '<', '>'];
- $filters = [];
- if (empty($query))
- return $filters;
- foreach (explode(self::DELIMITER, $query) as $q) {
- $q = trim($q);
- if (empty($q))
- continue;
- // Special case: User pasted UUID, turn into filter
- if (preg_match('/^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$/', $q)) {
- $filters[] = new Filter('machineuuid', '=', $q);
- continue;
- }
- // Special case: User pasted IP, turn into filter
- if (preg_match('/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/', $q)) {
- $filters[] = new Filter('clientip', '=', $q);
- continue;
- }
- /* find position of first operator */
- $pos = 10000;
- $operator = false;
- foreach ($operators as $op) {
- $newpos = strpos($q, $op);
- if ($newpos > -1 && ($newpos < $pos)) {
- $pos = $newpos;
- $operator = $op;
- }
- }
- if ($pos == 10000) {
- error_log("couldn't find operator in segment " . $q);
- /* TODO */
- continue;
- }
- $lhs = trim(substr($q, 0, $pos));
- $rhs = trim(substr($q, $pos + strlen($operator)));
-
- if ($lhs === 'gbram') {
- $filters[] = new RamGbFilter($operator, $rhs);
- } elseif ($lhs === 'runtime') {
- $filters[] = new RuntimeFilter($operator, $rhs);
- } elseif ($lhs === 'state') {
- $filters[] = new StateFilter($operator, $rhs);
- } elseif ($lhs === 'hddgb') {
- $filters[] = new Id44Filter($operator, $rhs);
- } elseif ($lhs === 'location') {
- $filters[] = new LocationFilter($operator, $rhs);
- } elseif ($lhs === 'subnet') {
- $filters[] = new SubnetFilter($operator, $rhs);
- } else {
- if (array_key_exists($lhs, Page_Statistics::$columns) && Page_Statistics::$columns[$lhs]['column']) {
- $filters[] = new Filter($lhs, $operator, $rhs);
- } else {
- Message::addError('invalid-filter-key', $lhs);
- }
- }
- }
-
- return $filters;
- }
-}
-
-class RamGbFilter extends Filter
-{
- public function __construct($operator, $argument)
- {
- parent::__construct('mbram', $operator, $argument);
- }
-
- public function whereClause(&$args, &$joins)
- {
- global $SIZE_RAM;
- $lower = floor(Page_Statistics::findBestValue($SIZE_RAM, (int)$this->argument, false) * 1024 - 100);
- $upper = ceil(Page_Statistics::findBestValue($SIZE_RAM, (int)$this->argument, true) * 1024 + 100);
- if ($this->operator == '=') {
- return " mbram BETWEEN $lower AND $upper";
- } elseif ($this->operator == '<') {
- return " mbram < $lower";
- } elseif ($this->operator == '<=') {
- return " mbram <= $upper";
- } elseif ($this->operator == '>') {
- return " mbram > $upper";
- } elseif ($this->operator == '>=') {
- return " mbram >= $lower";
- } elseif ($this->operator == '!=') {
- return " (mbram < $lower OR mbram > $upper)";
- } else {
- error_log("unimplemented operator in RamGbFilter: $this->operator");
-
- return ' 1';
- }
- }
-}
-
-class RuntimeFilter extends Filter
-{
- public function __construct($operator, $argument)
- {
- parent::__construct('lastboot', $operator, $argument);
- }
-
- public function whereClause(&$args, &$joins)
- {
- global $SIZE_RAM;
- $upper = time() - (int)$this->argument * 3600;
- $lower = $upper - 3600;
- $common = "state IN ('OCCUPIED', 'IDLE', 'STANDBY') AND";
- if ($this->operator == '=') {
- return "$common ({$this->column} BETWEEN $lower AND $upper)";
- } elseif ($this->operator == '<') {
- return "$common {$this->column} > $upper";
- } elseif ($this->operator == '<=') {
- return "$common {$this->column} > $lower";
- } elseif ($this->operator == '>') {
- return "$common {$this->column} < $lower";
- } elseif ($this->operator == '>=') {
- return "$common {$this->column} < $upper";
- } elseif ($this->operator == '!=') {
- return "$common ({$this->column} < $lower OR {$this->column} > $upper)";
- } else {
- error_log("unimplemented operator in RuntimeFilter: $this->operator");
- return ' 1';
- }
- }
-}
-
-class Id44Filter extends Filter
-{
- public function __construct($operator, $argument)
- {
- parent::__construct('id44mb', $operator, $argument);
- }
-
- public function whereClause(&$args, &$joins)
- {
- global $SIZE_ID44;
- if ($this->operator === '=' || $this->operator === '!=') {
- $lower = floor(Page_Statistics::findBestValue($SIZE_ID44, $this->argument, false) * 1024 - 100);
- $upper = ceil(Page_Statistics::findBestValue($SIZE_ID44, $this->argument, true) * 1024 + 100);
- } else {
- $lower = $upper = round($this->argument * 1024);
- }
-
- if ($this->operator === '=') {
- return " id44mb BETWEEN $lower AND $upper";
- } elseif ($this->operator === '!=') {
- return " id44mb < $lower OR id44mb > $upper";
- } elseif ($this->operator === '<=') {
- return " id44mb <= $upper";
- } elseif ($this->operator === '>=') {
- return " id44mb >= $lower";
- } elseif ($this->operator === '<') {
- return " id44mb < $lower";
- } elseif ($this->operator === '>') {
- return " id44mb > $upper";
- } else {
- error_log("unimplemented operator in Id44Filter: $this->operator");
-
- return ' 1';
- }
- }
-}
-
-class StateFilter extends Filter
-{
- public function __construct($operator, $argument)
- {
- parent::__construct(null, $operator, $argument);
- }
-
- public function whereClause(&$args, &$joins)
- {
- $map = [ 'on' => ['IDLE', 'OCCUPIED'], 'off' => ['OFFLINE'], 'idle' => ['IDLE'], 'occupied' => ['OCCUPIED'], 'standby' => ['STANDBY'] ];
- $neg = $this->operator == '!=' ? 'NOT ' : '';
- if (array_key_exists($this->argument, $map)) {
- $key = Filter::getNewKey($this->column);
- $args[$key] = $map[$this->argument];
- return " machine.state $neg IN ( :$key ) ";
- } else {
- Message::addError('invalid-filter-argument', 'state', $this->argument);
- return ' 1';
- }
- }
-}
-
-class LocationFilter extends Filter
-{
- public function __construct($operator, $argument)
- {
- parent::__construct('locationid', $operator, $argument);
- }
-
- public function whereClause(&$args, &$joins)
- {
- $recursive = (substr($this->operator, -1) === '~');
- $this->operator = str_replace('~', '=', $this->operator);
-
- if (is_array($this->argument)) {
- if ($recursive)
- Util::traceError('Cannot use ~ operator for location with array');
- } else {
- settype($this->argument, 'int');
- }
- $neg = $this->operator === '=' ? '' : 'NOT';
- if ($this->argument === 0) {
- return "machine.locationid IS $neg NULL";
- } else {
- $key = Filter::getNewKey($this->column);
- if ($recursive) {
- $args[$key] = array_keys(Location::getRecursiveFlat($this->argument));
- } else {
- $args[$key] = $this->argument;
- }
- return "machine.locationid $neg IN (:$key)";
- }
- }
-}
-
-class SubnetFilter extends Filter
-{
- public function __construct($operator, $argument)
- {
- parent::__construct(null, $operator, $argument);
- }
-
- public function whereClause(&$args, &$joins)
- {
- $argument = preg_replace('/[^0-9\.:]/', '', $this->argument);
- return " clientip LIKE '$argument%'";
- }
-}
-
-class IsClientFilter extends Filter
-{
- public function __construct($argument)
- {
- parent::__construct(null, null, $argument);
- }
-
- public function whereClause(&$args, &$joins)
- {
- if ($this->argument) {
- $joins[] = ' LEFT JOIN runmode USING (machineuuid)';
- return "(runmode.isclient <> 0 OR runmode.isclient IS NULL)";
- }
- $joins[] = ' INNER JOIN runmode USING (machineuuid)';
- return "runmode.isclient = 0";
- }
-}
diff --git a/modules-available/statistics/inc/filterset.inc.php b/modules-available/statistics/inc/filterset.inc.php
deleted file mode 100644
index 774bfd18..00000000
--- a/modules-available/statistics/inc/filterset.inc.php
+++ /dev/null
@@ -1,143 +0,0 @@
-<?php
-
-class FilterSet
-{
- /**
- * @var \Filter[]
- */
- private $filters;
- private $sortDirection;
- private $sortColumn;
-
- private $cache = false;
-
- public function __construct($filters)
- {
- $this->filters = $filters;
- }
-
- public function setSort($col, $direction)
- {
- $direction = ($direction === 'DESC' ? 'DESC' : 'ASC');
-
- if (!is_string($col) || !array_key_exists($col, Page_Statistics::$columns)) {
- /* default sorting column is clientip */
- $col = 'clientip';
- }
- if ($col === $this->sortColumn && $direction === $this->sortDirection)
- return;
- $this->cache = false;
- $this->sortDirection = $direction;
- $this->sortColumn = $col;
- }
-
- public function makeFragments(&$where, &$join, &$sort, &$args)
- {
- if ($this->cache !== false) {
- $where = $this->cache['where'];
- $join = $this->cache['join'];
- $sort = $this->cache['sort'];
- $args = $this->cache['args'];
- return;
- }
- /* generate where clause & arguments */
- $where = '';
- $joins = [];
- $sort = "";
- $args = [];
- if (empty($this->filters)) {
- $where = ' 1 ';
- } else {
- foreach ($this->filters as $filter) {
- $sep = ($where != '' ? ' AND ' : '');
- $where .= $sep . $filter->whereClause($args, $joins);
- }
- }
- $join = implode(' ', array_unique($joins));
-
- $col = $this->sortColumn;
- $isMapped = array_key_exists('map_sort', Page_Statistics::$columns[$col]);
- $concreteCol = ($isMapped ? Page_Statistics::$columns[$col]['map_sort'] : $col) ;
-
- if ($concreteCol === 'clientip') {
- $concreteCol = "INET_ATON(clientip)";
- }
-
- $sort = " ORDER BY " . $concreteCol . " " . $this->sortDirection
- . ", machineuuid ASC";
- $this->cache = compact('where', 'join', 'sort', 'args');
- }
-
- public function isNoId44Filter()
- {
- $filter = $this->hasFilter('Id44Filter');
- return $filter !== false && $filter->argument == 0;
- }
-
- public function getSortDirection()
- {
- return $this->sortDirection;
- }
-
- public function getSortColumn()
- {
- return $this->sortColumn;
- }
-
- public function filterNonClients()
- {
- if (Module::get('runmode') === false || $this->hasFilter('IsClientFilter') !== false)
- return;
- $this->cache = false;
- // Runmode module exists, add filter
- $this->filters[] = new IsClientFilter(true);
- }
-
- /**
- * @param string $type filter type (class name)
- * @return false|Filter The filter, false if not found
- */
- public function hasFilter($type)
- {
- foreach ($this->filters as $filter) {
- if (get_class($filter) === $type) {
- return $filter;
- }
- }
- return false;
- }
-
- /**
- * Add a location filter based on the allowed permissions for the given permission.
- * Returns false if the user doesn't have the given permission for any location.
- *
- * @param string $permission permission to use
- * @return bool false if no permission for any location, true otherwise
- */
- public function setAllowedLocationsFromPermission($permission)
- {
- $locs = User::getAllowedLocations($permission);
- if (empty($locs))
- return false;
- if (in_array(0, $locs)) {
- if (!isset($this->filters['permissions']))
- return true;
- unset($this->filters['permissions']);
- } else {
- $this->filters['permissions'] = new LocationFilter('=', $locs);
- }
- $this->cache = false;
- return true;
- }
-
- /**
- * @return false|array
- */
- public function getAllowedLocations()
- {
- if (isset($this->filters['permissions']->argument) && is_array($this->filters['permissions']->argument))
- return $this->filters['permissions']->argument;
- return false;
- }
-
-}
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 fe850109..00000000
--- a/modules-available/statistics/inc/parser.inc.php
+++ /dev/null
@@ -1,398 +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])) {
- $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[$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) && $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)) {
- $dev['s_' . preg_replace('/\s|-|_/', '', $out[1])] = $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)$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
new file mode 100644
index 00000000..5e6448c7
--- /dev/null
+++ b/modules-available/statistics/inc/statisticsfilter.inc.php
@@ -0,0 +1,855 @@
+<?php
+
+/* base class with rudimentary SQL generation abilities.
+ * WARNING: argument is escaped, but $column and $operator are passed unfiltered into SQL */
+
+abstract class StatisticsFilter
+{
+ /**
+ * Legacy delimiter for js_selectize filters - used to redirect old URLs
+ */
+ const LEGACY_DELIMITER = '~,~';
+
+ 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;
+
+
+ /*
+ * Simple filters that map directly to DB columns
+ */
+
+ const OP_ORDINAL = ['=', '!=', '<', '>', '<=', '>='];
+ const OP_STRCMP = ['~', '!~', '=', '!='];
+ const OP_NOMINAL = ['=', '!='];
+ const OP_LOCATIONS = ['~', '=', '!='];
+ const OP_FUZZY_ORDINAL = ['=', '!=', '~', '!~', '<', '>', '<=', '>='];
+
+ /**
+ * @var StatisticsFilter[]
+ */
+ public static $columns;
+
+ /*
+ * Class instance stuff
+ */
+
+ /**
+ * @var string|null db-based sort column for this field, null if not sortable
+ */
+ public $column;
+
+ /**
+ * @var string[] valid operators for this filter
+ */
+ public $ops;
+ /**
+ * @var string placeholder for input field
+ */
+ public $placeholder;
+
+ public function __construct($column, array $ops, string $placeholder = '')
+ {
+ $this->column = $column;
+ $this->ops = $ops;
+ $this->placeholder = $placeholder;
+ }
+
+ public function type(): string
+ {
+ return ($this->ops === self::OP_ORDINAL || $this->ops === self::OP_FUZZY_ORDINAL) ? 'int' : 'string';
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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)) {
+ // Yes keep $this in this call, get_class() !== get_class($this)
+ ErrorHandler::traceError("Invalid op '$operator' for " . get_class($this) . '::' . $this->column);
+ }
+ }
+
+ /*
+ * Static/Helpers
+ */
+
+ public static function findBestValue($array, $value, $up)
+ {
+ $best = 0;
+ for ($i = 0; $i < count($array); ++$i) {
+ if (abs($array[$i] - $value) < abs($array[$best] - $value)) {
+ $best = $i;
+ }
+ }
+ if (!$up && $best === 0) {
+ return $array[0];
+ }
+ if ($up && $best + 1 === count($array)) {
+ return $array[$best];
+ }
+ if ($up) {
+ return ($array[$best] + $array[$best + 1]) / 2;
+ }
+
+ return ($array[$best] + $array[$best - 1]) / 2;
+ }
+
+ public static function getNewKey($colname): string
+ {
+ return $colname . '_' . (self::$keyCounter++);
+ }
+
+ /**
+ * @return DatabaseFilter[]
+ */
+ public static function parseQuery(): array
+ {
+ // Get current settings from GET
+ $ops = Request::get('op', [], 'array');
+ $currentValues = ArrayUtil::mergeByKey([
+ 'filter' => Request::get('filter', [], 'array'),
+ 'op' => $ops,
+ 'argument' => Request::get('arg', [], 'array'),
+ ]);
+ if (Request::get('show') === false && empty($ops)) {
+ $currentValues['lastseen'] = [
+ 'filter' => true,
+ 'op' => '>',
+ 'argument' => gmdate('Y-m-d', strtotime('-30 day')),
+ ];
+ }
+ $filters = [];
+ foreach ($currentValues as $filterType => $data) {
+ if (!$data['filter'])
+ continue;
+ $operator = $data['op'];
+ $argument = $data['argument'];
+
+ if (array_key_exists($filterType, self::$columns)) {
+ $filters[$filterType] = self::$columns[$filterType]->bind($operator, $argument);
+ } else {
+ Message::addError('invalid-filter-key', $filterType);
+ }
+ }
+
+ return $filters;
+ }
+
+ public static function renderFilterBox(string $show, StatisticsFilterSet $filterSet): void
+ {
+ // Build location list, with permissions
+ if (Module::isAvailable('locations')) {
+ self::$columns['location']->filterLocations($filterSet->getAllowedLocations());
+ }
+ // Build column array for rendering
+ $columns = [];
+ $showCount = 0;
+ foreach (self::$columns as $key => $filter) {
+ $col = [
+ 'key' => $key,
+ 'name' => Dictionary::translateFile('filters', $key),
+ 'placeholder' => $filter->placeholder,
+ ];
+ $bind = $filterSet->hasFilterKey($key);
+ if ($filter->type() === 'int') {
+ $col['input'] = 'number';
+ } elseif ($filter->type() === 'string') {
+ $col['input'] = 'text';
+ } elseif ($filter->type() === 'date') {
+ $col['input'] = 'text';
+ $col['inputclass'] = 'is-date';
+ } elseif ($filter->type() === 'enum') {
+ $col['enum'] = true;
+ /** @var EnumStatisticsFilter $filter */
+ $col['values'] = $filter->values;
+ if ($bind !== null) {
+ // Current value from GET
+ foreach ($col['values'] as &$value) {
+ if ($value['key'] == $bind->argument) {
+ $value['selected'] = 'selected';
+ }
+ }
+ }
+ }
+ // current value from GET
+ if ($bind !== null) {
+ $col['currentvalue'] = $bind->argument;
+ $col['checked'] = 'checked';
+ $showCount++;
+ } elseif (!isset($col['show']) || !$col['show']) {
+ $col['collapse'] = 'collapse';
+ }
+ $col['op'] = $filter->ops;
+ foreach ($col['op'] as &$value) {
+ $value = ['op' => $value];
+ if ($bind !== null && $bind->op === $value['op']) {
+ $value['selected'] = 'selected';
+ }
+ }
+ $columns[$key] = $col;
+ }
+ if ($showCount < 2) {
+ unset($columns['clientip']['collapse']);
+ }
+ if ($showCount < 1) {
+ unset($columns['machineuuid']['collapse']);
+ }
+ $data = array(
+ 'show' => $show,
+ 'columns' => array_values($columns),
+ $show . 'ButtonClass' => 'active',
+ );
+
+ Permission::addGlobalTags($data['perms'], null, ['view.summary', 'view.list']);
+ Render::addTemplate('filterbox', $data);
+ }
+
+ public static function initConstants()
+ {
+ self::$columns = [
+ '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 MacAddressStatisticsFilter(),
+ 'firstseen' => new DateStatisticsFilter('firstseen', '2020-10-15 14:00'),
+ 'lastseen' => new DateStatisticsFilter('lastseen', '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 SystemModelStatisticsFilter(),
+ 'cpumodel' => new SimpleStatisticsFilter('cpumodel', self::OP_STRCMP, 'Pentium Pro 200 MHz'),
+ '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, ''),
+ 'currentuser' => new SimpleStatisticsFilter('currentuser', self::OP_STRCMP, 'login'),
+ 'state' => new StateStatisticsFilter(),
+ '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();
+ }
+ }
+
+}
+
+class SimpleStatisticsFilter extends StatisticsFilter
+{
+
+ 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] === '!') {
+ $op = 'NOT IN';
+ } else {
+ $op = 'IN';
+ }
+ } else {
+ if ($operator === '~' || $operator === '!~') {
+ $args[$key] = str_replace(array('=', '_', '%', '*', '?'), array('==', '=_', '=%', '%', '_'), $args[$key]);
+ $addendum = " ESCAPE '='";
+ }
+ $op = $operator;
+ if ($operator === '~') {
+ $op = 'LIKE';
+ } elseif ($operator === '!~') {
+ $op = 'NOT LIKE';
+ }
+ }
+
+ return 'm.' . $this->column . ' ' . $op . ' (:' . $key . ') ' . $addendum;
+ }
+
+}
+
+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
+{
+
+ public $values;
+
+ public function __construct(string $column, array $values, array $ops = self::OP_NOMINAL)
+ {
+ parent::__construct($column, $ops, '');
+ if (isset($values[0])) {
+ if (!is_array($values[0])) {
+ $values = array_map(function($e) { return [
+ 'key' => $e,
+ 'value' => $e,
+ ]; }, $values);
+ }
+ } else {
+ $values = array_map(function($v, $k) { return [
+ 'key' => $k,
+ 'value' => $v,
+ ]; }, $values, array_keys($values));
+ }
+ $this->values = $values;
+ }
+
+ public function type(): string { return 'enum'; }
+
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
+ {
+ 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';
+ }
+ }
+ 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
+{
+
+ public function __construct(string $column, string $placeholder)
+ {
+ parent::__construct($column, self::OP_ORDINAL, $placeholder);
+ }
+
+ public function type(): string { return 'date'; }
+
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
+ {
+ $key = self::getNewKey($this->column);
+
+ 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);
+ return '0';
+ }
+
+ if (isset($out['m'])) {
+ $span = 'minute';
+ } elseif (isset($out['h'])) {
+ $span = 'hour';
+ $argument .= ':00';
+ } else {
+ $span = 'day';
+ }
+
+ $args[$key] = strtotime($argument);
+ if ($operator === '=' || $operator === '!=') {
+ $key2 = self::getNewKey($this->column);
+ $args[$key2] = strtotime(' +1 ' . $span, $args[$key]);
+ return ($operator === '=' ? '' : 'NOT ') . 'm.' . $this->column . " BETWEEN :$key AND :$key2";
+ }
+ if ($operator === '>' || $operator === '<=') {
+ $args[$key] = strtotime('+1 ' . $span . ' -1 second', $args[$key]);
+ }
+
+ return 'm.' . $this->column . ' ' . $operator . ' :' . $key;
+ }
+
+}
+
+class RuntimeStatisticsFilter extends StatisticsFilter
+{
+
+ public function __construct()
+ {
+ parent::__construct('lastboot', self::OP_ORDINAL);
+ }
+
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
+ {
+ $upper = time() - (int)$argument * 3600;
+ $lower = $upper - 3600;
+ $common = "state IN ('OCCUPIED', 'IDLE', 'STANDBY') AND";
+ if ($operator == '<') { // These are inverted (uptime vs lastboot)
+ return "$common lastboot > $upper";
+ } elseif ($operator == '<=') {
+ return "$common lastboot > $lower";
+ } elseif ($operator == '>') {
+ return "$common lastboot < $lower";
+ } elseif ($operator == '>=') {
+ return "$common lastboot < $upper";
+ } elseif ($operator == '=') {
+ return "$common (lastboot BETWEEN $lower AND $upper)";
+ }
+ // !=
+ return "$common (lastboot NOT BETWEEN $lower AND > $upper)";
+ }
+}
+
+abstract class GbToMbRangeStatisticsFilter extends StatisticsFilter
+{
+
+ protected function rangeClause(string $operator, $argument, array $fuzzyVals): string
+ {
+ if ($operator === '~' || $operator === '!~') {
+ $lower = (int)floor(StatisticsFilter::findBestValue($fuzzyVals, (int)$argument, false) * 1024 - 500);
+ $upper = (int)ceil(StatisticsFilter::findBestValue($fuzzyVals, (int)$argument, true) * 1024 + 100);
+ $operator = str_replace('~', '=', $operator);
+ } else {
+ $lower = round($argument * 1024 - 500);
+ $upper = round($argument * 1024 + 1023);
+ }
+ if ($operator === '=')
+ return " {$this->column} BETWEEN $lower AND $upper";
+ if ($operator === '!=')
+ return " {$this->column} NOT BETWEEN $lower AND $upper";
+ if ($operator === '<')
+ return " {$this->column} < $lower";
+ if ($operator === '<=')
+ return " {$this->column} <= $upper";
+ if ($operator === '>')
+ return " {$this->column} > $upper";
+ return " {$this->column} >= $lower"; // >=
+ }
+
+}
+
+class RamGbStatisticsFilter extends GbToMbRangeStatisticsFilter
+{
+
+ public function __construct()
+ {
+ parent::__construct('mbram', self::OP_FUZZY_ORDINAL, 'GiB');
+ }
+
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
+ {
+ return parent::rangeClause($operator, $argument, self::SIZE_RAM);
+ }
+
+}
+
+class PartitionGbStatisticsFilter extends GbToMbRangeStatisticsFilter
+{
+
+ public function __construct(string $column)
+ {
+ parent::__construct($column, self::OP_FUZZY_ORDINAL, 'GiB');
+ }
+
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
+ {
+ return parent::rangeClause($operator, $argument, self::SIZE_PARTITION);
+ }
+}
+
+class StateStatisticsFilter extends EnumStatisticsFilter
+{
+
+ public function __construct()
+ {
+ parent::__construct('state', ['on', 'off', 'idle', 'occupied', 'standby']);
+ }
+
+ 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 ' : '';
+ if (array_key_exists($argument, $map)) {
+ $key = StatisticsFilter::getNewKey($this->column);
+ $args[$key] = $map[$argument];
+ return " m.state $neg IN ( :$key ) ";
+ }
+ Message::addError('invalid-filter-argument', 'state', $argument);
+ return ' 1';
+ }
+}
+
+class LocationStatisticsFilter extends EnumStatisticsFilter
+{
+
+ public function __construct()
+ {
+ $locs = [];
+ foreach (Location::getLocations(-1, 0, true) as $loc) {
+ $locs[] = [
+ 'key' => $loc['locationid'],
+ 'value' => $loc['locationpad'] . ' ' . $loc['locationname'],
+ ];
+ }
+ parent::__construct('locationid', $locs, self::OP_LOCATIONS);
+ }
+
+ public function type(): string { return 'enum'; }
+
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
+ {
+ $recursive = (substr($operator, -1) === '~');
+ $operator = str_replace('~', '=', $operator);
+
+ if ($recursive && is_array($argument)) {
+ ErrorHandler::traceError('Cannot use ~ operator for location with array');
+ }
+ if ($recursive) {
+ $argument = array_keys(Location::getRecursiveFlat($argument));
+ } elseif ($argument == 0) {
+ return 'locationid IS ' . ($operator === '!=' ? 'NOT' : '') . ' NULL';
+ }
+ return parent::whereClause($operator, $argument, $args, $joins);
+ }
+
+ public function filterLocations($list)
+ {
+ if ($list === false || in_array(0, $list))
+ return;
+ foreach ($this->values as &$loc) {
+ if (!in_array($loc['key'], $list)) {
+ $loc['disabled'] = 'disabled';
+ }
+ }
+ }
+}
+
+class IpStatisticsFilter extends StatisticsFilter
+{
+
+ public function __construct()
+ {
+ parent::__construct('clientip', self::OP_NOMINAL, '1.2.3.4, 1.2.3.*, 1.2.3/24');
+ }
+
+ public function whereClause(string $operator, $argument, array &$args, array &$joins) : string
+ {
+ $argument = strtolower(preg_replace('#[^0-9a-f.:/*]#i', '', $argument));
+ if (filter_var($argument, FILTER_VALIDATE_IP) !== false) {
+ // Valid \o/ - do nothing to $argument
+ } elseif (strpos($argument, '/') !== false) {
+ // TODO: IPv6 CIDR
+ $range = IpUtil::parseCidr($argument);
+ 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) {
+ // TODO: Probably valid IPv6, not yet in DB
+ } elseif (($num = substr_count($argument, '.')) !== 0 && $num <= 3) {
+ if (substr($argument, -1) === '.') {
+ $argument .= '*';
+ } elseif ($num < 3) {
+ $argument .= '.*';
+ }
+ } else {
+ Message::addError('invalid-ip-address', $argument);
+ return '0';
+ }
+ $operator = $operator[0] === '!' ? 'NOT LIKE' : 'LIKE';
+ return "clientip $operator '" . str_replace('*', '%', $argument) . "'";
+ }
+}
+
+class IsClientStatisticsFilter extends StatisticsFilter
+{
+
+ public function __construct()
+ {
+ parent::__construct(null, []);
+ }
+
+ public function whereClause(string $operator, $argument, array &$args, array &$joins): string
+ {
+ if ($argument) {
+ $joins[] = ' LEFT JOIN runmode ON (m.machineuuid = runmode.machineuuid)';
+ return "(runmode.isclient <> 0 OR runmode.isclient IS NULL)";
+ }
+ $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
+ */
+ private $inst;
+ public $op;
+ public $argument;
+
+ /**
+ * Called by StatisticsFilter::bind().
+ */
+ public function __construct(StatisticsFilter $inst, string $op, $argument)
+ {
+ $inst->validateOperator($op);
+ $this->inst = $inst;
+ $this->op = $op;
+ $this->argument = $argument;
+ }
+
+ /**
+ * 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(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
new file mode 100644
index 00000000..26595e93
--- /dev/null
+++ b/modules-available/statistics/inc/statisticsfilterset.inc.php
@@ -0,0 +1,139 @@
+<?php
+
+class StatisticsFilterSet
+{
+ /**
+ * @var \DatabaseFilter[]
+ */
+ private $filters;
+
+ private $cache = false;
+
+ /**
+ * @param DatabaseFilter[] $filters
+ */
+ public function __construct(array $filters)
+ {
+ $this->filters = $filters;
+ }
+
+ public function makeFragments(&$where, &$join, &$args)
+ {
+ if ($this->cache !== false) {
+ $where = $this->cache['where'];
+ $join = $this->cache['join'];
+ $args = $this->cache['args'];
+ return;
+ }
+ /* generate where clause & arguments */
+ $where = '';
+ $joins = [];
+ $args = [];
+ if (empty($this->filters)) {
+ $where = ' 1 ';
+ } else {
+ foreach ($this->filters as $filter) {
+ $sep = ($where != '' ? ' AND ' : '');
+ $where .= $sep . $filter->whereClause($args, $joins);
+ }
+ }
+ $join = implode(' ', array_unique($joins));
+ $this->cache = compact('where', 'join', 'args');
+ }
+
+ public function filterNonClients()
+ {
+ if (Module::get('runmode') === false || $this->hasFilter('IsClientStatisticsFilter') !== null)
+ return;
+ $this->cache = false;
+ // Runmode module exists, add filter
+ $this->filters[] = (new IsClientStatisticsFilter())->bind('=', true);
+ }
+
+ /**
+ * @param string $type filter type (class name)
+ * @return ?DatabaseFilter The filter, null if not found
+ */
+ public function hasFilter(string $type): ?DatabaseFilter
+ {
+ foreach ($this->filters as $filter) {
+ if ($filter->isClass($type)) {
+ return $filter;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * @param string $type filter type key/id
+ * @return ?DatabaseFilter The filter, null if not found
+ */
+ public function hasFilterKey(string $type): ?DatabaseFilter
+ {
+ if (isset($this->filters[$type]))
+ return $this->filters[$type];
+ return null;
+ }
+
+ /**
+ * Add a location filter based on the allowed permissions for the given permission.
+ * Returns false if the user doesn't have the given permission for any location.
+ *
+ * @param string $permission permission to use
+ * @return bool false if no permission for any location, true otherwise
+ */
+ public function setAllowedLocationsFromPermission(string $permission): bool
+ {
+ if (!Module::isAvailable('locations'))
+ return true;
+ $locs = User::getAllowedLocations($permission);
+ if (empty($locs))
+ return false;
+ if (in_array(0, $locs)) {
+ if (!isset($this->filters['permissions']))
+ return true;
+ unset($this->filters['permissions']);
+ } else {
+ $this->filters['permissions'] = StatisticsFilter::$columns['location']->bind('=', $locs);
+ }
+ $this->cache = false;
+ return true;
+ }
+
+ /**
+ * @return false|array
+ */
+ public function getAllowedLocations()
+ {
+ 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
new file mode 100644
index 00000000..6b9dfa21
--- /dev/null
+++ b/modules-available/statistics/inc/statisticshooks.inc.php
@@ -0,0 +1,55 @@
+<?php
+
+class StatisticsHooks
+{
+
+ private static $row = false;
+
+ private static function getRow(string $machineuuid)
+ {
+ if (self::$row !== false)
+ return;
+ self::$row = Database::queryFirst('SELECT hostname, clientip, locationid FROM machine WHERE machineuuid = :machineuuid',
+ ['machineuuid' => $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['clientip'];
+ }
+
+ /**
+ * Hook for baseconfig.
+ */
+ public static function baseconfigLocationResolver(string $machineuuid): int
+ {
+ self::getRow($machineuuid);
+ if (self::$row === false)
+ return 0;
+ return (int)self::$row['locationid'];
+ }
+
+ /**
+ * Hook to get inheritance tree for all config vars.
+ *
+ * @param string $machineuuid MachineUUID currently being edited
+ */
+ public static function baseconfigInheritance(string $machineuuid): array
+ {
+ self::getRow($machineuuid);
+ if (self::$row === false)
+ return [];
+ BaseConfig::prepareWithOverrides([
+ 'locationid' => self::$row['locationid'] ?? 0
+ ]);
+ return ConfigHolder::getRecursiveConfig(true);
+ }
+
+} \ No newline at end of file
diff --git a/modules-available/statistics/inc/statisticsstyling.inc.php b/modules-available/statistics/inc/statisticsstyling.inc.php
new file mode 100644
index 00000000..0e158026
--- /dev/null
+++ b/modules-available/statistics/inc/statisticsstyling.inc.php
@@ -0,0 +1,62 @@
+<?php
+
+class StatisticsStyling
+{
+
+ public static function ramColorClass(int $mb): string
+ {
+ if ($mb < 2500) {
+ return 'danger';
+ }
+ if ($mb < 5100) {
+ return 'warning';
+ }
+
+ return '';
+ }
+
+ public static function kvmColorClass(string $state): string
+ {
+ if ($state === 'DISABLED') {
+ return 'danger';
+ }
+ if ($state === 'UNKNOWN' || $state === 'UNSUPPORTED') {
+ return 'warning';
+ }
+
+ return '';
+ }
+
+ public static function hddColorClass(int $gb): string
+ {
+ if ($gb < 7) {
+ return 'danger';
+ }
+ if ($gb < 25) {
+ return 'warning';
+ }
+
+ 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 2bcb6b8d..bc8a5c91 100644
--- a/modules-available/statistics/install.inc.php
+++ b/modules-available/statistics/install.inc.php
@@ -1,8 +1,5 @@
<?php
-// locationid trigger
-$addTrigger = false;
-
$res = array();
// The main statistic table used for log entries
@@ -14,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`),
@@ -24,7 +21,7 @@ $res[] = tableCreate('statistic', "
// Main table containing all known clients
-$res[] = $machineCreate = tableCreate('machine', "
+$res[] = tableCreate('machine', "
`machineuuid` char(36) CHARACTER SET ascii NOT NULL,
`fixedlocationid` int(11) DEFAULT NULL COMMENT 'Manually set location (e.g. roomplanner)',
`subnetlocationid` int(11) DEFAULT NULL COMMENT 'Automatically determined location (e.g. from subnet match)',
@@ -43,8 +40,10 @@ $res[] = $machineCreate = 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,
@@ -67,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`),
@@ -77,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`)
");
@@ -107,10 +108,15 @@ $res[] = tableCreate('pciid', "
PRIMARY KEY (`category`,`id`)
");
-// need trigger?
-if ($machineCreate === UPDATE_DONE) {
- $addTrigger = true;
-}
+// baseconfig override per machine
+$res[] = tableCreate('setting_machine', '
+ `machineuuid` char(36) CHARACTER SET ascii NOT NULL,
+ `setting` VARCHAR(28) NOT NULL,
+ `value` TEXT NOT NULL,
+ `displayvalue` TEXT NOT NULL,
+ PRIMARY KEY (`machineuuid`,`setting`),
+ KEY `setting` (`setting`)
+');
//
// This was added/changed later -- keep update path
@@ -167,7 +173,6 @@ if (!tableHasColumn('machine', 'subnetlocationid')) {
finalResponse(UPDATE_FAILED, 'Adding subnetlocationid to machine failed: ' . Database::lastError());
}
$res[] = UPDATE_DONE;
- $addTrigger = true;
}
// And fixedlocationid - manually set location, currently used by roomplanner
if (!tableHasColumn('machine', 'fixedlocationid')) {
@@ -179,10 +184,10 @@ if (!tableHasColumn('machine', 'fixedlocationid')) {
// Now copy over the values from locationid, since this was used before
Database::exec("UPDATE machine SET fixedlocationid = locationid");
$res[] = UPDATE_DONE;
- $addTrigger = true;
}
-// If any of these was added, create the trigger
-if ($addTrigger) {
+
+$checkTrigger = Database::queryFirst("show triggers where `Trigger` = 'set_automatic_locationid'");
+if ($checkTrigger === false) {
$ret = Database::exec("
CREATE TRIGGER set_automatic_locationid
BEFORE UPDATE ON machine FOR EACH ROW
@@ -240,14 +245,14 @@ if (!tableHasColumn('machine', 'live_tmpsize')) {
ADD INDEX `live_tmpfree` (`live_tmpfree`),
ADD INDEX `live_memfree` (`live_memfree`)");
if ($ret === false) {
- finalResponse(UPDATE_FAILED, 'Adding state column to machine table failed: ' . Database::lastError());
+ finalResponse(UPDATE_FAILED, 'Adding mem-stat columns to machine table failed: ' . Database::lastError());
}
$res[] = UPDATE_DONE;
}
// 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) {
@@ -259,5 +264,108 @@ while ($row = $res2->fetch(PDO::FETCH_ASSOC)) {
}
}
+// 2019-10-31: New table for per-machine config override
+$res[] = tableAddConstraint('setting_machine', 'machineuuid', 'machine', 'machineuuid',
+ 'ON UPDATE CASCADE ON DELETE CASCADE');
+
+// 2020-04-25: Track enter/exit standby count, live CPU load
+if (!tableHasColumn('machine', 'live_cpuload')) {
+ $ret = Database::exec("ALTER TABLE `machine`
+ ADD COLUMN `live_cpuload` tinyint(3) UNSIGNED NOT NULL DEFAULT '255' AFTER `live_memfree`,
+ ADD COLUMN `live_cputemp` tinyint(3) UNSIGNED NOT NULL DEFAULT '0' AFTER `live_cpuload`,
+ ADD COLUMN `standbysem` tinyint(3) UNSIGNED NOT NULL DEFAULT '0',
+ ADD INDEX `live_cpuload` (`live_cpuload`),
+ ADD INDEX `live_cputemp` (`live_cputemp`)");
+ if ($ret === false) {
+ finalResponse(UPDATE_FAILED, 'Adding live_cpuload column to machine table failed: ' . Database::lastError());
+ }
+ $res[] = UPDATE_DONE;
+}
+
+// 2020-06-29: Track current runmode (as reported by client)
+if (!tableHasColumn('machine', 'currentrunmode')) {
+ $ret = Database::exec("ALTER TABLE `machine`
+ ADD COLUMN `currentrunmode` varchar(30) NOT NULL DEFAULT '' AFTER `hostname`");
+ if ($ret === false) {
+ finalResponse(UPDATE_FAILED, 'Adding live_cpuload column to machine table failed: ' . Database::lastError());
+ }
+ $res[] = UPDATE_DONE;
+}
+
+// 2019-01-25: Add memory/temp stats column
+if (!tableHasColumn('machine', 'live_id45size')) {
+ $ret = Database::exec("ALTER TABLE `machine`
+ ADD COLUMN `live_id45size` int(10) UNSIGNED NOT NULL DEFAULT '0' AFTER `live_tmpfree`,
+ ADD COLUMN `live_id45free` int(10) UNSIGNED NOT NULL DEFAULT '0' AFTER `live_id45size`");
+ if ($ret === false) {
+ finalResponse(UPDATE_FAILED, 'Adding mem-stat columns to machine table failed: ' . Database::lastError());
+ }
+ $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
new file mode 100644
index 00000000..ef423daa
--- /dev/null
+++ b/modules-available/statistics/lang/de/filters.json
@@ -0,0 +1,29 @@
+{
+ "badsectors": "Defekte Sektoren",
+ "clientip": "IP-Adresse",
+ "cpumodel": "CPU-Modell",
+ "currentuser": "Aktueller\/Letzter Benutzer",
+ "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",
+ "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 a9256d5a..023dac4c 100644
--- a/modules-available/statistics/lang/de/messages.json
+++ b/modules-available/statistics/lang/de/messages.json
@@ -1,8 +1,15 @@
{
+ "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 4b7ab8df..064805c2 100644
--- a/modules-available/statistics/lang/de/template-tags.json
+++ b/modules-available/statistics/lang/de/template-tags.json
@@ -1,30 +1,47 @@
{
"lang_64bitSupport": "64\u2009Bit Gast-Support",
- "lang_add": "Hinzuf\u00fcgen",
- "lang_add_filter": "Filter hinzuf\u00fcgen",
+ "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",
+ "lang_configuredRunMode": "Konfigurierter Betriebsmodus",
"lang_cores": "Kerne",
"lang_cpuCores": "CPU-Kerne",
"lang_cpuModel": "CPU",
- "lang_currentUser": "Aktueller\/Letzter Benutzer",
+ "lang_cpuload": "CPU-Last",
+ "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",
@@ -32,8 +49,9 @@
"lang_kvmSupport": "64\u2009Bit G\u00e4ste",
"lang_labelFilter": "Aktive Filter (UND-Logik)",
"lang_lastBoot": "Letzter Boot",
- "lang_lastLogin": "Letzer Login",
"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",
@@ -46,11 +64,11 @@
"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_memFree": "RAM frei (MB)",
+ "lang_mediaIntegrityErrors": "\"Media Integrity Errors\"",
"lang_memoryStats": "Arbeitsspeicher",
"lang_mobomodel": "Mainboard",
- "lang_model": "Modell",
"lang_modelCount": "Anzahl",
"lang_modelName": "Modellname",
"lang_modelNo": "Modell",
@@ -58,9 +76,14 @@
"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",
+ "lang_numConfigVars": "Anzahl \u00fcberschriebener Konfigurationsvariablen",
"lang_onlineMachines": "Gestartete Clients",
"lang_partName": "Name",
"lang_partSize": "Gr\u00f6\u00dfe",
@@ -68,49 +91,67 @@
"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",
"lang_rebootConfirm": "Ausgew\u00e4hlte Rechner wirklich neustarten?",
"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_runtimeHours": "Laufzeit (Stunden)",
"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_subnet": "Subnetz",
+ "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_swapFree": "swap frei (MB)",
- "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_tmpFree": "ID44 frei (MB)",
"lang_tmpGb": "Temp-HDD",
"lang_total": "Gesamt",
+ "lang_type": "Typ",
+ "lang_unused": "Ungenutzt",
"lang_usageDetails": "Nutzungsdetails",
"lang_usageState": "Zustand",
"lang_uuid": "UUID",
"lang_virtualCores": "Virtuelle Kerne",
+ "lang_wakeOnLan": "WakeOnLan",
"lang_when": "Wann",
"lang_withBadSectors": "Clients mit potentiell defekten Festplatten (mehr als 10 defekte Sektoren)"
} \ No newline at end of file
diff --git a/modules-available/statistics/lang/en/filters.json b/modules-available/statistics/lang/en/filters.json
new file mode 100644
index 00000000..79372115
--- /dev/null
+++ b/modules-available/statistics/lang/en/filters.json
@@ -0,0 +1,29 @@
+{
+ "badsectors": "Bad sectors",
+ "clientip": "IP address",
+ "cpumodel": "CPU model",
+ "currentuser": "Current\/last user",
+ "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",
+ "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 0f290f2e..139076d2 100644
--- a/modules-available/statistics/lang/en/messages.json
+++ b/modules-available/statistics/lang/en/messages.json
@@ -1,8 +1,15 @@
{
+ "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 08b995cb..10acfdb1 100644
--- a/modules-available/statistics/lang/en/template-tags.json
+++ b/modules-available/statistics/lang/en/template-tags.json
@@ -1,30 +1,47 @@
{
"lang_64bitSupport": "64\u2009Bit guest support",
- "lang_add": "Add",
- "lang_add_filter": "Add Filter",
+ "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",
+ "lang_configuredRunMode": "Configured mode of operation",
"lang_cores": "Cores",
"lang_cpuCores": "CPU cores",
"lang_cpuModel": "CPU",
- "lang_currentUser": "Current\/last user",
+ "lang_cpuload": "CPU load",
+ "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",
@@ -32,8 +49,9 @@
"lang_kvmSupport": "64\u2009Bit guests",
"lang_labelFilter": "Active filters (AND logic)",
"lang_lastBoot": "Last boot",
- "lang_lastLogin": "Last login",
"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",
@@ -46,11 +64,11 @@
"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_memFree": "RAM free (MB)",
+ "lang_mediaIntegrityErrors": "Media Integrity Errors",
"lang_memoryStats": "Memory",
"lang_mobomodel": "Mainboard",
- "lang_model": "Model",
"lang_modelCount": "Count",
"lang_modelName": "Model name",
"lang_modelNo": "Model",
@@ -58,9 +76,14 @@
"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",
+ "lang_numConfigVars": "Number of configuration variables overridden for this client",
"lang_onlineMachines": "Online clients",
"lang_partName": "Name",
"lang_partSize": "Size",
@@ -68,49 +91,67 @@
"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",
"lang_rebootConfirm": "Reboot selected machines?",
"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_runtimeHours": "Runtime (hours)",
"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_subnet": "Subnet",
+ "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_swapFree": "swap free (MB)",
- "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_tmpFree": "ID44 free (MB)",
"lang_tmpGb": "Temp HDD",
"lang_total": "Total",
+ "lang_type": "Type",
+ "lang_unused": "Unused",
"lang_usageDetails": "Detailed usage",
"lang_usageState": "State",
"lang_uuid": "UUID",
"lang_virtualCores": "Virtual cores",
+ "lang_wakeOnLan": "WakeOnLan",
"lang_when": "When",
"lang_withBadSectors": "Clients with potentially bad HDDs (more than 10 reallocated sectors)"
} \ No newline at end of file
diff --git a/modules-available/statistics/page.inc.php b/modules-available/statistics/page.inc.php
index 505fdf9a..4f11e835 100644
--- a/modules-available/statistics/page.inc.php
+++ b/modules-available/statistics/page.inc.php
@@ -1,24 +1,7 @@
<?php
-global $STATS_COLORS, $SIZE_ID44, $SIZE_RAM;
-
-$STATS_COLORS = array();
-for ($i = 0; $i < 10; ++$i) {
- $STATS_COLORS[] = '#55' . sprintf('%02s%02s', dechex((($i + 1) * ($i + 1)) / .3922), dechex(abs((5 - $i) * 51)));
-}
-//$STATS_COLORS = array('#57e', '#ee8', '#5ae', '#fb7', '#6d7', '#e77', '#3af', '#666', '#e0e', '#999');
-$SIZE_ID44 = array(0, 8, 16, 24, 30, 40, 50, 60, 80, 100, 120, 150, 180, 250, 300, 400, 500, 1000, 2000, 4000);
-$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);
-
class Page_Statistics extends Page
{
- /* some constants, TODO: Find a better place */
- const OP_NOMINAL = ['!=', '='];
- const OP_ORDINAL = ['!=', '<=', '>=', '=', '<', '>'];
- const OP_STRCMP = ['!~', '~', '=', '!='];
- public static $columns;
-
- private $query;
private $show;
/**
@@ -26,128 +9,6 @@ class Page_Statistics extends Page
*/
private $haveSubpage;
- /**
- * Do this here instead of const since we need to check for available modules while building array.
- */
- public static function initConstants()
- {
-
- Page_Statistics::$columns = [
- 'machineuuid' => [
- 'op' => Page_Statistics::OP_NOMINAL,
- 'type' => 'string',
- 'column' => true,
- ],
- 'macaddr' => [
- 'op' => Page_Statistics::OP_NOMINAL,
- 'type' => 'string',
- 'column' => true,
- ],
- 'firstseen' => [
- 'op' => Page_Statistics::OP_ORDINAL,
- 'type' => 'date',
- 'column' => true,
- ],
- 'lastseen' => [
- 'op' => Page_Statistics::OP_ORDINAL,
- 'type' => 'date',
- 'column' => true,
- ],
- 'logintime' => [
- 'op' => Page_Statistics::OP_ORDINAL,
- 'type' => 'date',
- 'column' => true,
- ],
- 'realcores' => [
- 'op' => Page_Statistics::OP_ORDINAL,
- 'type' => 'int',
- 'column' => true,
- ],
- 'systemmodel' => [
- 'op' => Page_Statistics::OP_STRCMP,
- 'type' => 'string',
- 'column' => true,
- ],
- 'cpumodel' => [
- 'op' => Page_Statistics::OP_STRCMP,
- 'type' => 'string',
- 'column' => true,
- ],
- 'hddgb' => [
- 'op' => Page_Statistics::OP_ORDINAL,
- 'type' => 'int',
- 'column' => false,
- 'map_sort' => 'id44mb'
- ],
- 'gbram' => [
- 'op' => Page_Statistics::OP_ORDINAL,
- 'type' => 'int',
- 'map_sort' => 'mbram',
- 'column' => false,
- ],
- 'kvmstate' => [
- 'op' => Page_Statistics::OP_NOMINAL,
- 'type' => 'enum',
- 'column' => true,
- 'values' => ['ENABLED', 'DISABLED', 'UNSUPPORTED']
- ],
- 'badsectors' => [
- 'op' => Page_Statistics::OP_ORDINAL,
- 'type' => 'int',
- 'column' => true
- ],
- 'clientip' => [
- 'op' => Page_Statistics::OP_NOMINAL,
- 'type' => 'string',
- 'column' => true
- ],
- 'hostname' => [
- 'op' => Page_Statistics::OP_STRCMP,
- 'type' => 'string',
- 'column' => true
- ],
- 'subnet' => [
- 'op' => Page_Statistics::OP_NOMINAL,
- 'type' => 'string',
- 'column' => false
- ],
- 'currentuser' => [
- 'op' => Page_Statistics::OP_NOMINAL,
- 'type' => 'string',
- 'column' => true
- ],
- 'state' => [
- 'op' => Page_Statistics::OP_NOMINAL,
- 'type' => 'enum',
- 'column' => true,
- 'values' => ['occupied', 'on', 'off', 'idle', 'standby']
- ],
- 'live_swapfree' => [
- 'op' => Page_Statistics::OP_ORDINAL,
- 'type' => 'int',
- 'column' => true
- ],
- 'live_memfree' => [
- 'op' => Page_Statistics::OP_ORDINAL,
- 'type' => 'int',
- 'column' => true
- ],
- 'live_tmpfree' => [
- 'op' => Page_Statistics::OP_ORDINAL,
- 'type' => 'int',
- 'column' => true
- ],
- ];
- if (Module::isAvailable('locations')) {
- Page_Statistics::$columns['location'] = [
- 'op' => Page_Statistics::OP_STRCMP,
- 'type' => 'enum',
- 'column' => false,
- 'values' => array_keys(Location::getLocationsAssoc()),
- ];
- }
- }
-
protected function doPreprocess()
{
User::load();
@@ -156,69 +17,135 @@ class Page_Statistics extends Page
Util::redirect('?do=Main');
}
+ if (Request::isGet()) {
+ $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) {
- if (User::hasPermission('view.summary')) {
+ if ($this->show === false && Request::isGet()) {
+ if (Request::get('uuid') !== false) {
+ $this->show = 'machine';
+ } elseif (User::hasPermission('view.summary')) {
$this->show = 'summary';
} elseif (User::hasPermission('view.list')) {
$this->show = 'list';
} else {
User::assertPermission('view.summary');
}
- } else {
+ }
+ if ($this->show !== false) {
$this->show = preg_replace('/[^a-z0-9_\-]/', '', $this->show);
+ if (!file_exists('modules/statistics/pages/' . $this->show . '.inc.php')) {
+ Message::addError('main.invalid-action', $this->show);
+ } else {
+ require_once 'modules/statistics/pages/' . $this->show . '.inc.php';
+ $this->haveSubpage = true;
+ SubPage::doPreprocess();
+ }
+ return;
}
- if (file_exists('modules/statistics/pages/' . $this->show . '.inc.php')) {
-
- require_once 'modules/statistics/pages/' . $this->show . '.inc.php';
- $this->haveSubpage = true;
- SubPage::doPreprocess();
-
- } else {
+ if (!Request::isPost())
+ return;
- $action = Request::post('action');
- if ($action === 'setnotes') {
- $uuid = Request::post('uuid', '', 'string');
- $res = Database::queryFirst('SELECT locationid FROM machine WHERE machineuuid = :uuid',
- array('uuid' => $uuid));
- if ($res === false) {
- Message::addError('unknown-machine', $uuid);
- Util::redirect('?do=statistics');
- }
- User::assertPermission("machine.note.edit", (int)$res['locationid']);
- $text = Request::post('content', null, 'string');
- if (empty($text)) {
- $text = null;
- }
- Database::exec('UPDATE machine SET notes = :text WHERE machineuuid = :uuid', array(
- 'uuid' => $uuid,
- 'text' => $text,
- ));
- Message::addSuccess('notes-saved');
- Util::redirect('?do=statistics&uuid=' . $uuid);
- } elseif ($action === 'delmachines') {
- $this->deleteMachines();
- Util::redirect('?do=statistics', true);
- } elseif ($action === 'rebootmachines') {
- $this->rebootControl(true);
- } elseif ($action === 'shutdownmachines') {
- $this->rebootControl(false);
- }
+ // POST
+ $action = Request::post('action');
+ if ($action === 'setnotes') {
+ $uuid = Request::post('uuid', '', 'string');
+ $res = Database::queryFirst('SELECT locationid FROM machine WHERE machineuuid = :uuid',
+ array('uuid' => $uuid));
+ if ($res === false) {
+ Message::addError('unknown-machine', $uuid);
+ Util::redirect('?do=statistics');
+ }
+ User::assertPermission("machine.note.edit", (int)$res['locationid']);
+ $text = Request::post('content', null, 'string');
+ if (empty($text)) {
+ $text = null;
+ }
+ Database::exec('UPDATE machine SET notes = :text WHERE machineuuid = :uuid', array(
+ 'uuid' => $uuid,
+ 'text' => $text,
+ ));
+ Message::addSuccess('notes-saved');
+ Util::redirect('?do=statistics&uuid=' . $uuid);
+ } elseif ($action === 'clear-machines') {
+ $this->deleteMachines(true);
+ } elseif ($action === 'delmachines') {
+ $this->deleteMachines(false);
+ Util::redirect('?do=statistics', true);
+ } elseif ($action === 'rebootmachines') {
+ $this->rebootControl(true);
+ } elseif ($action === 'shutdownmachines') {
+ $this->rebootControl(false);
+ } elseif ($action === 'wol') {
+ $this->wol();
+ } elseif ($action === 'benchmark') {
+ $this->vmstoreBenchmark();
+ } elseif ($action === 'prepare-exec') {
+ if (Module::isAvailable('rebootcontrol')) {
+ RebootControl::prepareExec();
+ }
+ }
+
+ // Make sure we don't render any content for POST requests - should be handled above and then
+ // redirected properly
+ Util::redirect('?do=statistics');
+ }
+ private function transformLegacyQuery()
+ {
+ if (!Request::isGet())
+ return;
+ $query = Request::get('filters', false, 'string');
+ if ($query === false)
+ return;
+ foreach (explode(StatisticsFilter::LEGACY_DELIMITER, $query) as $q) {
+ if (!preg_match('/^\s*(\w+)\s*([<>=!~]{1,2})\s*(.*?)\s*$/', $q, $out))
+ continue;
+ $key = $out[1];
+ $_GET['filter'][$key] = '1';
+ $_GET['op'][$key] = $out[2];
+ $_GET['arg'][$key] = $out[3];
+ }
+ unset($_GET['filters']);
+ if (!empty($_GET['filter'])) {
+ Util::redirect('?' . http_build_query($_GET));
}
+ }
- if (Request::isPost()) {
- // Make sure we don't render any content for POST requests - should be handled above and then
- // redirected properly
- Util::redirect('?do=statistics');
+ private function wol()
+ {
+ if (!Module::isAvailable('rebootcontrol'))
+ return;
+ $ids = Request::post('uuid', [], 'array');
+ $ids = array_values($ids);
+ if (empty($ids)) {
+ Message::addError('main.parameter-empty', 'uuid');
+ return;
}
+ $this->getAllowedMachines(".rebootcontrol.action.wol", $ids, $allowedMachines);
+ if (empty($allowedMachines))
+ return;
+ $taskid = RebootControl::wakeMachines($allowedMachines);
+ Util::redirect('?do=rebootcontrol&show=task&what=task&taskid=' . $taskid);
}
/**
* @param bool $reboot true = reboot, false = shutdown
*/
- private function rebootControl($reboot)
+ private function rebootControl(bool $reboot)
{
if (!Module::isAvailable('rebootcontrol'))
return;
@@ -228,16 +155,54 @@ class Page_Statistics extends Page
Message::addError('main.parameter-empty', 'uuid');
return;
}
- $allowedLocations = User::getAllowedLocations(".rebootcontrol.action." . ($reboot ? 'reboot' : 'shutdown'));
+ $this->getAllowedMachines(".rebootcontrol.action." . ($reboot ? 'reboot' : 'shutdown'), $ids, $allowedMachines);
+ if (empty($allowedMachines))
+ return;
+ if ($reboot && Request::post('kexec', false)) {
+ $action = RebootControl::KEXEC_REBOOT;
+ } elseif ($reboot) {
+ $action = RebootControl::REBOOT;
+ } else {
+ $action = RebootControl::SHUTDOWN;
+ }
+ $task = RebootControl::execute($allowedMachines, $action, 0);
+ if (Taskmanager::isTask($task)) {
+ Util::redirect("?do=rebootcontrol&show=task&what=task&taskid=" . $task["id"]);
+ }
+ }
+
+ /**
+ * @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);
if (empty($allowedLocations)) {
Message::addError('main.no-permission');
Util::redirect('?do=statistics');
}
- $res = Database::simpleQuery('SELECT machineuuid, clientip, locationid FROM machine WHERE machineuuid IN (:ids)', compact('ids'));
+ $res = Database::simpleQuery('SELECT machineuuid, clientip, macaddr, locationid FROM machine
+ WHERE machineuuid IN (:ids)', compact('ids'));
$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)) {
@@ -250,27 +215,9 @@ class Page_Statistics extends Page
if (!empty($ids)) {
Message::addWarning('unknown-machine', implode(', ', array_keys($ids)));
}
- if (!empty($allowedMachines)) {
- if (count($seenLocations) === 1) {
- $locactionId = (int)array_keys($seenLocations)[0];
- } else {
- $locactionId = 0;
- }
- if ($reboot && Request::post('kexec', false)) {
- $action = RebootControl::KEXEC_REBOOT;
- } elseif ($reboot) {
- $action = RebootControl::REBOOT;
- } else {
- $action = RebootControl::SHUTDOWN;
- }
- $task = RebootControl::execute($allowedMachines, $action, 0, $locactionId);
- if (Taskmanager::isTask($task)) {
- Util::redirect("?do=rebootcontrol&taskid=" . $task["id"]);
- }
- }
}
- private function deleteMachines()
+ private function deleteMachines($soft)
{
$ids = Request::post('uuid', [], 'array');
$ids = array_values($ids);
@@ -286,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'];
@@ -295,8 +242,18 @@ class Page_Statistics extends Page
}
}
if (!empty($delete)) {
- Database::exec('DELETE FROM machine WHERE machineuuid IN (:delete)', compact('delete'));
- Message::addSuccess('deleted-n-machines', count($delete));
+ if ($soft) {
+ // "Soft delete" -- keep all data, but set IP address to 0.0.0.0, so it will not be assigned to its
+ // old location anymore. Upon next boot some time in the future, the machine is hopefully relocated
+ // to somewhere else and will appear in a new location
+ Database::exec("UPDATE machine SET clientip = '0.0.0.0', fixedlocationid = NULL, subnetlocationid = NULL
+ WHERE machineuuid IN (:delete)", compact('delete'));
+ Message::addSuccess('cleared-n-machines', count($delete));
+ } else {
+ // Actually purge from DB
+ Database::exec('DELETE FROM machine WHERE machineuuid IN (:delete)', compact('delete'));
+ Message::addSuccess('deleted-n-machines', count($delete));
+ }
}
if (!empty($ids)) {
Message::addWarning('unknown-machine', implode(', ', array_keys($ids)));
@@ -310,876 +267,30 @@ class Page_Statistics extends Page
return;
}
- $uuid = Request::get('uuid', false, 'string');
- if ($uuid !== false) {
- $this->showMachine($uuid);
- return;
- }
-
- /* read filter */
- $this->query = Request::any('filters', false);
- if ($this->query === false) {
- $this->query = 'lastseen > ' . gmdate('Y-m-d', strtotime('-30 day'));
- }
- $sortColumn = Request::any('sortColumn');
- $sortDirection = Request::any('sortDirection');
-
- $filters = Filter::parseQuery($this->query);
- $filterSet = new FilterSet($filters);
- $filterSet->setSort($sortColumn, $sortDirection);
-
- if (!$filterSet->setAllowedLocationsFromPermission('view.' . $this->show)) {
- Message::addError('main.no-permission');
- Util::redirect('?do=main');
- }
-
- if ($this->show === 'list') {
- Render::openTag('div', array('class' => 'row'));
- $this->showFilter('list', $filterSet);
- Render::closeTag('div');
- $this->showMachineList($filterSet);
- return;
- } elseif ($this->show === 'summary') {
- $filterSet->filterNonClients();
- Render::openTag('div', array('class' => 'row'));
- $this->showFilter('summary', $filterSet);
- $this->showSummary($filterSet);
- $this->showMemory($filterSet);
- $this->showId44($filterSet);
- $this->showKvmState($filterSet);
- $this->showLatestMachines($filterSet);
- $this->showSystemModels($filterSet);
- Render::closeTag('div');
- } else {
- Message::addError('main.value-invalid', 'show', $this->show);
- }
+ Message::addError('main.value-invalid', 'show', $this->show);
}
- /**
- * @param \FilterSet $filterSet
- */
- private function showFilter($show, $filterSet)
- {
- $data = array(
- 'show' => $show,
- 'query' => $this->query,
- 'delimiter' => Filter::DELIMITER,
- 'sortDirection' => $filterSet->getSortDirection(),
- 'sortColumn' => $filterSet->getSortColumn(),
- 'columns' => json_encode(Page_Statistics::$columns),
- );
-
- if ($show === 'list') {
- $data['listButtonClass'] = 'active';
- $data['statButtonClass'] = '';
- } else {
- $data['listButtonClass'] = '';
- $data['statButtonClass'] = 'active';
- }
-
-
- $locsFlat = array();
- if (Module::isAvailable('locations')) {
- $allowed = $filterSet->getAllowedLocations();
- foreach (Location::getLocations() as $loc) {
- $locsFlat['L' . $loc['locationid']] = array(
- 'pad' => $loc['locationpad'],
- 'name' => $loc['locationname'],
- 'disabled' => $allowed !== false && !in_array($loc['locationid'], $allowed),
- );
- }
- }
-
- Permission::addGlobalTags($data['perms'], null, ['view.summary', 'view.list']);
- $data['locations'] = json_encode($locsFlat);
- Render::addTemplate('filterbox', $data);
-
-
- }
- private function capChart(&$json, &$rows, $cutoff, $minSlice = 0.015)
+ protected function doAjax()
{
- $total = 0;
- foreach ($json as $entry) {
- $total += $entry['value'];
- }
- if ($total === 0) {
+ if (!User::load())
return;
- }
- $cap = ceil($total * $cutoff);
- $accounted = 0;
- $id = 0;
- foreach ($json as $entry) {
- if (($accounted >= $cap || $entry['value'] / $total < $minSlice) && $id >= 3) {
- break;
- }
- ++$id;
- $accounted += $entry['value'];
- }
- for ($i = $id; $i < count($rows); ++$i) {
- $rows[$i]['collapse'] = 'collapse';
- }
- $json = array_slice($json, 0, $id);
- if ($accounted / $total < 0.99) {
- $json[] = array(
- 'color' => '#eee',
- 'label' => 'invalid',
- 'value' => ($total - $accounted),
- );
- }
- }
-
- private function redirectFirst($where, $join, $args)
- {
- $res = Database::queryFirst("SELECT machineuuid FROM machine $join WHERE ($where) LIMIT 1", $args);
- if ($res !== false) {
- Util::redirect('?do=statistics&uuid=' . $res['machineuuid']);
- }
- }
-
- /**
- * @param \FilterSet $filterSet
- */
- private function showSummary($filterSet)
- {
- $filterSet->makeFragments($where, $join, $sort, $args);
- $known = Database::queryFirst("SELECT Count(*) AS val FROM machine $join WHERE $where", $args);
- // If we only have one machine, redirect to machine details
- if ($known['val'] == 1) {
- $this->redirectFirst($where, $join, $args);
- }
- $on = Database::queryFirst("SELECT Count(*) AS val FROM machine $join WHERE state IN ('IDLE', 'OCCUPIED') AND ($where)", $args);
- $used = Database::queryFirst("SELECT Count(*) AS val FROM machine $join WHERE state = 'OCCUPIED' AND ($where)", $args);
- $hdd = Database::queryFirst("SELECT Count(*) AS val FROM machine $join WHERE badsectors >= 10 AND ($where)", $args);
- if ($on['val'] != 0) {
- $usedpercent = round($used['val'] / $on['val'] * 100);
- } else {
- $usedpercent = 0;
- }
- $data = array(
- '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']);
- } else {
- $x[1] = max($x[1], array_pop($points1['data']));
- $x[2] = max($x[2], array_pop($points2['data']));
- }
- $points1['data'][] = $x[1];
- $points2['data'][] = $x[2];
- ++$sum;
- if ($sum === 12) {
- $sum = 0;
- }
- }
- $data['json'] = json_encode(array('labels' => $labels, 'datasets' => array($points1, $points2)));
- $data['query'] = $this->query;
- if (Module::get('runmode') !== false) {
- $res = Database::queryFirst('SELECT Count(*) AS cnt FROM runmode');
- $data['runmode'] = $res['cnt'];
- }
- // Draw
- Render::addTemplate('summary', $data);
- }
-
- /**
- * @param \FilterSet $filterSet
- */
- private function showSystemModels($filterSet)
- {
- global $STATS_COLORS;
-
- $filterSet->makeFragments($where, $join, $sort, $args);
- $res = Database::simpleQuery('SELECT systemmodel, Round(AVG(realcores)) AS cores, Count(*) AS `count` FROM machine'
- . " $join WHERE $where GROUP BY systemmodel ORDER BY `count` DESC, systemmodel ASC", $args);
- $lines = array();
- $json = array();
- $id = 0;
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- if (empty($row['systemmodel'])) {
- continue;
- }
- settype($row['count'], 'integer');
- $row['id'] = 'systemid' . $id;
- $row['urlsystemmodel'] = urlencode($row['systemmodel']);
- $lines[] = $row;
- $json[] = array(
- 'color' => $STATS_COLORS[$id % count($STATS_COLORS)],
- 'label' => 'systemid' . $id,
- 'value' => $row['count'],
- );
- ++$id;
- }
- $this->capChart($json, $lines, 0.92);
- Render::addTemplate('cpumodels', array('rows' => $lines, 'query' => $this->query, 'json' => json_encode($json)));
- }
-
- /**
- * @param \FilterSet $filterSet
- */
- private function showMemory($filterSet)
- {
- global $STATS_COLORS, $SIZE_RAM;
-
- $filterSet->makeFragments($where, $join, $sort, $args);
- $res = Database::simpleQuery("SELECT mbram, Count(*) AS `count` FROM machine $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($SIZE_RAM); ++$i) {
- if ($SIZE_RAM[$i] < $gb) {
- continue;
- }
- if ($SIZE_RAM[$i] - $gb >= $gb - $SIZE_RAM[$i - 1]) {
- --$i;
- }
- $gb = $SIZE_RAM[$i];
- break;
- }
- if (isset($lines[$gb])) {
- $lines[$gb] += $row['count'];
- } else {
- $lines[$gb] = $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' => $this->ramColorClass($k * 1024));
- $json[] = array(
- 'color' => $STATS_COLORS[$id % count($STATS_COLORS)],
- 'label' => (string)$k,
- 'value' => $v,
- );
- ++$id;
- }
- $this->capChart($json, $data['rows'], 0.92);
- $data['json'] = json_encode($json);
- $data['query'] = $this->query;
- Render::addTemplate('memory', $data);
- }
-
- /**
- * @param \FilterSet $filterSet
- */
- private function showKvmState($filterSet)
- {
- $filterSet->makeFragments($where, $join, $sort, $args);
- $colors = array('UNKNOWN' => '#666', 'UNSUPPORTED' => '#ea5', 'DISABLED' => '#e55', 'ENABLED' => '#6d6');
- $res = Database::simpleQuery("SELECT kvmstate, Count(*) AS `count` FROM machine $join WHERE $where GROUP BY kvmstate ORDER BY `count` DESC", $args);
- $lines = array();
- $json = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- $lines[] = $row;
- $json[] = array(
- 'color' => isset($colors[$row['kvmstate']]) ? $colors[$row['kvmstate']] : '#000',
- 'label' => $row['kvmstate'],
- 'value' => $row['count'],
- );
- }
- Render::addTemplate('kvmstate', array('rows' => $lines, 'query' => $this->query,'json' => json_encode($json)));
- }
-
- /**
- * @param \FilterSet $filterSet
- */
- private function showId44($filterSet)
- {
- global $STATS_COLORS, $SIZE_ID44;
-
- $filterSet->makeFragments($where, $join, $sort, $args);
- $res = Database::simpleQuery("SELECT id44mb, Count(*) AS `count` FROM machine $join WHERE $where GROUP BY id44mb", $args);
- $lines = array();
- $total = 0;
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- $total += $row['count'];
- $gb = (int)ceil($row['id44mb'] / 1024);
- for ($i = 1; $i < count($SIZE_ID44); ++$i) {
- if ($SIZE_ID44[$i] < $gb) {
- continue;
- }
- if ($SIZE_ID44[$i] - $gb >= $gb - $SIZE_ID44[$i - 1]) {
- --$i;
- }
- $gb = $SIZE_ID44[$i];
- break;
- }
- if (isset($lines[$gb])) {
- $lines[$gb] += $row['count'];
- } else {
- $lines[$gb] = $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' => $this->hddColorClass($k));
- if ($k === 0) {
- $color = '#e55';
- } else {
- $color = $STATS_COLORS[$id++ % count($STATS_COLORS)];
- }
- $json[] = array(
- 'color' => $color,
- 'label' => (string)$k,
- 'value' => $v,
- );
- }
- $this->capChart($json, $data['rows'], 0.95);
- $data['json'] = json_encode($json);
- $data['query'] = $this->query;
- Render::addTemplate('id44', $data);
- }
-
- /**
- * @param \FilterSet $filterSet
- */
- private function showLatestMachines($filterSet)
- {
- $filterSet->makeFragments($where, $join, $sort, $args);
- $args['cutoff'] = ceil(time() / 3600) * 3600 - 86400 * 10;
-
- $res = Database::simpleQuery("SELECT machineuuid, clientip, hostname, firstseen, mbram, kvmstate, id44mb FROM machine $join"
- . " WHERE firstseen > :cutoff AND $where ORDER BY firstseen DESC LIMIT 32", $args);
- $rows = array();
- $count = 0;
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- if (empty($row['hostname'])) {
- $row['hostname'] = $row['clientip'];
- }
- $row['firstseen_int'] = $row['firstseen'];
- $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'] = $this->ramColorClass($row['mbram']);
- $row['kvmclass'] = $this->kvmColorClass($row['kvmstate']);
- $row['hddclass'] = $this->hddColorClass($row['gbtmp']);
- $row['kvmicon'] = $row['kvmstate'] === 'ENABLED' ? '✓' : '✗';
- if (++$count > 5) {
- $row['collapse'] = 'collapse';
- }
- $rows[] = $row;
- }
- Render::addTemplate('newclients', array('rows' => $rows, 'openbutton' => $count > 5));
- }
-
- /**
- * @param \FilterSet $filterSet
- */
- private function showMachineList($filterSet)
- {
- Module::isAvailable('js_stupidtable');
- $filterSet->makeFragments($where, $join, $sort, $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) ';
- }
- }
- $res = Database::simpleQuery('SELECT machineuuid, locationid, macaddr, clientip, lastseen,'
- . ' logintime, state, realcores, mbram, kvmstate, cpumodel, id44mb, hostname, notes IS NOT NULL AS hasnotes,'
- . ' badsectors ' . $xtra . ' FROM machine'
- . " $join WHERE $where $sort", $args);
- $rows = array();
- $singleMachine = 'none';
- // 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)
- // or add a notice to the confirmation dialog of the according action (nicer but a little more work)
- $deleteAllowedLocations = User::getAllowedLocations("machine.delete");
- $rebootAllowedLocations = User::getAllowedLocations('.rebootcontrol.action.reboot');
- $shutdownAllowedLocations = User::getAllowedLocations('.rebootcontrol.action.reboot');
- // Only make client clickable if user is allowed to view details page
- $detailsAllowedLocations = User::getAllowedLocations("machine.view-details");
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- if ($singleMachine === 'none') {
- $singleMachine = $row['machineuuid'];
- } else {
- $singleMachine = false;
- }
- $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);
- $octets = explode('.', $row['clientip']);
- if (count($octets) === 4) {
- $row['subnet'] = "$octets[0].$octets[1].$octets[2].";
- $row['lastoctet'] = $octets[3];
- }
- $row['ramclass'] = $this->ramColorClass($row['mbram']);
- $row['kvmclass'] = $this->kvmColorClass($row['kvmstate']);
- $row['hddclass'] = $this->hddColorClass($row['gbtmp']);
- if (empty($row['hostname'])) {
- $row['hostname'] = $row['clientip'];
- }
- if (isset($row['data'])) {
- if (!preg_match('/^(Disk.* bytes|Disk.*\d{5,} sectors)/m', $row['data'])) {
- $row['nohdd'] = true;
- }
- }
- $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);
- if ($data !== false) {
- $row['moduleName'] = $data['moduleName'];
- $row['modeName'] = $data['modeName'];
- }
- if (!$row['isclient'] && $row['state'] === 'IDLE') {
- $row['state'] = 'OCCUPIED';
- }
- }
- $row['state_' . $row['state']] = true;
- $row['locationname'] = Location::getName($row['locationid']);
- $rows[] = $row;
- }
- if ($singleMachine !== false && $singleMachine !== 'none') {
- Util::redirect('?do=statistics&uuid=' . $singleMachine);
- }
- $data = array(
- 'rowCount' => count($rows),
- 'rows' => $rows,
- 'query' => $this->query,
- 'delimiter' => Filter::DELIMITER,
- 'sortDirection' => $filterSet->getSortDirection(),
- 'sortColumn' => $filterSet->getSortColumn(),
- 'columns' => json_encode(Page_Statistics::$columns),
- 'showList' => 1,
- 'show' => 'list',
- 'redirect' => $_SERVER['QUERY_STRING'],
- 'rebootcontrol' => (Module::get('rebootcontrol') !== false),
- 'canReboot' => !empty($rebootAllowedLocations),
- 'canShutdown' => !empty($shutdownAllowedLocations),
- 'canDelete' => !empty($deleteAllowedLocations),
- );
- Render::addTemplate('clientlist', $data);
- }
-
- private function ramColorClass($mb)
- {
- if ($mb < 1500) {
- return 'danger';
- }
- if ($mb < 2500) {
- return 'warning';
- }
-
- return '';
- }
-
- private function kvmColorClass($state)
- {
- if ($state === 'DISABLED') {
- return 'danger';
- }
- if ($state === 'UNKNOWN' || $state === 'UNSUPPORTED') {
- return 'warning';
- }
-
- return '';
- }
-
- private function hddColorClass($gb)
- {
- if ($gb < 7) {
- return 'danger';
- }
- if ($gb < 25) {
- return 'warning';
- }
-
- return '';
- }
-
- public static function findBestValue($array, $value, $up)
- {
- $best = 0;
- for ($i = 0; $i < count($array); ++$i) {
- if (abs($array[$i] - $value) < abs($array[$best] - $value)) {
- $best = $i;
- }
- }
- if (!$up && $best === 0) {
- return $array[0];
- }
- if ($up && $best + 1 === count($array)) {
- return $array[$best];
- }
- if ($up) {
- return ($array[$best] + $array[$best + 1]) / 2;
- }
-
- return ($array[$best] + $array[$best - 1]) / 2;
- }
-
- private function fillSessionInfo(&$row)
- {
- if (!empty($row['currentuser'])) {
- $row['username'] = $row['currentuser'];
- if (strlen($row['currentsession']) === 36 && Module::isAvailable('dozmod')) {
- $lecture = Database::queryFirst("SELECT lectureid, displayname FROM sat.lecture WHERE lectureid = :lectureid",
- array('lectureid' => $row['currentsession']));
- if ($lecture !== false) {
- $row['currentsession'] = $lecture['displayname'];
- $row['lectureid'] = $lecture['lectureid'];
- }
- $row['session'] = $row['currentsession'];
- return;
- }
- }
- $res = Database::simpleQuery('SELECT dateline, username, data FROM statistic'
- . " WHERE clientip = :ip AND typeid = '.vmchooser-session-name'"
- . ' AND dateline BETWEEN :start AND :end', array(
- 'ip' => $row['clientip'],
- 'start' => $row['logintime'] - 60,
- 'end' => $row['logintime'] + 300,
- ));
- $session = false;
- while ($r = $res->fetch(PDO::FETCH_ASSOC)) {
- if ($session === false || abs($session['dateline'] - $row['logintime']) > abs($r['dateline'] - $row['logintime'])) {
- $session = $r;
- }
- }
- if ($session !== false) {
- $row['session'] = $session['data'];
- if (empty($row['currentuser'])) {
- $row['username'] = $session['username'];
- }
- }
- }
-
- private function showMachine($uuid)
- {
- $client = Database::queryFirst('SELECT machineuuid, locationid, macaddr, clientip, firstseen, lastseen, logintime, lastboot, state,
- mbram, live_tmpsize, live_tmpfree, live_swapsize, live_swapfree, live_memsize, live_memfree, Length(position) AS hasroomplan,
- kvmstate, cpumodel, id44mb, data, hostname, currentuser, currentsession, notes FROM machine WHERE machineuuid = :uuid',
- array('uuid' => $uuid));
- if ($client === false) {
- Message::addError('unknown-machine', $uuid);
+ $action = Request::any('action');
+ if ($action === 'bios') {
+ require_once 'modules/statistics/pages/machine.inc.php';
+ SubPage::ajaxCheckBios();
return;
}
- 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');
- die($client['data']);
- }
- // Runmode
- if (Module::isAvailable('runmode')) {
- $data = RunMode::getRunMode($uuid, RunMode::DATA_STRINGS);
- if ($data !== false) {
- $client += $data;
- }
- }
- // Rebootcontrol
- if (Module::get('rebootcontrol') !== false) {
- if (User::hasPermission('.rebootcontrol.action.reboot', (int)$client['locationid'])) {
- $client['canReboot'] = true;
- }
- if (User::hasPermission('.rebootcontrol.action.shutdown', (int)$client['locationid'])) {
- $client['canShutdown'] = true;
- }
- $client['rebootcontrol'] = $client['canReboot'] || $client['canShutdown'];
- }
- if (!isset($client['isclient'])) {
- $client['isclient'] = true;
- }
- // Mangle fields
- $NOW = time();
- if (!$client['isclient']) {
- if ($client['state'] === 'IDLE') {
- $client['state'] = 'OCCUPIED';
- }
- } else {
- if ($client['state'] === 'OCCUPIED') {
- $this->fillSessionInfo($client);
- }
- }
- $client['state_' . $client['state']] = true;
- $client['firstseen_s'] = date('d.m.Y H:i', $client['firstseen']);
- $client['lastseen_s'] = date('d.m.Y H:i', $client['lastseen']);
- $client['logintime_s'] = date('d.m.Y H:i', $client['logintime']);
- if ($client['lastboot'] == 0) {
- $client['lastboot_s'] = '-';
- } else {
- $uptime = $NOW - $client['lastboot'];
- $client['lastboot_s'] = date('d.m.Y H:i', $client['lastboot']);
- if ($client['state'] === 'IDLE' || $client['state'] === 'OCCUPIED') {
- $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);
- foreach (['tmp', 'swap', 'mem'] as $item) {
- if ($client['live_' . $item . 'size'] == 0)
- continue;
- $client['live_' . $item . 'percent'] = round(($client['live_' . $item . 'free'] / $client['live_' . $item . 'size']) * 100, 2);
- $client['live_' . $item . 'free_s'] = Util::readableFileSize($client['live_' . $item . 'free'], -1, 2);
- }
- $client['ramclass'] = $this->ramColorClass($client['mbram']);
- $client['kvmclass'] = $this->kvmColorClass($client['kvmstate']);
- $client['hddclass'] = $this->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]);
- }
- }
- }
- unset($client['data']);
- // BIOS update check
- if (!empty($client['biosrevision'])) {
- $mainboard = $client['mobomanufacturer'] . '##' . $client['mobomodel'];
- $system = $client['pcmanufacturer'] . '##' . $client['pcmodel'];
- $ret = $this->checkBios($mainboard, $system, $client['biosdate'], $client['biosrevision']);
- if ($ret === false) { // Not loaded, use AJAX
- $params = [
- 'mainboard' => $mainboard,
- 'system' => $system,
- 'date' => $client['biosdate'],
- 'revision' => $client['biosrevision'],
- ];
- $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);
- }
- }
- // Get locations
- if (Module::isAvailable('locations')) {
- $locs = Location::getLocationsAssoc();
- $next = (int)$client['locationid'];
- $output = array();
- while (isset($locs[$next])) {
- array_unshift($output, $locs[$next]);
- $next = $locs[$next]['parentlocationid'];
- }
- $client['locations'] = $output;
- }
- // Screens TODO Move everything else to hw table instead of blob parsing above
- // `devicetype`, `devicename`, `subid`, `machineuuid`
- $res = Database::simpleQuery("SELECT m.hwid, h.hwname, m.devpath AS connector, m.disconnecttime,"
- . " p.value AS resolution, q.prop AS projector FROM machine_x_hw m"
- . " INNER JOIN statistic_hw h ON (m.hwid = h.hwid AND h.hwtype = :screen)"
- . " 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));
- $client['screens'] = array();
- $ports = array();
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- if ($row['disconnecttime'] != 0)
- continue;
- $ports[] = $row['connector'];
- $client['screens'][] = $row;
- }
- array_multisort($ports, SORT_ASC, $client['screens']);
- Permission::addGlobalTags($client['perms'], null, ['hardware.projectors.edit', 'hardware.projectors.view']);
- // Throw output at user
- Render::addTemplate('machine-main', $client);
- // Sessions
- $NOW = time();
- $cutoff = $NOW - 86400 * 7;
- //if ($cutoff < $client['firstseen']) $cutoff = $client['firstseen'];
- $scale = 100 / ($NOW - $cutoff);
- $res = Database::simpleQuery('SELECT dateline, typeid, data FROM statistic'
- . " WHERE dateline > :cutoff AND typeid IN (:sessionLength, :offlineLength) AND machineuuid = :uuid ORDER BY dateline ASC", array(
- 'cutoff' => $cutoff - 86400 * 14,
- 'uuid' => $uuid,
- 'sessionLength' => Statistics::SESSION_LENGTH,
- 'offlineLength' => Statistics::OFFLINE_LENGTH,
- ));
- $spans['rows'] = array();
- $spans['graph'] = '';
- $last = false;
- $first = true;
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- 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) {
- // Special case: offline before
- $spans['graph'] .= '<div style="background:#444;left:0;width:' . round((min($row['dateline'], $client['lastboot']) - $cutoff) * $scale, 2) . '%">&nbsp;</div>';
- }
- $first = false;
- if ($row['dateline'] + $row['data'] < $cutoff || $row['data'] > 864000) {
- continue;
- }
- if ($last !== false && abs($last['dateline'] - $row['dateline']) < 30
- && abs($last['data'] - $row['data']) < 30
- ) {
- continue;
- }
- if ($last !== false && $last['dateline'] + $last['data'] > $row['dateline']) {
- $point = $last['dateline'] + $last['data'];
- $row['data'] -= ($point - $row['dateline']);
- $row['dateline'] = $point;
- }
- if ($row['dateline'] < $cutoff) {
- $row['data'] -= ($cutoff - $row['dateline']);
- $row['dateline'] = $cutoff;
- }
- $row['from'] = Util::prettyTime($row['dateline']);
- $row['duration'] = floor($row['data'] / 86400) . 'd ' . gmdate('H:i', $row['data']);
- if ($row['typeid'] === Statistics::OFFLINE_LENGTH) {
- $row['glyph'] = 'off';
- $color = '#444';
- } elseif ($row['typeid'] === Statistics::SUSPEND_LENGTH) {
- $row['glyph'] = 'pause';
- $color = '#686';
- } else {
- $row['glyph'] = 'user';
- $color = '#e77';
- }
- $spans['graph'] .= '<div style="background:' . $color . ';left:' . round(($row['dateline'] - $cutoff) * $scale, 2) . '%;width:' . round(($row['data']) * $scale, 2) . '%">&nbsp;</div>';
- if ($client['isclient']) {
- $spans['rows'][] = $row;
- }
- $last = $row;
- }
- if ($first && $client['lastboot'] > $cutoff) {
- // Special case: offline before
- $spans['graph'] .= '<div style="background:#444;left:0;width:' . round(($client['lastboot'] - $cutoff) * $scale, 2) . '%">&nbsp;</div>';
- } elseif ($first) {
- // Not seen in last two weeks
- $spans['graph'] .= '<div style="background:#444;left:0;width:100%">&nbsp;</div>';
- }
- if ($client['state'] === 'OCCUPIED') {
- $spans['graph'] .= '<div style="background:#e99;left:' . round(($client['logintime'] - $cutoff) * $scale, 2) . '%;width:' . round(($NOW - $client['logintime'] + 900) * $scale, 2) . '%">&nbsp;</div>';
- $spans['rows'][] = [
- 'from' => Util::prettyTime($client['logintime']),
- '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'][] = [
- 'from' => Util::prettyTime($client['lastseen']),
- 'duration' => '-',
- 'glyph' => 'off',
- ];
- } elseif ($client['state'] === 'STANDBY') {
- $spans['graph'] .= '<div style="background:#686;left:' . round(($client['lastseen'] - $cutoff) * $scale, 2) . '%;width:' . round(($NOW - $client['lastseen'] + 900) * $scale, 2) . '%">&nbsp;</div>';
- $spans['rows'][] = [
- 'from' => Util::prettyTime($client['lastseen']),
- 'duration' => '-',
- 'glyph' => 'pause',
- ];
- }
- $t = explode('-', date('Y-n-j-G', $cutoff));
- if ($t[3] >= 8 && $t[3] <= 22) {
- $start = mktime(22, 0, 0, $t[1], $t[2], $t[0]);
- } else {
- $start = mktime(22, 0, 0, $t[1], $t[2] - 1, $t[0]);
- }
- for ($i = $start; $i < $NOW; $i += 86400) {
- $spans['graph'] .= '<div style="background:rgba(0,0,90,.2);left:' . round(($i - $cutoff) * $scale, 2) . '%;width:' . round((10 * 3600) * $scale, 2) . '%">&nbsp;</div>';
- }
- 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['isclient'] = $client['isclient'];
- Render::addTemplate('machine-usage', $spans);
- // Any hdds?
- if (!empty($hdds['hdds'])) {
- Render::addTemplate('machine-hdds', $hdds);
- }
- // Client log
- if (Module::get('syslog') !== false) {
- $lres = Database::simpleQuery('SELECT logid, dateline, logtypeid, clientip, description, extra FROM clientlog'
- . ' WHERE machineuuid = :uuid ORDER BY logid DESC LIMIT 25', array('uuid' => $client['machineuuid']));
- $count = 0;
- $log = array();
- while ($row = $lres->fetch(PDO::FETCH_ASSOC)) {
- if (substr($row['description'], -5) === 'on :0' && strpos($row['description'], 'root logged') === false) {
- continue;
- }
- $row['date'] = Util::prettyTime($row['dateline']);
- $row['icon'] = $this->eventToIconName($row['logtypeid']);
- $log[] = $row;
- if (++$count === 10) {
- break;
+ 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;
}
- Render::addTemplate('syslog', array(
- 'machineuuid' => $client['machineuuid'],
- 'list' => $log,
- ));
- }
- // Notes
- if (User::hasPermission('machine.note.*', (int)$client['locationid'])) {
- Permission::addGlobalTags($client['perms'], (int)$client['locationid'], ['machine.note.edit']);
- Render::addTemplate('machine-notes', $client);
- }
- }
-
- private function eventToIconName($event)
- {
- switch ($event) {
- case 'session-open':
- return 'glyphicon-log-in';
- case 'session-close':
- return 'glyphicon-log-out';
- case 'partition-swap':
- return 'glyphicon-info-sign';
- case 'partition-temp':
- case 'smartctl-realloc':
- return 'glyphicon-exclamation-sign';
- default:
- return 'glyphicon-minus';
- }
- }
-
-
- protected function doAjax()
- {
- if (!User::load())
- return;
- if (Request::any('action') === 'bios') {
- $this->ajaxCheckBios();
- return;
+ header('Content-Type: application/json');
+ die(json_encode($reply));
}
$param = Request::any('lookup', false, 'string');
@@ -1188,138 +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;
}
- const BIOS_CACHE = '/tmp/bwlp-bios.json';
-
- private function ajaxCheckBios()
- {
- $mainboard = Request::any('mainboard', false, 'string');
- $system = Request::any('system', false, 'string');
- $date = Request::any('date', false, 'string');
- $revision = Request::any('revision', false, 'string');
- $reply = $this->checkBios($mainboard, $system, $date, $revision);
- if ($reply === false) {
- $data = Download::asString(CONFIG_BIOS_URL, 3, $err);
- if ($err < 200 || $err >= 300) {
- $reply = ['error' => 'HTTP: ' . $err];
- } else {
- file_put_contents(self::BIOS_CACHE, $data);
- $data = json_decode($data, true);
- $reply = $this->checkBios($mainboard, $system, $date, $revision, $data);
- }
- }
- if ($reply === false) {
- $reply = ['error' => 'Internal Error'];
- }
- if (isset($reply['status']) && $reply['status'] === 0)
- exit; // Show nothing, 0 means OK
- die(Render::parse('machine-bios-update', $reply));
- }
-
- private function checkBios($mainboard, $system, $date, $revision, $json = null)
- {
- if ($json === null) {
- if (!file_exists(self::BIOS_CACHE) || filemtime(self::BIOS_CACHE) + 3600 < time())
- return false;
- $json = json_decode(file_get_contents(self::BIOS_CACHE), true);
- }
- 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'])) {
- $match =& $json['system'][$system];
- } elseif (isset($json['mainboard'][$mainboard]) && isset($json['mainboard'][$mainboard]['fixes']) && isset($json['mainboard'][$mainboard]['match'])) {
- $match =& $json['mainboard'][$mainboard];
- } else {
- return ['status' => 0];
- }
- $key = $match['match'];
- if ($key === 'revision') {
- $cmp = function ($item) { $s = explode('.', $item); return $s[0] * 0x10000 + $s[1]; };
- $reference = $cmp($revision);
- } elseif ($key === 'date') {
- $cmp = function ($item) { $s = explode('.', $item); return $s[2] * 10000 + $s[1] * 100 + $s[0]; };
- $reference = $cmp($date);
- } else {
- return ['error' => 'Invalid comparison key: ' . $key];
- }
- $retval = ['fixes' => []];
- $level = 0;
- foreach ($match['fixes'] as $fix) {
- if ($cmp($fix[$key]) > $reference) {
- class_exists('Dictionary'); // Trigger setup of lang stuff
- $lang = isset($fix['text'][LANG]) ? LANG : 'en';
- $fix['text'] = $fix['text'][$lang];
- $retval['fixes'][] = $fix;
- $level = max($level, $fix['level']);
- }
- }
- $retval['url'] = $match['url'];
- $retval['status'] = $level;
- if ($level > 5) {
- $retval['class'] = 'danger';
- } elseif ($level > 3) {
- $retval['class'] = 'warning';
- } else {
- $retval['class'] = 'info';
- }
- return $retval;
- }
}
-
-Page_Statistics::initConstants();
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
new file mode 100644
index 00000000..f08cd71c
--- /dev/null
+++ b/modules-available/statistics/pages/list.inc.php
@@ -0,0 +1,220 @@
+<?php
+
+class SubPage
+{
+
+ public static function doPreprocess()
+ {
+ User::assertPermission('view.list');
+ }
+
+ public static function doRender()
+ {
+ $filters = StatisticsFilter::parseQuery();
+ $filterSet = new StatisticsFilterSet($filters);
+
+ if (!$filterSet->setAllowedLocationsFromPermission('view.list')) {
+ Message::addError('main.no-permission');
+ Util::redirect('?do=main');
+ }
+ StatisticsFilter::renderFilterBox('list', $filterSet);
+ self::showMachineList($filterSet);
+ }
+
+
+ private static function showMachineList(StatisticsFilterSet $filterSet): void
+ {
+ Module::isAvailable('js_stupidtable');
+ $filterSet->makeFragments($where, $join, $args);
+ $xtra = '';
+ if (Module::isAvailable('runmode')) {
+ $xtra .= ', runmode.module AS rmmodule, runmode.isclient';
+ if (strpos($join, 'runmode') === false) {
+ $join .= ' LEFT JOIN runmode ON (m.machineuuid = runmode.machineuuid) ';
+ }
+ }
+ $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.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 ON (m.machineuuid = s.machineuuid)
+ $join WHERE $where GROUP BY m.machineuuid", $args);
+ // 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)
+ // or add a notice to the confirmation dialog of the according action (nicer but a little more work)
+ $deleteAllowedLocations = User::getAllowedLocations("machine.delete");
+ $rebootAllowedLocations = User::getAllowedLocations('.rebootcontrol.action.reboot');
+ $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();
+ $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'] = 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((int)$row['mbram']);
+ $row['kvmclass'] = StatisticsStyling::kvmColorClass($row['kvmstate']);
+ $row['hddclass'] = StatisticsStyling::hddColorClass((int)$row['gbtmp']);
+ if (empty($row['hostname'])) {
+ $row['hostname'] = $row['clientip'];
+ }
+ 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);
+ if ($data !== false) {
+ $row['moduleName'] = $data['moduleName'];
+ $row['modeName'] = $data['modeName'];
+ }
+ if (!$row['isclient'] && $row['state'] === 'IDLE') {
+ $row['state'] = 'OCCUPIED';
+ }
+ if (!$row['isclient']) {
+ unset($row['currentuser']);
+ }
+ }
+ if ($row['state'] === 'IDLE' || $row['state'] === 'OCCUPIED') {
+ if ((!empty($row['currentrunmode']) || !empty($row['rmmodule']))
+ && $row['currentrunmode'] !== $row['rmmodule']) {
+ $row['wrongRunMode'] = true;
+ if (!empty($row['currentrunmode'])) {
+ $wrongModule = Module::get($row['currentrunmode']);
+ $row['currentrunmode'] = $wrongModule === false ? $row['currentrunmode'] : $wrongModule->getDisplayName();
+ }
+ }
+ }
+ $row['state_' . $row['state']] = true;
+ if ($row['locationid'] > 0) {
+ $row['location'] = $location[$row['locationid']];
+ }
+ 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;
+ }
+ // 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),
+ 'rows' => $rows,
+ 'showList' => 1,
+ 'show' => 'list',
+ 'redirect' => $_SERVER['QUERY_STRING'],
+ 'rebootcontrol' => (Module::get('rebootcontrol') !== false),
+ 'canReboot' => !empty($rebootAllowedLocations),
+ 'canShutdown' => !empty($shutdownAllowedLocations),
+ 'canDelete' => !empty($deleteAllowedLocations),
+ 'canWol' => !empty($wolAllowedLocations),
+ 'canExec' => !empty($execAllowedLocations),
+ 'canBenchmark' => !empty($benchmarkAllowedLocations),
+ 'roomsvg' => $roomsvg,
+ 'sidebar' => $side,
+ );
+ Render::addTemplate('clientlist', $data);
+ }
+
+ private static function buildLocationLookup(): array
+ {
+ $ret = [];
+ $i = 0;
+ foreach (Location::getLocationsAssoc() as $lid => $data) {
+ $ret[$lid] = ['sort' => ++$i, 'name' => $data['locationname']];
+ }
+ 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
new file mode 100644
index 00000000..1d46b523
--- /dev/null
+++ b/modules-available/statistics/pages/machine.inc.php
@@ -0,0 +1,753 @@
+<?php
+
+class SubPage
+{
+
+ public static function doPreprocess()
+ {
+ if (!Module::isAvailable('js_chart')) {
+ ErrorHandler::traceError('js_chart not available');
+ }
+ }
+
+ public static function doRender()
+ {
+ self::showMachine(Request::get('uuid', false, 'string'));
+ }
+
+ private static function fillSessionInfo(&$row)
+ {
+ if (!empty($row['currentuser'])) {
+ $row['username'] = $row['currentuser'];
+ if (strlen($row['currentsession']) === 36 && Module::isAvailable('dozmod')) {
+ $lecture = Database::queryFirst("SELECT lectureid, displayname FROM sat.lecture WHERE lectureid = :lectureid",
+ array('lectureid' => $row['currentsession']));
+ if ($lecture !== false) {
+ $row['currentsession'] = $lecture['displayname'];
+ $row['lectureid'] = $lecture['lectureid'];
+ }
+ $row['session'] = $row['currentsession'];
+ return;
+ }
+ }
+ $res = Database::simpleQuery('SELECT dateline, username, data FROM statistic'
+ . " WHERE clientip = :ip AND typeid = '.vmchooser-session-name'"
+ . ' AND dateline BETWEEN :start AND :end', array(
+ 'ip' => $row['clientip'],
+ 'start' => $row['logintime'] - 60,
+ 'end' => $row['logintime'] + 300,
+ ));
+ $session = false;
+ foreach ($res as $r) {
+ if ($session === false || abs($session['dateline'] - $row['logintime']) > abs($r['dateline'] - $row['logintime'])) {
+ $session = $r;
+ }
+ }
+ if ($session !== false) {
+ $row['session'] = $session['data'];
+ if (empty($row['currentuser'])) {
+ $row['username'] = $session['username'];
+ }
+ }
+ }
+
+ private static function showMachine($uuid)
+ {
+ $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, id45mb, data, hostname, currentuser, currentsession, notes
+ FROM machine WHERE machineuuid = :uuid',
+ array('uuid' => $uuid));
+ if ($client === false) {
+ Message::addError('unknown-machine', $uuid);
+ return;
+ }
+ 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: 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);
+ if ($data !== false) {
+ $client += $data;
+ }
+ }
+ // Rebootcontrol
+ if (Module::get('rebootcontrol') !== false) {
+ $client['canReboot'] = (User::hasPermission('.rebootcontrol.action.reboot', (int)$client['locationid']));
+ $client['canShutdown'] = (User::hasPermission('.rebootcontrol.action.shutdown', (int)$client['locationid']));
+ $client['canWol'] = (User::hasPermission('.rebootcontrol.action.wol', (int)$client['locationid']));
+ $client['canExec'] = (User::hasPermission('.rebootcontrol.action.exec', (int)$client['locationid']));
+ $client['rebootcontrol'] = $client['canReboot'] || $client['canShutdown'] || $client['canWol'] || $client['canExec'];
+ }
+ // Baseconfig
+ if (Module::get('baseconfig') !== false
+ && User::hasPermission('.baseconfig.view', (int)$client['locationid'])) {
+ $cvs = Database::queryFirst('SELECT Count(*) AS cnt FROM setting_machine WHERE machineuuid = :uuid', ['uuid' => $uuid]);
+ $client['overriddenVars'] = is_array($cvs) ? $cvs['cnt'] : 0;
+ $client['hasBaseconfig'] = true;
+ }
+ if (!isset($client['isclient'])) {
+ $client['isclient'] = true;
+ }
+ // Mangle fields
+ $NOW = time();
+ if (!$client['isclient']) {
+ if ($client['state'] === 'IDLE') {
+ $client['state'] = 'OCCUPIED';
+ }
+ } else {
+ if ($client['state'] === 'OCCUPIED') {
+ self::fillSessionInfo($client);
+ }
+ }
+ $client['state_' . $client['state']] = true;
+ $client['firstseen_s'] = date('d.m.Y H:i', $client['firstseen']);
+ $client['lastseen_s'] = date('d.m.Y H:i', $client['lastseen']);
+ $client['logintime_s'] = date('d.m.Y H:i', $client['logintime']);
+ if ($client['lastboot'] == 0) {
+ $client['lastboot_s'] = '-';
+ } else {
+ $uptime = $NOW - $client['lastboot'];
+ $client['lastboot_s'] = date('d.m.Y H:i', $client['lastboot']);
+ if ($client['state'] === 'IDLE' || $client['state'] === 'OCCUPIED') {
+ $client['lastboot_s'] .= ' (Up ' . floor($uptime / 86400) . 'd ' . gmdate('H:i', $uptime) . ')';
+ }
+ }
+ $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;
+ $client['live_' . $item . 'percent'] = round(($client['live_' . $item . 'free'] / $client['live_' . $item . 'size']) * 100, 2);
+ $client['live_' . $item . 'free_s'] = Util::readableFileSize($client['live_' . $item . 'free'], -1, 2);
+ }
+ if ($client['live_cpuload'] <= 100) {
+ $client['live_cpuload_s'] = $client['live_cpuload'] . "\xe2\x80\x89%";
+ $client['live_cpuidle'] = 100 - $client['live_cpuload'];
+ }
+ $client['live_cputemppercent'] = max(0, min(100, 110 - $client['live_cputemp']));
+ $client['ramclass'] = StatisticsStyling::ramColorClass((int)$client['mbram']);
+ $client['kvmclass'] = StatisticsStyling::kvmColorClass($client['kvmstate']);
+ $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);
+ }
+ // BIOS update check
+ 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['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();
+ $next = (int)$client['locationid'];
+ $output = array();
+ while (isset($locs[$next])) {
+ array_unshift($output, $locs[$next]);
+ $next = $locs[$next]['parentlocationid'];
+ }
+ $client['locations'] = $output;
+ }
+ // Screens TODO Move everything else to hw table instead of blob parsing above
+ // `devicetype`, `devicename`, `subid`, `machineuuid`
+ $res = Database::simpleQuery("SELECT m.hwid, h.hwname, m.devpath AS connector, m.disconnecttime,"
+ . " p.value AS resolution, q.prop AS projector FROM machine_x_hw m"
+ . " INNER JOIN statistic_hw h ON (m.hwid = h.hwid AND h.hwtype = :screen)"
+ . " 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' => HardwareInfo::SCREEN, 'uuid' => $uuid));
+ $client['screens'] = array();
+ $ports = array();
+ foreach ($res as $row) {
+ if ($row['disconnecttime'] != 0)
+ continue;
+ $ports[] = $row['connector'];
+ $client['screens'][] = $row;
+ }
+ array_multisort($ports, SORT_ASC, $client['screens']);
+ 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;
+ //if ($cutoff < $client['firstseen']) $cutoff = $client['firstseen'];
+ $scale = 100 / ($NOW - $cutoff);
+ $res = Database::simpleQuery('SELECT dateline, typeid, data FROM statistic'
+ . " WHERE dateline > :cutoff AND typeid IN (:sessionLength, :offlineLength) AND machineuuid = :uuid ORDER BY dateline ASC", array(
+ 'cutoff' => $cutoff - 86400 * 14,
+ 'uuid' => $uuid,
+ 'sessionLength' => Statistics::SESSION_LENGTH,
+ 'offlineLength' => Statistics::OFFLINE_LENGTH,
+ ));
+ $spans['rows'] = array();
+ $spans['graph'] = '';
+ $last = false;
+ $first = true;
+ 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) {
+ // Special case: offline before
+ $spans['graph'] .= '<div style="background:#444;left:0;width:' . round((min($row['dateline'], $client['lastboot']) - $cutoff) * $scale, 2) . '%">&nbsp;</div>';
+ }
+ $first = false;
+ if ($row['dateline'] + $row['data'] < $cutoff || $row['data'] > 864000) {
+ continue;
+ }
+ if ($last !== false && abs($last['dateline'] - $row['dateline']) < 30
+ && abs($last['data'] - $row['data']) < 30
+ ) {
+ continue;
+ }
+ if ($last !== false && $last['dateline'] + $last['data'] > $row['dateline']) {
+ $point = $last['dateline'] + $last['data'];
+ $row['data'] -= ($point - $row['dateline']);
+ $row['dateline'] = $point;
+ }
+ if ($row['dateline'] < $cutoff) {
+ $row['data'] -= ($cutoff - $row['dateline']);
+ $row['dateline'] = $cutoff;
+ }
+ $row['from'] = Util::prettyTime($row['dateline']);
+ $row['duration'] = floor($row['data'] / 86400) . 'd ' . gmdate('H:i', $row['data']);
+ if ($row['typeid'] === Statistics::OFFLINE_LENGTH) {
+ $row['glyph'] = 'off';
+ $color = '#444';
+ } elseif ($row['typeid'] === Statistics::SUSPEND_LENGTH) {
+ $row['glyph'] = 'pause';
+ $color = '#686';
+ } else {
+ $row['glyph'] = 'user';
+ $color = '#e77';
+ }
+ $spans['graph'] .= '<div style="background:' . $color . ';left:' . round(($row['dateline'] - $cutoff) * $scale, 2) . '%;width:' . round(($row['data']) * $scale, 2) . '%">&nbsp;</div>';
+ if ($client['isclient']) {
+ $spans['rows'][] = $row;
+ }
+ $last = $row;
+ }
+ if ($first && $client['lastboot'] > $cutoff) {
+ // Special case: offline before
+ $spans['graph'] .= '<div style="background:#444;left:0;width:' . round(($client['lastboot'] - $cutoff) * $scale, 2) . '%">&nbsp;</div>';
+ } elseif ($first) {
+ // Not seen in last two weeks
+ $spans['graph'] .= '<div style="background:#444;left:0;width:100%">&nbsp;</div>';
+ }
+ if ($client['state'] === 'OCCUPIED') {
+ $spans['graph'] .= '<div style="background:#e99;left:' . round(($client['logintime'] - $cutoff) * $scale, 2) . '%;width:' . round(($NOW - $client['logintime'] + 900) * $scale, 2) . '%">&nbsp;</div>';
+ $spans['rows'][] = [
+ 'from' => Util::prettyTime($client['logintime']),
+ 'duration' => '-',
+ 'glyph' => 'user',
+ ];
+ } 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'][] = [
+ 'from' => Util::prettyTime($client['lastseen']),
+ 'duration' => '-',
+ 'glyph' => 'off',
+ ];
+ } elseif ($client['state'] === 'STANDBY') {
+ $spans['graph'] .= '<div style="background:#686;left:' . round(($client['lastseen'] - $cutoff) * $scale, 2) . '%;width:' . round(($NOW - $client['lastseen'] + 900) * $scale, 2) . '%">&nbsp;</div>';
+ $spans['rows'][] = [
+ 'from' => Util::prettyTime($client['lastseen']),
+ 'duration' => '-',
+ 'glyph' => 'pause',
+ ];
+ }
+ $t = explode('-', date('Y-n-j-G', $cutoff));
+ if ($t[3] >= 8 && $t[3] <= 22) {
+ $start = mktime(22, 0, 0, $t[1], $t[2], $t[0]);
+ } else {
+ $start = mktime(22, 0, 0, $t[1], $t[2] - 1, $t[0]);
+ }
+ for ($i = $start; $i < $NOW; $i += 86400) {
+ $spans['graph'] .= '<div style="background:rgba(0,0,90,.2);left:' . round(($i - $cutoff) * $scale, 2) . '%;width:' . round((10 * 3600) * $scale, 2) . '%">&nbsp;</div>';
+ }
+ if (count($spans['rows']) > 10) {
+ $spans['hasrows2'] = true;
+ $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);
+ // Any hdds?
+ if (!empty($hdds['hdds'])) {
+ Render::addTemplate('machine-hdds', $hdds);
+ }
+ // Client log
+ if (Module::get('syslog') !== false) {
+ $lres = Database::simpleQuery('SELECT logid, dateline, logtypeid, clientip, description, extra FROM clientlog'
+ . ' WHERE machineuuid = :uuid ORDER BY logid DESC LIMIT 25', array('uuid' => $client['machineuuid']));
+ $count = 0;
+ $log = array();
+ foreach ($lres as $row) {
+ if (substr($row['description'], -5) === 'on :0' && strpos($row['description'], 'root logged') === false) {
+ continue;
+ }
+ $row['date'] = Util::prettyTime($row['dateline']);
+ $row['icon'] = self::eventToIconName($row['logtypeid']);
+ $log[] = $row;
+ if (++$count === 10) {
+ break;
+ }
+ }
+ Render::addTemplate('syslog', array(
+ 'machineuuid' => $client['machineuuid'],
+ 'list' => $log,
+ ));
+ }
+ // Notes
+ if (User::hasPermission('machine.note.*', (int)$client['locationid'])) {
+ Permission::addGlobalTags($client['perms'], (int)$client['locationid'], ['machine.note.edit']);
+ Render::addTemplate('machine-notes', $client);
+ }
+ }
+
+ 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':
+ return 'glyphicon-log-in';
+ case 'session-close':
+ return 'glyphicon-log-out';
+ case 'partition-swap':
+ return 'glyphicon-info-sign';
+ case 'partition-temp':
+ case 'smartctl-realloc':
+ return 'glyphicon-exclamation-sign';
+ default:
+ return 'glyphicon-minus';
+ }
+ }
+
+ const BIOS_CACHE = '/tmp/bwlp-bios.json';
+
+ public static function ajaxCheckBios()
+ {
+ $mainboard = Request::any('mainboard', false, 'string');
+ $system = Request::any('system', false, 'string');
+ $date = Request::any('date', false, 'string');
+ $revision = Request::any('revision', false, 'string');
+ $reply = self::checkBios($mainboard, $system, $date, $revision);
+ if ($reply === false) {
+ $data = Download::asString(CONFIG_BIOS_URL, 3, $err);
+ if ($err < 200 || $err >= 300) {
+ $reply = ['error' => 'HTTP: ' . $err];
+ } else {
+ file_put_contents(self::BIOS_CACHE, $data);
+ $data = json_decode($data, true);
+ $reply = self::checkBios($mainboard, $system, $date, $revision, $data);
+ }
+ }
+ if ($reply === false) {
+ $reply = ['error' => 'Internal Error'];
+ }
+ if (isset($reply['status']) && $reply['status'] === 0)
+ exit; // Show nothing, 0 means OK
+ die(Render::parse('machine-bios-update', $reply));
+ }
+
+ 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())
+ return false;
+ $json = json_decode(file_get_contents(self::BIOS_CACHE), true);
+ }
+ if (!is_array($json) || !isset($json['system']))
+ return ['error' => 'Malformed JSON, no system key'];
+ if (isset($json['system'][$system]['fixes']) && isset($json['system'][$system]['match'])) {
+ $match =& $json['system'][$system];
+ } 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' && $revision !== null) {
+ $cmp = function ($item) { $s = explode('.', $item); return $s[0] * 0x10000 + $s[1]; };
+ $reference = $cmp($revision);
+ } elseif ($key === 'date' && $date !== null) {
+ $cmp = function ($item) { $s = explode('.', $item); return $s[2] * 10000 + $s[1] * 100 + $s[0]; };
+ $reference = $cmp($date);
+ } else {
+ return ['error' => 'Invalid comparison key: ' . $key];
+ }
+ $retval = ['fixes' => []];
+ $level = 0;
+ foreach ($match['fixes'] as $fix) {
+ if ($cmp($fix[$key]) > $reference) {
+ class_exists('Dictionary'); // Trigger setup of lang stuff
+ $lang = isset($fix['text'][LANG]) ? LANG : 'en';
+ $fix['text'] = $fix['text'][$lang];
+ $retval['fixes'][] = $fix;
+ $level = max($level, $fix['level']);
+ }
+ }
+ $retval['url'] = $match['url'];
+ $retval['status'] = $level;
+ if ($level > 5) {
+ $retval['class'] = 'danger';
+ } elseif ($level > 3) {
+ $retval['class'] = 'warning';
+ } else {
+ $retval['class'] = 'info';
+ }
+ return $retval;
+ }
+
+ /**
+ * @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 ae9c6108..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;
}
@@ -69,14 +82,17 @@ class SubPage
// Finalize by updating machine table
foreach ($list as $entry) {
unset($entry['datelimit']);
- Database::exec('UPDATE machine old, machine new SET
+ Database::exec("UPDATE machine old, machine new SET
new.fixedlocationid = old.fixedlocationid,
new.position = old.position,
old.position = NULL,
+ old.subnetlocationid = NULL,
+ old.fixedlocationid = NULL,
new.notes = old.notes,
old.notes = NULL,
- old.lastseen = new.firstseen
- WHERE old.machineuuid = :old AND new.machineuuid = :new', $entry);
+ old.lastseen = new.firstseen,
+ old.clientip = '0.0.0.0'
+ WHERE old.machineuuid = :old AND new.machineuuid = :new", $entry);
}
Message::addSuccess('x-machines-replaced', count($list));
}
@@ -103,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
new file mode 100644
index 00000000..905f5d90
--- /dev/null
+++ b/modules-available/statistics/pages/summary.inc.php
@@ -0,0 +1,364 @@
+<?php
+
+class SubPage
+{
+
+ private static $STATS_COLORS;
+
+ public static function doPreprocess()
+ {
+ User::assertPermission('view.summary');
+ if (!Module::isAvailable('js_chart')) {
+ ErrorHandler::traceError('js_chart not available');
+ }
+ }
+
+ public static function doRender()
+ {
+ $filters = StatisticsFilter::parseQuery();
+ $filterSet = new StatisticsFilterSet($filters);
+
+ if (!$filterSet->setAllowedLocationsFromPermission('view.summary')) {
+ Message::addError('main.no-permission');
+ Util::redirect('?do=main');
+ }
+
+ // Prepare chart colors
+ self::$STATS_COLORS = [];
+ for ($i = 0; $i < 10; ++$i) {
+ self::$STATS_COLORS[] = '#55' . sprintf('%02s%02s', dechex(
+ (int)((($i + 1) * ($i + 1)) / .3922)),
+ dechex((int)(abs((5 - $i) * 51))));
+ }
+
+ $filterSet->filterNonClients();
+ StatisticsFilter::renderFilterBox('summary', $filterSet);
+ Render::openTag('div', array('class' => 'row'));
+ self::showSummary($filterSet);
+ self::showMemory($filterSet);
+ self::showId44($filterSet);
+ self::showKvmState($filterSet);
+ self::showLatestMachines($filterSet);
+ self::showSystemModels($filterSet);
+ Render::closeTag('div');
+ }
+
+ 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);
+ $on = Database::queryFirst("SELECT Count(*) AS val FROM machine m $join WHERE state IN ('IDLE', 'OCCUPIED') AND ($where)", $args);
+ $used = Database::queryFirst("SELECT Count(*) AS val FROM machine m $join WHERE state = 'OCCUPIED' AND ($where)", $args);
+ $hdd = Database::queryFirst("SELECT Count(*) AS val FROM machine m $join WHERE badsectors >= 10 AND ($where)", $args);
+ if ($on['val'] != 0) {
+ $usedpercent = round($used['val'] / $on['val'] * 100);
+ } else {
+ $usedpercent = 0;
+ }
+ $data = [
+ 'known' => $known['val'],
+ 'online' => $on['val'],
+ 'used' => $used['val'],
+ 'usedpercent' => $usedpercent,
+ 'badhdd' => $hdd['val'],
+ ];
+ // Graph
+ $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 {
+ if (is_array($locFilter->argument)) {
+ $locations = $locFilter->argument;
+ } else {
+ $locations = [$locFilter->argument];
+ }
+ $op = $locFilter->op;
+ }
+ //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;
+ }
+ }
+ 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);
+ $data['runmode'] = $res['cnt'];
+ }
+ // Draw
+ Render::addTemplate('summary', $data);
+ }
+
+ 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 = [];
+ $json = [];
+ $id = 0;
+ foreach ($res as $row) {
+ if (empty($row['systemmodel'])) {
+ continue;
+ }
+ settype($row['count'], 'integer');
+ $row['urlsystemmodel'] = urlencode($row['systemmodel']);
+ $row['idx'] = count($lines);
+ $lines[] = $row;
+ $json[] = [
+ 'color' => self::$STATS_COLORS[$id % count(self::$STATS_COLORS)],
+ 'value' => $row['count'],
+ ];
+ ++$id;
+ }
+ self::capChart($json, $lines, 0.92);
+ Render::addTemplate('cpumodels', ['rows' => $lines, 'json' => json_encode($json)]);
+ }
+
+ 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 = [];
+ 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 = ['rows' => []];
+ $json = [];
+ $id = 0;
+ foreach (array_reverse($lines, true) as $k => $v) {
+ $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)],
+ 'value' => $v,
+ ];
+ ++$id;
+ }
+ self::capChart($json, $data['rows'], 0.92);
+ $data['json'] = json_encode($json);
+ Render::addTemplate('memory', $data);
+ }
+
+ private static function showKvmState(StatisticsFilterSet $filterSet): void
+ {
+ $filterSet->makeFragments($where, $join, $args);
+ $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 = [];
+ $json = [];
+ foreach ($res as $row) {
+ $row['idx'] = count($lines);
+ $lines[] = $row;
+ $json[] = array(
+ 'color' => $colors[$row['kvmstate']] ?? '#000',
+ 'value' => $row['count'],
+ );
+ }
+ Render::addTemplate('kvmstate', array('rows' => $lines, 'json' => json_encode($json)));
+ }
+
+ 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;
+ foreach ($res as $row) {
+ $total += $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'][] = [
+ 'idx' => count($data['rows']),
+ 'gb' => $k,
+ 'count' => $v,
+ 'class' => StatisticsStyling::hddColorClass($k),
+ ];
+ if ($k === 0) {
+ $color = '#e55';
+ } else {
+ $color = self::$STATS_COLORS[$id++ % count(self::$STATS_COLORS)];
+ }
+ $json[] = array(
+ 'color' => $color,
+ 'value' => $v,
+ );
+ }
+ self::capChart($json, $data['rows'], 0.95);
+ $data['json'] = json_encode($json);
+ Render::addTemplate('id44', $data);
+ }
+
+ private static function showLatestMachines(StatisticsFilterSet $filterSet): void
+ {
+ $filterSet->makeFragments($where, $join, $args);
+ $args['cutoff'] = ceil(time() / 3600) * 3600 - 86400 * 10;
+
+ $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;
+ foreach ($res as $row) {
+ if (empty($row['hostname'])) {
+ $row['hostname'] = $row['clientip'];
+ }
+ $row['firstseen_int'] = $row['firstseen'];
+ $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((int)$row['mbram']);
+ $row['kvmclass'] = StatisticsStyling::kvmColorClass($row['kvmstate']);
+ $row['hddclass'] = StatisticsStyling::hddColorClass((int)$row['gbtmp']);
+ $row['kvmicon'] = $row['kvmstate'] === 'ENABLED' ? '✓' : '✗';
+ if (++$count > 5) {
+ $row['collapse'] = 'collapse';
+ }
+ $rows[] = $row;
+ }
+ Render::addTemplate('newclients', array('rows' => $rows, 'openbutton' => $count > 5));
+ }
+
+ /*
+ * HELPERS
+ */
+
+
+
+ private static function capChart(array &$json, array &$rows, float $cutoff, float $minSlice = 0.015): void
+ {
+ $total = 0;
+ foreach ($json as $entry) {
+ $total += $entry['value'];
+ }
+ if ($total === 0) {
+ return;
+ }
+ $cap = ceil($total * $cutoff);
+ $accounted = 0;
+ $id = 0;
+ foreach ($json as $entry) {
+ if (($accounted >= $cap || $entry['value'] / $total < $minSlice) && $id >= 3) {
+ break;
+ }
+ ++$id;
+ $accounted += $entry['value'];
+ }
+ for ($i = $id; $i < count($rows); ++$i) {
+ $rows[$i]['collapse'] = 'collapse';
+ }
+ $json = array_slice($json, 0, $id);
+ if ($accounted / $total < 0.99) {
+ $json[] = array(
+ 'color' => '#eee',
+ 'label' => 'invalid',
+ 'value' => ($total - $accounted),
+ );
+ }
+ }
+
+ /**
+ * @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 1bfc2893..7bd60b44 100644
--- a/modules-available/statistics/style.css
+++ b/modules-available/statistics/style.css
@@ -55,4 +55,63 @@
border-radius: 3px;
color: #fff;
padding: 1px 3px;
+}
+
+.dropdown-menu .btn.btn-machine-action {
+ text-align: left;
+}
+
+.open > .dropdown-menu {
+ min-width: inherit;
+}
+
+.filter-list .filter-row {
+ margin-bottom: 2px;
+}
+
+.filter-list input, .filter-list select {
+ padding: 3px 7px;
+}
+
+@media(min-width: 992px) {
+ .filter-list {
+ column-count:2;
+ column-gap:20px;
+ column-rule: 1px solid #eee;
+ }
+}
+
+.slx-focus {
+ animation-name: slxFocus;
+ animation-duration: .3s;
+ animation-iteration-count: 2;
+ animation-timing-function: ease;
+}
+
+@keyframes slxFocus {
+ 0% { background: unset }
+ 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 8e0a24f3..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>
@@ -65,9 +44,18 @@
<input type="checkbox" name="uuid[]" value="{{machineuuid}}" class="machine-checkbox">
<label></label>
</div>
+ <span class="pull-right">
{{#hasnotes}}
- <span class="glyphicon glyphicon-exclamation-sign pull-right"></span>
+ <a href="?do=Statistics&amp;uuid={{machineuuid}}#usernotes" class="badge" title="{{lang_hasNotes}}">
+ <span class="glyphicon glyphicon-tags"></span>
+ </a>
{{/hasnotes}}
+ {{#confvars}}
+ <a href="?do=baseconfig&amp;module=statistics&amp;machineuuid={{machineuuid}}" class="badge" title="{{lang_numConfigVars}}">
+ <span class="glyphicon glyphicon-pencil"></span>{{confvars}}
+ </a>
+ {{/confvars}}
+ </span>
{{#state_OFFLINE}}
<span class="glyphicon glyphicon-off" title="{{lang_machineOff}}"></span>
{{/state_OFFLINE}}
@@ -89,31 +77,57 @@
{{/link_details}}
<div class="small uuid">{{machineuuid}}</div>
{{#rmmodule}}
- <div class="small">{{lang_runMode}}:
+ <div class="small">{{lang_configuredRunMode}}:
<a class="slx-bold" href="?do=runmode&amp;module={{rmmodule}}">{{moduleName}}</a> / {{modeName}}
</div>
{{/rmmodule}}
+ {{#wrongRunMode}}
+ <div class="small text-danger">
+ {{lang_clientInDifferentRunmode}}: {{currentrunmode}}
+ {{^currentrunmode}}
+ {{lang_bootedWithoutAnyRunmode}}
+ {{/currentrunmode}}
+ </div>
+ {{/wrongRunMode}}
+ {{#currentuser}}
+ <div class="small">
+ {{lang_user}}:
+ <b>{{currentuser}}</b>
+ </div>
+ {{/currentuser}}
</td>
<td data-sort-value="{{clientip}}">
- <b><a href="?do=Statistics&amp;show=list&amp;filters=subnet={{subnet}}">{{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="{{locationname}}">{{locationname}}</td>
+ <td data-sort-value="{{location.sort}}">{{location.name}}</td>
</tr>
{{/rows}}
</tbody>
@@ -123,8 +137,9 @@
<span class="glyphicon glyphicon-refresh"></span>
{{lang_reset}}
</button>
- <span class="dropdown">
- <button class="btn btn-default dropdown-toggle btn-machine-action" type="button" id="dropdownMenu1" data-toggle="dropdown" aria-haspopup="true">
+ <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>
{{lang_listDropdown}}
<span class="caret"></span>
@@ -146,30 +161,81 @@
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>
- </span>
+ </div>
{{#rebootcontrol}}
- {{#canShutdown}}
- <button type="submit" name="action" value="shutdownmachines" class="btn btn-danger btn-machine-action"
- data-confirm="{{lang_shutdownConfirm}}" data-title="{{lang_shutdown}}">
- <span class="glyphicon glyphicon-off"></span>
- {{lang_shutdown}}
- </button>
- {{/canShutdown}}
- {{#canReboot}}
- <button type="submit" name="action" value="rebootmachines" class="btn btn-warning btn-machine-action"
- data-confirm="#confirm-reboot">
- <span class="glyphicon glyphicon-repeat"></span>
- {{lang_reboot}}
+ <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>
+ {{lang_remoteActions}}
+ <span class="caret"></span>
</button>
- {{/canReboot}}
+ <div class="dropdown-menu" style="padding:0" aria-labelledby="dropdownMenu2">
+ <div class="btn-group-vertical">
+ {{#canShutdown}}
+ <button type="submit" name="action" value="shutdownmachines" class="btn btn-danger btn-machine-action"
+ data-confirm="{{lang_shutdownConfirm}}" data-title="{{lang_shutdown}}">
+ <span class="glyphicon glyphicon-off"></span>
+ {{lang_shutdown}}
+ </button>
+ {{/canShutdown}}
+ {{#canReboot}}
+ <button type="submit" name="action" value="rebootmachines" class="btn btn-warning btn-machine-action"
+ data-confirm="#confirm-reboot">
+ <span class="glyphicon glyphicon-repeat"></span>
+ {{lang_reboot}}
+ </button>
+ {{/canReboot}}
+ {{#canWol}}
+ <button type="submit" name="action" value="wol" class="btn btn-primary btn-machine-action">
+ <span class="glyphicon glyphicon-bell"></span>
+ {{lang_wakeOnLan}}
+ </button>
+ {{/canWol}}
+ {{#canExec}}
+ <button type="submit" name="action" value="prepare-exec" class="btn btn-primary btn-machine-action">
+ <span class="glyphicon glyphicon-play"></span>
+ {{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 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>
{{lang_delete}}
</button>
+ <button type="button" class="btn btn-danger btn-machine-action 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="clear-machines" class="btn btn-danger btn-machine-action"
+ data-confirm="{{lang_sureClearIp}}">
+ <span class="glyphicon glyphicon-refresh"></span>
+ {{lang_resetClearIp}}
+ </button>
+ </div>
+ </div>
+ </div>
{{/canDelete}}
</div>
<div class="hidden" id="confirm-reboot">
@@ -196,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 () {
@@ -205,6 +288,23 @@ document.addEventListener("DOMContentLoaded", function () {
};
var $boxes = $('.machine-checkbox');
$boxes.change($fn);
+
+ // Shift click selection for the checkboxes
+ var lastChecked = null;
+ $boxes.click(function(e) {
+ if (!lastChecked) {
+ lastChecked = this;
+ return;
+ }
+ if (e.shiftKey) {
+ $boxes = $('.machine-checkbox');
+ const start = $boxes.index(this);
+ const end = $boxes.index(lastChecked);
+ $boxes.slice(Math.min(start, end), Math.max(start, end) + 1).prop('checked', lastChecked.checked);
+ }
+ lastChecked = this;
+ });
+
$("button[type=reset]").click(function() { setTimeout($fn, 1); });
if (window && window.opera && window.opera.version && Number(window.opera.version()) < 13) {
$(document).ready(function () {
@@ -221,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 d89a5b2f..e133bec6 100644
--- a/modules-available/statistics/templates/cpumodels.html
+++ b/modules-available/statistics/templates/cpumodels.html
@@ -10,20 +10,20 @@
<thead>
<tr>
<th data-sort="string">{{lang_modelName}}</th>
- <th data-sort="int" class="text-right text-nowrap">{{lang_cpuCores}}</th>
- <th data-sort="int" class="text-right text-nowrap">{{lang_modelCount}}</th>
+ <th data-sort="int" class="slx-smallcol text-right">{{lang_cpuCores}}</th>
+ <th data-sort="int" class="slx-smallcol text-right">{{lang_modelCount}}</th>
</tr>
</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 style="width:100%; table-layout: fixed;"><tr><td style="overflow:hidden;text-overflow: ellipsis;">
- <a class="filter-val" data-filter-val="{{systemmodel}}" href="?do=Statistics&amp;show=summary&amp;filters={{query}}~,~systemmodel={{urlsystemmodel}}">{{systemmodel}}</a>
+ <table class="slx-ellipsis"><tr><td>
+ <a class="filter-val" data-filter-val="{{systemmodel}}" href="#">{{systemmodel}}</a>
</td></tr></table>
</td>
<td data-sort-value="{{cores}}" class="text-right filter-col" data-filter-col="realcores">
- <a class="filter-val" data-filter-val="{{cores}}" href="?do=Statistics&amp;show=summary&amp;filters={{query}}~,~realcores={{cores}}">{{cores}}</a>
+ <a class="filter-val" data-filter-val="{{cores}}" href="#">{{cores}}</a>
</td>
<td class="text-right">{{count}}</td>
</tr>
@@ -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 07aa7320..f62c4d7c 100644
--- a/modules-available/statistics/templates/filterbox.html
+++ b/modules-available/statistics/templates/filterbox.html
@@ -1,201 +1,104 @@
-<div id="modal-add-filter" class="modal fade" role="dialog" style="position: absolute">
- <div class="modal-dialog" role="document">
- <div class="modal-content">
- <div class="modal-header">
- <b>{{lang_add_filter}}</b>
- </div>
- <form class="form-inline center" onsubmit="$('#add-btn').click(); return false">
- <div class="modal-body">
- <div class="form-group">
- <select id="columnSelect" name="column" class="form-control col-4-xs"> </select>
- </div>
- <div class="form-group">
- <select id="operatorSelect" name="operator" class="form-control col-4-xs"> </select>
- </div>
- <div class="form-group">
- <input name="argument" id="argumentInput" class="form-control col-4-xs">
- <select name="argument" id="argumentSelect" class="form-control col-4-xs"> </select>
- </div>
+<a href="#top" class="btn btn-default to-top-btn"><span class="glyphicon glyphicon-menu-up"></span></a>
+<h1>{{lang_moduleHeading}}</h1>
+
+<form id="query-form" method="GET" action="?do=statistics" role="form">
+ <input type="hidden" name="show" value="{{show}}">
+ <input type="hidden" name="do" value="statistics">
+ <div class="btn-group pull-right">
+ <button type="submit" hidden><!-- first button, so hitting enter in the form fields doesn't jump to summary -->
+ <button class="btn btn-default {{summaryButtonClass}}" type="submit" name="show" value="summary"
+ {{perms.view.summary.disabled}}>
+ <span class="glyphicon glyphicon-stats"></span>
+ {{lang_showVisualization}}
+ </button>
+ <button class="btn btn-default {{listButtonClass}}" type="submit" name="show" value="list"
+ {{perms.view.list.disabled}}>
+ <span class="glyphicon glyphicon-list"></span>
+ {{lang_showList}}
+ </button>
+ </div>
+
+ <h3>{{lang_labelFilter}}</h3>
+ <div class="filter-list">
+ {{#columns}}
+ <div class="{{collapse}} row filter-row" id="filter-{{key}}">
+ <div class="col-sm-4 col-xs-12">
+ <div class="checkbox checkbox-inline text-nowrap">
+ <input id="check-{{key}}" type="checkbox" name="filter[{{key}}]" value="1" class="filter-enable"
+ {{checked}}>
+ <label for="check-{{key}}">{{name}}</label>
</div>
- <div class="modal-footer">
- <button type="button" class="btn btn-default" data-dismiss="modal">{{lang_cancel}}</button>
- <button id="add-btn" type="button" class="btn btn-success" onclick="addFilterFromForm()">
- <span class="glyphicon glyphicon-plus"></span>
- {{lang_add}}
- </button>
- </div>
- </form>
+ </div>
+ <div class="col-sm-2 col-xs-3">
+ <select name="op[{{key}}]" class="form-control enable-check op">
+ {{#op}}
+ <option {{selected}}>{{op}}</option>
+ {{/op}}
+ </select>
+ </div>
+ <div class="col-sm-6 col-xs-9">
+ {{#input}}
+ <input name="arg[{{key}}]" type="{{.}}" class="form-control enable-check {{inputclass}} arg"
+ value="{{currentvalue}}" placeholder="{{placeholder}}">
+ {{/input}}
+ {{#enum}}
+ <select name="arg[{{key}}]" class="form-control enable-check arg">
+ {{#values}}
+ <option value="{{key}}" {{disabled}} {{selected}}>{{value}}</option>
+ {{/values}}
+ </select>
+ {{/enum}}
+ </div>
</div>
+ <div class="clearfix"></div><!-- fixes jumping around of items on firefox -->
+ {{/columns}}
</div>
-</div>
-
-<a href="#top" class="btn btn-default to-top-btn"><span class="glyphicon glyphicon-menu-up"></span></a>
-
-
-<div class="col-md-12">
- <!-- use GET here, to avoid the "resend form?" confirmation, and anyway this is stateless, so GET makes more sense -->
- <form id="queryForm" method="GET" action="?do=Statistics" class="" role="form">
- <input type="hidden" name="show" value="{{show}}">
- <button type="submit" hidden></button>
-
-
- <div class="btn-group pull-right">
- <button class="btn btn-default {{statButtonClass}}" type="submit" name="show" value="summary" {{perms.view.summary.disabled}}>
- <span class="glyphicon glyphicon-stats"></span>
- {{lang_showVisualization}}
+ <div class="row">
+ <div class="col-md-12 col-sm-10 col-xs-12">
+ <button type="submit" class="btn btn-primary pull-right">
+ <span class="glyphicon glyphicon-ok"></span>
+ {{lang_apply}}
</button>
- <button class="btn btn-default {{listButtonClass}}" type="submit" name="show" value="list" {{perms.view.list.disabled}}>
- <span class="glyphicon glyphicon-list"></span>
- {{lang_showList}}
+ <button type="button" class="btn btn-default" id="filter-expand">
+ <span class="glyphicon glyphicon-arrow-down"></span>
+ {{lang_more}}
</button>
</div>
- <h1>{{lang_moduleHeading}}</h1>
-
- <br/>
-
- <input type="hidden" name="do" value="statistics">
- <input type="hidden" name="sortColumn" id="sortColumn" value="{{sortColumn}}"/>
- <input type="hidden" name="sortDirection" id="sortDirection" value="{{sortDirection}}"/>
-
- <label for="filterInput">{{lang_labelFilter}}</label>
- <div class="row">
- <div class="col-md-12">
- <div class="input-group">
- <input type="text" name="filters" class="" id="filterInput"/>
- <span class="input-group-btn" style=" width: 1%; padding-bottom: 5px;">
- <button type="button" class="btn btn-success" onclick="popupFilter(null)">
- <span class="glyphicon glyphicon-plus"></span>
- {{lang_add_filter}}
- </button>
- </span>
- </div>
- </div>
- </div>
-
- <br/>
- </form>
-</div>
+ </div>
+ <div class="clearfix slx-space"></div>
+</form>
<script type="application/javascript"><!--
-var filterSelectize;
-
-var slxFilterNames = {
- machineuuid: '{{lang_uuid}}',
- macaddr: '{{lang_macAddr}}',
- firstseen: '{{lang_firstSeen}}',
- lastseen: '{{lang_lastSeen}}',
- lastboot: '{{lang_lastBoot}}',
- logintime: '{{lang_lastLogin}}',
- realcores: '{{lang_cores}}',
- systemmodel: '{{lang_model}}',
- cpumodel: '{{lang_cpuModel}}',
- hddgb: '{{lang_tmpGb}}',
- gbram: '{{lang_gbRam}}',
- kvmstate: '{{lang_kvmSupport}}',
- badsectors: '{{lang_reallocatedSectors}}',
- clientip: '{{lang_ip}}',
- state: '{{lang_usageState}}',
- location: '{{lang_location}}',
- currentuser: '{{lang_currentUser}}',
- subnet: '{{lang_subnet}}',
- runtime: '{{lang_runtimeHours}}',
- hostname: '{{lang_hostname}}',
- live_swapfree: '{{lang_swapFree}}',
- live_memfree: '{{lang_memFree}}',
- live_tmpfree: '{{lang_tmpFree}}'
-};
-
-slxLocations = {{{locations}}};
-
-var slxFilterDel = '{{delimiter}}';
-var $modal, $queryForm;
-
-
document.addEventListener("DOMContentLoaded", function () {
- /* some objects */
- var $columnSelect = $('#columnSelect');
- $modal = $('#modal-add-filter');
- $queryForm = $('#queryForm');
-
- var columns= {{{columns}}};
+ $('.is-date').datepicker({format : 'yyyy-mm-dd'});
- /* add options to column select */
- for (var key in columns) {
- $columnSelect.append($('<option>', {
- value: key, text: (slxFilterNames[key] ? slxFilterNames[key] : key) }));
- };
-
-
- /* initialize selectize */
- filterSelectize = $('#filterInput').selectize({
- delimiter: slxFilterDel,
- persist: false,
- plugins: ['remove_button'],
- create: function(input) {
- return {value: input, text: input}
- },
- onChange: function() {
- // if (initComplete && !$('#filterInput').is(':focus')) {
- // reload();
- // }
- },
- onItemRemove: function(value) {
- refresh();
- }
- })[0].selectize;
- /* add query */
- var str = "{{{query}}}";
- var eExp = /^(\w+)\s*([=><!~]+)\s*(.*)$/;
- str.split(slxFilterDel).forEach(function(v) {
- if (v.trim().length === 0)
- return;
- var match = eExp.exec(v);
- if (match && match.length === 4) {
- addFilter(match[1], match[2], match[3]);
- } else {
- filterSelectize.addOption({value: v, text: v});
- filterSelectize.addItem(v);
- }
+ $('#filter-expand').click(function() {
+ $('#query-form .filter-row.collapse').show();
+ $(this).remove();
});
- $('#columnSelect').on('change', function() {
- $('#operatorSelect option').remove();
- var col = $('#columnSelect').val();
- var opS = $('#operatorSelect');
- columns[col]['op'].sort(myOpSort);
- columns[col]['op'].forEach(function (v) {
- $(opS).append($('<option>', {
- value: v, text: v
- }));
+ // Cosmetic - less clutter in URL
+ $('#query-form').submit(function(e) {
+ $(this).find('.filter-row').each(function() {
+ var $row = $(this);
+ if ($row.find('.filter-enable').prop('checked'))
+ return;
+ $row.find('input, select').prop('name', '');
});
- /* also set the type of the input */
- if (columns[col]['type'] === 'date') {
- $('#argumentInput').datepicker({format : 'yyyy-mm-dd'});
- $('#argumentSelect').hide();
- } else if(columns[col]['type'] === 'enum') {
- $('#argumentSelect').empty();
- $('#argumentInput').hide();
- $('#argumentSelect').show();
- columns[col]['values'].forEach(function (v) {
- var t = v;
- var disabled = (col === 'location');
- if (col === 'location' && slxLocations['L' + v]) {
- t = slxLocations['L' + v].pad + ' ' + slxLocations['L' + v].name;
- disabled = slxLocations['L' + v].disabled;
- }
- $('#argumentSelect').append($('<option>', { value: v, text: t, disabled: disabled }));
- });
- } else {
- $('#argumentInput').datepicker('remove');
- $('#argumentSelect option').remove();
- $('#argumentInput').show();
- $('#argumentSelect').hide();
- }
});
+ var check = function() {
+ $(this).closest('.filter-row').find('.filter-enable:visible').prop('checked', true);
+ };
+
+ // This sucks - we need to wait a bit otherwise datepicker triggers change
+ setTimeout(function() {
+ $('.enable-check').change(check).keypress(check);
+ }, 100);
+
$('.filter-col').each(function(idx, elem) {
var e = $(elem);
var col = e.data('filter-col');
@@ -203,66 +106,23 @@ document.addEventListener("DOMContentLoaded", function () {
e.find('.filter-val').each(function(idx, elem) {
var e = $(elem);
var val = e.data('filter-val');
- if (!val) return;
+ 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();
});
});
});
-}, false);
-
-function popupFilter(field) {
- if (field != null) {
- $('#columnSelect').val(field);
- }
- $('#columnSelect').change();
- $modal.modal('show');
-}
-
-function addFilterFromForm() {
- var argument1 = $('#argumentInput').val();
- var argument2 = $('#argumentSelect').val();
- var argument = argument1 ? argument1 : argument2;
- var col = $('#columnSelect').val();
- var op = $('#operatorSelect').val();
-
- addFilter(col, op, argument);
- refresh(); // TODO: AJAX
-}
-
-function addFilter(col, op, argument) {
- var filterValue = col + ' ' + op + ' ' + argument;
- var filterText = filterValue;
- var displayArgument = argument;
- if (col === 'location' && slxLocations['L' + argument]) {
- displayArgument = slxLocations['L' + argument].name;
- }
- if (slxFilterNames[col]) {
- filterText = slxFilterNames[col] + ' ' + op + ' ' + displayArgument;
- }
- filterSelectize.addOption({value: filterValue, text: filterText});
- filterSelectize.addItem(filterValue);
-}
-
-function toggleSort(field) {
- $('#sort').val(field + ' ' + order);
- refresh();
-}
-
-/* equal sign should always be first, the rest doesn't matter*/
-function myOpSort(a,b) {
- if (a === '=') { return -1; }
- else if (a === b) {return 0}
- else { return 1;}
+ $('.auto-chart').each(function() {
+ makePieChart($(this));
+ });
-}
+}, false);
-function refresh() {
- $queryForm.submit(); /* TODO: use AJAX */
-}
// --></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 de1c71ad..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="?do=Statistics&amp;show=summary&amp;filters={{query}}~,~hddgb={{gb}}">{{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 efa3bad3..b3c65733 100644
--- a/modules-available/statistics/templates/kvmstate.html
+++ b/modules-available/statistics/templates/kvmstate.html
@@ -15,9 +15,9 @@
</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="?do=Statistics&amp;show=summary&amp;filters={{query}}~,~kvmstate={{kvmstate}}">{{kvmstate}}</a>
+ <a class="filter-val" data-filter-val="{{kvmstate}}" href="#">{{kvmstate}}</a>
</td>
<td class="text-right">{{count}}</td>
</tr>
@@ -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 b839dfca..57786510 100644
--- a/modules-available/statistics/templates/machine-hdds.html
+++ b/modules-available/statistics/templates/machine-hdds.html
@@ -4,21 +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}}
+ {{#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">
@@ -28,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 e49438eb..be32f9c7 100644
--- a/modules-available/statistics/templates/machine-main.html
+++ b/modules-available/statistics/templates/machine-main.html
@@ -1,6 +1,6 @@
<h1>
{{hostname}} {{#hostname}}–{{/hostname}} {{clientip}}
- {{#notes}}<a href="#usernotes"><span class="glyphicon glyphicon-exclamation-sign"></span></a>{{/notes}}
+ {{#notes}}<a href="#usernotes"><span class="glyphicon glyphicon-tags"></span></a>{{/notes}}
</h1>
<ol class="breadcrumb">
{{#locations}}
@@ -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,42 +104,61 @@
</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}}"/>
+ 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">
- {{lang_reboot}}/{{lang_shutdown}}
+ {{lang_remoteActions}}
</td>
<td>
<form method="post" action="?do=statistics">
<input type="hidden" name="token" value="{{token}}">
<input type="hidden" name="uuid" value="{{machineuuid}}">
- {{#canShutdown}}
- <button type="button" class="btn btn-sm btn-danger btn-machine-action" data-toggle="modal"
- data-target="#shutdown-confirm">
- <span class="glyphicon glyphicon-off"></span>
- {{lang_shutdown}}
+ <div class="slx-smallspace">
+ {{#canShutdown}}
+ <button type="button" class="btn btn-sm btn-danger btn-machine-action" data-toggle="modal"
+ data-target="#shutdown-confirm">
+ <span class="glyphicon glyphicon-off"></span>
+ {{lang_shutdown}}
+ </button>
+ {{/canShutdown}}
+ {{#canReboot}}
+ <button type="button" class="btn btn-sm btn-warning btn-machine-action" data-toggle="modal"
+ data-target="#reboot-confirm">
+ <span class="glyphicon glyphicon-repeat"></span>
+ {{lang_reboot}}
+ </button>
+ {{/canReboot}}
+ </div>
+ <div>
+ {{#canWol}}
+ <button type="submit" name="action" value="wol" class="btn btn-sm btn-primary btn-machine-action">
+ <span class="glyphicon glyphicon-bell"></span>
+ {{lang_wakeOnLan}}
</button>
- {{/canShutdown}}
- {{#canReboot}}
- <button type="button" class="btn btn-sm btn-warning btn-machine-action" data-toggle="modal"
- data-target="#reboot-confirm">
- <span class="glyphicon glyphicon-repeat"></span>
- {{lang_reboot}}
+ {{/canWol}}
+ {{#canExec}}
+ <button type="submit" name="action" value="prepare-exec" class="btn btn-sm btn-primary btn-machine-action">
+ <span class="glyphicon glyphicon-play"></span>
+ {{lang_remoteExec}}
</button>
- {{/canReboot}}
+ {{/canExec}}
+ </div>
<div class="modal fade" id="reboot-confirm" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
@@ -176,6 +211,19 @@
</td>
</tr>
{{/rebootcontrol}}
+ {{#hasBaseconfig}}
+ <tr>
+ <td class="text-nowrap">
+ {{lang_configVars}}
+ </td>
+ <td>
+ <a class="btn btn-sm btn-default" href="?do=baseconfig&amp;module=statistics&amp;machineuuid={{machineuuid}}&amp;redirect={{qstr_urlencode}}">
+ <span class="glyphicon glyphicon-edit"></span>
+ {{lang_edit}} ({{overriddenVars}})
+ </a>
+ </td>
+ </tr>
+ {{/hasBaseconfig}}
</table>
</div>
</div>
@@ -190,23 +238,44 @@
<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>
+ {{/cpu-sockets}}
+ {{#live_cpuload_s}}
+ <div class="meter">
+ <div class="text left">{{lang_cpuload}}</div>
+ <div class="text right">{{live_cpuload_s}}</div>
+ <div class="bar" style="width:{{live_cpuidle}}%"></div>
+ </div>
+ {{/live_cpuload_s}}
+ {{#live_cputemp}}
+ <div class="meter">
+ <div class="text left">{{lang_cputemp}}</div>
+ <div class="text right">{{live_cputemp}}&thinsp;°C</div>
+ <div class="bar" style="width:{{live_cputemppercent}}%"></div>
</div>
- {{/Sockets}}
+ {{/live_cputemp}}
</td>
</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>
@@ -214,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>
@@ -243,31 +316,72 @@
{{/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>
+ <div class="bar" style="width:{{live_id45percent}}%"></div>
+ </div>
+ {{/live_id45size}}
+ </td>
+ </tr>
<tr class="{{kvmclass}}">
<td class="text-nowrap">{{lang_64bitSupport}}</td>
<td>{{kvmstate}}</td>
@@ -313,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>
@@ -331,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 cfb86062..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="?do=Statistics&amp;show=summary&amp;filters={{query}}~,~gbram={{gb}}">{{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/config.json b/modules-available/statistics_reporting/config.json
index c439efa8..2bd54eb1 100644
--- a/modules-available/statistics_reporting/config.json
+++ b/modules-available/statistics_reporting/config.json
@@ -4,6 +4,7 @@
"statistics",
"locations",
"js_stupidtable",
- "js_jqueryui"
+ "js_jqueryui",
+ "bootstrap_datepicker"
]
} \ No newline at end of file
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 58e9e63b..bafe80bc 100644
--- a/modules-available/statistics_reporting/inc/queries.inc.php
+++ b/modules-available/statistics_reporting/inc/queries.inc.php
@@ -13,29 +13,31 @@ class Queries
}
}
- public static function getClientStatistics($from, $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,
m.locationid, m.firstseen -- , m.lastboot, m.logintime, m.state
- FROM machine m WHERE firstseen <= $to"); // " WHERE lastseen >= :from", compact('from'));
+ FROM machine m WHERE firstseen <= $to AND lastseen > $fromCutoff"); // " WHERE lastseen >= :from", compact('from'));
$machines = self::getStats3($res, $from, $to, $lowerTimeBound, $upperTimeBound);
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($from, $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,
m.locationid, m.firstseen -- , m.lastboot, m.logintime, m.state
- FROM machine m WHERE firstseen <= $to"); // " WHERE lastseen >= :from", compact('from'));
+ FROM machine m WHERE firstseen <= $to AND lastseen > $fromCutoff"); // " WHERE lastseen >= :from", compact('from'));
$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;
@@ -58,15 +60,16 @@ class Queries
return $locations;
}
- public static function getOverallStatistics($from, $to, $lowerTimeBound = 0, $upperTimeBound = 24)
+ public static function getOverallStatistics(int $from, int $to, $lowerTimeBound = 0, $upperTimeBound = 24)
{
+ $fromCutoff = $from - 86400 * 30;
$res = Database::simpleQuery("SELECT m.machineuuid, m.hostname, m.clientip,
m.locationid, m.firstseen -- , m.lastboot, m.logintime, m.state
- FROM machine m WHERE firstseen <= $to"); // " WHERE lastseen >= :from", compact('from'));
+ FROM machine m WHERE firstseen <= $to AND lastseen > $fromCutoff"); // " WHERE lastseen >= :from", compact('from'));
$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;
@@ -81,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) {
@@ -103,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),
@@ -129,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
@@ -225,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;
@@ -304,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];
@@ -324,30 +320,28 @@ 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($from, $to)
+ public static function getDozmodStats(int $from, int $to): array
{
- if (!Module::isAvailable('dozmod'))
- return array('disabled' => true);
+ if (Module::get('dozmod') === false)
+ return ['disabled' => true];
- $return = array();
+ $return = [];
$return['vms'] = Database::queryFirst("SELECT Count(*) AS `total`, Sum(If(createtime >= $from, 1, 0)) AS `new`,
Sum(If(updatetime >= $from, 1, 0)) AS `updated`, Sum(If(latestversionid IS NOT NULL, 1, 0)) AS `valid`
FROM sat.imagebase
@@ -363,7 +357,38 @@ class Queries
return $return;
}
- public static function getAggregatedMachineStats($from)
+ public static function getExamStats(int $from, int $to): array
+ {
+ if (Module::get('exams') === false)
+ return ['disabled' => true];
+ $return = [];
+ $eres = Database::simpleQuery("SELECT starttime, endtime, GROUP_CONCAT(exl.locationid) AS `locs` FROM exams
+ LEFT JOIN exams_x_location exl USING (examid)
+ WHERE starttime < $to AND endtime > $from
+ GROUP BY examid");
+ foreach ($eres as $row) {
+ // Get all boot events
+ $data = ['from' => $row['starttime'], 'to' => $row['endtime']];
+ if (empty($row['locs'])) {
+ $exam = Database::queryFirst("SELECT Count(*) AS `event`, Avg(s.data) AS length FROM statistic s
+ WHERE typeid = '~session-length'
+ AND dateline BETWEEN :from AND :to", $data);
+ } else {
+ $data['locs'] = explode(',', $row['locs']);
+ $exam = Database::queryFirst("SELECT Count(*) AS `sessions`, Avg(s.data) AS length FROM statistic s
+ INNER JOIN machine m USING (machineuuid)
+ WHERE typeid = '~session-length' AND m.locationid IN (:locs)
+ AND dateline BETWEEN :from AND :to", $data);
+ }
+ settype($exam['length'], 'int');
+ settype($exam['sessions'], 'int');
+ $exam['duration'] = $row['endtime'] - $row['starttime'];
+ $return[] = $exam;
+ }
+ return $return;
+ }
+
+ public static function getAggregatedMachineStats(int $from): array
{
$return = array();
$return['location'] = Database::queryAll("SELECT MD5(CONCAT(locationid, :salt)) AS `location`, Count(*) AS `count`
@@ -373,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;
}
@@ -387,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;
}
@@ -395,14 +429,29 @@ 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
- WHERE (dateline BETWEEN $from AND $to) AND typeid = '.vmchooser-session-name'
- GROUP BY username");
+ WHERE (dateline BETWEEN $from AND $to) AND typeid = '.vmchooser-session-name'");
return (int)$res['total'];
}
+ public static function getBaseSystemStats(int $from, int $to)
+ {
+ return Database::queryAll("SELECT `data` AS `system`, Count(*) AS `count`
+ FROM statistic
+ WHERE (dateline BETWEEN $from AND $to) AND typeid = 'boot-system'
+ GROUP BY `system`");
+ }
+
+ public static function getRunmodeStats(int $from, int $to)
+ {
+ return Database::queryAll("SELECT `data` AS `mode`, Count(*) AS `count`
+ FROM statistic
+ WHERE (dateline BETWEEN $from AND $to) AND typeid = 'boot-runmode'
+ GROUP BY `mode`");
+ }
+
}
diff --git a/modules-available/statistics_reporting/inc/remotereport.inc.php b/modules-available/statistics_reporting/inc/remotereport.inc.php
index fa84e7e5..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,47 +68,55 @@ 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($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);
- $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];
}
//
$data = array();
- $data['cpuCount'] = preg_match_all('/\bprocessor\s+:\s+(.*)$/m', $cpuInfo, $out);
+ $data['cpuCount'] = preg_match_all('/\bmodel name\s+:\s+(.*)$/m', $cpuInfo, $out);
if ($data['cpuCount'] > 0) {
$data['cpuModel'] = $out[1][0];
}
@@ -121,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 cc03e4d8..ec4bbf1a 100644
--- a/modules-available/statistics_reporting/page.inc.php
+++ b/modules-available/statistics_reporting/page.inc.php
@@ -11,7 +11,11 @@ class Page_Statistics_Reporting extends Page
/**
* @var int
*/
- private $days;
+ private $from;
+ /**
+ * @var int
+ */
+ private $to;
/**
* @var int
*/
@@ -46,7 +50,11 @@ class Page_Statistics_Reporting extends Page
$this->action = Request::any('action', 'show', 'string');
$this->type = Request::get('type', 'total', 'string');
- $this->days = Request::get('cutoff', 7, 'int');
+
+ // Format: yyyy-mm-dd
+ $fromString = Request::get('from', '-6 days', 'string');
+ $toString = Request::get('to', 'today', 'string');
+
$this->lower = Request::get('lower', 8, 'int');
$this->upper = Request::get('upper', 20, 'int');
@@ -55,9 +63,13 @@ class Page_Statistics_Reporting extends Page
$this->type = 'total';
}
+ // convert from/to dates to unixtime
+ $this->from = min(strtotime($fromString.' 00:00:00'), time());
+ $this->to = min(strtotime($toString.' 23:59:59'), time());
+
// timespan you want to see. default = last 7 days
- GetData::$from = strtotime("-" . ($this->days - 1) . " days 00:00:00");
- GetData::$to = time();
+ GetData::$from = $this->from;
+ GetData::$to = $this->to;
GetData::$lowerTimeBound = $this->lower;
GetData::$upperTimeBound = $this->upper;
/*
@@ -113,26 +125,22 @@ 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' : '',
);
}
- foreach (array(1,2,5,7,14,30,90) as $day) {
- $data['days'][] = array(
- 'days' => $day,
- 'selected' => ($day === $this->days) ? 'selected' : '',
- );
- }
+ $data['from'] = date_format(date_timestamp_set(new DateTime(), $this->from), 'Y-m-d');
+ $data['to'] = date_format(date_timestamp_set(new DateTime(), $this->to), 'Y-m-d');
$data['lower'] = $this->lower;
$data['upper'] = $this->upper;
@@ -177,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()
@@ -249,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');
@@ -265,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)) {
@@ -282,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) {
@@ -306,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) {
@@ -318,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/statistics_reporting/templates/columnChooser.html b/modules-available/statistics_reporting/templates/columnChooser.html
index d6ff90b7..8664f776 100644
--- a/modules-available/statistics_reporting/templates/columnChooser.html
+++ b/modules-available/statistics_reporting/templates/columnChooser.html
@@ -14,7 +14,23 @@
{{lang_displaySelection}}
</div>
<div class="panel-body">
- <div class="row top-row">
+ <div class="row">
+ <div class="col-sm-6 col-md-4" style="display: flex; align-items: center; margin-bottom: 20px">
+ <span style="width: 40px">From:</span>
+ <div class="input-group bootstrap-timepicker timepicker" style="margin: 0 20px">
+ <span class="input-group-addon"><i class="glyphicon glyphicon-calendar"></i></span>
+ <input name="from" id="date-from" class="form-control" autocomplete="off" value="{{from}}">
+ </div>
+ </div>
+ <div class="col-sm-6 col-md-4" style="display: flex; align-items: center; margin-bottom: 20px">
+ <span style="width: 40px">To:</span>
+ <div class="input-group bootstrap-timepicker timepicker" style="margin: 0 20px">
+ <span class="input-group-addon"><i class="glyphicon glyphicon-calendar"></i></span>
+ <input name="to" id="date-to" class="form-control" autocomplete="off" value="{{to}}">
+ </div>
+ </div>
+ </div>
+ <div class="row">
<div class="col-md-2">
<select name="type" id="select-table" class="form-control">
{{#tables}}
@@ -22,14 +38,8 @@
{{/tables}}
</select>
</div>
- <div class="col-md-2">
- <select name="cutoff" id="select-cutoff" class="form-control">
- {{#days}}
- <option value="{{days}}" {{selected}}>{{days}} {{lang_days}}</option>
- {{/days}}
- </select>
- </div>
- <div class="col-md-3" style="margin-top: 10px;">
+
+ <div class="col-md-5" style="margin-top: 10px;">
<div id="slider">
<div id="lower-handle" class="ui-slider-handle"></div>
<div id="upper-handle" class="ui-slider-handle"></div>
@@ -37,10 +47,8 @@
<input type="hidden" id="upper-field" name="upper" value="{{upper}}">
</div>
</div>
- <div class="col-md-1">
- <button type="submit" class="btn btn-primary">{{lang_show}}</button>
- </div>
- <div class="col-md-3">
+ <div class="col-md-5" style="display: flex; justify-content: flex-end">
+ <button type="submit" class="btn btn-primary" style="margin-right: 20px">{{lang_show}}</button>
<div class="input-group">
<select class="form-control" name="format">
<option value="json">JSON</option>
@@ -110,6 +118,10 @@
<script type="application/javascript">
document.addEventListener("DOMContentLoaded", function () {
+ const now = new Date();
+ $('#date-from').datepicker({format : 'yyyy-mm-dd', endDate: now, todayHighlight: true, weekStart: 1});
+ $('#date-to').datepicker({format : 'yyyy-mm-dd', endDate: now, todayHighlight: true, weekStart: 1});
+
var lowerHandle = $("#lower-handle");
var upperHandle = $("#upper-handle");
var lower = $('#lower-field').val();
diff --git a/modules-available/sysconfig/addconfig.inc.php b/modules-available/sysconfig/addconfig.inc.php
index 55944cfa..27af31e8 100644
--- a/modules-available/sysconfig/addconfig.inc.php
+++ b/modules-available/sysconfig/addconfig.inc.php
@@ -9,57 +9,34 @@ 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);
Util::redirect('?do=SysConfig');
}
self::$instance = new $step();
- if (Request::any('edit')) {
- self::$instance->edit = ConfigTgz::get(Request::any('edit'));
- if (self::$instance->edit === false)
- Util::traceError('Invalid config id for editing');
+ if (($editId = Request::any('edit', 0, 'int')) !== 0) {
+ self::$instance->edit = ConfigTgz::get($editId);
+ 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');
- }
-
- protected function taskError($status)
- {
- if (isset($status['data']['error'])) {
- $error = $status['data']['error'];
- } elseif (isset($status['statusCode'])) {
- $error = $status['statusCode'];
- } else {
- $error = Dictionary::translate('lang_unknwonTaskManager'); // TODO: No text
- }
- Message::addError('main.task-error', $error);
- Util::redirect('?do=SysConfig');
- }
-
/**
* Called before any HTML rendering happens, so you can
- * pepare stuff, validate input, and optionally redirect
+ * prepare stuff, validate input, and optionally redirect
* early if something is wrong, or you received post
* data etc.
*/
@@ -86,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();
}
@@ -123,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();
@@ -135,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,
@@ -153,15 +130,15 @@ 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();
- } elseif (Request::any('title')) {
- $title = Request::any('title');
} else {
- $title = '';
+ $title = Request::any('title', '', 'string');
}
$dummy = 0;
+ $sort = [];
foreach ($modGroups as &$mod) {
+ $sort[] = $mod['sortOrder'];
if (!empty($mod['modules']) && $mod['unique']) {
array_unshift($mod['modules'], array(
'moduleid' => 'x' . (++$dummy),
@@ -169,12 +146,13 @@ class AddConfig_Start extends AddConfig_Base
));
}
}
+ array_multisort($sort, SORT_ASC | SORT_NUMERIC, $modGroups);
unset($mod);
Render::addDialog(Dictionary::translate("lang_configurationCompilation"), false, 'cfg-start', array(
'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(),
));
}
@@ -186,29 +164,25 @@ 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()
{
$modules = Request::post('module');
- $title = Request::post('title');
+ $title = Request::post('title', Request::REQUIRED, 'string');
if (!is_array($modules)) {
Message::addError('missing-file');
Util::redirect('?do=SysConfig&action=addconfig');
}
- if (empty($title)) {
- Message::addError('missing-title');
- 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 1f78de81..4564537e 100644
--- a/modules-available/sysconfig/addmodule.inc.php
+++ b/modules-available/sysconfig/addmodule.inc.php
@@ -9,33 +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)
+ 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 (Request::any('edit')) {
- self::$instance->edit = ConfigModule::get(Request::any('edit'));
- if (self::$instance->edit === false)
- Util::traceError('Invalid module id for editing');
- if (!preg_match('/^' . self::$instance->edit->moduleType() . '_/', $step))
- Util::traceError('Module to edit is of different type!');
+ if ($editId === null) {
+ $editId = Request::any('edit', 0, 'int');
+ }
+ if ($editId !== 0) {
+ self::$instance->edit = ConfigModule::get($editId);
+ if (self::$instance->edit === null)
+ ErrorHandler::traceError('Invalid module id for editing');
+ if ($step !== 'AddModule_Assign' && !preg_match('/^' . self::$instance->edit->moduleType() . '_/', $step))
+ ErrorHandler::traceError('Module to edit is of different type!');
Util::addRedirectParam('edit', self::$instance->edit->id());
}
}
@@ -61,7 +67,7 @@ abstract class AddModule_Base
/**
* Called before any HTML rendering happens, so you can
- * pepare stuff, validate input, and optionally redirect
+ * prepare stuff, validate input, and optionally redirect
* early if something is wrong, or you received post
* data etc.
*/
@@ -89,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();
}
@@ -97,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 (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();
@@ -108,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();
}
@@ -136,45 +142,98 @@ class AddModule_Start extends AddModule_Base
}
+/**
+ * End dialog for adding module. Here the user
+ * can assign the module to configs.
+ */
+class AddModule_Assign extends AddModule_Base
+{
+
+ protected function preprocessInternal()
+ {
+ $assign = Request::any('assign', false, 'boolean');
+
+ if ($assign) {
+ $configIds = Request::any('configs', [], 'array');
+ $moduleId = $this->edit->id();
+ $moduleType = $this->edit->moduleType();
+
+ if (ConfigModule::getList()[$moduleType]['unique']) {
+ $moduleIds = [];
+ foreach (ConfigModule::getAll($moduleType) ?? [] as $module) {
+ $moduleIds[] = $module->id();
+ }
+
+ Database::exec("DELETE FROM configtgz_x_module WHERE configid IN (:configids) AND moduleid IN (:moduleids)",
+ array('configids' => $configIds, 'moduleids' => $moduleIds));
+ }
+
+ foreach ($configIds as $configId) {
+ Database::exec("INSERT INTO configtgz_x_module (configid, moduleid) VALUES (:configid, :moduleid)", array(
+ 'configid' => $configId,
+ 'moduleid' => $moduleId
+ ));
+ ConfigTgz::get($configId)->generate();
+ }
+
+ Util::redirect('?do=SysConfig');
+ }
+ }
+
+ protected function renderInternal()
+ {
+ $data = ['configs' => SysConfig::getAll()];
+ if (count($data['configs']) === 0)
+ Util::redirect('?do=SysConfig');
+
+ $moduleType = $this->edit->moduleType();
+ if (ConfigModule::getList()[$moduleType]['unique']) {
+ $modules = Database::queryAll('SELECT configtgz_module.moduleid as moduleid, configtgz_module.title as title, configtgz_x_module.configid as configid'
+ . ' FROM configtgz_module INNER JOIN configtgz_x_module ON configtgz_module.moduleid = configtgz_x_module.moduleid'
+ . ' WHERE configtgz_module.moduletype = :moduletype',
+ array('moduletype' => $moduleType));
+
+ $modulesByConfigId = [];
+ foreach ($modules as $module) {
+ $modulesByConfigId[$module['configid']] = $module;
+ }
+
+ foreach ($data['configs'] as &$config) {
+ if (!isset($modulesByConfigId[$config['configid']])) continue;
+ $config['replaces'] = $modulesByConfigId[$config['configid']]['title'];
+ }
+ }
+
+ $data['edit'] = $this->edit->id();
+ Render::addDialog(Dictionary::translate('lang_moduleAssign'), false, 'assign', $data);
+ }
+
+}
+
/*
* 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 80b7cff1..42187171 100644
--- a/modules-available/sysconfig/addmodule_adauth.inc.php
+++ b/modules-available/sysconfig/addmodule_adauth.inc.php
@@ -13,15 +13,14 @@ class AdAuth_Start extends AddModule_Base
protected function renderInternal()
{
- $ADAUTH_COMMON_FIELDS = array('title', 'server', 'searchbase', 'binddn', 'bindpw', 'home', 'homeattr', 'ssl', 'fixnumeric', 'genuid', 'certificate', 'mapping', 'nohomewarn');
+ $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();
- }
- if ($data['fixnumeric'] === false) {
- $data['fixnumeric'] = 's';
+ } else {
+ $data['ssl'] = true;
}
postToArray($data, $ADAUTH_COMMON_FIELDS, true);
$obdn = Request::post('originalbinddn');
@@ -36,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);
}
@@ -90,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'),
@@ -106,7 +104,6 @@ class AdAuth_CheckConnection extends AddModule_Base
'bindpw' => Request::post('bindpw'),
'home' => Request::post('home'),
'ssl' => Request::post('ssl'),
- 'fixnumeric' => Request::post('fixnumeric'),
'genuid' => Request::post('genuid'),
'certificate' => Request::post('certificate', ''),
'taskid' => $this->scanTask['id'],
@@ -133,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';
@@ -142,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
//
@@ -197,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'),
@@ -208,7 +199,6 @@ class AdAuth_SelfSearch extends AddModule_Base
'bindpw' => Request::post('bindpw'),
'home' => Request::post('home'),
'ssl' => Request::post('ssl') === 'on',
- 'fixnumeric' => Request::post('fixnumeric'),
'genuid' => Request::post('genuid'),
'fingerprint' => Request::post('fingerprint'),
'certificate' => Request::post('certificate', ''),
@@ -286,13 +276,12 @@ class AdAuth_HomeAttrCheck extends AddModule_Base
'bindpw' => Request::post('bindpw'),
'home' => Request::post('home'),
'ssl' => Request::post('ssl') === 'on',
- 'fixnumeric' => Request::post('fixnumeric'),
'genuid' => Request::post('genuid'),
'fingerprint' => Request::post('fingerprint'),
'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,12 +348,11 @@ class AdAuth_CheckCredentials extends AddModule_Base
'home' => Request::post('home'),
'homeattr' => Request::post('homeattr'),
'ssl' => Request::post('ssl') === 'on',
- 'fixnumeric' => Request::post('fixnumeric'),
'genuid' => Request::post('genuid'),
'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'
))
@@ -424,16 +412,15 @@ class AdAuth_HomeDir extends AddModule_Base
'home' => Request::post('home'),
'homeattr' => Request::post('homeattr'),
'ssl' => Request::post('ssl') === 'on',
- 'fixnumeric' => Request::post('fixnumeric'),
'genuid' => Request::post('genuid'),
'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"';
@@ -454,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');
@@ -478,12 +465,13 @@ 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', 'fixnumeric', 'genuid',
+ foreach (['searchbase', 'binddn', 'server', 'bindpw', 'home', 'nohomewarn', 'homeattr', 'certificate', 'genuid',
'ldapAttrMountOpts', 'shareHomeMountOpts'] as $key) {
$module->setData($key, Request::post($key, '', 'string'));
}
@@ -505,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);
@@ -513,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()
@@ -522,6 +510,11 @@ class AdAuth_Finish extends AddModule_Base
$this->taskIds = array(
'tm-config' => $tgz,
);
+
+ if ($this->edit === null) {
+ AddModule_Base::setStep('AddModule_Assign', $module->id());
+ }
+
}
protected function renderInternal()
diff --git a/modules-available/sysconfig/addmodule_branding.inc.php b/modules-available/sysconfig/addmodule_branding.inc.php
index 6e628926..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);
@@ -93,42 +93,59 @@ class Branding_ProcessFile extends AddModule_Base
*
* @param string $svgName file to download to
* @param string $url url to download from
- * @return boolean true of download succeded, false on download error (also returns true if downloaded file doesn't
+ * @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,31 +225,30 @@ 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;
+ }
Util::redirect('?do=SysConfig');
}
diff --git a/modules-available/sysconfig/addmodule_custommodule.inc.php b/modules-available/sysconfig/addmodule_custommodule.inc.php
index c234f765..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,8 +55,9 @@ 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');
if (!isset($status['statusCode'])) {
unlink($tempfile);
@@ -66,47 +67,29 @@ class CustomModule_ProcessUpload extends AddModule_Base
unlink($tempfile);
$this->taskError($status);
}
- // Sort files for better display
- $dirs = array();
- foreach ($status['data']['entries'] as $file) {
- if ($file['isdir']) continue;
- $dirs[dirname($file['name'])][] = $file;
- }
- ksort($dirs);
- $list = array();
- foreach ($dirs as $dir => $files) {
- $list[] = array(
- 'name' => $dir,
- 'isdir' => true
- );
- sort($files);
- foreach ($files as $file) {
- $file['size'] = Util::readableFileSize($file['size']);
- $list[] = $file;
- }
- }
- if ($this->edit !== false)
+ $list = SysConfig::archiveContentsFromTask($status, $userGroupWarn);
+
+ if ($this->edit !== null) {
$title = $this->edit->title();
- elseif (isset($_FILES['modulefile']['name']))
+ } else if (isset($_FILES['modulefile']['name'])) {
$title = basename($_FILES['modulefile']['name']);
- else
+ } 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,
- 'title' => $title
+ 'edit' => $this->edit === null ? null : $this->edit->id(),
+ 'title' => $title,
+ 'userGroupWarn' => $userGroupWarn,
));
- Session::save();
}
}
class CustomModule_CompressModule extends AddModule_Base
{
-
- private $taskId = false;
-
+
protected function preprocessInternal()
{
$title = Request::post('title');
@@ -116,14 +99,15 @@ class CustomModule_CompressModule extends AddModule_Base
Util::redirect('?do=SysConfig&action=addmodule&step=CustomModule_Start');
}
// Recompress using task manager
- $this->taskId = 'tgzmod' . mt_rand() . '-' . microtime(true);
+ $taskId = 'tgzmod' . mt_rand() . '-' . microtime(true);
$destFile = tempnam(sys_get_temp_dir(), 'bwlp-') . '.tgz';
Taskmanager::submit('RecompressArchive', array(
- 'id' => $this->taskId,
- 'inputFiles' => array($tempfile),
- 'outputFile' => $destFile
+ 'id' => $taskId,
+ 'inputFiles' => [$tempfile => false],
+ 'outputFile' => $destFile,
+ 'forceRoot' => Request::post('force-owner', 0, 'int') !== 0,
), true);
- $status = Taskmanager::waitComplete($this->taskId, 5000);
+ $status = Taskmanager::waitComplete($taskId, 10000);
unlink($tempfile);
if (!isset($status['statusCode'])) {
$this->tmError();
@@ -132,30 +116,30 @@ 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;
+ }
Util::redirect('?do=SysConfig');
}
diff --git a/modules-available/sysconfig/addmodule_ldapauth.inc.php b/modules-available/sysconfig/addmodule_ldapauth.inc.php
index 6d612e9e..6a385d9c 100644
--- a/modules-available/sysconfig/addmodule_ldapauth.inc.php
+++ b/modules-available/sysconfig/addmodule_ldapauth.inc.php
@@ -9,15 +9,14 @@ class LdapAuth_Start extends AddModule_Base
protected function renderInternal()
{
- $LDAPAUTH_COMMON_FIELDS = array('title', 'server', 'searchbase', 'binddn', 'bindpw', 'home', 'homeattr', 'ssl', 'fixnumeric', 'genuid', 'certificate', 'mapping', 'nohomewarn');
+ $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();
- }
- if (!isset($data['fixnumeric']) || $data['fixnumeric'] === false) {
- $data['fixnumeric'] = 's';
+ } else {
+ $data['ssl'] = true;
}
postToArray($data, $LDAPAUTH_COMMON_FIELDS, true);
if (isset($data['server']) && preg_match('/^(.*)\:(636|389)$/', $data['server'], $out)) {
@@ -28,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);
}
@@ -65,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;
}
}
@@ -80,11 +78,10 @@ class LdapAuth_CheckConnection extends AddModule_Base
'bindpw' => Request::post('bindpw'),
'home' => Request::post('home'),
'ssl' => Request::post('ssl'),
- 'fixnumeric' => Request::post('fixnumeric'),
'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';
@@ -152,11 +149,10 @@ class LdapAuth_CheckCredentials extends AddModule_Base
'bindpw' => Request::post('bindpw'),
'home' => Request::post('home'),
'ssl' => Request::post('ssl') === 'on',
- 'fixnumeric' => Request::post('fixnumeric'),
'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',
))
@@ -193,16 +189,15 @@ class LdapAuth_HomeDir extends AddModule_Base
'bindpw' => Request::post('bindpw'),
'home' => Request::post('home'),
'ssl' => Request::post('ssl') === 'on',
- 'fixnumeric' => Request::post('fixnumeric'),
'genuid' => Request::post('genuid'),
'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"';
@@ -223,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');
@@ -247,12 +242,13 @@ 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', 'fixnumeric', 'genuid',
+ foreach (['searchbase', 'binddn', 'server', 'bindpw', 'home', 'nohomewarn', 'certificate', 'genuid',
'ldapAttrMountOpts', 'shareHomeMountOpts'] as $key) {
$module->setData($key, Request::post($key, '', 'string'));
}
@@ -274,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()
@@ -291,6 +288,10 @@ class LdapAuth_Finish extends AddModule_Base
$this->taskIds = array(
'tm-config' => $tgz,
);
+
+ if ($this->edit === null) {
+ AddModule_Base::setStep('AddModule_Assign', $module->id());
+ }
}
protected function renderInternal()
diff --git a/modules-available/sysconfig/addmodule_screensaver.inc.php b/modules-available/sysconfig/addmodule_screensaver.inc.php
new file mode 100644
index 00000000..7b6d0afb
--- /dev/null
+++ b/modules-available/sysconfig/addmodule_screensaver.inc.php
@@ -0,0 +1,246 @@
+<?php
+
+/*
+ * Wizard for configuring the xscreensaver (client side).
+ */
+
+class Screensaver_Start extends AddModule_Base
+{
+ private $session_data;
+
+ protected function preprocessInternal()
+ {
+ /* Load or initialise session data */
+ 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 !== null) {
+ $this->session_data = array(
+ 'title' => $this->edit->title(),
+ 'qss' => $this->edit->getData('qss'),
+ 'messages' => $this->edit->getData('messages'),
+ 'texts' => $this->edit->getData('texts'),
+ );
+ } else {
+ $this->session_data = array(
+ 'title' => '',
+ 'qss' => Dictionary::translate('saver_QssDefault'),
+ 'messages' => array(
+ 'General' => array(
+ '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'),
+ 'text-shutdown-locked' => '',
+ 'text-idle-kill' => Dictionary::translate('saver_TextDefaultIdleKill'),
+ 'text-idle-kill-locked' => Dictionary::translate('saver_TextDefaultIdleKillLocked'),
+ 'text-no-timeout' => '',
+ 'text-no-timeout-locked' => '',
+ ),
+ );
+ }
+ $this->session_data['next'] = 'idle-kill';
+ Session::set('data', $this->session_data);
+ }
+
+ protected function renderInternal()
+ {
+ /* Load summernote module if available */
+ Module::isAvailable('summernote');
+ Render::addDialog(Dictionary::translateFile('config-module', 'screensaver_title'), false, 'screensaver-start', array(
+ 'step' => 'Screensaver_Text',
+ 'next' => 'idle-kill',
+ 'edit' => $this->edit !== null ? $this->edit->id() : 0,
+ 'id' => 'start',
+ 'title' => $this->session_data['title'],
+ 'qss' => $this->session_data['qss'],
+ ));
+ }
+}
+
+class Screensaver_Text extends AddModule_Base
+{
+ private $session_data;
+
+ protected function preprocessInternal()
+ {
+ /* Load session data */
+ $this->session_data = Session::get('data');
+ $id = Request::post('id', '', 'string');
+
+ if ($id === 'start') {
+ Screensaver_Helper::processQssData($this->session_data);
+ } 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);
+
+
+ if ($next === 'finish')
+ Util::redirect('?do=SysConfig&action=addmodule&step=Screensaver_Finish');
+ elseif ($next === 'start')
+ Util::redirect('?do=SysConfig&action=addmodule&step=Screensaver_Start&back=true');
+ }
+
+ protected function renderInternal()
+ {
+ /* Load summernote module if available */
+ Module::isAvailable('summernote');
+ $next = $this->session_data['next'];
+
+ $data = array(
+ 'edit' => $this->edit !== null ? $this->edit->id() : 0,
+ );
+
+ /* Prepare and translate labels for the frontend */
+ $data['id'] = $next;
+ /* Convert the id to a language tag (camelCase) styled string */
+ $tag = implode(array_map('ucwords', explode('-', $next)));
+
+ /* For translate module:
+ * Dictionary::translate('saver_TitleNoTimeout');
+ * Dictionary::translate('saver_DescriptionNoTimeout');
+ * Dictionary::translate('saver_TitleIdleKill');
+ * Dictionary::translate('saver_DescriptionIdleKill');
+ * Dictionary::translate('saver_TitleShutdown');
+ * Dictionary::translate('saver_DescriptionShutdown');
+ */
+ $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];
+ $data['text_locked_value'] = $this->session_data['texts']['text-' . $next . '-locked'];
+ $data['inherit_locked'] = $this->session_data['texts'][$next . '-inherit'];
+ $data['step'] = 'Screensaver_Text';
+
+ /* Set next and prev pages */
+ if ($next === 'idle-kill') {
+ $data['next'] = 'no-timeout';
+ $data['prev'] = 'start';
+ } elseif ($next === 'no-timeout') {
+ $data['next'] = 'shutdown';
+ $data['prev'] = 'idle-kill';
+ } elseif ($next === 'shutdown') {
+ $data['next'] = 'finish';
+ $data['prev'] = 'no-timeout';
+ $data['lastStep'] = true;
+ }
+
+ Render::addDialog(Dictionary::translateFile('config-module', 'screensaver_title'), false, 'screensaver-text', $data);
+ }
+}
+
+class Screensaver_Finish extends AddModule_Base
+{
+ protected function preprocessInternal()
+ {
+ /* Get session data */
+ $session_data = Session::get('data');
+
+ if (empty($session_data['title'])) {
+ Message::addError('missing-title');
+ Util::redirect('?do=SysConfig');
+ }
+
+ /* Only create an instance, if it's a new one */
+ if ($this->edit !== null) {
+ $module = $this->edit;
+ } else {
+ $module = ConfigModule::getInstance('Screensaver');
+ }
+
+ /* Set all the data to the module instance */
+ $module->setData('qss', $session_data['qss']);
+ $module->setData('messages', $session_data['messages']);
+ $module->setData('texts', $session_data['texts']);
+
+ /* Insert or update database entries */
+ if ($this->edit !== null) {
+ $module->update($session_data['title']);
+ } else {
+ $module->insert($session_data['title']);
+ }
+
+ $task = $module->generate($this->edit === null);
+
+ // Yay
+ if ($task !== false && $this->edit !== null)
+ Message::addSuccess('module-edited');
+ elseif ($task !== false) {
+ Message::addSuccess('module-added');
+ AddModule_Base::setStep('AddModule_Assign', $module->id());
+ return;
+ }
+ Util::redirect('?do=SysConfig');
+ }
+}
+
+class Screensaver_Helper
+{
+ public static function processQssData(&$session_data) {
+ /* Process post data from the Screensaver_Start */
+ $session_data['title'] = Request::post('title', $session_data['title'], 'string');
+ if (empty($session_data['title'])) {
+ Message::addError('missing-title');
+ Util::redirect('?do=SysConfig');
+ }
+ $session_data['qss'] = Request::post('qss', $session_data['qss'], 'string');
+ $helperMode = Request::post('helper_mode', 'false', 'string');
+ if ($helperMode !== 'false') {
+ // Get all the helper variables and build the qss
+ $bg_color_1 = Request::post('bg_color_1', '', 'string');
+ self::fixColor($bg_color_1, '#443');
+ $bg_color_2 = Request::post('bg_color_2', '', 'string');
+ self::fixColor($bg_color_2, '#000');
+ $label_color = Request::post('label_color', '', 'string');
+ self::fixColor($label_color, '#f64');
+ $label_size = Request::post('label_size', 10, 'int') . 'pt';
+ $clock_color = Request::post('clock_color', '', 'string');
+ self::fixColor($clock_color, '#999');
+ $clock_size = Request::post('clock_size', 20, 'int') . 'pt';
+ $header_color = Request::post('header_color', '', 'string');
+ self::fixColor($header_color, $label_color);
+ $header_size = Request::post('header_size', 20, 'int') . 'pt';
+
+ $session_data['qss'] = "#Saver {\n background: qlineargradient(spread:pad, x1:0, y1:0, x2:0, y2:1, stop:0 " .
+ $bg_color_1 . ", stop:1 " . $bg_color_2 . ")\n}\n\n" .
+ "QLabel {\n color: " . $label_color . ";\n font-size: " . $label_size . ";\n}\n\n" .
+ "#lblClock {\n color: " . $clock_color . ";\n font-size: " . $clock_size . ";\n}\n\n" .
+ "#lblHeader {\n color: " . $header_color . ";\n font-size: " . $header_size . ";\n}";
+ }
+ }
+
+ private static function fixColor(&$color, $fix)
+ {
+ if (!preg_match('/^#([0-9a-f]{3}|[0-6a-f]{6})$/i', $color)) {
+ $color = $fix;
+ }
+ }
+
+ public static function processScreensaverText(&$session_data, $name) {
+ /* Process post data from the Screensaver_Text */
+ $session_data['messages']['General'][$name] = Request::post('msg_value', '', 'string');
+ $session_data['texts']['text-' . $name] = Request::post('text_value', '', 'string');
+ $inherit_locked = Request::post('inherit_locked', 'false', 'string');
+ $session_data['texts'][$name . '-inherit'] = $inherit_locked;
+
+ if ($inherit_locked !== 'false') {
+ $session_data['messages']['General'][$name . '-locked'] = $session_data['messages']['General'][$name];
+ $session_data['texts']['text-' . $name . '-locked'] = $session_data['texts']['text-' . $name];
+ } else {
+ $session_data['messages']['General'][$name . '-locked'] = Request::post('msg_locked_value', '', 'string');
+ $session_data['texts']['text-' . $name . '-locked'] = Request::post('text_locked_value', '', 'string');
+ }
+ }
+}
+
diff --git a/modules-available/sysconfig/addmodule_sshconfig.inc.php b/modules-available/sysconfig/addmodule_sshconfig.inc.php
index ec01f878..2447f9be 100644
--- a/modules-available/sysconfig/addmodule_sshconfig.inc.php
+++ b/modules-available/sysconfig/addmodule_sshconfig.inc.php
@@ -9,14 +9,18 @@ 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(),
- 'apl' => $this->edit->getData('allowPasswordLogin') === 'yes'
+ 'PWD_' . strtoupper($this->edit->getData('allowPasswordLogin')) . '_selected' => 'selected',
+ 'USR_' . strtoupper($this->edit->getData('allowedUsersLogin')) . '_selected' => 'selected',
);
} else {
- $data = array();
+ $data = array(
+ 'PWD_NO_selected' => 'selected',
+ 'USR_ROOT_ONLY_selected' => 'selected',
+ );
}
Render::addDialog(Dictionary::translateFile('config-module', 'sshconfig_title'), false, 'sshconfig-start', $data + array(
'step' => 'SshConfig_Finish',
@@ -36,15 +40,17 @@ 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');
}
- $module->setData('allowPasswordLogin', Request::post('allowPasswordLogin') === 'yes');
+ $module->setData('allowPasswordLogin', Request::post('allowPasswordLogin'));
+ $module->setData('allowedUsersLogin', Request::post('allowedUsersLogin'));
$port = Request::post('listenPort', '');
if ($port === '') {
$port = 22;
@@ -53,23 +59,25 @@ class SshConfig_Finish extends AddModule_Base
Message::addError('main.value-invalid', 'port', Request::post('listenPort'));
Util::redirect('?do=SysConfig&action=addmodule&step=SshConfig_Start');
}
- if (!$module->setData('publicKey', Request::post('publicKey'))) {
- Message::addError('main.value-invalid', 'pubkey', Request::post('publicKey'));
- Util::redirect('?do=SysConfig&action=addmodule&step=SshConfig_Start');
- }
- if ($this->edit !== false)
+ $module->setData('publicKey', 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;
+ }
Util::redirect('?do=SysConfig');
}
diff --git a/modules-available/sysconfig/addmodule_sshkey.inc.php b/modules-available/sysconfig/addmodule_sshkey.inc.php
new file mode 100644
index 00000000..9f5bd1d3
--- /dev/null
+++ b/modules-available/sysconfig/addmodule_sshkey.inc.php
@@ -0,0 +1,72 @@
+<?php
+
+/*
+ * Wizard for configuring the sshd (client side).
+ */
+
+class SshKey_Start extends AddModule_Base
+{
+
+ protected function renderInternal()
+ {
+ if ($this->edit !== null) {
+ $data = $this->edit->getData(null) + array(
+ 'title' => $this->edit->title(),
+ 'edit' => $this->edit->id(),
+ );
+ } else {
+ $data = array();
+ }
+ Render::addDialog(Dictionary::translateFile('config-module', 'sshkey_title'), false, 'sshkey-start', $data + array(
+ 'step' => 'SshKey_Finish',
+ ));
+ }
+
+}
+
+class SshKey_Finish extends AddModule_Base
+{
+
+ protected function preprocessInternal()
+ {
+ $title = Request::post('title');
+ if (empty($title)) {
+ Message::addError('missing-title');
+ return;
+ }
+ // Seems ok, create entry
+ if ($this->edit === null) {
+ $module = ConfigModule::getInstance('SshKey');
+ } else {
+ $module = $this->edit;
+ }
+ if ($module === false) {
+ Message::addError('main.error-read', 'sshkey.inc.php');
+ Util::redirect('?do=SysConfig&action=addmodule&step=SshKey_Start');
+ }
+ if (!$module->setData('publicKey', Request::post('publicKey'))) {
+ Message::addError('main.value-invalid', 'pubkey', Request::post('publicKey'));
+ Util::redirect('?do=SysConfig&action=addmodule&step=SshKey_Start');
+ }
+ 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 === null, NULL, 200)) {
+ Util::redirect('?do=SysConfig&action=addmodule&step=SshKey_Start');
+ }
+ // Yay
+ if ($this->edit !== null) {
+ Message::addSuccess('module-edited');
+ } else {
+ Message::addSuccess('module-added');
+ AddModule_Base::setStep('AddModule_Assign', $module->id());
+ return;
+ }
+ Util::redirect('?do=SysConfig');
+ }
+
+}
diff --git a/modules-available/sysconfig/api.inc.php b/modules-available/sysconfig/api.inc.php
index bb2d9f5e..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
@@ -24,61 +24,51 @@ function deliverEmpty($message)
die('Config file could not be found or read!');
}
-$runmode = false;
-if (Module::isAvailable('runmode')) {
- $runmode = RunMode::getRunMode($uuid);
- if ($runmode !== false) {
- $runmode = RunMode::getModuleConfig($runmode['module']);
+$locationId = false;
+if (Module::isAvailable('locations')) {
+ $locationId = Location::getFromIpAndUuid($ip, $uuid);
+ if ($locationId !== false) {
+ $locationChain = Location::getLocationRootChain($locationId);
+ $locationChain[] = 0;
}
}
-if ($runmode !== false && $runmode->noSysconfig && file_exists(SysConfig::GLOBAL_MINIMAL_CONFIG)) {
- $row = array('filepath' => SysConfig::GLOBAL_MINIMAL_CONFIG, 'title' => 'config');
-} else {
- $locationId = false;
- if (Module::isAvailable('locations')) {
- $locationId = Location::getFromIpAndUuid($ip, $uuid);
- if ($locationId !== false) {
- $locationChain = Location::getLocationRootChain($locationId);
- $locationChain[] = 0;
- }
- }
- if ($locationId === false) {
- $locationId = 0;
- $locationChain = array(0);
- }
+if ($locationId === false) {
+ $locationId = 0;
+ $locationChain = array(0);
+}
- // Get config module path
+// Get config module path
- // We get all the configs for the whole location chain up to root
- $res = Database::simpleQuery("SELECT c.title, c.filepath, c.status, cl.locationid FROM configtgz c"
- . " INNER JOIN configtgz_location cl USING (configid)"
- . " WHERE cl.locationid IN (" . implode(',', $locationChain) . ")");
+// We get all the configs for the whole location chain up to root
+$res = Database::simpleQuery("SELECT c.title, c.filepath, c.status, cl.locationid FROM configtgz c"
+ . " INNER JOIN configtgz_location cl USING (configid)"
+ . " WHERE cl.locationid IN (" . implode(',', $locationChain) . ")");
- $best = 1000;
- $row = false;
- while ($r = $res->fetch(PDO::FETCH_ASSOC)) {
- settype($r['locationid'], 'int');
- $index = array_search($r['locationid'], $locationChain);
- if ($index === false || $index > $best)
- continue;
- if (!file_exists($r['filepath'])) {
- if ($r['locationid'] === 0) {
- EventLog::failure("The global config.tgz '{$r['title']}' was not found at '{$r['filepath']}'. Please regenerate the system configuration");
- } else {
- EventLog::warning("config.tgz '{$r['title']}' for location $locationId not found at '{$r['filepath']}', trying fallback....");
- }
- continue;
+$best = 1000;
+$row = false;
+foreach ($res as $r) {
+ settype($r['locationid'], 'int');
+ $index = array_search($r['locationid'], $locationChain);
+ if ($index === false || $index > $best)
+ continue;
+ if (!file_exists($r['filepath'])) {
+ if ($r['locationid'] === 0) {
+ EventLog::failure("The global config.tgz '{$r['title']}' was not found at '{$r['filepath']}'. Please regenerate the system configuration");
+ } else {
+ EventLog::warning("config.tgz '{$r['title']}' for location $locationId not found at '{$r['filepath']}', trying fallback....");
}
- $best = $index;
- $row = $r;
+ continue;
}
+ $best = $index;
+ $row = $r;
+}
- if ($row === false) {
- // TODO Not found in DB
- deliverEmpty("No config.tgz for location $locationId found (src $ip)");
- }
+if ($row === false) {
+ // TODO Not found in DB
+ deliverEmpty("No config.tgz for location $locationId found (src $ip)");
}
+@ob_end_clean(); // Disable gzip output handler since this is already a compressed file
Header('Content-Type: application/gzip');
Header('Content-Disposition: attachment; filename=' . Util::sanitizeFilename($row['title']) . '.tgz');
$ret = readfile($row['filepath']);
diff --git a/modules-available/sysconfig/clientscript.js b/modules-available/sysconfig/clientscript.js
index 1553d678..9dbb0745 100644
--- a/modules-available/sysconfig/clientscript.js
+++ b/modules-available/sysconfig/clientscript.js
@@ -1,88 +1,113 @@
// Mouseover and clicking
-var $ct = $('#conftable').find('.confrow');
-$ct.click(function() {
- showmod(this, 'bold');
-}).mouseenter(function() {
- showmod(this, 'fade');
-}).mouseleave(function() {
- showmod(this, 'reset');
-});
-var $mt = $('#modtable').find('.modrow');
-$mt.click(function() {
- showconf(this, 'bold');
-}).mouseenter(function() {
- showconf(this, 'fade');
-}).mouseleave(function() {
- showconf(this, 'reset');
-});
+(function() {
+ var boldItem = false;
+ var modToConf = false;
-var boldItem = false;
-var revList = false;
-
-function showpre(e, action) {
- if (boldItem && action !== 'bold') return 'reset';
- if (boldItem) {
- if (e === boldItem) action = 'fade';
- boldItem = false;
- }
- $mt.removeClass("slx-bold slx-fade");
- $ct.removeClass("slx-bold slx-fade");
- return action;
-}
-
-function buildRevList() {
- revList = {};
- $ct.each(function() {
- var elem = $(this);
- var cid = elem.data('id')+'';
- var list = (elem.data('modlist')+'').split(',');
+ var $ct = $('#conftable').find('.confrow .title');
+ $ct.click(function () {
+ showmod(this, 'bold');
+ }).mouseenter(function () {
+ showmod(this, 'fade');
+ }).mouseleave(function () {
+ showmod(this, 'reset');
+ });
+ var $mt = $('#modtable').find('.modrow .title');
+ $mt.click(function () {
+ showconf(this, 'bold');
+ }).mouseenter(function () {
+ showconf(this, 'fade');
+ }).mouseleave(function () {
+ showconf(this, 'reset');
+ });
+ var $confirm = $('#delete-item-list');
+ $('.btn-del-module').click(function() {
+ var mid = $(this).val() + '';
+ var list = modToConf[mid];
+ if (!list || !list.length) {
+ $confirm.append($msgs).addClass('hidden');
+ return;
+ }
+ var $msgs = $confirm.find('ul').empty();
for (var i = 0; i < list.length; ++i) {
- if (!revList[list[i]]) revList[list[i]] = [];
- revList[list[i]].push(cid);
+ $msgs.append($('<li>').text(
+ $('.confrow[data-id="' + list[i] + '"] .title').text()
+ ));
}
+ $confirm.removeClass('hidden');
});
-}
+ $('.btn-del-config').click(function() {
+ $confirm.addClass('hidden');
+ });
+
+ buildRevList();
+ var mods = [];
+ $('#modtable .modrow').each(function() { mods.push($(this).data('id')) });
+ mods.forEach(function(e) { if (modToConf[e] === undefined) $('.modrow[data-id=' + e + '] .icon-unused').removeClass('hidden') });
+
+ function showpre(e, action) {
+ if (boldItem && action !== 'bold') return 'reset';
+ if (boldItem) {
+ if (e === boldItem) action = 'fade';
+ boldItem = false;
+ }
+ $mt.removeClass("slx-bold slx-fade");
+ $ct.removeClass("slx-bold slx-fade");
+ return action;
+ }
-function showconf(e, action) {
- action = showpre(e, action);
- if (action === 'reset') return;
- var $e = $(e);
- if (!revList) buildRevList();
- var mid = $e.data('id')+'';
- var list = revList[mid];
- if (list && list.length > 0) $ct.each(function() {
- var elem = $(this);
- var cid = elem.data('id')+'';
- if (list.indexOf(cid) === -1)
- elem.addClass('slx-fade');
- else if (action === 'bold')
- elem.addClass('slx-bold');
- }); else $ct.addClass('slx-fade');
- if (action === 'bold') {
- boldItem = e;
- $e.addClass("slx-bold");
+ function buildRevList() {
+ modToConf = {};
+ $ct.each(function () {
+ var elem = $(this).parent();
+ var cid = elem.data('id') + '';
+ var list = (elem.data('modlist') + '').split(',');
+ for (var i = 0; i < list.length; ++i) {
+ if (!modToConf[list[i]]) modToConf[list[i]] = [];
+ modToConf[list[i]].push(cid);
+ }
+ });
}
-}
-function showmod(e, action) {
- action = showpre(e, action);
- if (action === 'reset') return;
- var $e = $(e);
- var list = ($e.data('modlist')+'').split(',');
- $mt.each(function () {
- var elem = $(this);
- if (list.indexOf(elem.data('id')+'') === -1)
- elem.addClass("slx-fade");
- else if (action === 'bold')
- elem.addClass("slx-bold");
- });
- if (action === 'bold') {
- boldItem = e;
- $e.addClass("slx-bold");
+ function showconf(e, action) {
+ action = showpre(e, action);
+ if (action === 'reset') return;
+ var $e = $(e);
+ var mid = $e.parent().data('id') + '';
+ var list = modToConf[mid];
+ if (list && list.length > 0) $ct.each(function () {
+ var elem = $(this);
+ var cid = elem.parent().data('id') + '';
+ if (list.indexOf(cid) === -1)
+ elem.addClass('slx-fade');
+ else if (action === 'bold')
+ elem.addClass('slx-bold');
+ }); else $ct.addClass('slx-fade');
+ if (action === 'bold') {
+ boldItem = e;
+ $e.addClass("slx-bold");
+ }
}
-}
+
+ function showmod(e, action) {
+ action = showpre(e, action);
+ if (action === 'reset') return;
+ var $e = $(e);
+ var list = ($e.parent().data('modlist') + '').split(',');
+ $mt.each(function () {
+ var elem = $(this);
+ if (list.indexOf(elem.parent().data('id') + '') === -1)
+ elem.addClass("slx-fade");
+ else if (action === 'bold')
+ elem.addClass("slx-bold");
+ });
+ if (action === 'bold') {
+ boldItem = e;
+ $e.addClass("slx-bold");
+ }
+ }
+})();
// Polling for updated status (outdated, missing, ok)
@@ -91,23 +116,29 @@ var statusChecks = 0;
function checkBuildStatus() {
var mods = [];
var confs = [];
- $(".refmod.btn-primary").each(function (index) {
+ $(".modrow .btn-rebuild.btn-primary").each(function (index) {
mods.push($(this).val());
});
- $(".refconf.btn-primary").each(function (index) {
+ $(".confrow .btn-rebuild.btn-primary").each(function (index) {
confs.push($(this).val());
});
if (mods.length === 0 && confs.length === 0) return;
if (++statusChecks < 10) setTimeout(checkBuildStatus, 150 + 100 * statusChecks);
$.post('?do=SysConfig', { mods: mods.join(), confs: confs.join(), token: TOKEN, action: 'status' }, function (data) {
if (typeof data === 'undefined') return;
- if (typeof data.mods === 'object') updateButtonColor($(".refmod.btn-primary"), data.mods);
- if (typeof data.confs === 'object') updateButtonColor($(".refconf.btn-primary"), data.confs);
+ if (typeof data.mods === 'object') updateButtonColor('.modrow', data.mods);
+ if (typeof data.confs === 'object') updateButtonColor('.confrow', data.confs);
}, 'json');
}
-function updateButtonColor(list,ids) {
- list.each(function() {
- if (ids.indexOf($(this).val()) >= 0) $(this).removeClass('btn-primary').addClass('btn-default');
- });
+function updateButtonColor(rowclass,ids) {
+ for (var i = 0; i < ids.length; ++i) {
+ var e = ids[i];
+ var $row = $(rowclass + '[data-id=' + e.id + ']');
+ $row.find('.btn-rebuild').removeClass('btn-primary').addClass('btn-default');
+ if (e.warnings && (typeof e.warnings === 'string') && e.warnings.length > 0) {
+ $row.find('.row-warnings').text(e.warnings);
+ $row.find('.btn-warnings').removeClass('hidden');
+ }
+ }
}
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/cron.inc.php b/modules-available/sysconfig/hooks/cron.inc.php
index b518ca06..b959060d 100644
--- a/modules-available/sysconfig/hooks/cron.inc.php
+++ b/modules-available/sysconfig/hooks/cron.inc.php
@@ -1,7 +1,5 @@
<?php
-Trigger::ldadp();
-
// Cleanup orphaned config<->location where the location has been deleted
Database::exec("DELETE c FROM configtgz_location c
LEFT JOIN location l USING (locationid)
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 a9035d78..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,29 +274,33 @@ 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(): int
+ {
+ return $this->currentVersion;
+ }
/**
* Get the module type.
*
* @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];
}
@@ -287,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;
@@ -306,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,
@@ -321,23 +346,25 @@ 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');
- if (empty($title))
- $title = $this->moduleTitle;
+ ErrorHandler::traceError('ConfigModule::update called when moduleId == 0');
+ if (!empty($title)) {
+ $this->moduleTitle = $title;
+ }
if (!$this->validateConfig())
return false;
// Update
Database::exec("UPDATE configtgz_module SET title = :title, contents = :contents, status = :status, dateline = :now "
. " WHERE moduleid = :moduleid LIMIT 1", array(
'moduleid' => $this->moduleId,
- 'title' => $title,
+ 'title' => $this->moduleTitle,
'contents' => json_encode($this->moduleData),
'status' => 'OUTDATED',
'now' => time(),
));
+ $this->moduleStatus = 'OUTDATED';
return true;
}
@@ -346,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
@@ -388,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;
@@ -403,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();
@@ -453,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);
}
@@ -482,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()
}
@@ -493,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();
}
}
@@ -506,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/adauth.inc.php b/modules-available/sysconfig/inc/configmodule/adauth.inc.php
index ed7b318d..5e68f48c 100644
--- a/modules-available/sysconfig/inc/configmodule/adauth.inc.php
+++ b/modules-available/sysconfig/inc/configmodule/adauth.inc.php
@@ -12,5 +12,6 @@ ConfigModule::registerModule(
Dictionary::translateFileModule('sysconfig', 'config-module', 'adAuth_title'), // Title
Dictionary::translateFileModule('sysconfig', 'config-module', 'adAuth_description'), // Description
Dictionary::translateFileModule('sysconfig', 'config-module', 'group_authentication'), // Group
- false // Only one per config?
+ false, // Only one per config?
+ 300
);
diff --git a/modules-available/sysconfig/inc/configmodule/branding.inc.php b/modules-available/sysconfig/inc/configmodule/branding.inc.php
index fd11dade..7013e3ae 100644
--- a/modules-available/sysconfig/inc/configmodule/branding.inc.php
+++ b/modules-available/sysconfig/inc/configmodule/branding.inc.php
@@ -5,7 +5,8 @@ ConfigModule::registerModule(
Dictionary::translateFileModule('sysconfig', 'config-module', 'branding_title'), // Title
Dictionary::translateFileModule('sysconfig', 'config-module', 'branding_description'), // Description
Dictionary::translateFileModule('sysconfig', 'config-module', 'group_branding'), // Group
- true // Only one per config?
+ true, // Only one per config?
+ 600
);
class ConfigModule_Branding extends ConfigModule
@@ -13,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;
@@ -48,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 336d794f..0b8e38d2 100644
--- a/modules-available/sysconfig/inc/configmodule/customodule.inc.php
+++ b/modules-available/sysconfig/inc/configmodule/customodule.inc.php
@@ -6,54 +6,68 @@ ConfigModule::registerModule(
Dictionary::translateFileModule('sysconfig', 'config-module', 'custom_description'), // Description
Dictionary::translateFileModule('sysconfig', 'config-module', 'group_generic'), // Group
false, // Only one per config?
- 100 // Sort order
+ 900 // Sort order
);
class ConfigModule_CustomModule extends ConfigModule
{
const MODID = 'CustomModule';
- const VERSION = 1;
-
+ const VERSION = 2;
+
+ /** @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...
+ // No temp file given from wizard
+ // Old archive still exists? pretend it worked...
+ if ($this->archive() === '' || !file_exists($this->archive()))
+ return false;
+ if ($this->currentVersion() == 1) {
+ // Need an upgrade
+ return Taskmanager::submit('RecompressArchive', array(
+ 'inputFiles' => [$this->archive() => false],
+ 'outputFile' => $tgz,
+ 'forceRoot' => true, // Force this for old modules for backward compat
+ ));
+ }
+ // 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))
return false;
$this->tmpFile = $value;
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 e8df2877..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
@@ -19,5 +19,6 @@ ConfigModule::registerModule(
Dictionary::translateFileModule('sysconfig', 'config-module', 'ldapAuth_title'), // Title
Dictionary::translateFileModule('sysconfig', 'config-module', 'ldapAuth_description'), // Description
Dictionary::translateFileModule('sysconfig', 'config-module', 'group_authentication'), // Group
- false // Only one per config?
+ false, // Only one per config?
+ 300
);
diff --git a/modules-available/sysconfig/inc/configmodule/screensaver.inc.php b/modules-available/sysconfig/inc/configmodule/screensaver.inc.php
new file mode 100644
index 00000000..1797331c
--- /dev/null
+++ b/modules-available/sysconfig/inc/configmodule/screensaver.inc.php
@@ -0,0 +1,102 @@
+<?php
+
+ConfigModule::registerModule(
+ ConfigModule_Screensaver::MODID, // ID
+ Dictionary::translateFileModule('sysconfig', 'config-module', 'screensaver_title'), // Title
+ Dictionary::translateFileModule('sysconfig', 'config-module', 'screensaver_description'), // Description
+ Dictionary::translateFileModule('sysconfig', 'config-module', 'group_screensaver'), // Group
+ true, // Only one per config?
+ 700 // Sort order
+);
+
+class ConfigModule_Screensaver extends ConfigModule
+{
+ const MODID = 'Screensaver';
+ const VERSION = 1;
+
+ protected function generateInternal(string $tgz, ?string $parent)
+ {
+ /* Validate if all data are available */
+ if (!$this->validateConfig())
+ return false;
+
+ /* Give the Taskmanager the job and create the tgz */
+ $taskId = 'xscreensaver' . mt_rand() . '-' . microtime(true);
+
+ return Taskmanager::submit('MakeTarball', array(
+ 'id' => $taskId,
+ 'files' => $this->getFileArray(),
+ 'destination' => $tgz,
+ ), false);
+ }
+
+ protected function moduleVersion(): int
+ {
+ return self::VERSION;
+ }
+
+ protected function validateConfig(): bool
+ {
+ 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(string $key, $value): bool
+ {
+ switch ($key) {
+ case 'qss':
+ case 'texts':
+ case 'messages':
+ break;
+ default:
+ return false;
+ }
+ $this->moduleData[$key] = $value;
+ return true;
+ }
+
+ public function allowDownload(): bool
+ {
+ return false;
+ }
+
+ /**
+ * Creates a map with filepath => file content
+ */
+ private function getFileArray(): array
+ {
+ $files = array(
+ '/opt/openslx/xscreensaver/style.qss' => $this->moduleData['qss'],
+ '/opt/openslx/xscreensaver/text-idle-kill' => $this->wrapHtmlTags('text-idle-kill'),
+ '/opt/openslx/xscreensaver/text-no-timeout' => $this->wrapHtmlTags('text-no-timeout'),
+ '/opt/openslx/xscreensaver/text-shutdown' => $this->wrapHtmlTags('text-shutdown'),
+ );
+
+ /* Create the message.ini from the messages array */
+ $messages = '';
+ foreach ($this->moduleData['messages'] as $category => $array) {
+ $messages .= '[' . $category . ']' . "\n";
+ foreach ($array as $key => $message) {
+ $messages .= $key . '="' . str_replace(['\\', '"', "\n", "\r"], '-', $message) . '"' . "\n";
+ }
+ }
+ $files['/opt/openslx/xscreensaver/messages.ini'] = $messages;
+
+ /* Add locked files if there are any */
+ if (isset($this->moduleData['texts']['text-idle-kill-locked']))
+ $files['/opt/openslx/xscreensaver/text-idle-kill-locked'] = $this->wrapHtmlTags('text-idle-kill-locked');
+ if (isset($this->moduleData['texts']['text-no-timeout-locked']))
+ $files['/opt/openslx/xscreensaver/text-no-timeout-locked'] = $this->wrapHtmlTags('text-no-timeout-locked');
+ if (isset($this->moduleData['texts']['text-shutdown-locked']))
+ $files['/opt/openslx/xscreensaver/text-shutdown-locked'] = $this->wrapHtmlTags('text-shutdown-locked');
+
+ return $files;
+ }
+
+ 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 61f69581..a62d1035 100644
--- a/modules-available/sysconfig/inc/configmodule/sshconfig.inc.php
+++ b/modules-available/sysconfig/inc/configmodule/sshconfig.inc.php
@@ -5,7 +5,8 @@ ConfigModule::registerModule(
Dictionary::translateFileModule('sysconfig', 'config-module', 'sshconfig_title'), // Title
Dictionary::translateFileModule('sysconfig', 'config-module', 'sshconfig_description'), // Description
Dictionary::translateFileModule('sysconfig', 'config-module', 'group_sshconfig'), // Group
- false // Only one per config?
+ true, // Only one per config?
+ 500
);
class ConfigModule_SshConfig extends ConfigModule
@@ -13,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;
@@ -22,36 +23,50 @@ class ConfigModule_SshConfig extends ConfigModule
'failOnParentFail' => false,
'parent' => $parent
);
- // Create config module, which will also check if the pubkey is valid
return Taskmanager::submit('SshdConfigGenerator', $config);
}
- protected function moduleVersion()
+ protected function moduleVersion(): int
{
return self::VERSION;
}
- protected function validateConfig()
+ protected function validateConfig(): bool
{
- return isset($this->moduleData['publicKey']) && isset($this->moduleData['allowPasswordLogin']) && isset($this->moduleData['listenPort']);
+ // UPGRADE
+ if (isset($this->moduleData['allowPasswordLogin']) && !isset($this->moduleData['allowedUsersLogin'])) {
+ $this->moduleData['allowPasswordLogin'] = strtoupper($this->moduleData['allowPasswordLogin']);
+ if (!in_array($this->moduleData['allowPasswordLogin'], ['NO', 'USER_ONLY', 'YES'])) {
+ $this->moduleData['allowPasswordLogin'] = 'NO';
+ }
+ $this->moduleData['allowedUsersLogin'] = 'ALL';
+ }
+ return isset($this->moduleData['allowPasswordLogin']) && isset($this->moduleData['allowedUsersLogin'])
+ && isset($this->moduleData['listenPort']);
}
- public function setData($key, $value)
+ public function setData(string $key, $value): bool
{
switch ($key) {
case 'publicKey':
- break;
+ if ($value === false) {
+ error_log('Unsetting publicKey');
+ unset($this->moduleData[$key]);
+ return true;
+ }
+ return false;
case 'allowPasswordLogin':
- if ($value === true || $value === 'yes')
- $value = 'yes';
- elseif ($value === false || $value === 'no')
- $value = 'no';
- else
+ if (!in_array($value, ['NO', 'USER_ONLY', 'YES']))
+ return false;
+ break;
+ case 'allowedUsersLogin';
+ if (!in_array($value, ['ROOT_ONLY', 'USER_ONLY', 'ALL']))
return false;
break;
case 'listenPort':
if (!is_numeric($value) || $value < 1 || $value > 65535)
return false;
+ $value = (int)$value;
break;
default:
return false;
diff --git a/modules-available/sysconfig/inc/configmodule/sshkey.inc.php b/modules-available/sysconfig/inc/configmodule/sshkey.inc.php
new file mode 100644
index 00000000..e4a55ad7
--- /dev/null
+++ b/modules-available/sysconfig/inc/configmodule/sshkey.inc.php
@@ -0,0 +1,55 @@
+<?php
+
+ConfigModule::registerModule(
+ ConfigModule_SshKey::MODID, // ID
+ Dictionary::translateFileModule('sysconfig', 'config-module', 'sshkey_title'), // Title
+ Dictionary::translateFileModule('sysconfig', 'config-module', 'sshkey_description'), // Description
+ Dictionary::translateFileModule('sysconfig', 'config-module', 'group_sshkey'), // Group
+ false, // Only one per config?
+ 510
+);
+
+class ConfigModule_SshKey extends ConfigModule
+{
+ const MODID = 'SshKey';
+ const VERSION = 1;
+
+ protected function generateInternal(string $tgz, ?string $parent)
+ {
+ if (!$this->validateConfig())
+ return false;
+ $config = array(
+ 'files' => [
+ '/root/.ssh/authorized_keys.d/sshkey_' . $this->id() . '_' . Util::sanitizeFilename($this->title()) . '.pub'
+ => $this->moduleData['publicKey']],
+ 'destination' => $tgz,
+ 'failOnParentFail' => false,
+ 'parent' => $parent
+ );
+ // Create config module, which will also check if the pubkey is valid
+ return Taskmanager::submit('MakeTarball', $config);
+ }
+
+ protected function moduleVersion(): int
+ {
+ return self::VERSION;
+ }
+
+ protected function validateConfig(): bool
+ {
+ return isset($this->moduleData['publicKey']);
+ }
+
+ public function setData(string $key, $value): bool
+ {
+ switch ($key) {
+ case 'publicKey':
+ break;
+ default:
+ return false;
+ }
+ $this->moduleData[$key] = $value;
+ return true;
+ }
+
+}
diff --git a/modules-available/sysconfig/inc/configmodulebaseldap.inc.php b/modules-available/sysconfig/inc/configmodulebaseldap.inc.php
index ad3d32c5..770a40e6 100644
--- a/modules-available/sysconfig/inc/configmodulebaseldap.inc.php
+++ b/modules-available/sysconfig/inc/configmodulebaseldap.inc.php
@@ -3,15 +3,15 @@
abstract class ConfigModuleBaseLdap extends ConfigModule
{
- const VERSION = 3;
+ const VERSION = 4;
private static $REQUIRED_FIELDS = array('server', 'searchbase');
- private static $OPTIONAL_FIELDS = array('binddn', 'bindpw', 'home', 'ssl', 'fixnumeric', 'fingerprint', 'certificate', 'homeattr',
+ private static $OPTIONAL_FIELDS = array('binddn', 'bindpw', 'home', 'ssl', 'fingerprint', 'certificate', 'homeattr',
'shareRemapMode', 'shareRemapCreate', 'shareDocuments', 'shareDownloads', 'shareDesktop', 'shareMedia',
'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'],
@@ -32,12 +32,43 @@ abstract class ConfigModuleBaseLdap extends ConfigModule
return $list;
}
- protected function generateInternal($tgz, $parent)
+ public static function getActiveModuleIds()
{
- $np = Trigger::ldadp($this->id(), $parent);
- if ($np !== false) {
- $parent = $np;
+ return Database::queryColumnArray("SELECT DISTINCT moduleid FROM configtgz_module"
+ . " INNER JOIN configtgz_x_module USING (moduleid)"
+ . " INNER JOIN configtgz USING (configid)"
+ . " INNER JOIN configtgz_location USING (configid)"
+ . " WHERE moduletype IN ('AdAuth', 'LdapAuth')");
+ }
+
+ /**
+ * Launch all ldadp instances that need to be running.
+ *
+ * @param string $command start, restart, check
+ * @param bool|int|int[] $ids list of IDs to run command on, or false meaning "all"
+ * @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(string $command = 'start', $ids = false, string $parent = null)
+ {
+ if ($ids === false) {
+ $ids = self::getActiveModuleIds();
+ } elseif (!is_array($ids)) {
+ $ids = [$ids];
}
+ $task = Taskmanager::submit('LdadpLauncher', array(
+ 'ids' => $ids,
+ 'command' => $command,
+ 'parentTask' => $parent,
+ 'failOnParentFail' => false
+ ));
+ if (!isset($task['id']))
+ return false;
+ return $task['id'];
+ }
+
+ protected function generateInternal(string $tgz, ?string $parent)
+ {
$config = $this->moduleData;
if (isset($config['certificate']) && !is_string($config['certificate'])) {
unset($config['certificate']);
@@ -64,15 +95,14 @@ abstract class ConfigModuleBaseLdap extends ConfigModule
if (!isset($config['shareHomeDrive'])) {
$config['shareHomeDrive'] = 'H:';
}
- if (!isset($config['fixnumeric'])) {
- $config['fixnumeric'] = 's';
- }
- $config['genuid'] = isset($config['genuid']) && !empty($config['genuid']);
+ // This is now always on, as we mask it transparently in our lightdm greeter
+ $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);
if (is_array($task) && isset($task['id'])) {
- Trigger::ldadp(null, $task['id']);
+ self::ldadp('restart', $this->id(), $task['id']);
}
return $task;
}
@@ -81,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;
@@ -112,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 374cb5e0..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,25 +37,32 @@ 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;
- $this->configTitle = $title;
+ if (!empty($title)) {
+ $this->configTitle = $title;
+ }
$this->modules = array();
// Get all modules to put in config
$idstr = '0'; // Passed directly in query. Make sure no SQL injection is possible
@@ -67,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']
@@ -77,45 +83,41 @@ class ConfigTgz
// Update name
Database::exec("UPDATE configtgz SET title = :title, status = :status, dateline = :now WHERE configid = :configid LIMIT 1", array(
'configid' => $this->configId,
- 'title' => $title,
+ 'title' => $this->configTitle,
'status' => 'OUTDATED',
'now' => time(),
));
- return true;
}
/**
*
- * @param bool $deleteOnError
- * @param int $timeoutMs
- * @return string - OK (success)
- * - OUTDATED (updating failed, but old version still exists)
- * - MISSING (failed and no old version available)
+ * @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)
+ 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) {
if (!empty($module['filepath']) && file_exists($module['filepath'])) {
- $files[] = $module['filepath'];
- }
- if ($module['moduletype'] === 'SshConfig') {
- // HACK XXX TODO Global + SSH ugly
- self::rebuildEmptyGlobalConfig();
+ // Dupcheck only for custom modules for now
+ $files[$module['filepath']] = ($module['moduletype'] === 'CustomModule');
}
}
- $task = self::recompress($files, $this->file);
+ $task = self::recompress($files, $this->file, $parentTask);
// Wait for completion
- if ($timeoutMs > 0 && !Taskmanager::isFailed($task) && !Taskmanager::isFinished($task))
+ if ($timeoutMs > 0 && !Taskmanager::isFailed($task) && !Taskmanager::isFinished($task)) {
$task = Taskmanager::waitComplete($task, $timeoutMs);
- if ($task === true || (isset($task['statusCode']) && $task['statusCode'] === Taskmanager::TASK_FINISHED)) {
+ }
+ if (Taskmanager::isFinished($task)) {
// Success!
- $this->markUpdated();
+ $this->markUpdated($task);
return true;
}
if (!is_array($task) || !isset($task['id']) || Taskmanager::isFailed($task)) {
@@ -135,55 +137,81 @@ 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!');
- $ret = Database::exec("DELETE FROM configtgz WHERE configid = :configid LIMIT 1", array(
- 'configid' => $this->configId
- ), true) !== false;
+ 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;
+ 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()
+ private function markUpdated(array $task): void
{
if ($this->configId === 0)
- Util::traceError('ConfigTgz::markUpdated called with invalid config id!');
- if ($this->areAllModulesUpToDate())
- return $this->mark('OK');
- return $this->mark('OUTDATED');
+ ErrorHandler::traceError('ConfigTgz::markUpdated called with invalid config id!');
+ if ($this->areAllModulesUpToDate()) {
+ if (empty($task['data']['warnings'])) {
+ $warnings = '';
+ } else {
+ // There have been warnings while generating the combined archive
+ // Most likely duplicate file entries.
+ // Get mapping of moduleid to module name for prettier log display
+ $res = Database::simpleQuery('SELECT moduleid, title FROM configtgz_module');
+ $mods = [];
+ foreach ($res as $row) {
+ $mods[$row['moduleid']] = $row['title'];
+ }
+ // Now extract module id from filename and if applicable, replace filename by module name
+ $warnings = preg_replace_callback('#/opt/openslx/configs/modules/(\w+)_id-(\d+)__.*$#m', function ($m) use ($mods) {
+ if (!isset($mods[$m[2]]))
+ return $m[0];
+ return $mods[$m[2]] . ' (' . $m[1] . '#' . $m[2] . ')';
+ }, $task['data']['warnings']);
+ }
+ Database::exec("UPDATE configtgz SET status = :status, warnings = :warnings
+ WHERE configid = :configid LIMIT 1", [
+ 'configid' => $this->configId,
+ 'status' => 'OK',
+ 'warnings' => $warnings,
+ ]);
+ return;
+ }
+ $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", array(
+ Database::exec("UPDATE configtgz SET status = :status WHERE configid = :configid LIMIT 1", [
'configid' => $this->configId,
- 'status' => $status
- ));
- return $status;
+ 'status' => $status,
+ ]);
}
/*
@@ -191,28 +219,30 @@ class ConfigTgz
*/
/**
- * @param string[] $files source files to include
+ * @param bool[] $files source files to include key = file, value = dupCheck
* @param string $destFile where to store final result
* @return false|array taskmanager task
*/
- private static function recompress($files, $destFile)
+ 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);
if ($file !== false) {
- $files[] = $file;
+ $files[$file] = true;
}
}
// Hand over to tm
return Taskmanager::submit('RecompressArchive', array(
'inputFiles' => $files,
- 'outputFile' =>$destFile
+ 'outputFile' =>$destFile,
+ 'parentTask' => $parentTask,
+ 'failOnParentFail' => false,
));
}
@@ -221,57 +251,27 @@ 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();
}
}
- // Build the global "empty" config that just includes global hooks
- self::rebuildEmptyGlobalConfig();
- }
-
- /**
- * Rebuild the general "empty" config that only contains global hook modules
- * and forced ones.
- */
- private static function rebuildEmptyGlobalConfig()
- {
- static $onceOnly = false;
- if ($onceOnly)
- return;
- $onceOnly = true;
- // HACK TODO XXX -- just stuff (global) ssh config into this one for now, needs proper fix :-(
- $res = Database::simpleQuery("SELECT DISTINCT cm.filepath FROM configtgz_module cm
- INNER JOIN configtgz_x_module cxm USING (moduleid)
- INNER JOIN configtgz_location cl USING (configid)
- WHERE cm.moduletype = 'SshConfig' AND cm.status = 'OK'
- ORDER BY locationid ASC");
- $extra = [];
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- if (file_exists($row['filepath'])) {
- $extra[] = $row['filepath'];
- break;
- }
- }
- self::recompress($extra, SysConfig::GLOBAL_MINIMAL_CONFIG);
}
/**
* @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)
@@ -293,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']
@@ -303,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;
@@ -359,50 +359,52 @@ 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();
+ }
}
/**
* (Re)generating a config tgz succeeded. Update db entry.
*
+ * @param array $task the task object
* @param array $args contains 'configid' and optionally 'deleteOnError'
*/
- public static function generateSucceeded($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;
}
- $config->markUpdated();
+ $config->markUpdated($task);
}
-
+
}
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 5ccdbd53..c28e0355 100644
--- a/modules-available/sysconfig/inc/ppd.inc.php
+++ b/modules-available/sysconfig/inc/ppd.inc.php
@@ -134,7 +134,7 @@ class Ppd
'JCLEnd' => '.*',
// TODO: The above three need to be either completely absent, or all three must be defined
/*
- * Resolution and Appearence Control, section 5.9
+ * Resolution and Appearance Control, section 5.9
*/
/*
* Gray Levels and Halftoning, section 5.10
@@ -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
@@ -417,30 +420,25 @@ class Ppd
$valueTranslation = $value;
}
// Key-value-pair parsed, now the fun part
- // Special cases for openening closing certain groups
+ // 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') {
- $this->error = 'Line ' . $no . ': OpenSubGroup with no preceeding OpenGroup';
+ 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 13549948..09860c7d 100644
--- a/modules-available/sysconfig/inc/sysconfig.inc.php
+++ b/modules-available/sysconfig/inc/sysconfig.inc.php
@@ -3,17 +3,42 @@
class SysConfig
{
- const GLOBAL_MINIMAL_CONFIG = '/opt/openslx/configs/config-global.tgz';
-
- 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;
}
+ public static function archiveContentsFromTask($status, &$userGroupWarn = null) : array
+ {
+ // Sort files for better display
+ $dirs = array();
+ foreach ($status['data']['entries'] as $file) {
+ if ($file['isdir']) continue;
+ $dirs[dirname($file['name'])][] = $file;
+ if ($file['userId'] > 0 || $file['groupId'] > 0) {
+ $userGroupWarn = true;
+ }
+ }
+ ksort($dirs);
+ $list = array();
+ foreach ($dirs as $dir => $files) {
+ $list[] = array(
+ 'name' => $dir,
+ 'isdir' => true
+ );
+ sort($files);
+ foreach ($files as $file) {
+ $file['size'] = Util::readableFileSize($file['size']);
+ $list[] = $file;
+ }
+ }
+ return $list;
+ }
+
} \ No newline at end of file
diff --git a/modules-available/sysconfig/install.inc.php b/modules-available/sysconfig/install.inc.php
index 1ccec59b..53882882 100644
--- a/modules-available/sysconfig/install.inc.php
+++ b/modules-available/sysconfig/install.inc.php
@@ -7,6 +7,7 @@ $update[] = tableCreate('configtgz', "
`title` varchar(200) NOT NULL,
`filepath` varchar(255) NOT NULL,
`status` enum('OK','OUTDATED','MISSING') NOT NULL DEFAULT 'MISSING',
+ `warnings` TEXT NULL DEFAULT NULL,
`dateline` int(10) unsigned NOT NULL DEFAULT '0',
PRIMARY KEY (`configid`)
");
@@ -16,7 +17,7 @@ $update[] = tableCreate('configtgz_module', "
`title` varchar(200) NOT NULL,
`moduletype` varchar(16) NOT NULL,
`filepath` varchar(250) NOT NULL,
- `contents` text NOT NULL,
+ `contents` longblob NOT NULL,
`version` int(10) unsigned NOT NULL DEFAULT '0',
`status` enum('OK','MISSING','OUTDATED') NOT NULL DEFAULT 'MISSING',
`dateline` int(10) unsigned NOT NULL DEFAULT '0',
@@ -40,15 +41,13 @@ $update[] = tableCreate('configtgz_location', "
");
// Constraints
-if (in_array(UPDATE_DONE, $update)) {
- // To self
- $update[] = tableAddConstraint('configtgz_x_module', 'configid', 'configtgz', 'configid',
- '');
- $update[] = tableAddConstraint('configtgz_x_module', 'moduleid', 'configtgz_module', 'moduleid',
- '');
- $update[] = tableAddConstraint('configtgz_location', 'configid', 'configtgz', 'configid',
- 'ON DELETE CASCADE ON UPDATE CASCADE');
-}
+$update[] = tableAddConstraint('configtgz_x_module', 'configid', 'configtgz', 'configid',
+ 'ON DELETE CASCADE ON UPDATE CASCADE');
+$update[] = tableAddConstraint('configtgz_x_module', 'moduleid', 'configtgz_module', 'moduleid',
+ 'ON DELETE CASCADE ON UPDATE CASCADE');
+$update[] = tableAddConstraint('configtgz_location', 'configid', 'configtgz', 'configid',
+ 'ON DELETE CASCADE ON UPDATE CASCADE');
+// No constraint to location table since we use locationid 0 for global (NULL would require special handling for UPDATE)
// Update path
@@ -88,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'])]);
}
@@ -103,25 +102,77 @@ 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']]);
}
}
+// 2020-01-16: Change contents column type
+Database::exec("ALTER TABLE `configtgz_module` CHANGE `contents` `contents` LONGBLOB NOT NULL");
+
+// 2020-11-02: Add warnings column
+if (!tableHasColumn('configtgz', 'warnings')) {
+ if (Database::exec("ALTER TABLE `configtgz` ADD `warnings` TEXT NULL DEFAULT NULL") === false) {
+ finalResponse(UPDATE_FAILED, 'Could not add warnings to configtgz: ' . Database::lastError());
+ }
+ $update[] = UPDATE_DONE;
+}
+
// ----- rebuild configs ------
-// TEMPORARY HACK; Rebuild configs.. move somewhere else?
+// PERMANENT HACK; Rebuild configs.. move somewhere else?
Module::isAvailable('sysconfig');
$list = ConfigModule::getAll();
-if ($list === false) {
- EventLog::warning('Could not regenerate AD/LDAP configs - please do so manually');
+$parentTask = null;
+$configList = [];
+if ($list === null) {
+ EventLog::warning('Could not regenerate configs - please do so manually');
} else {
- foreach ($list as $ad) {
- if ($ad->needRebuild()) {
- $ad->generate(false);
+ foreach ($list as $confMod) {
+ if ($confMod->moduleType() === 'SshConfig') {
+ // 2020-11-12: Split SshConfig into SshConfig and SshKey
+ $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($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());
+ $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()]);
+ error_log(implode(',', $config->getModuleIds()) . ' -> ' . implode(',', $new));
+ $config->update('', $new);
+ $configList[] = $config;
+ }
+ }
+ }
+ }
+ }
+ if ($confMod->needRebuild()) {
+ $update[] = UPDATE_DONE;
+ $task = $confMod->generate(false, $parentTask);
+ if ($task !== false) {
+ $parentTask = $task;
+ }
}
}
+ foreach ($configList as $config) {
+ $config->generate(false, 0, $parentTask);
+ }
}
+// Start any changed services
+ConfigModuleBaseLdap::ldadp();
+
// Create response for browser
responseFromArray($update);
diff --git a/modules-available/sysconfig/lang/de/config-module.json b/modules-available/sysconfig/lang/de/config-module.json
index 4e178b65..33c743a5 100644
--- a/modules-available/sysconfig/lang/de/config-module.json
+++ b/modules-available/sysconfig/lang/de/config-module.json
@@ -8,9 +8,15 @@
"group_authentication": "Authentifizierung",
"group_branding": "Einrichtungsspezifisches Logo",
"group_generic": "Generisch",
- "group_sshconfig": "SSH",
+ "group_screensaver": "Bildschirmschoner Styling",
+ "group_sshconfig": "SSH-Dämon",
+ "group_sshkey": "SSH-Key",
"ldapAuth_description": "Mit diesem Modul l\u00e4sst sich eine generische LDAP-Authentifizierung einrichten.",
"ldapAuth_title": "LDAP Authentifizierung",
+ "screensaver_title": "Bildschirmschoner Anpassungen",
+ "screensaver_description": "Mit diesem Modul können sie den Style (QSS) und die Texte des Bildschirmschoners anpassen.",
"sshconfig_description": "Mit diesem Modul l\u00e4sst sich steuern, ob und wie der sshd auf den gebooteten Clients startet, und welche Funktionen er zur Verf\u00fcgung stellt. Wenn Sie keinen sshd auf den Clients nutzen wollen, brauchen Sie kein solches Modul zu erstellen.",
- "sshconfig_title": "SSH-D\u00e4mon"
-} \ No newline at end of file
+ "sshconfig_title": "SSH-D\u00e4mon",
+ "sshkey_title": "SSH-Key",
+ "sshkey_description": "Einen öffentlichen SSH-Schlüssel zu den authorized_keys des root-Benutzers hinzufügen. Mit dem zugehörigen privaten Schlüssel kann dann via SSH auf die gebooteten Clients zugegriffen werden, sofern root-Login im zugehörigen SSH-Dämon-Modul aktiviert wurde."
+}
diff --git a/modules-available/sysconfig/lang/de/messages.json b/modules-available/sysconfig/lang/de/messages.json
index 5bceb2f0..6bc2f2e5 100644
--- a/modules-available/sysconfig/lang/de/messages.json
+++ b/modules-available/sysconfig/lang/de/messages.json
@@ -1,5 +1,5 @@
{
- "config-activated": "Konfiguration {{0}} wurde aktiviert",
+ "config-delete-error": "Datenbankfehler: {{0}}",
"config-deleted": "Konfiguration {{0}} wurde gel\u00f6scht",
"config-invalid": "Konfiguration mit ID {{0}} existiert nicht",
"could-not-determine-binddn": "Konnte Bind-DN nicht ermitteln ({{0}})",
@@ -9,7 +9,6 @@
"module-added": "Modul erfolgreich hinzugef\u00fcgt",
"module-deleted": "Modul {{0}} wurde gel\u00f6scht",
"module-edited": "Modul wurde aktualisiert",
- "module-in-use": "Modul {{0}} wird noch durch Konfiguration {{1}} verwendet",
"module-rebuild-failed": "Neubau des Moduls fehlgeschlagen",
"module-rebuilding": "Modul wird neu generiert",
"module-rebuilt": "Modul wurde neu generiert",
diff --git a/modules-available/sysconfig/lang/de/module.json b/modules-available/sysconfig/lang/de/module.json
index 4b401437..4bc1642f 100644
--- a/modules-available/sysconfig/lang/de/module.json
+++ b/modules-available/sysconfig/lang/de/module.json
@@ -1,11 +1,28 @@
{
"config-module": "Konfigurationsmodul",
- "lang_clientSshConfig": "SSH-Konfiguration",
"lang_configurationCompilation": "Konfiguration zusammenstellen",
"lang_contentOf": "Inhalt von",
"lang_moduleAdd": "Modul hinzuf\u00fcgen",
+ "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"
+ "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.",
+ "saver_MessageDefaultNoTimeoutLocked": "Dieser Rechner ist gesperrt.",
+ "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_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_TitleIdleKill": "Idle Kill",
+ "saver_TitleNoTimeout": "Ohne Timeout",
+ "saver_TitleShutdown": "Herunterfahren"
} \ No newline at end of file
diff --git a/modules-available/sysconfig/lang/de/template-tags.json b/modules-available/sysconfig/lang/de/template-tags.json
index 8e6ba87a..16b2c672 100644
--- a/modules-available/sysconfig/lang/de/template-tags.json
+++ b/modules-available/sysconfig/lang/de/template-tags.json
@@ -8,8 +8,6 @@
"lang_adText3": "Normalerweise k\u00f6nnen Sie als Bind DN die Kurzform im Format dom\u00e4ne\\benutzer angeben. Wenn dies nicht funktioniert, m\u00fcssen Sie den DN des Benutzers ermitteln. Z.B. unter Eingabe des folgenden Befehls auf einem DC:",
"lang_adText4": "Nach Eingabe aller ben\u00f6tigten Daten wird im n\u00e4chsten Schritt \u00fcberpr\u00fcft, ob die Kommunikation mit dem AD m\u00f6glich ist.",
"lang_add": "Hinzuf\u00fcgen",
- "lang_allowPass": "Login mit Passwort zulassen",
- "lang_allowPassInfo": "Wenn aktiviert, l\u00e4sst der sshd Logins mit Benutzername\/Passwort-Kombination zu. Ansonsten werden nur Logins nach dem pubkey-Verfahren zugelassen.",
"lang_asteriskMandatory": "Mit (*) gekennzeichnete Felder sind Pflichtfelder",
"lang_availableModules": "Verf\u00fcgbare Konfigurationsmodule",
"lang_availableSystem": "Verf\u00fcgbare Systemkonfigurationen",
@@ -44,9 +42,8 @@
"lang_driveLetterNote": "WICHTIG: Bitte w\u00e4hlen Sie einen Laufwerksbuchstaben, der in den eingesetzten VMs verf\u00fcgbar ist, da ansonsten auf einen anderen Buchstaben ausgewichen werden muss.",
"lang_editLong": "Modul oder Konfiguration bearbeiten.",
"lang_editingLocationInfo": "Sie setzen die Konfiguration eines bestimmten Raums\/Orts, nicht die globale Konfiguration",
- "lang_fixNumeric": "Numerischen Account-Namen muss ein 's' vorangestellt werden",
- "lang_fixNumericDescription": "Wenn Sie diese Option aktivieren, m\u00fcssen Benutzer, deren Account-Name nur aus Ziffern besteht, diesem ein 's' voranstellen beim Login. Diese Option ist beim alten Login-Manager (KDM) zwingend erforderlich, da sonst der Loginvorgang fehlschl\u00e4gt. Mit dem neuen lightdm-basierten Login-Screen lassen sich numerische Account-Namen jedoch direkt verwenden. Wenn Sie an Ihrer Einrichtung keine numerischen Account-Namen verwenden, hat diese Option keine Auswirkung.",
"lang_folderRedirection": "Folder Redirection",
+ "lang_forceRootOwner": "Besitzrechte des Inhalts auf root:root setzen",
"lang_genUid": "uid-Nummern generieren",
"lang_genUidDescription": "Wenn aktiviert, generiert der Satellitenserver nummerische IDs f\u00fcr die Benutzer, anstatt diese aus dem LDAP\/AD zu extrahieren.",
"lang_generateModule": "Modul erzeugen",
@@ -74,10 +71,17 @@
"lang_mapModeNativeFallback": "Nativ in der VM einbinden; Fallback auf VMware Shared Folders",
"lang_mapModeNone": "Verzeichnisse nicht durchreichen",
"lang_mapModeVmware": "VMware Shared Folders [VMwareTools]",
+ "lang_modStillUsedBy": "Modul noch in Verwendung durch:",
+ "lang_mode": "Modus",
+ "lang_modeAdvanced": "Fortgeschrittener Modus",
+ "lang_modeEasy": "Vereinfachter Modus",
"lang_moduleChoose": "Bitte w\u00e4hlen Sie aus, welche Art Konfigurationsmodul Sie erstellen m\u00f6chten.",
"lang_moduleConfiguration": "Konfigurationsmodule",
"lang_moduleName": "Modulname",
+ "lang_moduleOwnerWarn": "Einige Dateien oder Verzeichnisse in diesem Archiv haben als Besitzer order Gruppe etwas anderes als \"root\" gesetzt. Dies ist nur in besonderen F\u00e4llen sinnvoll bzw. erforderlich.",
"lang_moduleTitle": "Titel",
+ "lang_moduleUnused": "Ungenutzt",
+ "lang_moduleUnusedLong": "Dieses Modul ist mit keiner Systemkonfiguration verkn\u00fcpft.",
"lang_mountOptionsNote": "Diese Einstellungen beziehen sich nur auf Linux und \u00e4hnliche Systeme (sowohl das MiniLinux als auch laufende VMs) und beeinflussen die Optionen, die beim Mounten des Verzeichnisses verwendet werden sollen. Sofern es im LDAP\/AD ein Nutzerattribut gibt, welches die passenden Optionen enth\u00e4lt, k\u00f6nnen Sie dieses hier angeben. Das Attribut wird dann vorrangig behandelt. Ist das Attribut leer oder nicht vorhanden, werden die Optionen verwendet, die Sie im Feld \"feste Mount-Optionen\" eingetragen haben. Sind beide Felder leer, werden verschiedene Optionen automatisch durchprobiert.",
"lang_name": "Name",
"lang_newConfiguration": "Neue Konfiguration",
@@ -91,13 +95,27 @@
"lang_noValidCert": "Der Server besitzt kein oder ein nicht valides Zertifikat.",
"lang_onProblemSearchBase": "Werden keine Benutzer gefunden, dann \u00fcberpr\u00fcfen Sie bitte die Suchbasis",
"lang_or": "oder",
+ "lang_pwlogin_user_only": "Alle au\u00dfer root",
"lang_rebuild": "Neu generieren",
"lang_rebuildLong": "Modul oder Konfiguration neu generieren. Das entsprechende Modul bzw. Konfiguration ist aktuell und sollte nicht neu generiert werden m\u00fcssen.",
"lang_rebuildOutdatedLong": "Modul oder Konfiguration neu generieren. Das entsprechende Modul bzw. Konfiguration ist veraltet oder nicht vorhanden.",
"lang_redirectionWarning": "ACHTUNG: Diese Funktion ist experimentell. Sie biegt nach dem Starten mittels openslx.exe die ausgew\u00e4hlten Verzeichnisse auf das Home-Verzeichnis des angemeldeten Benutzers um (getestet mit Windows 7 und 10). Da hierzu undokumentierte Windows-Einstellungen zur Laufzeit ge\u00e4ndert werden ist nicht garantiert, dass diese Methode in sp\u00e4teren Versionen\/Updates von Windows noch funktioniert. Wir empfehlen, stattdessen die Verzeichnisse - sofern gew\u00fcnscht - bereits in der Vorlage auf den oben konfigurierten Laufwerksbuchstaben des Home-Verzeichnisses umzukonfigurieren.",
+ "lang_replaces": "Ersetzt Modul: ",
"lang_restartWizard": "Wizard neu starten",
"lang_rootKey": "root pubkey (\u00f6ffentlicher Schl\u00fcssel)",
- "lang_rootKeyInfo": "Tragen Sie hier den \u00f6ffentlichen Schl\u00fcssel eines Schl\u00fcsselpaars ein, mit dem Sie sich als root-Benutzer an den Clients anmelden wollen. Lassen Sie das Feld leer, um diese Funktion nicht zu verwenden.",
+ "lang_rootKeyInfo": "Tragen Sie hier den \u00f6ffentlichen Schl\u00fcssel eines Schl\u00fcsselpaars ein, mit dem Sie sich als root-Benutzer an den Clients anmelden wollen.",
+ "lang_screenBackground": "Hintergrund",
+ "lang_screenBackgroundDescription": " - Ein Hintergrund, bestehend aus einem zweifarbigem Gradienten.",
+ "lang_screenClock": "Uhr",
+ "lang_screenColor": "Farbe",
+ "lang_screenHeader": "Header",
+ "lang_screenLabel": "Label",
+ "lang_screenLocked": "Sperrbildschirm",
+ "lang_screenQss": "QSS",
+ "lang_screenSize": "Gr\u00f6\u00dfe",
+ "lang_screenText": "Inhaltstext Bearbeiten",
+ "lang_screenTextInherit": "Werte Erben",
+ "lang_screenUnlocked": "Bildschirmschoner",
"lang_searchBase": "Suchbasis",
"lang_selectFile": "Bitte w\u00e4hlen Sie ein Archiv",
"lang_selectHomeAttribute": "Home-Attribut",
@@ -117,9 +135,12 @@
"lang_show": "Ansehen",
"lang_showLong": "Inhalt des Moduls anzeigen.",
"lang_skip": "Weiter",
- "lang_sshMultipleHeadsup": "Wenn Sie mehrere Pubkeys angeben wollen, k\u00f6nnen Sie entsprechend mehrere Module vom Typ SSH zu einer Systemkonfiguration hinzuf\u00fcgen. In diesem Fall sollten Sie jedoch darauf achten, dass alle Konfigurationen die gleichen Einstellungen f\u00fcr Port und Passwortlogin haben, da ansonsten undefiniert ist, welche der Einstellungen greifen wird.",
+ "lang_sshAllowPass": "Login via Passwort zulassen",
+ "lang_sshAllowPassInfo": "Legt fest, ob sich per SSH mit Passwort eingeloggt werden darf, oder nur das pubkey-Verfahren erlaubt ist.",
+ "lang_sshAllowedUsers": "Zugelassene Nutzer",
+ "lang_sshAllowedUsersInfo": "Legt fest, welche Nutzer sich per SSH einloggen d\u00fcrfen. Der spezielle Nutzer \"demo\" kann sich generell nicht per SSH einloggen, unabh\u00e4ngig der Konfiguration.",
"lang_ssl": "SSL",
- "lang_sslDescription": "Die Verbindung zum AD-Server mit SSL sichern. (Die Verbindung zwischen Client und Proxy wird in jedem Fall mit SSL abgewickelt.)",
+ "lang_sslDescription": "Die Verbindung zum AD\/LDAP-Server mit SSL sichern. (Die Verbindung zwischen Client und Proxy wird in jedem Fall mit SSL abgewickelt.)",
"lang_supportedFiles": "Unterst\u00fctzte Archivformate",
"lang_systemConfiguration": "Systemkonfiguration",
"lang_systemConfigurationAlert": "Bevor Sie eine Systemkonfiguration erstellen k\u00f6nnen, m\u00fcssen Sie zun\u00e4chst ein Konfigurationsmodul erzeugen.",
@@ -134,5 +155,8 @@
"lang_userDirectory": "Benutzerverzeichnis",
"lang_userDirectoryInfo1": "Optionale Angabe: Wenn die Clients f\u00fcr die Benutzer ein eigenes Verzeichnis (Homeverzeichnis, Benutzerverzeichnis) von einem Server einbinden sollen, geben Sie bitte hier das Format in UNC-Notation an, also z.B.",
"lang_userDirectoryInfo2": "%s ist dabei ein Platzhalter f\u00fcr den Login-Namen des Benutzers.",
- "lang_userDirectoryInfo3": "Das Verzeichnis wird mit den gleichen Zugangsdaten eingebunden, die der Benutzer beim Login angibt. (D.h. kein Kerberos Support o.\u00e4.)"
+ "lang_userDirectoryInfo3": "Das Verzeichnis wird mit den gleichen Zugangsdaten eingebunden, die der Benutzer beim Login angibt. (D.h. kein Kerberos Support o.\u00e4.)",
+ "lang_user_all": "Alle Nutzer",
+ "lang_user_root_only": "Nur root",
+ "lang_user_user_only": "Alle au\u00dfer root"
} \ No newline at end of file
diff --git a/modules-available/sysconfig/lang/en/config-module.json b/modules-available/sysconfig/lang/en/config-module.json
index efe6f697..d4e1a8cc 100644
--- a/modules-available/sysconfig/lang/en/config-module.json
+++ b/modules-available/sysconfig/lang/en/config-module.json
@@ -8,9 +8,15 @@
"group_authentication": "Authentication",
"group_branding": "Branding",
"group_generic": "Generic",
- "group_sshconfig": "SSH",
+ "group_screensaver": "Screen saver styling",
+ "group_sshconfig": "SSH config",
+ "group_sshkey": "SSH key",
"ldapAuth_description": "This module enables you to create a simple LDAP authentication module.",
"ldapAuth_title": "LDAP Authentication",
+ "screensaver_title": "Screensaver customization",
+ "screensaver_description": "With this module you can customize the style (QSS) and texts of the screensaver.",
"sshconfig_description": "Here you can set whether the sshd on the clients will start, and what options it will use.",
- "sshconfig_title": "SSH daemon"
-} \ No newline at end of file
+ "sshconfig_title": "SSH daemon",
+ "sshkey_title": "SSH key",
+ "sshkey_description": "Add a public key to the authorized_keys file of the root user. You can then use the according private key to log in on a running client as root via SSH. root login needs to be enabled in the according SSH daemon module."
+}
diff --git a/modules-available/sysconfig/lang/en/messages.json b/modules-available/sysconfig/lang/en/messages.json
index 6e50b80c..d34e4bfa 100644
--- a/modules-available/sysconfig/lang/en/messages.json
+++ b/modules-available/sysconfig/lang/en/messages.json
@@ -1,5 +1,5 @@
{
- "config-activated": "Configuration {{0}} has been activated",
+ "config-delete-error": "Database error: {{0}}",
"config-deleted": "Deleted configuration {{0}}",
"config-invalid": "Configuration with id {{0}} does not exist",
"could-not-determine-binddn": "Could not determine bind dn ({{0}})",
@@ -9,7 +9,6 @@
"module-added": "Module successfully added",
"module-deleted": "Module {{0}} was deleted",
"module-edited": "Module has been edited",
- "module-in-use": "Module {{0}} is still used by Configuration {{1}}",
"module-rebuild-failed": "Rebuilding module failed",
"module-rebuilding": "Module is rebuilding...",
"module-rebuilt": "Module was rebuilt",
diff --git a/modules-available/sysconfig/lang/en/module.json b/modules-available/sysconfig/lang/en/module.json
index ba2d0591..dbfacdb8 100644
--- a/modules-available/sysconfig/lang/en/module.json
+++ b/modules-available/sysconfig/lang/en/module.json
@@ -1,11 +1,28 @@
{
"config-module": "Config module",
- "lang_clientSshConfig": "SSH configuration",
"lang_configurationCompilation": "Compile configuration",
"lang_contentOf": "Content of",
"lang_moduleAdd": "Add Module",
+ "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"
+ "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.",
+ "saver_MessageDefaultNoTimeoutLocked": "This computer is locked.",
+ "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_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_TitleIdleKill": "Idle Kill",
+ "saver_TitleNoTimeout": "No Timeout",
+ "saver_TitleShutdown": "Shutdown"
} \ No newline at end of file
diff --git a/modules-available/sysconfig/lang/en/template-tags.json b/modules-available/sysconfig/lang/en/template-tags.json
index 0921ccdb..eddd03d4 100644
--- a/modules-available/sysconfig/lang/en/template-tags.json
+++ b/modules-available/sysconfig/lang/en/template-tags.json
@@ -8,8 +8,6 @@
"lang_adText3": "Next the distinguished name of the user must be specified. You can determine this by dsquery command line program on a domain controller as the following call:",
"lang_adText4": "After entering all required data in the next step, it checks whether communication is possible with the AD.",
"lang_add": "Add",
- "lang_allowPass": "Allow password login",
- "lang_allowPassInfo": "When active, logins via username and password are allowed. Otherwise, only pubkey authentication is possible.",
"lang_asteriskMandatory": "Fields marked with (*) are mandatory",
"lang_availableModules": "Available Configuration Modules",
"lang_availableSystem": "Available System Configuration",
@@ -44,9 +42,8 @@
"lang_driveLetterNote": "IMPORTANT: Pick a drive letter for the home directory that will be free in the Virtual Machines. Otherwise, a random letter will be assigned.",
"lang_editLong": "Edit module or configuration.",
"lang_editingLocationInfo": "You're setting the configuration for a specific location, not the global one",
- "lang_fixNumeric": "Numeric account names have to be prefixed by 's'",
- "lang_fixNumericDescription": "If enabled, users with account names that consist entirely of digits have to prefix their user id by 's' when logging in. This is required with the old login manager (KDM) to prevent crashes. The new lightdm-based login manager will accept numeric account names, so you can leave this option disabled. If your organization doesn't have any numeric account names, this option will have no effect.",
"lang_folderRedirection": "Folder Redirection",
+ "lang_forceRootOwner": "Change ownership of archive content to root:root",
"lang_genUid": "Generate uid numbers",
"lang_genUidDescription": "When selected, the satellite server will generate numeric IDs for the users, instead of extracting them from AD\/LDAP.",
"lang_generateModule": "Generating module",
@@ -74,10 +71,17 @@
"lang_mapModeNativeFallback": "Natively map inside VM; fallback to VMware Shared Folders",
"lang_mapModeNone": "Don't map shares at all",
"lang_mapModeVmware": "VMware Shared Folders [VMwareTools]",
+ "lang_modStillUsedBy": "Module still in use by:",
+ "lang_mode": "Mode",
+ "lang_modeAdvanced": "Advanced Mode",
+ "lang_modeEasy": "Easy Mode",
"lang_moduleChoose": "Please select which type of configuration module you want to create.",
"lang_moduleConfiguration": "Module Configuration",
"lang_moduleName": "Module Name",
+ "lang_moduleOwnerWarn": "Some files or directories in this archive belong to another user or group than \"root\". This is only necessary\/required in special cases.",
"lang_moduleTitle": "Title",
+ "lang_moduleUnused": "Unused",
+ "lang_moduleUnusedLong": "This module is not attached to any system configuration.",
"lang_mountOptionsNote": "These settings are relevant for the MiniLinux and VMs containing non-Windows OSes. If you specify an LDAP user attribute, its contents will be used as mount options when mounting the user's home directory. If the attribute is not specified or its contents are empty, the mount attributes specified in the other field will be used. If you leave both fields empty, the clients will try to determine the options automatically.",
"lang_name": "Name",
"lang_newConfiguration": "New Configuration",
@@ -91,13 +95,27 @@
"lang_noValidCert": "The server did not supply a certificate, or the certificate is invalid.",
"lang_onProblemSearchBase": "If no users are found, please check the search base",
"lang_or": "or",
+ "lang_pwlogin_user_only": "Everyone except root",
"lang_rebuild": "Rebuild",
"lang_rebuildLong": "Rebuild module or configuration.",
"lang_rebuildOutdatedLong": "Rebuild module or configuration. The module\/configuration is outdated or missing and should be regenerated.",
- "lang_redirectionWarning": "WARNING: This feature is experimental. It remaps the selected folders after the VM booted (via openslx.exe) to the logged in user's home drive. This might cause problems with applications that start before the pathes are patched, as they will see the old unpatched settings. Please note that this is usign undocumented or unsupported techniques to achieve this goal. It is not guaranteed that this method will work in future versions or updates of Windows. If you want to reliably remap these directories, you might want to change their locations in the VM before uploading it.",
+ "lang_redirectionWarning": "WARNING: This feature is experimental. It remaps the selected folders after the VM booted (via openslx.exe) to the logged in user's home drive. This might cause problems with applications that start before the paths are patched, as they will see the old unpatched settings. Please note that this is usign undocumented or unsupported techniques to achieve this goal. It is not guaranteed that this method will work in future versions or updates of Windows. If you want to reliably remap these directories, you might want to change their locations in the VM before uploading it.",
+ "lang_replaces": "Replaces module: ",
"lang_restartWizard": "Restart wizard",
"lang_rootKey": "root pubkey",
- "lang_rootKeyInfo": "Here you can add the public key of a keypair that you want to use for authentication as root-user. Leave this field blank to disable the feature.",
+ "lang_rootKeyInfo": "Here you can add the public key of a keypair that you want to use for authentication as root-user.",
+ "lang_screenBackground": "Background",
+ "lang_screenBackgroundDescription": " - A background consisting of a gradient with two colors.",
+ "lang_screenClock": "Clock",
+ "lang_screenColor": "Color",
+ "lang_screenHeader": "Header",
+ "lang_screenLabel": "Label",
+ "lang_screenLocked": "Lockscreen",
+ "lang_screenQss": "QSS",
+ "lang_screenSize": "Size",
+ "lang_screenText": "Edit Contenttext",
+ "lang_screenTextInherit": "Inherit Values",
+ "lang_screenUnlocked": "Screensaver",
"lang_searchBase": "Search Base",
"lang_selectFile": "Please select an archive",
"lang_selectHomeAttribute": "Home attribute",
@@ -117,9 +135,12 @@
"lang_show": "Show",
"lang_showLong": "Show content of module.",
"lang_skip": "Next",
- "lang_sshMultipleHeadsup": "If you want to add multiple SSH keys to the system, you can simply create multiple modules of type SSH and add them all to your system config. Note that you should have matching port and password settings for those configs, as it is currently undefined which of the settings will apply to the final configuration.",
+ "lang_sshAllowPass": "Allow login with password",
+ "lang_sshAllowPassInfo": "Set whether users can log in via SSH using the account's password; otherwise, only pubkey-auth is enabled.",
+ "lang_sshAllowedUsers": "Allowed users",
+ "lang_sshAllowedUsersInfo": "Decides which users are allowed to log in via SSH. The special user account \"demo\" is never allowed to log in via SSH, regardless of this setting.",
"lang_ssl": "SSL",
- "lang_sslDescription": "Use SSL encryption to talk to AD server.",
+ "lang_sslDescription": "Use SSL encryption to talk to AD\/LDAP server.",
"lang_supportedFiles": "Supported File Formats",
"lang_systemConfiguration": "System Configuration",
"lang_systemConfigurationAlert": "Before you can create a system configuration, you must first create a configuration module.",
@@ -134,5 +155,8 @@
"lang_userDirectory": "User Directory",
"lang_userDirectoryInfo1": "Optional: If the clients should embed a separate directory (home directory, user directory) from a server for the user, please enter here the format in UNC notation, eg",
"lang_userDirectoryInfo2": "%s is a placeholder for the user's login name.",
- "lang_userDirectoryInfo3": "The directory is loaded with the same credentials that the user specifies when login. (That is no Kerberos support, etc.)"
+ "lang_userDirectoryInfo3": "The directory is loaded with the same credentials that the user specifies when login. (That is no Kerberos support, etc.)",
+ "lang_user_all": "Everyone",
+ "lang_user_root_only": "Only root",
+ "lang_user_user_only": "Everyone except root"
} \ No newline at end of file
diff --git a/modules-available/sysconfig/page.inc.php b/modules-available/sysconfig/page.inc.php
index 05a83924..b11f399e 100644
--- a/modules-available/sysconfig/page.inc.php
+++ b/modules-available/sysconfig/page.inc.php
@@ -22,39 +22,6 @@ class Page_SysConfig extends Page
private $haveOverriddenLocations = false;
- /**
- * Add a known configuration module. Every addmoule_* file should call this
- * for its module provided.
- *
- * @param string $id Internal identifier for the module
- * @param string $startClass Class to start wizard for creating such a module
- * @param string $title Title of this module type
- * @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
- */
- public static function addModule($id, $startClass, $title, $description, $group, $unique, $sortOrder = 0)
- {
- self::$moduleTypes[$id] = array(
- 'startClass' => $startClass,
- 'title' => $title,
- 'description' => $description,
- 'group' => $group,
- 'unique' => $unique,
- 'sortOrder' => $sortOrder
- );
- }
-
- /**
- *
- * @return array All registered module types
- */
- public static function getModuleTypes()
- {
- return self::$moduleTypes;
- }
-
protected function doPreprocess()
{
User::load();
@@ -146,7 +113,7 @@ class Page_SysConfig extends Page
Render::addTemplate('sysconfig_heading');
- $action = Request::any('action', 'list');
+ $action = Request::any('action', 'list', 'string');
switch ($action) {
case 'addmodule':
User::assertPermission('module.edit');
@@ -177,25 +144,20 @@ class Page_SysConfig extends Page
return;
case 'module':
User::assertPermission('module.view-list');
- $listid = Request::post('list');
- if ($listid !== false) {
- $this->listModuleContents($listid);
- return;
- }
- break;
+ $listid = Request::post('list', Request::REQUIRED, 'int');
+ $this->listModuleContents($listid);
+ return;
case 'config':
User::assertPermission('config.view-list');
- $listid = Request::post('list');
- if ($listid !== false) {
- $this->listConfigContents($listid);
- return;
- }
- break;
+ $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) {
@@ -213,12 +175,12 @@ class Page_SysConfig extends Page
private function listConfigs()
{
// Configs
- $res = Database::simpleQuery("SELECT c.configid, c.title, c.filepath, c.status, c.dateline,
- GROUP_CONCAT(DISTINCT cl.locationid) AS loclist, GROUP_CONCAT(cxm.moduleid) AS modlist
+ $res = Database::simpleQuery("SELECT c.configid, c.title, c.filepath, c.status, c.dateline, c.warnings,
+ GROUP_CONCAT(DISTINCT cl.locationid) AS loclist, GROUP_CONCAT(DISTINCT cxm.moduleid) AS modlist
FROM configtgz c
LEFT JOIN configtgz_x_module cxm USING (configid)
LEFT JOIN configtgz_location cl ON (c.configid = cl.configid)
- GROUP BY configid
+ GROUP BY configid, title
ORDER BY title ASC");
$configs = array();
if ($this->currentLoc !== 0) {
@@ -227,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 {
@@ -247,6 +209,8 @@ class Page_SysConfig extends Page
$this->haveOverriddenLocations = true;
}
$configs[] = array(
+ 'warnings' => $row['warnings'],
+ 'warnings_hidden' => (!empty($row['warnings']) && $row['status'] === 'OK') ? '' : 'hidden',
'configid' => $row['configid'],
'config' => $row['title'],
'modlist' => $row['modlist'],
@@ -273,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);
@@ -304,27 +268,7 @@ class Page_SysConfig extends Page
Taskmanager::addErrorMessage($status);
Util::redirect('?do=sysconfig&locationid=' . $this->currentLoc);
}
-
- // Sort files for better display
- $dirs = array();
- foreach ($status['data']['entries'] as $file) {
- if ($file['isdir'])
- continue;
- $dirs[dirname($file['name'])][] = $file;
- }
- ksort($dirs);
- $list = array();
- foreach ($dirs as $dir => $files) {
- $list[] = array(
- 'name' => $dir,
- 'isdir' => true
- );
- sort($files);
- foreach ($files as $file) {
- $file['size'] = Util::readableFileSize($file['size']);
- $list[] = $file;
- }
- }
+ $list = SysConfig::archiveContentsFromTask($status);
// render the template
Render::addDialog(Dictionary::translate('lang_contentOf') . ' ' . $row['title'], false, 'custom-filelist', array(
@@ -348,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']
@@ -363,11 +307,7 @@ class Page_SysConfig extends Page
private function activateConfig()
{
- $configid = Request::post('activate', false, 'int');
- if ($configid === false) {
- Message::addError('main.empty-field');
- Util::redirect('?do=sysconfig&locationid=' . $this->currentLoc);
- }
+ $configid = Request::post('activate', Request::REQUIRED, 'int');
// Validate that either the configid is valid (in case we override for a specific location)
// or that if the locationid is 0 (=global) that the configid exists, because it's not allowed
// to unset the global config
@@ -386,15 +326,18 @@ class Page_SysConfig extends Page
Database::exec("INSERT INTO configtgz_location (locationid, configid) VALUES (:locationid, :configid)"
. " ON DUPLICATE KEY UPDATE configid = :configid", compact('locationid', 'configid'));
}
- Event::activeConfigChanged();
+ $task = ConfigModuleBaseLdap::ldadp();
+ if ($task !== false) {
+ TaskmanagerCallback::addCallback($task, 'ldadpStartup');
+ }
Util::redirect('?do=sysconfig&locationid=' . $this->currentLoc);
}
private function rebuildConfig()
{
- $configid = Request::post('rebuild', 'MISSING');
+ $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);
}
@@ -410,21 +353,19 @@ class Page_SysConfig extends Page
private function delModule()
{
- $moduleid = Request::post('del', 'MISSING');
- $row = Database::queryFirst("SELECT title, filepath FROM configtgz_module WHERE moduleid = :moduleid LIMIT 1", array('moduleid' => $moduleid));
- if ($row === false) {
+ $moduleid = Request::post('del', Request::REQUIRED, 'int');
+ $module = Database::queryFirst("SELECT title, filepath FROM configtgz_module WHERE moduleid = :moduleid LIMIT 1", array('moduleid' => $moduleid));
+ if ($module === false) {
Message::addError('config-invalid', $moduleid);
Util::redirect('?do=sysconfig');
}
- $existing = Database::queryFirst("SELECT title FROM configtgz_x_module"
- . " INNER JOIN configtgz USING (configid)"
- . " WHERE moduleid = :moduleid LIMIT 1", array('moduleid' => $moduleid));
- if ($existing !== false) {
- Message::addError('module-in-use', $row['title'], $existing['title']);
- Util::redirect('?do=sysconfig');
- }
+ // Get config.tgz using this module *before* deleting it
+ $existing = Database::simpleQuery("SELECT configid FROM configtgz_x_module
+ WHERE moduleid = :moduleid", array('moduleid' => $moduleid));
+ // Delete DB entries and file
+ Database::exec("DELETE FROM configtgz_module WHERE moduleid = :moduleid LIMIT 1", array('moduleid' => $moduleid));
$task = Taskmanager::submit('DeleteFile', array(
- 'file' => $row['filepath']
+ 'file' => $module['filepath']
));
if (isset($task['statusCode']) && $task['statusCode'] === Taskmanager::TASK_WAITING) {
$task = Taskmanager::waitComplete($task['id']);
@@ -432,15 +373,21 @@ class Page_SysConfig extends Page
if (!isset($task['statusCode']) || $task['statusCode'] === Taskmanager::TASK_ERROR) {
Message::addWarning('main.task-error', $task['data']['error']);
} elseif ($task['statusCode'] === Taskmanager::TASK_FINISHED) {
- Message::addSuccess('module-deleted', $row['title']);
+ Message::addSuccess('module-deleted', $module['title']);
+ }
+ // Rebuild depending config.tgz
+ foreach ($existing as $crow) {
+ $config = ConfigTgz::get($crow['configid']);
+ if ($config !== null) {
+ $config->generate();
+ }
}
- Database::exec("DELETE FROM configtgz_module WHERE moduleid = :moduleid LIMIT 1", array('moduleid' => $moduleid));
Util::redirect('?do=sysconfig');
}
private function downloadModule()
{
- $moduleid = Request::post('download', 'MISSING');
+ $moduleid = Request::post('download', Request::REQUIRED);
$row = Database::queryFirst("SELECT title, filepath FROM configtgz_module WHERE moduleid = :moduleid LIMIT 1", array('moduleid' => $moduleid));
if ($row === false) {
Message::addError('config-invalid', $moduleid);
@@ -453,13 +400,13 @@ class Page_SysConfig extends Page
private function rebuildModule()
{
- $moduleid = Request::post('rebuild', 'MISSING');
+ $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)
@@ -471,13 +418,15 @@ class Page_SysConfig extends Page
private function delConfig()
{
- $configid = Request::post('del', 'MISSING');
+ $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);
}
- if ($config->delete() !== false) {
+ if ($config->delete() === false) {
+ Message::addError('config-delete-error', Database::lastError());
+ } else {
Message::addSuccess('config-deleted', $config->title());
}
Util::redirect('?do=sysconfig&locationid=' . $this->currentLoc);
@@ -515,22 +464,14 @@ class Page_SysConfig extends Page
if (Request::post('action') === 'status') {
$mods = Request::post('mods');
$confs = Request::post('confs');
- $outMods = array();
- $outConfs = array();
$mods = explode(',', $mods);
$confs = explode(',', $confs);
// Mods
- $res = Database::simpleQuery("SELECT moduleid FROM configtgz_module
+ $outMods = Database::queryAll("SELECT moduleid AS id FROM configtgz_module
WHERE moduleid in (:mods) AND status = 'OK'", compact('mods'));
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- $outMods[] = $row['moduleid'];
- }
// Confs
- $res = Database::simpleQuery("SELECT configid FROM configtgz
+ $outConfs = Database::queryAll("SELECT configid AS id, warnings FROM configtgz
WHERE configid in (:confs) AND status = 'OK'", compact('confs'));
- while ($row = $res->fetch(PDO::FETCH_ASSOC)) {
- $outConfs[] = $row['configid'];
- }
Header('Content-Type: application/json');
die(json_encode(array('mods' => $outMods, 'confs' => $outConfs)));
}
diff --git a/modules-available/sysconfig/templates/ad-selfsearch.html b/modules-available/sysconfig/templates/ad-selfsearch.html
index e6a19468..0eefc372 100644
--- a/modules-available/sysconfig/templates/ad-selfsearch.html
+++ b/modules-available/sysconfig/templates/ad-selfsearch.html
@@ -42,7 +42,6 @@
{{#mapping}}
<input type="hidden" name="mapping[{{field}}]" value="{{value}}">
{{/mapping}}
- <input name="fixnumeric" value="{{fixnumeric}}" type="hidden">
<input name="genuid" value="{{genuid}}" type="hidden">
<button type="submit" class="btn btn-primary">&laquo; {{lang_back}}</button>
</form>
@@ -67,7 +66,6 @@
{{#mapping}}
<input type="hidden" name="mapping[{{field}}]" value="{{value}}">
{{/mapping}}
- <input name="fixnumeric" value="{{fixnumeric}}" type="hidden">
<input name="genuid" value="{{genuid}}" type="hidden">
<input name="fingerprint" value="{{fingerprint}}" type="hidden">
<button id="nextbutton" type="submit" class="btn btn-primary" style="display:none">{{lang_skip}} &raquo;</button>
diff --git a/modules-available/sysconfig/templates/ad-start.html b/modules-available/sysconfig/templates/ad-start.html
index 274473ff..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>
@@ -67,8 +67,8 @@
<br>
<div>
<div class="checkbox">
- <input id="num-cb" type="checkbox" name="genuid" {{#genuid}}checked{{/genuid}}>
- <label for="num-cb"><b>{{lang_genUid}}</b></label>
+ <input id="genuid-cb" type="checkbox" name="genuid" {{#genuid}}checked{{/genuid}}>
+ <label for="genuid-cb"><b>{{lang_genUid}}</b></label>
</div>
<div>
<i>{{lang_genUidDescription}}</i>
@@ -77,17 +77,7 @@
<br>
<div>
<div class="checkbox">
- <input id="num-cb" type="checkbox" name="fixnumeric" {{#fixnumeric}}checked{{/fixnumeric}}>
- <label for="num-cb"><b>{{lang_fixNumeric}}</b></label>
- </div>
- <div>
- <i>{{lang_fixNumericDescription}}</i>
- </div>
- </div>
- <br>
- <div>
- <div class="checkbox">
- <input if="ssl-cb" type="checkbox" name="ssl" onchange="$('#cert-box').css('display', this.checked ? '' : 'none')" {{#ssl}}checked{{/ssl}}>
+ <input id="ssl-cb" type="checkbox" name="ssl" onchange="$('#cert-box').css('display', this.checked ? '' : 'none')" {{#ssl}}checked{{/ssl}}>
<label for="ssl-cb"><b>{{lang_ssl}}</b></label>
</div>
<div>
diff --git a/modules-available/sysconfig/templates/ad_ldap-checkconnection.html b/modules-available/sysconfig/templates/ad_ldap-checkconnection.html
index e686c29f..ced65650 100644
--- a/modules-available/sysconfig/templates/ad_ldap-checkconnection.html
+++ b/modules-available/sysconfig/templates/ad_ldap-checkconnection.html
@@ -30,7 +30,6 @@
<input type="hidden" name="mapping[{{field}}]" value="{{value}}">
{{/mapping}}
- <input name="fixnumeric" value="{{fixnumeric}}" type="hidden">
<input name="genuid" value="{{genuid}}" type="hidden">
<button type="submit" class="btn btn-primary">&laquo; {{lang_back}}</button>
</form>
@@ -55,7 +54,6 @@
{{#mapping}}
<input type="hidden" name="mapping[{{field}}]" value="{{value}}">
{{/mapping}}
- <input name="fixnumeric" value="{{fixnumeric}}" type="hidden">
<input name="genuid" value="{{genuid}}" type="hidden">
<input name="originalbinddn" value="{{binddn}}" type="hidden">
<button id="nextbutton" type="submit" class="btn btn-primary" style="display:none">{{lang_next}} &raquo;</button>
diff --git a/modules-available/sysconfig/templates/ad_ldap-checkcredentials.html b/modules-available/sysconfig/templates/ad_ldap-checkcredentials.html
index d698d994..b560eecd 100644
--- a/modules-available/sysconfig/templates/ad_ldap-checkcredentials.html
+++ b/modules-available/sysconfig/templates/ad_ldap-checkcredentials.html
@@ -25,7 +25,6 @@
{{#mapping}}
<input type="hidden" name="mapping[{{field}}]" value="{{value}}">
{{/mapping}}
- <input name="fixnumeric" value="{{fixnumeric}}" type="hidden">
<input name="genuid" value="{{genuid}}" type="hidden">
<button type="submit" class="btn btn-primary">&laquo; {{lang_back}}</button>
</form>
@@ -49,13 +48,13 @@
{{#mapping}}
<input type="hidden" name="mapping[{{field}}]" value="{{value}}">
{{/mapping}}
- <input name="fixnumeric" value="{{fixnumeric}}" type="hidden">
<input name="genuid" value="{{genuid}}" type="hidden">
<input name="fingerprint" value="{{fingerprint}}" type="hidden">
<input name="originalbinddn" value="{{binddn}}" type="hidden">
<button id="nextbutton" type="submit" class="btn btn-primary" style="display:none">{{lang_skip}} &raquo;</button>
</form>
</div>
+<div class="clearfix"></div>
<script type="text/javascript">
function ldapCb(task)
{
diff --git a/modules-available/sysconfig/templates/ad_ldap-homedir.html b/modules-available/sysconfig/templates/ad_ldap-homedir.html
index 8a6c10de..33f55c16 100644
--- a/modules-available/sysconfig/templates/ad_ldap-homedir.html
+++ b/modules-available/sysconfig/templates/ad_ldap-homedir.html
@@ -17,7 +17,6 @@
{{#mapping}}
<input type="hidden" name="mapping[{{field}}]" value="{{value}}">
{{/mapping}}
- <input name="fixnumeric" value="{{fixnumeric}}" type="hidden">
<input name="genuid" value="{{genuid}}" type="hidden">
<input name="fingerprint" value="{{fingerprint}}" type="hidden">
diff --git a/modules-available/sysconfig/templates/assign.html b/modules-available/sysconfig/templates/assign.html
new file mode 100644
index 00000000..9e83f965
--- /dev/null
+++ b/modules-available/sysconfig/templates/assign.html
@@ -0,0 +1,31 @@
+<form role="form" enctype="multipart/form-data" method="post" action="?do=SysConfig&amp;action=addmodule&amp;step=AddModule_Assign">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="edit" value="{{edit}}">
+ <input type="hidden" name="assign" value="true">
+
+ {{#configs}}
+ <div class="input-group">
+ <span class="input-group-addon">
+ <div class="checkbox">
+ <input type="checkbox" name="configs[]" value="{{configid}}" id="config{{configid}}">
+ <label></label>
+ </div>
+ </span>
+ <label class="form-control config-label" for="config{{configid}}">
+ <span>{{title}}</span>
+ {{#replaces}}<span class="text-danger">{{lang_replaces}} {{.}}</span>{{/replaces}}
+ </label>
+ </div>
+ {{/configs}}
+
+ <div class="text-right" style="margin-top: 12px">
+ <button type="submit" class="btn btn-primary"><span class="glyphicon glyphicon-floppy-disk"></span> {{lang_save}}</button>
+ </div>
+</form>
+
+<style>
+ .config-label {
+ display: flex !important;
+ justify-content: space-between !important;
+ }
+</style> \ No newline at end of file
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-filelist.html b/modules-available/sysconfig/templates/custom-filelist.html
index 344eece3..20cedfda 100644
--- a/modules-available/sysconfig/templates/custom-filelist.html
+++ b/modules-available/sysconfig/templates/custom-filelist.html
@@ -4,11 +4,23 @@
{{#files}}
<tr>
{{#isdir}}
- <td class="fileEntry slx-bold" colspan="2">{{name}}</td>
+ <td class="fileEntry slx-bold" colspan="4">{{name}}</td>
{{/isdir}}
{{^isdir}}
- <td class="fileEntry">{{name}}</td>
- <td>{{size}}</td>
+ <td class="fileEntry">
+ {{name}}
+ {{#linkTarget}}
+ -&gt;
+ <span class="text-nowrap">{{linkTarget}}</span>
+ {{/linkTarget}}
+ </td>
+ <td class="text-nowrap">{{user}}{{#user}}{{#group}}/{{/group}}{{/user}}{{group}}</td>
+ <td class="text-nowrap">{{userId}}:{{groupId}}</td>
+ <td class="text-nowrap">
+ {{^linkTarget}}
+ {{size}}
+ {{/linkTarget}}
+ </td>
{{/isdir}}
</tr>
{{/files}}
diff --git a/modules-available/sysconfig/templates/custom-fileselect.html b/modules-available/sysconfig/templates/custom-fileselect.html
index f14a6fde..5f190f08 100644
--- a/modules-available/sysconfig/templates/custom-fileselect.html
+++ b/modules-available/sysconfig/templates/custom-fileselect.html
@@ -4,7 +4,8 @@
<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" placeholder="Mein Konfigurationsmodul" autofocus="autofocus">
+ <input type="text" name="title" value="{{title}}" class="form-control" placeholder="Mein Konfigurationsmodul"
+ autofocus="autofocus" required>
</div>
<div class="pull-right">
<button type="submit" class="btn btn-primary">{{lang_next}} &raquo;</button>
@@ -12,20 +13,43 @@
<div class="clearfix"></div>
<hr>
<p>{{lang_checkFileContent}}</p>
+ {{#userGroupWarn}}
+ <div class="alert alert-warning">
+ {{lang_moduleOwnerWarn}}
+ </div>
+ <div class="checkbox">
+ <input id="force-owner" type="checkbox" name="force-owner" value="1" checked>
+ <label for="force-owner">{{lang_forceRootOwner}}</label>
+ </div>
+ <div class="slx-space"></div>
+ {{/userGroupWarn}}
<table class="table table-bordered table-condensed">
- {{#files}}
+ {{#files}}
<tr>
{{#isdir}}
- <td class="fileEntry slx-bold" colspan="2">{{name}}</td>
+ <td class="fileEntry slx-bold" colspan="4">{{name}}</td>
{{/isdir}}
{{^isdir}}
- <td class="fileEntry">{{name}}</td>
- <td>{{size}}</td>
+ <td class="fileEntry">
+ {{name}}
+ {{#linkTarget}}
+ -&gt;
+ <span class="text-nowrap">{{linkTarget}}</span>
+ {{/linkTarget}}
+ </td>
+ <td class="text-nowrap">{{user}}{{#user}}{{#group}}/{{/group}}{{/user}}{{group}}</td>
+ <td class="text-nowrap">{{userId}}:{{groupId}}</td>
+ <td class="text-nowrap">
+ {{^linkTarget}}
+ {{size}}
+ {{/linkTarget}}
+ </td>
{{/isdir}}
</tr>
- {{/files}}
+ {{/files}}
</table>
<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/js.html b/modules-available/sysconfig/templates/js.html
index 157e8d12..63e2b8c6 100644
--- a/modules-available/sysconfig/templates/js.html
+++ b/modules-available/sysconfig/templates/js.html
@@ -1,4 +1,11 @@
-<div class="hidden" id="confirm-delete">{{lang_confirmDeleteQuestion}}</div>
+<div class="hidden" id="confirm-delete">
+ {{lang_confirmDeleteQuestion}}
+ <div id="delete-item-list" class="hidden">
+ <div class="slx-space"></div>
+ <div class="slx-bold">{{lang_modStillUsedBy}}</div>
+ <ul></ul>
+ </div>
+</div>
<script type="application/javascript"><!--
document.addEventListener("DOMContentLoaded", function () {
checkBuildStatus();
diff --git a/modules-available/sysconfig/templates/ldap-finish.html b/modules-available/sysconfig/templates/ldap-finish.html
index a735e792..bd998bfd 100644
--- a/modules-available/sysconfig/templates/ldap-finish.html
+++ b/modules-available/sysconfig/templates/ldap-finish.html
@@ -12,6 +12,7 @@
<div id="finish" class="pull-right" style="display:none">
<a href="?do=SysConfig" class="btn btn-primary">{{lang_toSystemConfiguration}}</a>
</div>
+<div class="clearfix"></div>
<script type="text/javascript">
function ldapCb(task)
{
diff --git a/modules-available/sysconfig/templates/ldap-start.html b/modules-available/sysconfig/templates/ldap-start.html
index b3495741..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>
@@ -68,8 +68,8 @@
<br>
<div>
<div class="checkbox">
- <input id="num-cb" type="checkbox" name="genuid" {{#genuid}}checked{{/genuid}}>
- <label for="num-cb"><b>{{lang_genUid}}</b></label>
+ <input id="genuid-cb" type="checkbox" name="genuid" {{#genuid}}checked{{/genuid}}>
+ <label for="genuid-cb"><b>{{lang_genUid}}</b></label>
</div>
<div>
<i>{{lang_genUidDescription}}</i>
@@ -78,16 +78,6 @@
<br>
<div>
<div class="checkbox">
- <input id="num-cb" type="checkbox" name="fixnumeric" {{#fixnumeric}}checked{{/fixnumeric}}>
- <label for="num-cb"><b>{{lang_fixNumeric}}</b></label>
- </div>
- <div>
- <i>{{lang_fixNumericDescription}}</i>
- </div>
- </div>
- <br>
- <div>
- <div class="checkbox">
<input id="ssl-cb" type="checkbox" name="ssl" onchange="$('#cert-box').css('display', this.checked ? '' : 'none')" {{#ssl}}checked{{/ssl}}>
<label for="ssl-cb"><b>{{lang_ssl}}</b></label>
</div>
diff --git a/modules-available/sysconfig/templates/list-configs.html b/modules-available/sysconfig/templates/list-configs.html
index ea6705da..1370155f 100644
--- a/modules-available/sysconfig/templates/list-configs.html
+++ b/modules-available/sysconfig/templates/list-configs.html
@@ -20,11 +20,17 @@
<input type="hidden" name="locationid" value="{{locationid}}">
<table id="conftable" class="slx-table table-hover" style="width:100%">
{{#configs}}
- <tr>
- <td data-id="{{configid}}" data-modlist="{{modlist}}" class="confrow slx-pointer" width="100%" title="{{dateline_s}}">
- <table class="slx-ellipsis"><tr><td>{{config}}</td></tr></table>
+ <tr data-id="{{configid}}" data-modlist="{{modlist}}" class="confrow">
+ <td class="title slx-pointer" width="100%" title="{{dateline_s}}">
+ <table class="slx-ellipsis"><tr><td>
+ <button type="button" class="btn btn-xs btn-default btn-warnings {{warnings_hidden}}" data-confirm="#confirm-mod-{{configid}}">
+ <span class="glyphicon glyphicon-exclamation-sign text-danger"></span>
+ </button>
+ {{config}}
+ </td></tr></table>
</td>
<td>
+ <pre id="confirm-mod-{{configid}}" class="hidden row-warnings">{{warnings}}</pre>
{{^current}}
<button class="btn btn-primary btn-xs" name="activate" value="{{configid}}" {{perms.config.assign.disabled}}>
<span class="glyphicon glyphicon-flag"></span>
@@ -49,10 +55,10 @@
{{^locationid}}
<button
{{#needrebuild}}
- class="refconf btn btn-primary btn-xs"
+ class="btn-rebuild btn btn-primary btn-xs"
{{/needrebuild}}
{{^needrebuild}}
- class="refconf btn btn-default btn-xs"
+ class="btn-rebuild btn btn-default btn-xs"
{{/needrebuild}}
name="rebuild" value="{{configid}}" title="{{lang_rebuild}}"
{{perms.config.edit.disabled}}>
@@ -66,7 +72,7 @@
href="?do=SysConfig&amp;action=addconfig&amp;edit={{configid}}" title="{{lang_edit}}">
<span class="glyphicon glyphicon-edit"></span>
</a>
- <button type="submit" class="btn btn-danger btn-xs" name="del" value="{{configid}}"
+ <button type="submit" class="btn btn-danger btn-xs btn-del-config" name="del" value="{{configid}}"
title="{{lang_delete}}" {{perms.config.edit.disabled}} data-confirm="#confirm-delete"
data-title="{{config}}">
<span class="glyphicon glyphicon-trash"></span>
diff --git a/modules-available/sysconfig/templates/list-legend.html b/modules-available/sysconfig/templates/list-legend.html
index 809a0449..49974a5f 100644
--- a/modules-available/sysconfig/templates/list-legend.html
+++ b/modules-available/sysconfig/templates/list-legend.html
@@ -25,6 +25,10 @@
<span class="btn btn-danger btn-xs" title="{{lang_delete}}"><span class="glyphicon glyphicon-trash"></span></span>
{{lang_deleteLong}}
</p>
+ <p>
+ <span class="glyphicon glyphicon-question-sign" title="{{lang_moduleUnused}}"></span>
+ {{lang_moduleUnusedLong}}
+ </p>
{{#showLocationBadge}}
<p>
<span class="badge">+4</span>
diff --git a/modules-available/sysconfig/templates/list-modules.html b/modules-available/sysconfig/templates/list-modules.html
index fee3e0f3..5bd19446 100644
--- a/modules-available/sysconfig/templates/list-modules.html
+++ b/modules-available/sysconfig/templates/list-modules.html
@@ -10,10 +10,13 @@
<input type="hidden" name="action" value="module">
<table id="modtable" class="slx-table table-hover" style="width:100%">
{{#modules}}
- <tr>
+ <tr data-id="{{id}}" class="modrow">
<td class="badge text-nowrap">{{moduleType}}</td>
- <td data-id="{{id}}" class="modrow slx-pointer" width="100%" title="{{lang_lastEdited}} {{dateline_s}}">
- <table class="slx-ellipsis"><tr><td>{{title}}</td></tr></table>
+ <td class="title slx-pointer" width="100%" title="{{lang_lastEdited}} {{dateline_s}}">
+ <table class="slx-ellipsis"><tr><td>
+ <span class="glyphicon glyphicon-question-sign pull-right icon-unused hidden" title="{{lang_moduleUnused}}"></span>
+ {{title}}
+ </td></tr></table>
</td>
<td class="text-nowrap">
{{#allowDownload}}
@@ -27,10 +30,10 @@
<td class="text-nowrap">
<button
{{#needRebuild}}
- class="refmod btn btn-primary btn-xs"
+ class="btn-rebuild btn btn-primary btn-xs"
{{/needRebuild}}
{{^needRebuild}}
- class="refmod btn btn-default btn-xs"
+ class="btn-rebuild btn btn-default btn-xs"
{{/needRebuild}}
name="rebuild" value="{{id}}" title="{{lang_rebuild}}" {{perms.module.edit.disabled}}>
<span class="glyphicon glyphicon-refresh"></span>
@@ -40,7 +43,7 @@
title="{{lang_edit}}">
<span class="glyphicon glyphicon-edit"></span>
</a>
- <button type="submit" class="btn btn-danger btn-xs" name="del" value="{{id}}"
+ <button type="submit" class="btn btn-danger btn-xs btn-del-module" name="del" value="{{id}}"
title="{{lang_delete}}" {{perms.module.edit.disabled}} data-confirm="#confirm-delete"
data-title="{{title}}">
<span class="glyphicon glyphicon-trash"></span>
diff --git a/modules-available/sysconfig/templates/screensaver-start.html b/modules-available/sysconfig/templates/screensaver-start.html
new file mode 100644
index 00000000..96be0cd5
--- /dev/null
+++ b/modules-available/sysconfig/templates/screensaver-start.html
@@ -0,0 +1,123 @@
+<form role="form" enctype="multipart/form-data" method="post" action="?do=SysConfig&amp;action=addmodule&amp;step={{step}}">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="next" value="{{next}}">
+ <input type="hidden" name="id" value="{{id}}">
+ <input type="hidden" name="edit" value="{{edit}}">
+
+ <div class="form-group">
+ <div class="input-group">
+ <span class="input-group-addon">{{lang_moduleName}}</span>
+ <input type="text" tabindex="1" name="title" value="{{title}}" class="form-control" autofocus required>
+ </div>
+ </div>
+
+ <input type="hidden" id="helper-mode" name="helper_mode" value="false">
+ <div class="form-group">
+ <div class="input-group btn-group">
+ <span class="input-group-addon slx-ga"">{{lang_mode}}</span>
+ <a class="btn btn-default" tabindex="2" id="btn-easy-mode" type="button" onclick="switchMode(1)">
+ <span class="glyphicon glyphicon-user"></span>
+ {{lang_modeEasy}}
+ </a>
+ <a class="btn btn-default active" tabindex="3" id="btn-advanced-mode" onclick="switchMode(0)">
+ <span class="glyphicon glyphicon-education"></span>
+ {{lang_modeAdvanced}}
+ </a>
+ </div>
+ </div>
+
+ <div class="form-group" id="advanced-mode">
+ <div class="input-group">
+ <span class="input-group-addon slx-ga">{{lang_screenQss}}</span>
+ <textarea tabindex="4" name="qss" rows="20" class="form-control">{{qss}}</textarea>
+ </div>
+ </div>
+
+ <div id="easy-mode" hidden>
+ <div class="form-group">
+ <label>{{lang_screenBackground}}</label>
+ {{lang_screenBackgroundDescription}}
+ <div class="input-group">
+ <span class="input-group-addon slx-ga">{{lang_screenColor}} 1</span>
+ <input id="screensaver-background-color-1" tabindex="5" type="text" name="bg_color_1" value="" class="form-control" placeholder="#443">
+ </div>
+ <div class="input-group">
+ <span class="input-group-addon slx-ga">{{lang_screenColor}} 2</span>
+ <input id="screensaver-background-color-2" tabindex="6" type="text" name="bg_color_2" value="" class="form-control" placeholder="#000">
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label >{{lang_screenLabel}}</label>
+ <div class="input-group">
+ <span class="input-group-addon slx-ga">{{lang_screenColor}}</span>
+ <input type="text" tabindex="7" name="label_color" value="" class="form-control" placeholder="#f64">
+ </div>
+ <div class="input-group">
+ <span class="input-group-addon slx-ga">{{lang_screenSize}}</span>
+ <input type="number" tabindex="8" name="label_size" value="10" class="form-control" placeholder="10">
+ <span class="input-group-addon">pt</span>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label >{{lang_screenClock}}</label>
+ <div class="input-group">
+ <span class="input-group-addon slx-ga">{{lang_screenColor}}</span>
+ <input type="text" tabindex="9" name="clock_color" value="" class="form-control" placeholder="#999">
+ </div>
+ <div class="input-group">
+ <span class="input-group-addon slx-ga">{{lang_screenSize}}</span>
+ <input type="number" tabindex="10" name="clock_size" value="20" class="form-control" placeholder="20">
+ <span class="input-group-addon">pt</span>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label >{{lang_screenHeader}}</label>
+ <div class="input-group">
+ <span class="input-group-addon slx-ga">{{lang_screenColor}}</span>
+ <input type="text" tabindex="11" name="header_color" value="" class="form-control" placeholder="#f640">
+ </div>
+ <div class="input-group">
+ <span class="input-group-addon slx-ga">{{lang_screenSize}}</span>
+ <input type="number" tabindex="12" name="header_size" value="20" class="form-control" placeholder="20">
+ <span class="input-group-addon">pt</span>
+ </div>
+ </div>
+ </div>
+
+ <hr>
+ <div class="btn-group">
+ <a class="btn btn-default" id="btn-back" tabindex="5"
+ {{#edit}}href="?do=sysconfig"{{/edit}}{{^edit}}href="?do=SysConfig&action=addmodule"{{/edit}}>{{lang_back}}</a>
+ </div>
+ <div class="btn-group pull-right">
+ <button type="submit" id="btn-next" tabindex="6" class="btn btn-primary">{{lang_next}} &raquo;</button>
+ </div>
+ <div class="clearfix"></div>
+</form>
+
+<script type="text/javascript">
+ function switchMode(mode) {
+ // 0 = advanced mode
+ // 1 = easy mode
+ if (mode === 0) {
+ $('#easy-mode').hide();
+ $('#advanced-mode').show();
+ $('#btn-easy-mode').removeClass('active');
+ $('#btn-advanced-mode').addClass('active');
+ $('#helper-mode').val('false');
+ $('#btn-back').prop('tabindex', 5);
+ $('#btn-next').prop('tabindex', 6);
+ } else if (mode === 1) {
+ $('#advanced-mode').hide();
+ $('#easy-mode').show();
+ $('#btn-advanced-mode').removeClass('active');
+ $('#btn-easy-mode').addClass('active');
+ $('#helper-mode').val('true');
+ $('#btn-back').prop('tabindex', 13);
+ $('#btn-next').prop('tabindex', 14);
+ }
+ }
+</script>
diff --git a/modules-available/sysconfig/templates/screensaver-text.html b/modules-available/sysconfig/templates/screensaver-text.html
new file mode 100644
index 00000000..acf39cc5
--- /dev/null
+++ b/modules-available/sysconfig/templates/screensaver-text.html
@@ -0,0 +1,121 @@
+<form role="form" enctype="multipart/form-data" method="post" action="?do=SysConfig&amp;action=addmodule&amp;step={{step}}">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" id="next" name="next" value="{{next}}">
+ <input type="hidden" name="id" value="{{id}}">
+ <input type="hidden" name="edit" value="{{edit}}">
+
+ <div class="form-group">
+ <h4><label>{{title}}</label></h4>
+ <h5>{{description}}</h5>
+
+ <h4>{{lang_screenUnlocked}}</h4>
+ <div class="input-group">
+ <span class="input-group-addon slx-ga">{{lang_title}}</span>
+ <input type="text" tabindex="1" name="msg_value" value="{{msg_value}}" class="form-control">
+ </div>
+ </div>
+
+ <div class="form-group">
+ <span class="input-group-addon top-addon">{{lang_screenText}}</span>
+ <textarea class="form-control summernote" id ="text-id" name="text_value" rows="5" cols="30">{{text_value}}</textarea>
+ </div>
+ <hr>
+ <h4>{{lang_screenLocked}}</h4>
+ <input type="hidden" class="slx-ga" id="inherit_locked" name="inherit_locked" value="{{inherit_locked}}">
+ <div class="form-group">
+ <div class="input-group btn-group">
+ <span class="input-group-addon slx-ga">{{lang_screenTextInherit}}</span>
+ <a class="btn btn-default" id="btn-inherit-on" type="button" onclick="switchMode(true)" tabindex="2">
+ <span class="glyphicon glyphicon-ok"></span>
+ </a>
+ <a class="btn btn-default active" id="btn-inherit-off" onclick="switchMode(false)" tabindex="3">
+ <span class="glyphicon glyphicon-remove"></span>
+ </a>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <div class="input-group">
+ <span class="input-group-addon slx-ga">{{lang_title}}</span>
+ <input type="text" id="msg-locked-id" tabindex="4" name="msg_locked_value" value="{{msg_locked_value}}" class="form-control">
+ </div>
+ </div>
+
+ <div class="form-group">
+ <span class="input-group-addon top-addon">{{lang_screenText}}</span>
+ <textarea class="form-control summernote" id ="text-locked-id" name="text_locked_value" rows="5" cols="30">{{text_locked_value}}</textarea>
+ </div>
+
+ <div class="btn-group">
+ <button class="btn btn-default" type="submit" onclick="goBack()" tabindex="5">{{lang_back}}</button>
+ </div>
+ <div class="btn-group pull-right">
+ <button type="submit" class="btn btn-primary" tabindex="6">
+ {{#lastStep}}
+ <span class="glyphicon glyphicon-floppy-disk"></span>
+ {{lang_save}}
+ {{/lastStep}}
+ {{^lastStep}}{{lang_next}} &raquo;{{/lastStep}}
+ </button>
+ </div>
+ <div class="clearfix"></div>
+</form>
+
+<script type="text/javascript">
+ document.addEventListener("DOMContentLoaded", function () {
+ // Init summernote to e.g. disable video because xscreensaver can't handle it
+ $('.summernote').summernote({
+ toolbar: [
+ // [groupName, [list of button]]
+ ['style', ['bold', 'italic', 'underline', 'clear']],
+ ['font', ['strikethrough', 'superscript', 'subscript']],
+ ['fontsize', ['fontsize']],
+ ['color', ['color']],
+ ['para', ['style', 'ul', 'ol', 'paragraph']],
+ ['height', ['height']],
+ ['insert', ['picture', 'link', 'table', 'hr']],
+ ['misc', ['undo', 'redo', 'codeview', 'fullscreen']]
+ ]
+ });
+ switchMode({{inherit_locked}});
+ }, false);
+
+ function switchMode(mode) {
+ // true = inherit on
+ // false = inherit off
+ if (mode) {
+ $('#msg-locked-id').prop('disabled', true);
+ $('#text-locked-id').summernote('disable');
+ $('#btn-inherit-on').addClass('active');
+ $('#btn-inherit-off').removeClass('active');
+ $('#inherit_locked').val(true);
+ } else {
+ $('#msg-locked-id').prop('disabled', false);
+ $('#text-locked-id').summernote('enable');
+ $('#btn-inherit-on').removeClass('active');
+ $('#btn-inherit-off').addClass('active');
+ $('#inherit_locked').val(false);
+ }
+ }
+
+ function goBack() {
+ $('#next').val('{{prev}}');
+ }
+</script>
+
+<style>
+ .top-addon {
+ border-right: 1px solid #ccc !important;
+ border-top-right-radius: 4px !important;
+ border-bottom-left-radius: 0 !important;
+ border-bottom-right-radius: 0 !important;
+ border-bottom: 0 !important;
+ }
+
+ /* Used to override some summernote css to get a proper addon header */
+ .note-editor.note-frame {
+ border-color: #ccc !important;
+ border-top-left-radius: 0 !important;
+ border-top-right-radius: 0 !important;
+ }
+</style> \ No newline at end of file
diff --git a/modules-available/sysconfig/templates/sshconfig-start.html b/modules-available/sysconfig/templates/sshconfig-start.html
index 33108161..b56be415 100644
--- a/modules-available/sysconfig/templates/sshconfig-start.html
+++ b/modules-available/sysconfig/templates/sshconfig-start.html
@@ -3,35 +3,43 @@
<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">
- <div class="checkbox">
- <input type="checkbox" name="allowPasswordLogin" value="yes" {{#apl}}checked{{/apl}}>
- <label><b>{{lang_allowPass}}</b></label>
- </div>
+ <label>{{lang_sshAllowedUsers}}
+ <select class="form-control" name="allowedUsersLogin">
+ <option value="ROOT_ONLY" {{USR_ROOT_ONLY_selected}}>{{lang_user_root_only}}</option>
+ <option value="USER_ONLY" {{USR_USER_ONLY_selected}}>{{lang_user_user_only}}</option>
+ <option value="ALL" {{USR_ALL_selected}}>{{lang_user_all}}</option>
+ </select>
+ </label>
<div>
- <i>{{lang_allowPassInfo}}</i>
+ <i>{{lang_sshAllowedUsersInfo}}</i>
</div>
</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" pattern="[a-z0-9\-]+ +[a-zA-Z0-9=/\+]+ +.*">
- <i>{{lang_rootKeyInfo}}</i>
+ <label>{{lang_sshAllowPass}}
+ <select class="form-control" name="allowPasswordLogin">
+ <option value="NO" {{PWD_NO_selected}}>{{lang_no}}</option>
+ <option value="USER_ONLY" {{PWD_USER_ONLY_selected}}>{{lang_pwlogin_user_only}}</option>
+ <option value="YES" {{PWD_YES_selected}}>{{lang_yes}}</option>
+ </select>
+ </label>
+ <div>
+ <i>{{lang_sshAllowPassInfo}}</i>
+ </div>
</div>
<div class="form-group">
<label for="port">{{lang_listenPort}}</label>
<input class="form-control" type="text" name="listenPort" value="{{listenPort}}" id="port" pattern="\d+" placeholder="22">
<i>{{lang_listenPortInfo}}</i>
</div>
- <p>
- <i>
- {{lang_sshMultipleHeadsup}}
- </i>
- </p>
+ <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>
<div class="clearfix"></div>
</form>
-
diff --git a/modules-available/sysconfig/templates/sshkey-start.html b/modules-available/sysconfig/templates/sshkey-start.html
new file mode 100644
index 00000000..8033740c
--- /dev/null
+++ b/modules-available/sysconfig/templates/sshkey-start.html
@@ -0,0 +1,21 @@
+<form role="form" enctype="multipart/form-data" method="post" action="?do=SysConfig&amp;action=addmodule&amp;step={{step}}">
+ <input type="hidden" name="token" value="{{token}}">
+ <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" 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>
+ <div class="clearfix"></div>
+</form>
+
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 406ae73c..6b0ac981 100644
--- a/modules-available/systemstatus/hooks/main-warning.inc.php
+++ b/modules-available/systemstatus/hooks/main-warning.inc.php
@@ -1,7 +1,26 @@
<?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)) {
+ if ($current === $wanted && isset($storeUsage['freeKb']) && $storeUsage['freeKb'] < 60000000) { // 60GB
+ Message::addWarning('systemstatus.storage-low-vmstore', true, Util::readableFileSize($storeUsage['freeKb'], -1 , 1));
+ }
+ if (isset($systemUsage['freeKb']) && $systemUsage['freeKb'] < 600000) { // 600MB
+ Message::addWarning('systemstatus.storage-low-system', true, Util::readableFileSize($systemUsage['freeKb'], -1 , 1));
+ }
} \ No newline at end of file
diff --git a/modules-available/systemstatus/inc/systemstatus.inc.php b/modules-available/systemstatus/inc/systemstatus.inc.php
new file mode 100644
index 00000000..c50e0ef6
--- /dev/null
+++ b/modules-available/systemstatus/inc/systemstatus.inc.php
@@ -0,0 +1,160 @@
+<?php
+
+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,
+ * usedPercent, sizeKb, freeKb.
+ * @param array|false $systemUsage
+ * @param array|false $storeUsage
+ * @param string|false $currentSource What's currently mounted as vmstore
+ * @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): bool
+ {
+ $task = Taskmanager::submit('DiskStat');
+ if ($task === false)
+ return false;
+ $task = Taskmanager::waitComplete($task, 3000);
+
+ if (empty($task['data']['list']))
+ return false;
+ $wantedSource = Property::getVmStoreUrl();
+ $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 actual directory of the vmstore, or / if we use internal storage
+ if ($entry['mountPoint'] === $storePoint) {
+ $storeUsage = $entry;
+ }
+ // Always report free space on system disk
+ if ($entry['mountPoint'] === '/') {
+ $systemUsage = $entry;
+ }
+ // Record what's mounted at destination, regardless of config, to indicate something is wrong
+ 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 1a6df1b3..551e937a 100644
--- a/modules-available/systemstatus/lang/de/messages.json
+++ b/modules-available/systemstatus/lang/de/messages.json
@@ -1,4 +1,7 @@
{
- "reboot-unconfirmed": "Sicherheitsabfrage zum Reboot nicht best\u00e4tigt",
+ "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}}"
} \ No newline at end of file
diff --git a/modules-available/systemstatus/lang/de/module.json b/modules-available/systemstatus/lang/de/module.json
index dd1a115c..3b310b1d 100644
--- a/modules-available/systemstatus/lang/de/module.json
+++ b/modules-available/systemstatus/lang/de/module.json
@@ -1,8 +1,11 @@
{
"module_name": "System-Status",
+ "page_title": "Systemstatus des Servers",
"tab_DmsdLog": "bwLehrpool-Suite Server Log",
+ "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 a3041fbc..f2dd9a21 100644
--- a/modules-available/systemstatus/lang/de/permissions.json
+++ b/modules-available/systemstatus/lang/de/permissions.json
@@ -1,4 +1,11 @@
{
+ "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.",
"serverreboot": "Server neustarten.",
"show.overview.addresses": "Zeige Adresskonfiguration auf \u00dcbersichtsseite.",
"show.overview.diskstat": "Zeige Speicherplatzwerte auf \u00dcbersichtsseite.",
@@ -6,8 +13,10 @@
"show.overview.services": "Zeige Dienste auf \u00dcbersichtsseite.",
"show.overview.systeminfo": "Zeige Systemwerte auf \u00dcbersichtsseite.",
"tab.dmsdlog": "Zugriff auf bwLehrpool-Suite-Server Statusausgabe.",
+ "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 eeb75f9a..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_downloads": "Downloads",
+ "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,9 +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!",
@@ -31,10 +51,10 @@
"lang_systemPartition": "Systempartition",
"lang_systemStoreError": "Fehler beim Ermitteln des verf\u00fcgbaren Systemspeichers",
"lang_total": "Gesamt",
- "lang_unknownState": "Unbekannter Status",
"lang_updatedPackages": "Ausstehende Updates",
- "lang_uploads": "Uploads",
+ "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 5098eb76..2afc3431 100644
--- a/modules-available/systemstatus/lang/en/messages.json
+++ b/modules-available/systemstatus/lang/en/messages.json
@@ -1,4 +1,7 @@
{
- "reboot-unconfirmed": "Confirmation prompt to reboot not confirmed",
+ "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}}"
} \ No newline at end of file
diff --git a/modules-available/systemstatus/lang/en/module.json b/modules-available/systemstatus/lang/en/module.json
index 9f6d937a..cc2b5283 100644
--- a/modules-available/systemstatus/lang/en/module.json
+++ b/modules-available/systemstatus/lang/en/module.json
@@ -1,8 +1,11 @@
{
"module_name": "System Status",
+ "page_title": "System status of server",
"tab_DmsdLog": "bwLehrpool-Suite log",
+ "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 879fa882..21e7538f 100644
--- a/modules-available/systemstatus/lang/en/permissions.json
+++ b/modules-available/systemstatus/lang/en/permissions.json
@@ -1,4 +1,11 @@
{
+ "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.",
"serverreboot": "Reboot server.",
"show.overview.addresses": "Show addresses on overview page.",
"show.overview.diskstat": "Show diskstats on overview page.",
@@ -6,8 +13,10 @@
"show.overview.services": "Show services on overview page.",
"show.overview.systeminfo": "Show systeminfo on overview page.",
"tab.dmsdlog": "Show bwLehrpool-Suite status.",
+ "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 5f6ad898..7758c71c 100644
--- a/modules-available/systemstatus/lang/en/template-tags.json
+++ b/modules-available/systemstatus/lang/en/template-tags.json
@@ -1,40 +1,60 @@
{
"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_downloads": "Downloads",
+ "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_logicCPUs": "Logic CPUs",
+ "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",
"lang_notDetermined": "Could not be determined",
- "lang_occupied": "Occupied",
- "lang_onlyOS": "OS Only",
+ "lang_occupied": "In use",
+ "lang_onlyOS": "OS only",
"lang_overview": "Overview",
- "lang_ramUsage": "RAM Usage",
+ "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!",
- "lang_swapUsage": "swap Usage",
+ "lang_swapUsage": "swap usage",
"lang_swapWarning": "Memory swap is being used. This may be an indication that the satellite server does not have enough physical memory available. In the case of performance problems or server instability you should consider equipping the server with more RAM.",
"lang_system": "System",
- "lang_systemPartition": "System Partition",
+ "lang_systemPartition": "System partition",
"lang_systemStoreError": "Error querying available system storage",
"lang_total": "Total",
- "lang_unknownState": "Unknown status",
"lang_updatedPackages": "Pending updates",
- "lang_uploads": "Uploads",
- "lang_uptimeOS": "OS Uptime",
- "lang_vmStore": "VM Store",
+ "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 04423eaf..f774c4e0 100644
--- a/modules-available/systemstatus/page.inc.php
+++ b/modules-available/systemstatus/page.inc.php
@@ -3,7 +3,7 @@
class Page_SystemStatus extends Page
{
- private $rebootTask = false;
+ const TM_UPDATE_UUID = '345-45763457-24356-234324556';
protected function doPreprocess()
{
@@ -14,29 +14,96 @@ class Page_SystemStatus extends Page
Util::redirect('?do=Main');
}
- if (Request::post('action') === 'reboot') {
+ $action = Request::post('action', false, 'string');
+ $aptAction = null;
+ switch ($action) {
+ case 'reboot':
User::assertPermission("serverreboot");
- $this->rebootTask = Taskmanager::submit('Reboot');
+ $task = Taskmanager::submit('Reboot');
+ if (Taskmanager::isTask($task)) {
+ Util::redirect('?do=systemstatus&taskid=' . $task['id']);
+ }
+ 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');
}
User::assertPermission('*');
}
+ private function handleServiceAction(string $action)
+ {
+ $service = Request::post('service', Request::REQUIRED, 'string');
+ $task = Taskmanager::submit('Systemctl', ['operation' => $action, 'service' => $service]);
+ $extra = '';
+ $cmp = preg_replace('/(@.*|\.service)$/', '', $service);
+ User::assertPermission("restart.$cmp");
+ if ($cmp === 'dmsd') {
+ $extra = '#id-DmsdLog_pane';
+ } elseif ($cmp === 'ldadp') {
+ $extra = '#id-LdadpLog_pane';
+ } elseif ($cmp === 'dnbd3-server') {
+ $extra = '#id-Dnbd3Log_pane';
+ }
+ Util::redirect('?do=systemstatus&taskid=' . $task['id'] . '&taskname=' . urlencode($service) . $extra);
+ }
+
protected function doRender()
{
$data = array();
- if (is_array($this->rebootTask) && isset($this->rebootTask['id'])) {
- $data['rebootTask'] = $this->rebootTask['id'];
- }
- $tabs = array('DmsdLog', 'Netstat', 'PsList', 'LdadpLog', 'LighttpdLog');
+ $data['taskid'] = Request::get('taskid', '', 'string');
+ $data['taskname'] = Request::get('taskname', 'Reboot', 'string');
+ $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),
'enabled' => User::hasPermission('tab.' . $tab),
+ 'important' => $tab === 'ListUpgradable'
+ && (SystemStatus::getAptLastDbUpdateTime() + 864000 < time() || SystemStatus::getUpgradableSecurityCount() > 0),
);
}
Permission::addGlobalTags($data['perms'], null, ['serverreboot']);
+ $pkgs = SystemStatus::getPackagesRequiringReboot();
+ if (!empty($pkgs)) {
+ $data['packages'] = implode(', ', $pkgs);
+ }
Render::addTemplate('_page', $data);
}
@@ -52,90 +119,82 @@ 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 ajaxDmsdUsers()
+
+ protected function ajaxListUpgradable()
{
- User::assertPermission("show.overview.dmsdusers");
- $ret = Download::asStringPost('http://127.0.0.1:9080/status/fileserver', false, 2, $code);
- $args = array();
- if ($code != 200) {
- $args['dmsd_error'] = true;
- } else {
- $data = @json_decode($ret, true);
- if (is_array($data)) {
- $args['uploads'] = $data['activeUploads'];
- $args['downloads'] = $data['activeDownloads'];
+ 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';
}
- 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);
- $args['packages'] = implode(', ', $lines);
+
+ foreach ($task['data']['packages'] as &$pkg) {
+ if (substr($pkg['source'], -9) === '-security') {
+ $pkg['row_class'] = 'bg-danger';
+ } else {
+ $pkg['row_class'] = '';
+ }
}
- echo Render::parse('ajax-reboot', $args);
+ 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");
- $task = Taskmanager::submit('DiskStat');
- if ($task === false)
+ if (!SystemStatus::diskStat($systemUsage, $storeUsage, $currentSource, $wantedSource))
return;
- $task = Taskmanager::waitComplete($task, 3000);
-
- if (!isset($task['data']['list']) || empty($task['data']['list'])) {
- Taskmanager::addErrorMessage($task);
- return;
- }
- $store = Property::getVmStoreUrl();
- $storeUsage = false;
- $systemUsage = false;
- if ($store !== false) {
- if ($store === '<local>')
- $storePoint = '/';
- else
- $storePoint = CONFIG_VMSTORE_DIR;
- // Determine free space
- foreach ($task['data']['list'] as $entry) {
- if ($entry['mountPoint'] === $storePoint) {
- $storeUsage = array(
- 'percent' => $entry['usedPercent'],
- 'size' => Util::readableFileSize($entry['sizeKb'] * 1024),
- 'free' => Util::readableFileSize($entry['freeKb'] * 1024),
- 'color' => $this->usageColor($entry['usedPercent'])
- );
- }
- if ($entry['mountPoint'] === '/') {
- $systemUsage = array(
- 'percent' => $entry['usedPercent'],
- 'size' => Util::readableFileSize($entry['sizeKb'] * 1024),
- 'free' => Util::readableFileSize($entry['freeKb'] * 1024),
- 'color' => $this->usageColor($entry['usedPercent'])
- );
- }
- }
- $data = array(
- 'store' => $storeUsage,
- 'system' => $systemUsage
- );
- // Determine if proper vm store is being used
- if ($store !== '<local>') {
- $data['storeMissing'] = $store;
- }
- foreach ($task['data']['list'] as $entry) {
- if ($entry['mountPoint'] !== CONFIG_VMSTORE_DIR)
- continue;
- if ($store !== $entry['fileSystem']) {
- $data['wrongStore'] = $entry['fileSystem'];
- break;
- }
- $data['storeMissing'] = false;
- }
- } else {
+ $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, 250000);
+ } elseif ($currentSource === false) { // No current source, nothing mounted
+ $data['storeMissing'] = true;
+ } else { // Something else mounted
+ $data['wrongStore'] = $currentSource;
}
echo Render::parse('diskstat', $data);
}
@@ -148,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;
}
@@ -165,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'];
@@ -189,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);
@@ -242,8 +307,9 @@ class Page_SystemStatus extends Page
$tasks = array();
$todo = ['dmsd', 'tftpd-hpa'];
- if (Module::isAvailable('dnbd3') && Dnbd3::isEnabled()) {
+ if (Module::get('dnbd3') !== false) {
$todo[] = 'dnbd3-server';
+ $todo[] = 'dnbd3-master-proxy';
}
foreach ($todo as $svc) {
@@ -252,10 +318,16 @@ class Page_SystemStatus extends Page
'task' => Taskmanager::submit('Systemctl', ['service' => $svc, 'operation' => 'is-active'])
);
}
- $tasks[] = array(
- 'name' => 'LDAP/AD-Proxy',
- 'task' => Trigger::ldadp()
- );
+ $ldapIds = $ldadp = false;
+ if (Module::isAvailable('sysconfig')) {
+ $ldapIds = ConfigModuleBaseLdap::getActiveModuleIds();
+ if (!empty($ldapIds)) {
+ $ldadp = array( // No name - no display
+ 'task' => ConfigModuleBaseLdap::ldadp('check', $ldapIds) // TODO: Proper --check usage
+ );
+ $tasks[] =& $ldadp;
+ }
+ }
$deadline = time() + 10;
do {
$done = true;
@@ -271,13 +343,40 @@ class Page_SystemStatus extends Page
} while (!$done && time() < $deadline);
foreach ($tasks as $task) {
+ if (!isset($task['name']))
+ continue;
$fail = Taskmanager::isFailed($task['task']);
- $data['services'][] = array(
+ $entry = array(
'name' => $task['name'],
+ 'service' => $task['name'],
'fail' => $fail,
- 'data' => isset($task['data']) ? $task['data'] : null,
- 'unknown' => $task['task'] === false
);
+ if ($fail) {
+ if (!isset($task['task']['data'])) {
+ $entry['error'] = 'Taskmanager Error';
+ } elseif (isset($task['task']['data']['messages'])) {
+ $entry['error'] = $task['task']['data']['messages'];
+ }
+ }
+ $data['services'][] = $entry;
+ }
+ if ($ldadp !== false) {
+ //error_log(print_r($ldadp, true));
+ preg_match_all('/^ldadp@(\d+)\.service\s+(\S+)$/m', $ldadp['task']['data']['messages'], $out, PREG_SET_ORDER);
+ $instances = [];
+ foreach ($out as $instance) {
+ $instances[$instance[1]] = $instance[2];
+ }
+ foreach ($ldapIds as $id) {
+ $status = $instances[$id] ?? 'failed';
+ $fail = ($status !== 'running');
+ $data['services'][] = [
+ 'name' => 'LDAP/AD Proxy #' . $id,
+ 'service' => 'ldadp@' . $id,
+ 'fail' => $fail,
+ 'error' => $fail ? $status : false,
+ ];
+ }
}
echo Render::parse('services', $data);
@@ -285,107 +384,87 @@ class Page_SystemStatus extends Page
protected function ajaxDmsdLog()
{
- User::assertPermission("tab.dmsdlog");
- $fh = @fopen('/var/log/dmsd.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;
+ $this->showJournal('dmsd.service', 'tab.dmsdlog');
+ }
+
+ protected function ajaxDnbd3Log()
+ {
+ $this->showJournal('dnbd3-server.service', 'tab.dnbd3log');
+ }
+
+ protected function showJournal($service, $permission)
+ {
+ $cmp = preg_replace('/(@.*|\.service)$/', '', $service);
+ User::assertPermission($permission);
+ $output = [
+ 'name' => $service,
+ 'service' => $service,
+ 'task' => Taskmanager::submit('Systemctl', ['operation' => 'journal', 'service' => $service]),
+ 'restart_disabled' => User::hasPermission('restart.' . $cmp)
+ ? '' : 'disabled',
+ ];
+ echo Render::parse('ajax-journal', ['modules' => [$output]]);
+ }
+
+ private function grepLighttpdLog(string $file, int $num): array
+ {
+ $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/dmsd.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;
- }
- echo '<pre>', htmlspecialchars(substr($data, $start), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), '</pre>';
+ fclose($fh);
+ return array_slice($ret, -$num);
}
protected function ajaxLighttpdLog()
{
User::assertPermission("tab.lighttpdlog");
- $fh = @fopen('/var/log/lighttpd/error.log', 'r');
- if ($fh === false) {
- echo 'Error opening log file';
- return;
+ $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);
}
- fseek($fh, -6000, SEEK_END);
- $data = fread($fh, 6000);
- @fclose($fh);
- if ($data === false) {
- echo 'Error reading from log file';
- return;
- }
- // 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);
- }
- }
- if (strlen($data) < 5990) {
- $start = 0;
- } else {
- $start = strpos($data, "\n") + 1;
- }
- 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()
{
User::assertPermission("tab.ldadplog");
- $haveSysconfig = Module::isAvailable('sysconfig');
- $files = glob('/var/log/ldadp/*.log', GLOB_NOSORT);
- if ($files === false || empty($files)) echo('No logs found');
- $now = time();
- foreach ($files as $file) {
- $mod = filemtime($file);
- if ($now - $mod > 86400) continue;
- // New enough - handle
- preg_match(',/(\d+)\.log,', $file, $out);
- $module = $haveSysconfig ? ConfigModule::get($out[1]) : false;
- if ($module === false) {
- echo '<h4>Module ', $out[1], '</h4>';
- } else {
- echo '<h4>Module ', htmlspecialchars($module->title()), '</h4>';
- }
- $fh = @fopen($file, 'r');
- if ($fh === false) {
- echo '<pre>Error opening log file</pre>';
- continue;
- }
- fseek($fh, -5000, SEEK_END);
- $data = fread($fh, 5000);
- @fclose($fh);
- if ($data === false) {
- echo '<pre>Error reading from log file</pre>';
- continue;
- }
- if (strlen($data) < 4990) {
- $start = 0;
+ if (!Module::isAvailable('sysconfig')) {
+ die('SysConfig module not enabled');
+ }
+ $ids = ConfigModuleBaseLdap::getActiveModuleIds();
+ //error_log(print_r($ids, true));
+ $output = [];
+ foreach ($ids as $id) {
+ $module = ConfigModule::get($id);
+ if ($module === null) {
+ $name = "#$id";
} else {
- $start = strpos($data, "\n") + 1;
+ $name = $module->title();
}
- echo '<pre>', htmlspecialchars(substr($data, $start), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), '</pre>';
+ $service = "ldadp@{$id}.service";
+ $output[] = [
+ 'name' => $name,
+ 'service' => $service,
+ 'task' => Taskmanager::submit('Systemctl', ['operation' => 'journal', 'service' => $service]),
+ ];
}
+ //error_log(print_r($output, true));
+ echo Render::parse('ajax-journal', ['modules' => $output]);
}
protected function ajaxNetstat()
@@ -396,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>';
}
@@ -412,31 +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 usageColor($percent)
+ /**
+ * @return array{percent: numeric, size: string, free: string, color: string, filesystem: string}
+ */
+ private function convertDiskStat(array $stat, int $minFreeMb): array
{
- if ($percent <= 50) {
- $r = $b = $percent / 3;
- $g = (100 - $percent * (50 / 80));
- } elseif ($percent <= 70) {
- $r = 55 + ($percent - 50) * (30 / 20);
+ return [
+ 'percent' => $stat['usedPercent'],
+ 'size' => Util::readableFileSize($stat['sizeKb'] * 1024),
+ 'free' => Util::readableFileSize($stat['freeKb'] * 1024),
+ 'color' => $this->usageColor($stat, $minFreeMb),
+ 'filesystem' => $stat['fileSystem'],
+ ];
+ }
+
+ private function usageColor(array $stat, int $minFreeMb): string
+ {
+ $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 29e26b5e..0be0a8c5 100644
--- a/modules-available/systemstatus/permissions/permissions.json
+++ b/modules-available/systemstatus/permissions/permissions.json
@@ -5,6 +5,9 @@
"tab.dmsdlog": {
"location-aware": false
},
+ "tab.dnbd3log": {
+ "location-aware": false
+ },
"tab.netstat": {
"location-aware": false
},
@@ -17,6 +20,9 @@
"tab.lighttpdlog": {
"location-aware": false
},
+ "tab.listupgradable": {
+ "location-aware": false
+ },
"show.overview.addresses": {
"location-aware": false
},
@@ -31,5 +37,26 @@
},
"show.overview.systeminfo": {
"location-aware": false
+ },
+ "restart.dnbd3-server": {
+ "location-aware": false
+ },
+ "restart.ldadp": {
+ "location-aware": false
+ },
+ "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 3d0f9dfb..dedcf01a 100644
--- a/modules-available/systemstatus/templates/_page.html
+++ b/modules-available/systemstatus/templates/_page.html
@@ -1,12 +1,12 @@
<h1>{{lang_moduleHeading}}</h1>
-{{#rebootTask}}
-<div data-tm-id="{{rebootTask}}" data-tm-log="messages">Reboot...</div>
-{{/rebootTask}}
+{{#taskid}}
+<div data-tm-id="{{taskid}}" data-tm-log="messages">{{taskname}}</div>
+{{/taskid}}
<ul class="nav nav-tabs tabs-up">
<li class="active">
- <a href="#id-default_pane" id="id-default" class="active" data-toggle="tab" role="tab">
+ <a href="#id-default_pane" id="id-default" class="ajax-tab" data-toggle="tab" role="tab">
{{lang_overview}}
</a>
</li>
@@ -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>
@@ -100,7 +106,16 @@
</button>
<div class="hidden" id="confirm-reboot">{{lang_areYouSureReboot}}</div>
</form>
- <div id="dmsd-users"></div>
+ <div id="dmsd-users">
+ {{lang_runningUploads}}: <span class="uploads">??</span>,
+ {{lang_runningDownloads}}: <span class="downloads">??</span>
+ <div class="alert alert-warning collapse">{{lang_dmsdUnreachable}}</div>
+ </div>
+ <div>
+ {{#packages}}
+ {{lang_updatedPackages}}: {{packages}}
+ {{/packages}}
+ </div>
</div>
</div>
</div>
@@ -119,20 +134,48 @@
<script type="text/javascript"><!--
document.addEventListener("DOMContentLoaded", function() {
- $('#diskstat').load('?do=SystemStatus&action=DiskStat');
- $('#addresses').load('?do=SystemStatus&action=AddressList');
- $('#systeminfo').load('?do=SystemStatus&action=SystemInfo');
- $('#services').load('?do=SystemStatus&action=Services');
- $('#dmsd-users').load('?do=SystemStatus&action=DmsdUsers');
var slxDone = {};
- $('.ajax-tab').on('shown.bs.tab', function (e) {
+ var loadTab = function (e) {
var $this = $(this);
var w = $this.attr('id');
if (!slxDone[w]) {
slxDone[w] = true;
var $pane = $('#' + w + '_pane');
- $pane.load('?do=SystemStatus&action=' + w.substring(3));
+ var tab = w.substring(3);
+ if (tab === 'default') {
+ $('#diskstat').load('?do=SystemStatus&action=DiskStat');
+ $('#addresses').load('?do=SystemStatus&action=AddressList');
+ $('#systeminfo').load('?do=SystemStatus&action=SystemInfo');
+ $('#services').load('?do=SystemStatus&action=Services');
+ } else {
+ $pane.load('?do=SystemStatus&action=' + tab, function() {
+ $(this).find('button[data-confirm]').click(slxModalConfirmHandler);
+ });
+ }
}
+ };
+ $('.ajax-tab').on('shown.bs.tab', loadTab);
+ // Need a better solution for this -- there is already code handling tabs in slx-fixes, maybe put this in there?
+ if (location.hash === '' || location.hash === '#' || location.hash === '#id-default_pane') {
+ history.replaceState(null, null, '#id-default_pane');
+ loadTab.call($('#id-default'));
+ }
+ var $dmsd = $('#dmsd-users');
+ $.ajax({
+ url: '?do=dozmod&section=special&action=dmsd-status',
+ timeout: 3000,
+ dataType: 'json'
+ }).done(function (data) {
+ if (!data || data.error) {
+ $dmsd.find('.alert').show();
+ } else {
+ if (data.downloads !== null) $dmsd.find('.downloads').text(data.downloads);
+ if (data.uploads !== null) $dmsd.find('.uploads').text(data.uploads);
+ }
+ }).fail(function () {
+ $dmsd.find('.alert').show();
});
}, false);
//--></script>
+
+<div class="hidden" id="confirm-restart">{{lang_confirmRestart}}</div> \ No newline at end of file
diff --git a/modules-available/systemstatus/templates/ajax-journal.html b/modules-available/systemstatus/templates/ajax-journal.html
new file mode 100644
index 00000000..5b476d9c
--- /dev/null
+++ b/modules-available/systemstatus/templates/ajax-journal.html
@@ -0,0 +1,20 @@
+<form method="post" action="?do=systemstatus">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="action" value="service-restart">
+ {{#modules}}
+ <div class="slx-space">
+ <div class="pull-right">
+ <button class="btn btn-warning btn-sm pull-right" name="service" value="{{service}}"
+ data-confirm="#confirm-restart" {{restart_disabled}}>
+ <span class="glyphicon glyphicon-refresh"></span>
+ {{lang_restart}}
+ </button>
+ </div>
+ <h4>{{name}}</h4>
+ <div data-tm-id="{{task.id}}" data-tm-log="messages">{{service}}</div>
+ </div>
+ {{/modules}}
+</form>
+<script>
+ tmInit();
+</script> \ No newline at end of file
diff --git a/modules-available/systemstatus/templates/ajax-reboot.html b/modules-available/systemstatus/templates/ajax-reboot.html
deleted file mode 100644
index a1aaf1e6..00000000
--- a/modules-available/systemstatus/templates/ajax-reboot.html
+++ /dev/null
@@ -1,14 +0,0 @@
-<div>
- {{^dmsd_error}}
- {{lang_uploads}}: {{uploads}},
- {{lang_downloads}}: {{downloads}}
- {{/dmsd_error}}
- {{#dmsd_error}}
- <div class="alert alert-warning">{{lang_dmsdUnreachable}}</div>
- {{/dmsd_error}}
-</div>
-<div>
- {{#packages}}
- {{lang_updatedPackages}}: {{packages}}
- {{/packages}}
-</div> \ No newline at end of file
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/services.html b/modules-available/systemstatus/templates/services.html
index 29b33687..2614fca9 100644
--- a/modules-available/systemstatus/templates/services.html
+++ b/modules-available/systemstatus/templates/services.html
@@ -1,20 +1,20 @@
+<form method="post" action="?do=systemstatus">
+ <input type="hidden" name="token" value="{{token}}">
+ <input type="hidden" name="action" value="service-start">
+ <table class="table">
{{#services}}
- {{#unknown}}
- <div class="alert alert-warning">
- {{name}}: {{lang_unknownState}}
- </div>
- {{/unknown}}
- {{^unknown}}
+ <tr class="bg-{{#fail}}danger{{/fail}}{{^fail}}success{{/fail}}">
+ <td>
{{#fail}}
- <div class="alert alert-danger">
- {{name}}: <b>{{lang_failure}}</b>
- {{#data.messages}}<pre>{{data.messages}}</pre>{{/data.messages}}
- </div>
+ <button class="btn btn-default btn-xs pull-right" name="service" value="{{service}}">
+ {{lang_restart}}
+ </button>
{{/fail}}
- {{^fail}}
- <div class="alert alert-success">
- {{name}}: {{lang_OK}}
- </div>
- {{/fail}}
- {{/unknown}}
+ {{name}}: {{#fail}}{{lang_failure}}{{/fail}}{{^fail}}{{lang_OK}}{{/fail}}
+ <div class="clearfix"></div>
+ {{#error}}<pre>{{error}}</pre>{{/error}}
+ </td>
+ </tr>
{{/services}}
+ </table>
+</form> \ No newline at end of file
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 ed4a1532..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,68 +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 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;
- $.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;
+ (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;
+ }
+
+ {{#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/lang/en/template-tags.json b/modules-available/translation/lang/en/template-tags.json
index e7365ca6..c0c37cb5 100644
--- a/modules-available/translation/lang/en/template-tags.json
+++ b/modules-available/translation/lang/en/template-tags.json
@@ -20,5 +20,5 @@
"lang_translation": "Translation",
"lang_translationHeading": "Translation Management",
"lang_unused": "Unused",
- "lang_unusedUnreliableHint": "Detection of unused tags only includes currently activated modules. It's possible that a tag marked \"unused\" is actually refered to in a module not activated."
+ "lang_unusedUnreliableHint": "Detection of unused tags only includes currently activated modules. It's possible that a tag marked \"unused\" is actually referred to in a module not activated."
} \ No newline at end of file
diff --git a/modules-available/translation/page.inc.php b/modules-available/translation/page.inc.php
index 7d5229d7..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) {
@@ -1031,10 +1014,8 @@ class Page_Translation extends Page
unlink($file);
}
} else {
- // JSON_PRETTY_PRINT is only available starting with php 5.4.0.... Use upgradephp's json_encode
- require_once('inc/up_json_encode.php');
ksort($data); // Sort by key, so the diff on the output is cleaner
- $json = up_json_encode($data, JSON_PRETTY_PRINT); // Also for better diffability of the json files, we pretty print
+ $json = json_encode($data, JSON_PRETTY_PRINT); // Also for better diffability of the json files, we pretty print
//exits the function in case the action was unsuccessful
if (file_put_contents($file, $json) === false) {
Message::addError('main.error-write', $file);
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 5ec68318..348c59fc 100644
--- a/modules-available/vmstore/lang/en/template-tags.json
+++ b/modules-available/vmstore/lang/en/template-tags.json
@@ -1,17 +1,27 @@
{
+ "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.",
"lang_nfsHelp3": "The first line allows read\/write access for the satellite server. The second line grants read-only access for every other IP address. You could limit the second line to specific IP ranges only if desired.",
"lang_nfsHelp4": "If using DNBD3 in exclusive mode, you can remove the second line completely, so only the satellite server has access to the NFS store.",
- "lang_noAdditionalInformation": "No additional cofiguration required",
+ "lang_noAdditionalInformation": "No additional configuration required",
"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
new file mode 100644
index 00000000..53a5993c
--- /dev/null
+++ b/modules-available/webinterface/baseconfig/getconfig.inc.php
@@ -0,0 +1,9 @@
+<?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']);
+} \ No newline at end of file
diff --git a/modules-available/webinterface/hooks/config-tgz.inc.php b/modules-available/webinterface/hooks/config-tgz.inc.php
new file mode 100644
index 00000000..49bf8f11
--- /dev/null
+++ b/modules-available/webinterface/hooks/config-tgz.inc.php
@@ -0,0 +1,6 @@
+<?php
+
+$file = '/opt/openslx/configs/modules/self-signed-ca.tar';
+if (!is_file($file) || !is_readable($file)) {
+ unset($file);
+}
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
new file mode 100755
index 00000000..e9ed8b45
--- /dev/null
+++ b/pack.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+
+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
+
+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/script/slx-fixes.js b/script/slx-fixes.js
index 992ca337..a06761e4 100644
--- a/script/slx-fixes.js
+++ b/script/slx-fixes.js
@@ -61,6 +61,7 @@ $(document).ready(function() {
var $title, $body, $button, $modal = null, $cache = {};
slxModalConfirmHandler = function (e) {
e.preventDefault();
+ e.stopImmediatePropagation();
var $this = $(this);
if ($modal === null) {
$modal = $('<div class="modal fade" id="modal-autogen" 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>'
@@ -74,7 +75,7 @@ $(document).ready(function() {
$title.text($this.data('title') || $this.text());
$button.html($this.data('close') || $this.html()).attr('class', $this.attr('class')).removeClass('btn-xs btn-sm btn-lg').off('click').click(function() {
// Click and reconnect click handler so pressing "back" on the next page works
- $this.off('click').click().click(slxModalConfirmHandler);
+ $this.off('click', slxModalConfirmHandler).click().click(slxModalConfirmHandler);
});
var $wat, str = $this.data('confirm');
if (str.substr(0, 9) === '#confirm-') {
diff --git a/script/taskmanager.js b/script/taskmanager.js
index ae1d2f09..dd00f864 100644
--- a/script/taskmanager.js
+++ b/script/taskmanager.js
@@ -127,9 +127,11 @@ function tmResult(data, status)
}
var log = obj.find('.data-tm-log');
if (log) {
- var lKey = obj.attr('data-tm-log');
- if (task.data && task.data[lKey]) {
- log.text(task.data[lKey]).show();
+ if (!obj.data('tm-log-fail-only') || task.statusCode === "TASK_ERROR") {
+ var lKey = obj.data('tm-log');
+ if (task.data && task.data[lKey]) {
+ log.text(task.data[lKey]).show();
+ }
}
}
var cb = obj.attr('data-tm-callback');
diff --git a/style/default.css b/style/default.css
index 99c4321f..19e1ba08 100644
--- a/style/default.css
+++ b/style/default.css
@@ -586,15 +586,18 @@ it only applies if they're in a container that has the checkbox class */
table.slx-ellipsis {
width:100%;
table-layout: fixed;
+ margin: 0;
+ border-collapse: collapse;
}
table.slx-ellipsis td {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+ padding: 0;
}
-div.disabled, ul.nav > li.disabled {
+div.disabled, ul.nav > li.disabled, label.disabled {
cursor: not-allowed;
}
@@ -617,3 +620,14 @@ div.disabled-hack {
padding: 0;
display: inline-block;
}
+
+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
new file mode 100644
index 00000000..c42a43aa
--- /dev/null
+++ b/tools/global-candidates.php
@@ -0,0 +1,75 @@
+<?php
+
+$langs = $argc > 1 && $argv[1] ? $argv[1] : 'en';
+
+echo "Scanning for $langs... (pass as comma separated list, no spaces)\n";
+
+$tags = [];
+$strings = [];
+
+foreach (glob('./modules-available/*/lang/{' . $langs . '}/template-tags.json', GLOB_NOSORT | GLOB_BRACE) as $file) {
+ preg_match('#modules-available/([^/]+)/lang/(..)#', $file, $out);
+ $module = $out[1];
+ $lang = $out[2];
+ $j = json_decode(file_get_contents($file), true);
+ if (!is_array($j)) continue;
+ foreach ($j as $k => $v) {
+ if (!isset($tags[$k])) {
+ $tags[$k] = ['modules' => [], 'lang' => []];
+ }
+ $tags[$k]['modules'][$module] = true;
+ if (!isset($tags[$k]['lang'][$lang])) {
+ $tags[$k]['lang'][$lang] = [];
+ }
+ $tags[$k]['lang'][$lang][$v] = true;
+ if (!isset($strings[$v])) {
+ $strings[$v] = [];
+ }
+ if (!isset($strings[$v][$k])) {
+ $strings[$v][$k] = [];
+ }
+ $strings[$v][$k][$module] = true;
+ }
+}
+
+if ($argc > 1) {
+ $find = array_flip(array_slice($argv, 2));
+ print_r($find);
+} else $find = [];
+
+echo "\n\nDUPLICATE TAG NAME ACROSS DIFFERENT MODULES:\n";
+foreach ($tags as $k => &$tag) {
+ if (isset($find[$k])) {
+ echo "## LOOKUP: '$k'\n";
+ print_r($tag['lang']);
+ }
+ if (count($tag['modules']) < 4) continue;
+ $tag['modules'] = array_keys($tag['modules']);
+ foreach ($tag['lang'] as &$lang) {
+ $lang = array_keys($lang);
+ }
+ unset($lang);
+ echo "## Common tag '$k'\n";
+ echo " In " . count($tag['modules']) . " modules: " . implode(', ', $tag['modules']) . "\n";
+ foreach ($tag['lang'] as $lang => $str) {
+ echo " " . count($str) . " in $lang: '" . implode("', '", $str) . "'\n";
+ if (count($str) / count($tag['modules']) < 0.26) {
+ echo " +++ Possible candidate +++\n";
+ }
+ }
+}
+unset($tag);
+
+echo "\n\nDUPLICATE STRINGS WITH DIFFERENT NAMES:\n";
+foreach ($strings as $text => $data) {
+ if (count($data) < 3) continue;
+ echo "## '$text' ##\n";
+ foreach ($data as $tag => $mods) {
+ echo " As $tag in";
+ foreach($mods as $mod => $count) {
+ echo " $mod($count) ";
+ }
+ echo "\n";
+ }
+}
+
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) {