Webapp: Configuration Editor (#104399)

Add a new "Settings" view, to edit the Flamenco Manager configuration
via the web interface. Saving the form will write directly to
`flamenco-manager.yaml`. Depending on how they are used internally by
Flamenco Manager, some settings take effect immediately; most require
a restart of the Manager process, though.

Reviewed-on: https://projects.blender.org/studio/flamenco/pulls/104399
Reviewed-by: Sybren A. Stüvel <sybren@blender.org>
This commit is contained in:
Vivian Leung 2025-08-21 11:15:53 +02:00 committed by Sybren A. Stüvel
parent 790159d735
commit 91e26b101e
8 changed files with 1203 additions and 0 deletions

View File

@ -15,6 +15,9 @@
<li> <li>
<router-link :to="{ name: 'last-rendered' }">Last Rendered</router-link> <router-link :to="{ name: 'last-rendered' }">Last Rendered</router-link>
</li> </li>
<li>
<router-link :to="{ name: 'settings' }">Settings</router-link>
</li>
</ul> </ul>
</nav> </nav>
</header> </header>

View File

@ -41,6 +41,7 @@
--table-color-border: var(--color-border); --table-color-border: var(--color-border);
--input-height: 35px;
--header-height: 25px; --header-height: 25px;
--footer-height: 25px; --footer-height: 25px;
--grid-gap: 6px; --grid-gap: 6px;

View File

@ -0,0 +1,29 @@
<style scoped>
select {
height: var(--input-height);
}
</style>
<script>
export default {
props: ['modelValue', 'id', 'disabled', 'options'],
emits: ['update:modelValue'],
};
</script>
<template>
<select
class="time"
:id="id"
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
:disabled="disabled">
<!-- This option only shows if its current value does not match any of the preset options. -->
<option v-if="!(modelValue in options)" :value="modelValue" selected>
{{ modelValue }}
</option>
<template :key="o" v-for="o in Object.keys(options)">
<option :value="o">{{ options[o] }}</option>
</template>
</select>
</template>

View File

@ -0,0 +1,96 @@
<template>
<div class="form-row">
<label :for="id">{{ label }}</label>
<input
:required="required"
type="number"
:disabled="disabled"
:id="id"
:value="value"
:min="min"
:max="max"
@input="onInput"
@change="onChange" />
<span :class="{ hidden: !errorMsg, error: errorMsg }">{{ errorMsg }}</span>
</div>
</template>
<script>
export default {
name: 'FormInputNumber',
props: {
label: {
type: String,
required: true,
},
value: {
type: Number,
required: true,
},
id: {
type: String,
required: true,
},
disabled: {
type: Boolean,
required: false,
},
required: {
type: Boolean,
required: false,
},
placeholder: {
type: String,
required: false,
},
min: {
type: Number,
required: false,
},
max: {
type: Number,
required: false,
},
},
emits: ['update:value'],
data() {
return {
errorMsg: '',
};
},
computed: {
name() {
return this.label.toLowerCase();
},
},
watch: {},
methods: {
onInput(event) {
// Update the v-model value
this.$emit('update:value', event.target.value);
},
onChange(event) {
// Supports .lazy
// Can add validation here
if (event.target.value === '' && this.required) {
this.errorMsg = 'Field required.';
} else {
this.errorMsg = '';
}
if (event.target.value < this.min) {
this.errorMsg = `The value cannot be below ${this.min}`;
}
if (event.target.value > this.max) {
this.errorMsg = `The value cannot be above ${this.max}`;
}
},
},
};
</script>
<style scoped>
input[type='number'] {
max-width: 75px;
}
</style>

View File

@ -0,0 +1,147 @@
<template>
<div class="form-row">
<label class="form-switch-row">
<span>{{ label }}</span>
<span class="switch">
<input v-model="model" :value="value" :name="name" type="checkbox" />
<span class="slider round"></span>
</span>
</label>
<p>
{{ description }}
<template v-if="moreInfoText">
{{ moreInfoText }}
<a v-if="moreInfoLinkLabel && moreInfoLinkUrl" class="link" :href="moreInfoLinkUrl"
>{{ moreInfoLinkLabel }}
</a>
<span>{{ `.` }}</span>
</template>
</p>
</div>
</template>
<script>
export default {
name: 'FormInputSwitchCheckbox',
props: {
label: {
type: String,
required: true,
},
modelValue: {
type: [Array, Boolean],
required: true,
},
name: {
type: String,
required: false,
},
value: {
type: [Boolean, Object],
required: false,
},
description: {
type: String,
required: false,
},
moreInfoText: {
type: String,
required: false,
},
moreInfoLinkUrl: {
type: String,
required: false,
},
moreInfoLinkLabel: {
type: String,
required: false,
},
},
emits: ['update:modelValue'],
computed: {
model: {
get() {
return this.modelValue;
},
set(value) {
this.$emit('update:modelValue', value);
},
},
},
};
</script>
<style scoped>
.form-switch-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
}
/* The switch - the box around the slider */
.switch {
position: relative;
display: inline-block;
width: 52px;
height: 30px;
}
/* Hide default HTML checkbox */
.switch input[type='checkbox'] {
opacity: 0;
width: 0;
height: 0;
}
/* The slider */
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--color-text-muted);
border: var(--border-width) solid var(--border-color);
-webkit-transition: 0.4s;
transition: 0.4s;
}
.slider:before {
position: absolute;
content: '';
height: 20px;
width: 20px;
left: 4px;
bottom: 3px;
background-color: white;
-webkit-transition: 0.4s;
transition: 0.4s;
}
input:checked + .slider {
background-color: var(--color-accent);
}
input:focus + .slider {
box-shadow: 0 0 1px var(--color-accent);
border: var(--border-width) solid white;
}
input:checked + .slider:before {
-webkit-transform: translateX(20px);
-ms-transform: translateX(20px);
transform: translateX(20px);
}
/* Rounded sliders */
.slider.round {
border-radius: 34px;
}
.slider.round:before {
border-radius: 50%;
}
</style>

View File

@ -0,0 +1,77 @@
<template>
<div
:class="{
hidden: hidden,
'form-row': !hidden,
}">
<label v-if="label" :for="id">{{ label }}</label>
<input
:placeholder="placeholder"
:required="required"
type="text"
:disabled="disabled"
:id="id"
:value="value"
@input="onInput"
@change="onChange" />
<span :class="{ hidden: !errorMsg, error: errorMsg }">{{ errorMsg }}</span>
</div>
</template>
<script>
export default {
name: 'FormInputText',
props: {
label: {
type: String,
required: true,
},
value: {
type: String,
required: true,
},
id: {
type: String,
required: true,
},
disabled: {
type: Boolean,
required: false,
},
required: {
type: Boolean,
required: false,
},
placeholder: {
type: String,
required: false,
},
hidden: {
type: Boolean,
required: false,
},
},
emits: ['update:value'],
data() {
return {
errorMsg: '',
};
},
watch: {},
methods: {
onInput(event) {
// Update the v-model value
this.$emit('update:value', event.target.value);
},
onChange(event) {
// Supports .lazy
// Can add validation here
if (event.target.value === '' && this.required) {
this.errorMsg = 'Field required.';
} else {
this.errorMsg = '';
}
},
},
};
</script>

View File

@ -31,6 +31,11 @@ const router = createRouter({
name: 'last-rendered', name: 'last-rendered',
component: () => import('../views/LastRenderedView.vue'), component: () => import('../views/LastRenderedView.vue'),
}, },
{
path: '/settings',
name: 'settings',
component: () => import('../views/SettingsView.vue'),
},
], ],
}); });

View File

@ -0,0 +1,845 @@
<script>
import NotificationBar from '@/components/footer/NotificationBar.vue';
import UpdateListener from '@/components/UpdateListener.vue';
import DropdownSelect from '@/components/settings/DropdownSelect.vue';
import FormInputSwitchCheckbox from '@/components/settings/FormInputSwitchCheckbox.vue';
import FormInputText from '@/components/settings/FormInputText.vue';
import FormInputNumber from '@/components/settings/FormInputNumber.vue';
import { MetaApi } from '@/manager-api';
import { getAPIClient } from '@/api-client';
const timeDurationOptions = {
'0s': 'Zero',
'1m0s': '1 Minute', // worker timeout
'5m0s': '5 Minutes',
'10m0s': '10 Minutes', // task timeout, DB check period
'30m0s': '30 Minutes',
'1h0m0s': '1 Hour',
'24h0m0s': '1 Day', // GC period
'168h0m0s': '1 Week',
'744h0m0s': '1 Month', // GC maxAge
};
const platformOptions = {
darwin: 'Darwin (MacOS)',
windows: 'Windows',
linux: 'Linux',
all: 'All Operating Systems',
};
const audienceOptions = {
all: 'All',
users: 'Users',
workers: 'Workers',
};
// The type determines which form component will be rendered and used to modify a value
const inputTypes = {
string: 'string', // input type=string
timeDuration: 'timeDuration', // dropdown
boolean: 'boolean', // switch checkbox
number: 'number', // input type=number
platform: 'Platform', // dropdown
audience: 'Audience', // dropdown
};
const categories = [
{
id: 'core-settings',
label: 'Core Settings',
settings: ['manager_name', 'database', 'database_check_period', 'listen', 'autodiscoverable'],
},
{
id: 'storage',
label: 'Storage',
settings: ['local_manager_storage_path', 'shared_storage_path', 'shaman'],
},
{
id: 'timeout-failures',
label: 'Timeout & Failures',
settings: [
'task_timeout',
'worker_timeout',
'blocklist_threshold',
'task_fail_after_softfail_count',
],
},
{
id: 'mqtt',
label: 'MQTT',
settings: ['mqtt'],
},
{ id: 'variables', label: 'Variables' },
];
// the initialFormValues object matches the hierarchy from flamenco-manager.yaml, making it easy to override and import
// For each of the sections: core, storage, timeout-failures, mqtt, and variables:
// - type is the expected input type that determines which input component to render
// - label is what's displayed on the user interface
// - value is the setting's input value
const initialFormValues = {
_meta: {
version: 3,
},
// Core
manager_name: {
type: inputTypes.string,
label: 'Name',
value: null,
},
database: {
type: inputTypes.string,
label: 'Database',
value: null,
},
database_check_period: {
type: inputTypes.timeDuration,
label: 'Database Check Period',
value: null,
},
listen: {
type: inputTypes.string,
label: 'Listening IP and Port Number',
value: null,
},
autodiscoverable: {
type: inputTypes.boolean,
label: 'Auto Discoverable',
value: null,
description:
'This enables the autodiscovery. The manager uses UPnP/SSDP to broadcast its location on the network so it can be discovered by workers. Enabled by default.',
},
// Storage
local_manager_storage_path: {
type: inputTypes.string,
label: 'Local Manager Storage Path',
value: null,
},
shared_storage_path: {
type: inputTypes.string,
label: 'Shared Storage Path',
value: null,
},
shaman: {
enabled: {
type: inputTypes.boolean,
label: 'Enable Shaman Storage',
value: null,
description: `Shaman is a file storage server built into Flamenco Manager. It accepts uploaded files via HTTP, and stores them based on their SHA256-checksum and their file length. It can recreate directory structures by symlinking those files. Effectively, it ensures that when you create a new render job, you only have to upload files that are new or have changed.
Note that Shaman uses symlinking, and thus is incompatible with platforms or storage systems that do not support symbolic links.
`,
moreInfoText: `For more information, see`,
moreInfoLinkUrl: `https://flamenco.blender.org/usage/shared-storage/shaman/`,
moreInfoLinkLabel: `Shaman Storage System`,
},
garbageCollect: {
period: { type: inputTypes.timeDuration, label: 'Period', value: null },
maxAge: { type: inputTypes.timeDuration, label: 'Max Age', value: null },
},
},
// Timeout Failures
task_timeout: {
type: inputTypes.timeDuration,
label: 'Task Timeout',
value: null,
},
worker_timeout: {
type: inputTypes.timeDuration,
label: 'Worker Timeout',
value: null,
},
blocklist_threshold: {
type: inputTypes.number,
label: 'Blocklist Threshold',
value: null,
},
task_fail_after_softfail_count: {
type: inputTypes.number,
label: 'Task Fail after Soft Fail Count',
value: null,
},
// MQTT
mqtt: {
enabled: {
type: inputTypes.boolean,
label: 'Enable MQTT Client',
value: null,
description: `Flamenco Manager can send its internal events to an MQTT broker. Other MQTT clients can listen to those events, in order to respond to what happens on the render farm.
`,
moreInfoText: 'For more information about the built-in MQTT client, see',
moreInfoLinkUrl: 'https://flamenco.blender.org/usage/manager-configuration/mqtt/',
moreInfoLinkLabel: `Manager Configuration: MQTT`,
},
client: {
broker: { type: inputTypes.string, label: 'Broker', value: null },
clientID: { type: inputTypes.string, label: 'Client ID', value: null },
topic_prefix: {
type: inputTypes.string,
label: 'Topic Prefix',
value: null,
},
username: { type: inputTypes.string, label: 'Username', value: null },
password: { type: inputTypes.string, label: 'Password', value: null },
},
},
// Variables
variables: {},
};
export default {
name: 'ConfigurationSettingsView',
components: {
NotificationBar,
UpdateListener,
DropdownSelect,
FormInputText,
FormInputNumber,
FormInputSwitchCheckbox,
},
data: () => ({
// Make a deep copy so it can be compared to the original for isDirty check to work
config: JSON.parse(JSON.stringify(initialFormValues)),
originalConfig: JSON.parse(JSON.stringify(initialFormValues)),
newVariableName: '',
metaAPI: new MetaApi(getAPIClient()),
// Static data
inputTypes,
timeDurationOptions,
platformOptions,
audienceOptions,
categories,
}),
created() {
this.importConfig();
},
computed: {
isDirty() {
return JSON.stringify(this.originalConfig) !== JSON.stringify(this.config);
},
},
methods: {
canAddVariable() {
// Duplicate variable name
if (this.newVariableName in this.config.variables) {
// TODO: add error message here
return false;
}
// Whitespace only
if (!this.newVariableName.trim()) {
// TODO: add error message here
return false;
}
return true;
},
handleAddVariable() {
this.config.variables = {
...this.config.variables,
[this.newVariableName.trim()]: {
values: [
{
platform: { type: inputTypes.platform, label: 'Platform', value: '' },
audience: { type: inputTypes.audience, label: 'Audience', value: '' },
value: { type: inputTypes.string, label: 'Value', value: '' },
},
],
},
};
this.newVariableName = '';
},
handleDeleteVariable(variableName) {
delete this.config.variables[variableName];
},
/**
* Adds a blank config for the specified variable
* @param variableName the variable name to delete a config from
*/
handleAddVariableConfig(variableName) {
this.config.variables[variableName].values.push({
platform: { type: inputTypes.platform, label: 'Platform', value: '' },
audience: { type: inputTypes.audience, label: 'Audience', value: '' },
value: { type: inputTypes.string, label: 'Value', value: '' },
});
},
/**
* Deletes the specified config for the specified variable
* @param variableName the variable name to delete a config from
* @param index the index of the config to delete
*/
handleDeleteVariableConfig(variableName, index) {
this.config.variables[variableName].values.splice(index, 1);
},
canSave() {
// TODO: include checks for form validation
return this.isDirty;
},
/**
* Returns the form values as an object ready to be exported to the backend config
*/
exportConfig() {
const configKeys = Object.keys(this.config);
const configToSave = {};
configKeys.forEach((key) => {
if (key === 'mqtt') {
const { broker, clientID, topic_prefix, username, password } = this.config.mqtt.client;
configToSave.mqtt = {
client: {
broker: broker.value,
clientID: clientID.value,
topic_prefix: topic_prefix.value,
username: username.value,
password: password.value,
},
};
} else if (key === 'shaman') {
const { period, maxAge } = this.config.shaman.garbageCollect;
const { enabled } = this.config.shaman;
configToSave.shaman = {
enabled: enabled.value,
garbageCollect: {
// empty strings are invalid durations, so set it to 0s if empty
// this is only an issue when shaman is disabled, otherwise the required attribute prevents empty strings
period: period.value ?? '0s',
maxAge: maxAge.value ?? '0s',
},
};
} else if (key === 'variables') {
configToSave.variables = {};
// This needs to be dynamic, as variable names and the amount of entries for each are not fixed
Object.keys(this.config.variables).forEach((variable) => {
// Initialize the values list for each variable
configToSave.variables[variable] = { values: [] };
this.config[key][variable].values.forEach((entry, index) => {
// Initialize an empty object for each entry of a variable
configToSave.variables[variable].values.push({});
Object.keys(entry).forEach((entryKey) => {
// Grab the content from either platform, value, or audience
const formValue = this.config.variables[variable].values[index][entryKey].value;
// No need to save the content if audience is "all", since that is the default
// Otherwise save the content
if (entryKey === 'audience' && formValue === 'all') {
return;
}
configToSave.variables[variable].values[index][entryKey] = formValue;
});
});
});
} else if (key === '_meta') {
// _meta is hardcoded so grab it as it is
configToSave._meta = this.config._meta;
} else {
// Set the flat values
configToSave[key] = this.config[key].value;
}
});
return configToSave;
},
/**
* Exports the form config and overwrites the existing flamenco-manager.yaml
*/
async saveConfig() {
const configToSave = this.exportConfig();
try {
await this.metaAPI.updateConfigurationFile(configToSave);
// Update the original config so that isDirty reads false after a successful save
this.originalConfig = JSON.parse(JSON.stringify(this.config));
} catch (e) {
console.error(e);
}
},
/**
* Imports the config from the backend and populates the form values
*/
async importConfig() {
const existingConfig = await this.getYamlConfig();
const configKeys = Object.keys(existingConfig);
configKeys.forEach((key) => {
if (key === 'mqtt') {
Object.keys(this.config.mqtt.client).forEach(
(nestedKey) =>
(this.config.mqtt.client[nestedKey].value = existingConfig.mqtt.client[nestedKey])
);
} else if (key === 'shaman') {
this.config.shaman.enabled.value = existingConfig.shaman.enabled;
Object.keys(this.config.shaman.garbageCollect).forEach(
(nestedKey) =>
(this.config.shaman.garbageCollect[nestedKey].value =
existingConfig.shaman.garbageCollect[nestedKey])
);
} else if (key === 'variables') {
// This helps with importing the variables to the form
const blankVariableEntry = {
platform: { value: '', type: '', label: '' },
value: { value: '', type: '', label: '' },
audience: { value: '', type: '', label: '' },
};
Object.keys(existingConfig.variables).forEach((variable) => {
// Initialize the values list for each variable
this.config.variables[variable] = { values: [] };
existingConfig.variables[variable].values.forEach((entry, index) => {
// Initialize an empty object for each entry of a variable
this.config.variables[variable].values.push({});
Object.keys(blankVariableEntry).forEach((entryKey) => {
// Set the content for platform, value, and audience
this.config.variables[variable].values[index][entryKey] = {
value:
existingConfig.variables[variable].values[index][entryKey] ??
(entryKey === 'audience' ? 'all' : ''), // If the audience value is blank, set it to the default 'all'
label: inputTypes[entryKey] ?? 'Value',
type: inputTypes[entryKey] ?? inputTypes.string,
};
});
});
});
} else if (key === '_meta') {
// Copy the _meta exactly as is
this.config._meta = existingConfig._meta;
} else {
// Set the flat values
this.config[key].value = existingConfig[key];
}
});
// make a copy to use for isDirty check
this.originalConfig = JSON.parse(JSON.stringify(this.config));
},
/**
* Retrieve the config from flamenco-manager.yaml
*/
async getYamlConfig() {
const config = await this.metaAPI.getConfigurationFile();
return config;
},
// SocketIO connection event handlers:
// TODO: reload config if clean; if dirty, show a warning that the form may be out of date
onSIOReconnected() {},
onSIODisconnected(reason) {},
},
};
</script>
<template>
<main class="yaml-view-container">
<nav class="nav-container">
<div v-for="category in categories" :key="category">
<a :href="'#' + category.id">{{ category.label }}</a>
</div>
<!-- TODO: Add a "Reset to Previous Value button" -->
<button type="submit" form="config-form" class="save-button" :disabled="!canSave()">
Save
</button>
</nav>
<aside class="side-container">
<div class="dialog">
<div class="dialog-content">
<h2>Settings</h2>
<p>
This editor allows you to configure the settings for the Flamenco Server. These changes
will directly edit the
<span class="file-name"> flamenco-manager.yaml </span>
file. For more information, see
<a class="link" href="https://flamenco.blender.org/usage/manager-configuration/">
Manager Configuration
</a>
</p>
</div>
<!-- TODO: add attribute descriptions when onFocus -->
</div>
</aside>
<form id="config-form" class="form-container" @submit.prevent="saveConfig">
<h1 id="flamenco-manager-setup">Flamenco Manager Setup</h1>
<template v-for="category in categories" :key="category">
<h2 :id="category.id">{{ category.label }}</h2>
<!-- Variables -->
<template v-if="category.id === 'variables'">
<div class="form-row">
<div class="form-variable-row">
<input
@keydown.enter.prevent="canAddVariable() ? handleAddVariable() : null"
placeholder="variableName"
type="text"
:id="newVariableName"
v-model="newVariableName" />
<button
type="button"
title="Enter a variable"
@click="handleAddVariable"
:disabled="!canAddVariable()">
Add Variable
</button>
</div>
</div>
<section
class="form-variable-section"
v-for="(variable, variableName) in config.variables"
:key="variableName">
<div class="form-variable-header">
<h3>
<pre>{{ variableName }}</pre>
</h3>
<button type="button" @click="handleDeleteVariable(variableName)">Delete</button>
</div>
<div class="form-variable-row" v-for="(entry, index) in variable.values" :key="index">
<FormInputText
required
:id="variableName + '[' + index + ']' + '.value'"
v-model:value="entry.value.value"
:label="index === 0 ? entry.value.label : ''" />
<div class="form-row-secondary">
<label v-if="index === 0" :for="variableName + index + '.platform'">{{
entry.platform.label
}}</label>
<DropdownSelect
:options="platformOptions"
v-model="entry.platform.value"
:id="variableName + index + '.platform'" />
</div>
<div class="form-row-secondary">
<label v-if="index === 0" :for="variableName + index + '.audience'">{{
entry.audience.label
}}</label>
<DropdownSelect
:options="audienceOptions"
v-model="entry.audience.value"
:id="variableName + index + '.audience'" />
</div>
<button type="button" @click="handleDeleteVariableConfig(variableName, index)">
Delete
</button>
</div>
<button type="button" @click="handleAddVariableConfig(variableName)">Add</button>
</section>
</template>
<!-- Render all other sections dynamically -->
<template v-else>
<section class="form-section">
<template v-for="key in category.settings" :key="key">
<!-- Shaman -->
<template v-if="key === 'shaman'">
<h3>Shaman Storage</h3>
<template v-for="(shamanSetting, key) in config.shaman" :key="key">
<template v-if="shamanSetting.type === inputTypes.boolean">
<FormInputSwitchCheckbox
:label="shamanSetting.label"
v-model="shamanSetting.value"
:description="shamanSetting.description"
:moreInfoText="shamanSetting.moreInfoText"
:moreInfoLinkUrl="shamanSetting.moreInfoLinkUrl"
:moreInfoLinkLabel="shamanSetting.moreInfoLinkLabel" />
</template>
<!-- Shaman Garbage Collect -->
<template v-else-if="key === 'garbageCollect'">
<span>Garbage Collection Settings</span>
<template
v-for="(garbageCollectSetting, key) in shamanSetting"
:key="'garbageCollect' + key">
<div
class="form-row"
v-if="garbageCollectSetting.type === inputTypes.timeDuration">
<label :for="'shaman.garbageCollect.' + key">{{
garbageCollectSetting.label
}}</label>
<DropdownSelect
:disabled="!config.shaman.enabled.value"
:options="timeDurationOptions"
v-model="garbageCollectSetting.value"
:id="'shaman.garbageCollect.' + key" />
</div>
</template>
</template>
</template>
</template>
<!-- MQTT -->
<template v-else-if="key === 'mqtt'">
<template v-for="(mqttSetting, mqttKey) in config.mqtt" :key="mqttKey">
<template v-if="mqttSetting.type === inputTypes.boolean">
<FormInputSwitchCheckbox
:label="mqttSetting.label"
v-model="mqttSetting.value"
:description="mqttSetting.description"
:moreInfoText="mqttSetting.moreInfoText"
:moreInfoLinkUrl="mqttSetting.moreInfoLinkUrl"
:moreInfoLinkLabel="mqttSetting.moreInfoLinkLabel" />
</template>
<!-- MQTT Client -->
<template
v-else-if="mqttKey === 'client'"
v-for="(clientSetting, clientKey) in config.mqtt.client"
:key="clientKey">
<template v-if="clientSetting.type === inputTypes.string">
<FormInputText
:disabled="!config.mqtt.enabled.value"
:id="'mqtt.client.' + clientKey"
v-model:value="clientSetting.value"
:label="clientSetting.label" />
</template>
</template>
</template>
</template>
<!-- Render all other input types dynamically -->
<template v-else-if="config[key].type === inputTypes.string">
<FormInputText
:id="key"
v-model:value="config[key].value"
:label="config[key].label" />
</template>
<template v-else-if="config[key].type === inputTypes.boolean">
<FormInputSwitchCheckbox
:label="config[key].label"
v-model="config[key].value"
:description="config[key].description" />
</template>
<template v-if="config[key].type === inputTypes.number">
<FormInputNumber
:label="config[key].label"
:min="0"
v-model:value="config[key].value"
:id="key" />
</template>
<div v-else-if="config[key].type === inputTypes.timeDuration" class="form-row">
<label :for="key">
{{ config[key].label }}
</label>
<DropdownSelect
:options="timeDurationOptions"
v-model="config[key].value"
:id="key" />
</div>
</template>
</section>
</template>
</template>
</form>
</main>
<footer class="app-footer">
<notification-bar />
<update-listener
ref="updateListener"
mainSubscription=""
@sioReconnected="onSIOReconnected"
@sioDisconnected="onSIODisconnected" />
</footer>
</template>
<style>
.yaml-view-container {
--nav-height: 35px;
--button-height: 35px;
--min-form-area-width: 525px;
--max-form-area-width: 1fr;
--min-side-area-width: 250px;
--max-side-area-width: 425px;
--max-form-width: 650px;
--form-padding: 75px;
--side-padding: 25px;
--container-margin: 25px;
--row-item-spacer: 25px;
--column-item-spacer: 25px;
--section-spacer: 25px;
--container-padding: 25px;
}
.hidden {
display: none;
}
.error {
color: var(--color-status-failed);
}
.file-name {
font-style: italic;
}
.link {
text-decoration: underline;
}
#core-settings,
#storage,
#timeout-failures,
#mqtt,
#variables {
scroll-margin-top: calc(var(--section-spacer) * 2);
}
#core-settings:target,
#storage:target,
#timeout-failures:target,
#mqtt:target,
#variables:target {
color: var(--color-accent-text);
}
.save-button {
background-color: var(--color-accent-background);
color: var(--color-accent-text);
padding: 5px 64px;
border-radius: var(--border-radius);
margin-left: auto;
border: var(--border-width) solid var(--color-accent);
}
.save-button:hover {
background-color: var(--color-accent);
}
.save-button:active {
color: var(--color-accent);
background-color: var(--color-accent-background);
}
p {
line-height: 1.5;
color: var(--color-text-hint);
margin: 0;
white-space: pre-line;
}
button {
height: var(--button-height);
}
.nav-container {
position: sticky;
top: 0;
height: var(--nav-height);
grid-area: header;
gap: var(--row-item-spacer);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
background-color: var(--color-background-column);
padding: 2px 10px;
z-index: 100;
}
.yaml-view-container {
grid-column-start: col-1;
grid-column-end: col-3;
display: grid;
grid-gap: var(--grid-gap);
grid-template-areas:
'header header'
'side main'
'footer footer';
grid-template-columns: minmax(var(--min-side-area-width), var(--max-side-area-width)) minmax(
var(--min-form-area-width),
var(--max-form-area-width)
);
grid-template-rows: var(--nav-height) 1fr;
}
.side-container {
grid-area: side;
margin: var(--container-margin);
}
.dialog {
background-color: var(--color-background-column);
border-radius: var(--border-radius);
min-height: calc(100vh - var(--nav-height) - var(--header-height) - var(--footer-height) - 100px);
position: sticky;
top: 70px;
padding: var(--side-padding);
flex: 1;
}
.form-container {
display: flex;
flex-direction: column;
align-items: start;
grid-area: main;
margin: var(--container-margin) var(--container-margin) var(--container-margin) 0px;
max-width: var(--max-form-width);
background-color: var(--color-background-column);
border-radius: var(--border-radius);
padding: calc(var(--form-padding) - var(--section-spacer)) var(--form-padding);
}
h2 {
margin: var(--section-spacer) 0 var(--section-spacer) 0;
}
h3 {
margin: var(--section-spacer) 0 0 0;
}
.form-section {
display: flex;
flex-direction: column;
width: 100%;
max-width: var(--max-form-width);
gap: var(--column-item-spacer);
margin-bottom: 50px;
}
.form-row {
display: flex;
align-items: start;
flex-direction: column;
gap: 8px;
width: 100%;
}
.form-variable-section {
display: flex;
flex-direction: column;
width: 100%;
max-width: var(--max-form-width);
margin-bottom: var(--section-spacer);
}
.form-variable-row {
display: flex;
flex-direction: row;
align-items: end;
margin-bottom: 15px;
gap: var(--row-item-spacer);
width: 100%;
}
.form-variable-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
margin: var(--section-spacer) 0 5px 0;
}
.form-variable-header h3 {
margin: 0;
}
.form-row-secondary {
display: flex;
align-items: start;
flex-direction: column;
gap: 8px;
}
input {
height: var(--input-height);
}
input:disabled {
background-color: var(--color-background-column);
}
</style>