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:
parent
790159d735
commit
91e26b101e
@ -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>
|
||||||
|
@ -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;
|
||||||
|
29
web/app/src/components/settings/DropdownSelect.vue
Normal file
29
web/app/src/components/settings/DropdownSelect.vue
Normal 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>
|
96
web/app/src/components/settings/FormInputNumber.vue
Normal file
96
web/app/src/components/settings/FormInputNumber.vue
Normal 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>
|
147
web/app/src/components/settings/FormInputSwitchCheckbox.vue
Normal file
147
web/app/src/components/settings/FormInputSwitchCheckbox.vue
Normal 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>
|
77
web/app/src/components/settings/FormInputText.vue
Normal file
77
web/app/src/components/settings/FormInputText.vue
Normal 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>
|
@ -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'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
845
web/app/src/views/SettingsView.vue
Normal file
845
web/app/src/views/SettingsView.vue
Normal 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>
|
Loading…
x
Reference in New Issue
Block a user