doc/pages/06.contribute/10.packaging_apps/60.advanced/20.config_panels/config_panels.md
2024-08-05 17:54:43 +02:00

15 KiB

title template taxonomy routes
Config Panels docs
category
docs
default
/packaging_config_panels

From a practical point of view, one of the main purpose of config panel is to "expose" settings from the app's configuration files to YunoHost's admins which are therefore able to manipulate them from a nice web ui. This is especially relevant for apps which do not provide any sort of admin UI and expect admins to manually edit the configuration files.

Technically speaking, config panels are used both for apps packaging and also core features (domain configuration, global settings)

! Please: Keep in mind the YunoHost spirit, and try to build your panels in such a way as to expose only really useful, "high-level" parameters, and if there are many of them, to relegate those corresponding to rarer use cases to "Advanced" sub-sections. Keep it simple, focus on common needs, don't expect the admins to have 3 PhDs in computer science.

Community examples

Overview

From an app packager perspective, config panels are defined in config_panel.toml at the root of the app repository. It is coupled to the scripts/config script, which may be used to define custom getters/setters/validations/actions. However, most use cases should be covered automagically by the core, thus it may not be necessary to define such a config script at all!

The config_panel.toml describes one or several panels, containing sections, each containing questions generally binded to a params in the app's actual configuration files.

Let's imagine that the upstream app is configured using this simple config.yml file stored in the app's install directory (typically /var/www/$app/config.yml):

title: 'My dummy app'
theme: 'white'
max_rate: 10
max_age: 365

A simple configuration panel can be created with this syntax:

version = "1.0"
[main]

    [main.main]
        [main.main.title]
        ask.en = "Title"
        type = "string"
        bind = ":__INSTALL_DIR__/config.yml"

        [main.main.theme]
        ask.en = "Theme"
        type = "select"
        choices = ["white", "dark"]
        bind = ":__INSTALL_DIR__/config.yml"

    [main.limits]
        [main.limits.max_rate]
        ask.en = "Maximum display rate"
        type = "number"
        bind = ":__INSTALL_DIR__/config.yml"

        [main.limits.max_age]
        ask.en = "Duration of a dummy"
        type = "number"
        bind = ":__INSTALL_DIR__/config.yml"

Here, a main panel is created, containing the main and limits sections, containing questions corresponding to app options in config.yml file. Thanks to the bind property, all those questions are read/written to their values in the actual config.yml file.

Supported questions types and properties

You can learn more about the full list of option types and their properties in their dedicated page.

The "bind" statement

Without any bind statement attached to a config panel property, values are only read/written from/to the app's settings file (/etc/yunohost/$app/settings.yml). This is usually not very useful in practice.

Using bind = ":/some/config/file", one declares that the actual truth used by the app lives in /some/config/file. The config panel will read/write the value from/to this file. YunoHost will automagically adapt to classic formats such as YAML, TOML, JSON, INI, PHP, .env-like and .py. (At least, assuming we're dealing with simple key<->value mappings)

A simple real-life example looks like:

[main.main.theme]
type = "string"
bind = ":__INSTALL_DIR__/config.yml"

In which case, YunoHost will look for something like a key/value, with the key being theme inside the app's config.yml.

If the question id in the config panel (here, theme) differs from the key in the actual conf file (let's say it's not theme but css_theme), then the syntax becomes:

[main.main.theme]
type = "string"
bind = "css_theme:__FINALPATH__/config.yml"

You may also encounter situations such as:

{
    "foo": {
        "max": 123
    },
    "bar": {
        "max": 456
    }
}

In which case if we want to interface with foo's max value, let's write:

bind = "foo>max:__INSTALL_DIR__/conf.json"

"Binding" to an entire file

Useful when using a question file or text for which you want to save the raw content directly as a file on the system.

For example to be able to manipulate an image:

[panel.section.logo]
type = "file"
bind = "__INSTALL_DIR__/assets/logo.png"

Or an entire text file:

[panel.section.config_content]
type = "text"
bind = "__INSTALL_DIR__/config.ini"
default = "This is the default content"

Custom getters/setters/validators (a.k.a bind=null)

More complex use-case may appear, such as:

  • you want to expose some "dynamic" information in the config panel, such as computed health status, computed disk usage, dynamic list of options, ...
  • password handling, where the data may be written but can't be read
  • the config file format is not supposed (e.g. xml, csv, ...)
  • ...

You can create specific getter/setters functions inside the config script of the app to customize how the information is read/written. The basic structure of the script is:

#!/bin/bash
source /usr/share/yunohost/helpers

ynh_abort_if_errors

# Put your getter, setter, validator or action here

# Keep this last line
ynh_app_config_run $1

Custom getters

A question's getter is the function used to read the current value/state. Custom getters are defined using bash functions called getter__QUESTION_SHORT_KEY() which returns data through stdout.

Stdout can generated using one of those formats:

  1. either just the raw value,
  2. or a yaml, containing the value and other metadata and properties (for example the style of an alert, the list of available choices of a select, etc.)

[details summary="Basic example with raw stdout: get the timezone on the system" class="helper-card-subtitle text-muted"]

config_panel.toml

[main.main.timezone]
ask = "Timezone"
type = "string"

scripts/config

get__timezone() {
    echo "$(cat /etc/timezone)"
}

[/details]

[details summary="Basic example with yaml-formated stdout : Display a list of available plugins" class="helper-card-subtitle text-muted"]

config_panel.toml

[main.plugins.plugins]
ask = "Plugin to activate"
type = "tags"
choices = []

scripts/config

get__plugins() {
    echo "choices: [$(ls $install_dir/plugins/ | tr '\n' ',')]"
}

[/details]

[details summary="Advanced example with yaml-formated stdout : Display the status of a VPN" class="helper-card-subtitle text-muted"]

config_panel.toml

[main.cube.status]
ask = "Custom getter alert"
type = "alert"
style = "info"
bind = "null" # no behaviour on

scripts/config

get__status() {
    if [ -f "/sys/class/net/tun0/operstate" ] && [ "$(cat /sys/class/net/tun0/operstate)" == "up" ]
    then
        cat << EOF
style: success
ask:
  en: Your VPN is running :)
EOF
    else
        cat << EOF
style: danger
ask:
  en: Your VPN is down
EOF
    fi
}

[/details]

Custom setters

A question's setter is the function used to set new value/state. Custom setters are defined using bash functions called setter__QUESTION_SHORT_KEY(). In the context of the setter function, variables named with the various quetion's short keys are avaible ... for example the user-specified date for question [main.main.theme] is available as $theme.

When doing non-trivial operations to set a value, you may want to use ynh_print_info to inform the admin about what's going on.

[details summary="Basic example : Set the system timezone" class="helper-card-subtitle text-muted"]

config_panel.toml

[main.main.timezone]
ask = "Timezone"
type = "string"

scripts/config

set__timezone() {
    echo "$timezone" > /etc/timezone
    ynh_print_info "The timezone has been changed to $timezone"
}

[/details]

User input validations

You will sometimes need to validate data provided by the user before saving it.

Simple validation can be achieved using a regex pattern:

pattern.regexp = '^.+@.+$'
pattern.error = 'An email is required for this field'

You can also restrict the accepted values using a choices list.

choices.foo = "Foo (some explanation)"
choices.bar = "Bar (moar explanation)"
choices.loremipsum = "Lorem Ipsum Dolor Sit Amet"

Some other type specific argument exist like

type validation arguments
number, range min, max, step
file accept
boolean yes no

See also : custom validators

Custom validators

In addition to the "simple" validation mechanism (see the 'option' doc), custom validators can be defined in a similar fashion as custom getters/setters:

validate__login_user() {
    if [[ "${#login_user}" -lt 4 ]]
    then
        echo 'User login is too short, should be at least 4 chars'
    fi
}

visible & enabled expression evaluation

Sometimes we may want to conditionaly display a message or prompt for a value, for this we have the visible prop. And we may want to allow a user to trigger an action only if some condition are met, for this we have the enabled prop.

Expressions are evaluated against a context containing previous values of the current section's options. This quite limited current design exists because on the web-admin or on the CLI we cannot guarantee that a value will be present in the form if the user queried only a single panel/section/option. In the case of an action, the user will be shown or asked for each of the options of the section in which the button is present.

The expression has to be written in javascript (this has been designed for the web-admin first and is converted to python on the fly on the cli).

Available operators are: ==, !=, >, >=, <, <=, !, &&, ||, +, -, *, /, % and match().

Examples

# simple "my_option_id" is thruthy/falsy
visible = "my_option_id"
visible = "!my_option_id"
# misc
visible = "my_value >= 10"
visible = "-(my_value + 1) < 0"
visible = "!!my_value || my_other_value"

For a more complete set of examples, check the tests at the end of the file.

match()

For more complex evaluation we can use regex matching.

[my_string]
default = "Lorem ipsum dolor et si qua met!"

[my_boolean]
type = "boolean"
visible = "my_string && match(my_string, '^Lorem [ia]psumE?')"

Match the content of a file.

[my_file]
type = "file"
accept = ".txt"
bind = "/etc/random/lorem.txt"

[my_boolean]
type = "boolean"
visible = "my_file && match(my_file, '^Lorem [ia]psumE?')"

with a file with content like:

Lorem ipsum dolor et si qua met!

Actions

"Actions" correspond to config panel buttons triggering specific pieces of code. For example, one could implement an action to trigger a scan of Nextcloud files, or install a plugin inside an app that does not already provide an interface to do so. In core config panels, buttons are for example used to trigger certificate renewal.

The most basic example looks like this, using type = 'button':

[panel.section.my_action]
type = "button"
ask = "Run action"
# (NB: no need to set `bind` to "null")

And then defining the controller, prefixed by run__ inside the app's config script:

run__my_action() {
    ynh_print_info "Running 'my_action'..."
}

You may build more complex actions, where the actions uses other form inputs:

[panel.my_action_section]
name = "Action section"
    [panel.my_action_section.my_repo]
    type = "url"
    bind = "null" # this value won't be saved as a setting, it's ephemeral and only relevant for the action
    ask = "gimme a repo link"

    [panel.my_action_section.my_repo_name]
    type = "string"
    bind = "null" # this value won't be saved as a setting, it's ephemeral and only relevant for the action
    ask = "gimme a custom folder name"

    [panel.my_action_section.my_action]
    type = "button"
    ask = "Clone the repo"
    # the button is clickable only once the previous values are provided
    enabled = "my_repo && my_repo_name"
run__my_action() {
    ynh_print_info "Cloning '$my_repo'..."
    cd /tmp
    git clone "$my_repo" "$my_repo_name"
}

Overwrite config panel mechanism

All main configuration helpers are overwritable, example:

ynh_app_config_apply() {

    # Stop vpn client
    touch /tmp/.ynh-vpnclient-stopped
    systemctl stop ynh-vpnclient

    _ynh_app_config_apply

    # Start vpn client
    systemctl start ynh-vpnclient
    rm -f /tmp/.ynh-vpnclient-stopped

}

List of main configuration helpers:

  • ynh_app_config_get
  • ynh_app_config_show
  • ynh_app_config_validate
  • ynh_app_config_apply
  • ynh_app_config_run

More info on this can be found by reading vpnclient_ynh config script

Important technical notes

Options short keys have to be unique

For performance reasons, questions short keys have to be unique in all the config_panel.toml file, not just inside its panel or its section. Hence it's not possible to have:

[manual.vpn.server_ip]
[advanced.dns.server_ip]

In which two questions have "real variable name" is server_ip and therefore conflict with each other.

! Some short keys are forbidden cause it can interfer with config scripts (old, file_hash, types, binds, formats, changed) and you probably should avoid to use common settings name to avoid to bind your question to this settings (e.g. id, install_time, mysql_pwd, path, domain, port, db_name, current_revision, admin)

bind versus app settings

! IMPORTANT: with the exception of bind = "null" options, options ids should almost always correspond to an app setting initialized/reused during install/upgrade. Not doing so may result in inconsistencies between the config panel mechanism and the use of ynh_add_config. See also discussions in https://github.com/YunoHost/issues/issues/1973