mirror of
https://github.com/Lissy93/dashy.git
synced 2026-06-06 08:44:23 -04:00
✨ Adds new config view/export menu
This commit is contained in:
@@ -337,7 +337,27 @@
|
|||||||
"copy-clipboard-tooltip": "Copy all app config to system clipboard, in YAML format",
|
"copy-clipboard-tooltip": "Copy all app config to system clipboard, in YAML format",
|
||||||
"download-file-btn": "Download as File",
|
"download-file-btn": "Download as File",
|
||||||
"download-file-tooltip": "Download all app config to your device, in a YAML file",
|
"download-file-tooltip": "Download all app config to your device, in a YAML file",
|
||||||
"view-title": "View Config"
|
"current-config-title": "Current Config",
|
||||||
|
"preview-toggle": "YAML preview",
|
||||||
|
"issues-title": "Schema issues ({n})",
|
||||||
|
"config-list-title": "Config List",
|
||||||
|
"col-title": "Title",
|
||||||
|
"col-path": "Path",
|
||||||
|
"col-content": "Content",
|
||||||
|
"col-status": "Status",
|
||||||
|
"col-actions": "Actions",
|
||||||
|
"content-summary": "{sections} sections, {items} items, {widgets} widgets",
|
||||||
|
"status-loading": "loading",
|
||||||
|
"status-valid": "valid",
|
||||||
|
"status-warnings": "{n} warnings",
|
||||||
|
"status-error": "error",
|
||||||
|
"status-unknown": "unknown",
|
||||||
|
"edit-current-btn": "Edit Config",
|
||||||
|
"edit-current-tooltip": "Open the current config in the JSON editor",
|
||||||
|
"copy-fail-msg": "Unable to copy to clipboard, see log",
|
||||||
|
"download-row-tooltip": "Download YAML",
|
||||||
|
"preview-row-tooltip": "Toggle YAML Preview",
|
||||||
|
"apply-row-tooltip": "Apply config"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"critical-error": {
|
"critical-error": {
|
||||||
|
|||||||
@@ -1,40 +1,113 @@
|
|||||||
<template>
|
<template>
|
||||||
<modal
|
<modal
|
||||||
:name="modalName"
|
:name="modalName"
|
||||||
:resizable="true"
|
:resizable="true"
|
||||||
width="50%"
|
width="75%"
|
||||||
height="80%"
|
height="75%"
|
||||||
classes="dashy-modal export-modal"
|
classes="dashy-modal export-modal"
|
||||||
|
@before-open="buildRows"
|
||||||
@closed="modalClosed"
|
@closed="modalClosed"
|
||||||
>
|
>
|
||||||
<div class="export-config-inner" v-if="allowViewConfig">
|
<div class="export-config-inner" v-if="allowViewConfig">
|
||||||
<!-- Download and Copy to CLipboard Buttons -->
|
<section class="current-config">
|
||||||
<h3>{{ $t('interactive-editor.export.export-title') }}</h3>
|
<h3>{{ $t('interactive-editor.export.current-config-title') }}</h3>
|
||||||
<div class="download-button-container">
|
<p class="config-path">
|
||||||
<Button :click="copyConfigToClipboard"
|
<a :href="currentConfigHref" target="_blank" rel="noopener noreferrer">
|
||||||
v-tooltip="tooltip($t('interactive-editor.export.copy-clipboard-tooltip'))">
|
{{ currentConfigPath }}
|
||||||
{{ $t('interactive-editor.export.copy-clipboard-btn') }}
|
</a>
|
||||||
<CopyConfigIcon />
|
</p>
|
||||||
</Button>
|
<div class="download-button-container">
|
||||||
<Button :click="downloadConfig"
|
<Button :click="copyToClipboard"
|
||||||
v-tooltip="tooltip($t('interactive-editor.export.download-file-tooltip'))">
|
v-tooltip="tooltip($t('interactive-editor.export.copy-clipboard-tooltip'))">
|
||||||
{{ $t('interactive-editor.export.download-file-btn') }}
|
{{ $t('interactive-editor.export.copy-clipboard-btn') }}
|
||||||
<DownloadConfigIcon />
|
<CopyConfigIcon />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button :click="downloadCurrent"
|
||||||
|
v-tooltip="tooltip($t('interactive-editor.export.download-file-tooltip'))">
|
||||||
|
{{ $t('interactive-editor.export.download-file-btn') }}
|
||||||
|
<DownloadConfigIcon />
|
||||||
|
</Button>
|
||||||
|
<Button :click="editCurrent"
|
||||||
|
v-tooltip="tooltip($t('interactive-editor.export.edit-current-tooltip'))">
|
||||||
|
{{ $t('interactive-editor.export.edit-current-btn') }}
|
||||||
|
<EditIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="collapsible">
|
||||||
|
<button type="button" class="collapsible-header" @click="previewOpen = !previewOpen">
|
||||||
|
<span>{{ $t('interactive-editor.export.preview-toggle') }}</span>
|
||||||
|
<span class="chev">{{ previewOpen ? '▾' : '▸' }}</span>
|
||||||
|
</button>
|
||||||
|
<pre v-if="previewOpen" class="yaml-preview">{{ currentConfigYaml }}</pre>
|
||||||
|
</div>
|
||||||
|
<div v-if="currentIssues.length" class="issue-list">
|
||||||
|
<h4>{{ $t('interactive-editor.export.issues-title', { n: currentIssues.length }) }}</h4>
|
||||||
|
<ul>
|
||||||
|
<li v-for="(issue, i) in currentIssues" :key="i">{{ issue }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="config-list">
|
||||||
|
<h3>{{ $t('interactive-editor.export.config-list-title') }}</h3>
|
||||||
|
<table>
|
||||||
|
<colgroup>
|
||||||
|
<col class="col-title" />
|
||||||
|
<col class="col-path" />
|
||||||
|
<col class="col-content" />
|
||||||
|
<col class="col-status" />
|
||||||
|
<col class="col-actions" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ $t('interactive-editor.export.col-title') }}</th>
|
||||||
|
<th>{{ $t('interactive-editor.export.col-path') }}</th>
|
||||||
|
<th>{{ $t('interactive-editor.export.col-content') }}</th>
|
||||||
|
<th>{{ $t('interactive-editor.export.col-status') }}</th>
|
||||||
|
<th>{{ $t('interactive-editor.export.col-actions') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-for="row in rows" :key="row.id">
|
||||||
|
<tr>
|
||||||
|
<td class="cell-truncate">
|
||||||
|
<button type="button" class="link-cell" :title="row.title"
|
||||||
|
@click="openConfig(row)">{{ row.title }}</button>
|
||||||
|
</td>
|
||||||
|
<td class="cell-truncate mono">
|
||||||
|
<a :href="rawPathHref(row)" :title="row.path"
|
||||||
|
target="_blank" rel="noopener noreferrer">{{ row.path }}</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ row.summary }}</td>
|
||||||
|
<td>
|
||||||
|
<span :class="['status-pill', `status-${row.status}`]">
|
||||||
|
{{ statusLabel(row) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button type="button" @click="downloadRow(row)" :disabled="!row.yamlText"
|
||||||
|
v-tooltip="tooltip($t('interactive-editor.export.download-row-tooltip'))">
|
||||||
|
<DownloadConfigIcon />
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="openConfig(row)" :disabled="!row.yamlText"
|
||||||
|
v-tooltip="tooltip($t('interactive-editor.export.apply-row-tooltip'))">
|
||||||
|
<PreviewIcon />
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="toggleRow(row)" :disabled="!row.yamlText" class="arr"
|
||||||
|
v-tooltip="tooltip($t('interactive-editor.export.preview-row-tooltip'))">
|
||||||
|
{{ expandedRow === row.id ? '▾' : '▸' }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="expandedRow === row.id" class="row-preview">
|
||||||
|
<td colspan="5"><pre class="yaml-preview">{{ row.yamlText }}</pre></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<!-- Show path to which config file is being used -->
|
<AccessError v-else />
|
||||||
<div class="config-path-info">
|
|
||||||
<h3>Config Location</h3>
|
|
||||||
<p>
|
|
||||||
The base config file you are currently using is
|
|
||||||
<a :href="configPath">{{ configPath }}</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<!-- View Config in Tree Mode Section -->
|
|
||||||
<h3>{{ $t('interactive-editor.export.view-title') }}</h3>
|
|
||||||
<json-viewer :value="config" class="config-tree-view" />
|
|
||||||
</div>
|
|
||||||
<AccessError v-else />
|
|
||||||
</modal>
|
</modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -46,8 +119,27 @@ import { modalNames } from '@/utils/config/defaults';
|
|||||||
import AccessError from '@/components/Configuration/AccessError';
|
import AccessError from '@/components/Configuration/AccessError';
|
||||||
import DownloadConfigIcon from '@/assets/interface-icons/config-download-file.svg';
|
import DownloadConfigIcon from '@/assets/interface-icons/config-download-file.svg';
|
||||||
import CopyConfigIcon from '@/assets/interface-icons/interactive-editor-copy-clipboard.svg';
|
import CopyConfigIcon from '@/assets/interface-icons/interactive-editor-copy-clipboard.svg';
|
||||||
|
import EditIcon from '@/assets/interface-icons/config-edit-json.svg';
|
||||||
|
import PreviewIcon from '@/assets/interface-icons/config-preview.svg';
|
||||||
|
import request from '@/utils/request';
|
||||||
|
import { formatConfigPath, makePageName, makeRoutePath } from '@/utils/config/ConfigHelpers';
|
||||||
|
import { validateConfig, formatIssue } from '@/utils/config/validateConfig';
|
||||||
import { ErrorHandler, InfoHandler, InfoKeys } from '@/utils/logging/ErrorHandler';
|
import { ErrorHandler, InfoHandler, InfoKeys } from '@/utils/logging/ErrorHandler';
|
||||||
|
|
||||||
|
/* Counts num of sections, items and widgets for a given config */
|
||||||
|
const sumBy = (arr, key) => {
|
||||||
|
return (arr || [])
|
||||||
|
.reduce(
|
||||||
|
(n, item) => n + (Array.isArray(item?.[key]) ? item[key].length : 0), 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const basename = (path) => {
|
||||||
|
if (!path) return null;
|
||||||
|
const last = path.split('/').filter(Boolean).pop();
|
||||||
|
return last && /\.ya?ml$/i.test(last) ? last : null;
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ExportConfigMenu',
|
name: 'ExportConfigMenu',
|
||||||
components: {
|
components: {
|
||||||
@@ -55,60 +147,161 @@ export default {
|
|||||||
AccessError,
|
AccessError,
|
||||||
CopyConfigIcon,
|
CopyConfigIcon,
|
||||||
DownloadConfigIcon,
|
DownloadConfigIcon,
|
||||||
|
PreviewIcon,
|
||||||
|
EditIcon,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
modalName: modalNames.EXPORT_CONFIG_MENU,
|
modalName: modalNames.EXPORT_CONFIG_MENU,
|
||||||
|
previewOpen: false,
|
||||||
|
expandedRow: null,
|
||||||
|
rows: [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
props: {},
|
|
||||||
computed: {
|
computed: {
|
||||||
config() {
|
config() {
|
||||||
return this.$store.state.configSource;
|
return this.$store.state.configSource;
|
||||||
},
|
},
|
||||||
|
rootConfig() {
|
||||||
|
return this.$store.state.rootConfig;
|
||||||
|
},
|
||||||
allowViewConfig() {
|
allowViewConfig() {
|
||||||
return this.$store.getters.permissions.allowViewConfig;
|
return this.$store.getters.permissions.allowViewConfig;
|
||||||
},
|
},
|
||||||
configPath() {
|
currentConfigYaml() {
|
||||||
|
return JsYaml.dump(this.config);
|
||||||
|
},
|
||||||
|
currentConfigPath() {
|
||||||
return this.$store.state.currentConfigInfo?.confPath
|
return this.$store.state.currentConfigInfo?.confPath
|
||||||
|| import.meta.env.VITE_APP_CONFIG_PATH
|
|| import.meta.env.VITE_APP_CONFIG_PATH
|
||||||
|| '/conf.yml';
|
|| '/conf.yml';
|
||||||
|
},
|
||||||
|
currentConfigHref() {
|
||||||
|
return formatConfigPath(this.currentConfigPath);
|
||||||
|
},
|
||||||
|
currentIssues() {
|
||||||
|
return validateConfig(this.config).errors.map((e) => formatIssue(e));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
convertJsonToYaml() {
|
buildRows() {
|
||||||
return JsYaml.dump(this.config);
|
this.expandedRow = null;
|
||||||
|
const root = this.rootConfig || {};
|
||||||
|
const rootRow = this.makeRow({
|
||||||
|
id: 'root',
|
||||||
|
title: root.pageInfo?.title || this.currentConfigPath,
|
||||||
|
path: this.currentConfigPath,
|
||||||
|
isRoot: true,
|
||||||
|
});
|
||||||
|
rootRow.yamlText = JsYaml.dump(root);
|
||||||
|
this.applyValidation(rootRow, root);
|
||||||
|
const pageRows = (root.pages || []).map((page) => this.makeRow({
|
||||||
|
id: makePageName(page.name),
|
||||||
|
title: page.name,
|
||||||
|
path: page.path,
|
||||||
|
isRoot: false,
|
||||||
|
}));
|
||||||
|
this.rows = [rootRow, ...pageRows];
|
||||||
|
// Iterate this.rows (not pageRows) so loadRow mutates the reactive proxies
|
||||||
|
this.rows.forEach((row) => { if (!row.isRoot) this.loadRow(row); });
|
||||||
},
|
},
|
||||||
downloadConfig() {
|
makeRow(base) {
|
||||||
const filename = 'dashy_conf.yml';
|
return {
|
||||||
const config = this.convertJsonToYaml();
|
status: 'loading', summary: '', yamlText: '', errorCount: 0, ...base,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async loadRow(row) {
|
||||||
|
try {
|
||||||
|
const res = await request.get(formatConfigPath(row.path));
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JsYaml.load(res.data) || {};
|
||||||
|
} catch (parseErr) {
|
||||||
|
row.status = 'error';
|
||||||
|
ErrorHandler(`Sub-config parse failed: ${row.path}`, parseErr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row.yamlText = typeof res.data === 'string' ? res.data : JsYaml.dump(parsed);
|
||||||
|
row.title = parsed.pageInfo?.title || row.title || row.path;
|
||||||
|
this.applyValidation(row, parsed);
|
||||||
|
} catch (fetchErr) {
|
||||||
|
row.status = 'unknown';
|
||||||
|
ErrorHandler(`Sub-config fetch failed: ${row.path}`, fetchErr);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
applyValidation(row, cfg) {
|
||||||
|
const { errors } = validateConfig(cfg);
|
||||||
|
row.summary = this.summarize(cfg);
|
||||||
|
row.errorCount = errors.length;
|
||||||
|
row.status = errors.length ? 'warnings' : 'valid';
|
||||||
|
},
|
||||||
|
summarize(cfg) {
|
||||||
|
const sections = Array.isArray(cfg?.sections) ? cfg.sections.length : 0;
|
||||||
|
const items = sumBy(cfg?.sections, 'items');
|
||||||
|
const widgets = sumBy(cfg?.sections, 'widgets');
|
||||||
|
return this.$t('interactive-editor.export.content-summary', { sections, items, widgets });
|
||||||
|
},
|
||||||
|
statusLabel(row) {
|
||||||
|
if (row.status === 'warnings') {
|
||||||
|
return this.$t('interactive-editor.export.status-warnings', { n: row.errorCount });
|
||||||
|
}
|
||||||
|
return this.$t(`interactive-editor.export.status-${row.status}`);
|
||||||
|
},
|
||||||
|
downloadYaml(text, filename) {
|
||||||
const element = document.createElement('a');
|
const element = document.createElement('a');
|
||||||
element.setAttribute('href', `data:text/plain;charset=utf-8,${encodeURIComponent(config)}`);
|
element.setAttribute('href', `data:text/plain;charset=utf-8,${encodeURIComponent(text)}`);
|
||||||
element.setAttribute('download', filename);
|
element.setAttribute('download', filename);
|
||||||
element.style.display = 'none';
|
element.style.display = 'none';
|
||||||
document.body.appendChild(element);
|
document.body.appendChild(element);
|
||||||
element.click();
|
element.click();
|
||||||
document.body.removeChild(element);
|
document.body.removeChild(element);
|
||||||
|
},
|
||||||
|
downloadCurrent() {
|
||||||
|
this.downloadYaml(this.currentConfigYaml, 'dashy_conf.yml');
|
||||||
InfoHandler('Config downloaded as YAML file', InfoKeys.EDITOR);
|
InfoHandler('Config downloaded as YAML file', InfoKeys.EDITOR);
|
||||||
},
|
},
|
||||||
copyConfigToClipboard() {
|
downloadRow(row) {
|
||||||
const config = this.convertJsonToYaml();
|
const filename = basename(row.path) || `${row.id}.yml`;
|
||||||
if (navigator.clipboard) {
|
this.downloadYaml(row.yamlText, filename);
|
||||||
navigator.clipboard.writeText(config);
|
InfoHandler(`Config '${row.id}' downloaded as YAML file`, InfoKeys.EDITOR);
|
||||||
this.$toast(this.$t('config.data-copied-msg'));
|
},
|
||||||
} else {
|
async copyToClipboard() {
|
||||||
|
if (!navigator.clipboard) {
|
||||||
ErrorHandler('Clipboard access requires HTTPS. See: https://bit.ly/3N5WuAA');
|
ErrorHandler('Clipboard access requires HTTPS. See: https://bit.ly/3N5WuAA');
|
||||||
this.$toast.error('Unable to copy, see log');
|
this.$toast.error(this.$t('interactive-editor.export.copy-fail-msg'));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
InfoHandler('Config copied to clipboard', InfoKeys.EDITOR);
|
try {
|
||||||
|
await navigator.clipboard.writeText(this.currentConfigYaml);
|
||||||
|
this.$toast(this.$t('config.data-copied-msg'));
|
||||||
|
InfoHandler('Config copied to clipboard', InfoKeys.EDITOR);
|
||||||
|
} catch (e) {
|
||||||
|
ErrorHandler('Clipboard write failed', e);
|
||||||
|
this.$toast.error(this.$t('interactive-editor.export.copy-fail-msg'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggleRow(row) {
|
||||||
|
this.expandedRow = this.expandedRow === row.id ? null : row.id;
|
||||||
|
},
|
||||||
|
openConfig(row) {
|
||||||
|
const target = makeRoutePath('home', row.isRoot ? null : row.id);
|
||||||
|
// this.$modal.hide(this.modalName);
|
||||||
|
if (this.$route.path !== target) this.$router.push(target);
|
||||||
|
},
|
||||||
|
editCurrent() {
|
||||||
|
this.$modal.hide(this.modalName);
|
||||||
|
this.$store.commit(StoreKeys.CONF_MENU_INDEX, 2);
|
||||||
|
this.$modal.show(modalNames.CONF_EDITOR);
|
||||||
|
this.$store.commit(StoreKeys.SET_MODAL_OPEN, true);
|
||||||
|
},
|
||||||
|
rawPathHref(row) {
|
||||||
|
return formatConfigPath(row.path);
|
||||||
},
|
},
|
||||||
modalClosed() {
|
modalClosed() {
|
||||||
this.$store.commit(StoreKeys.SET_MODAL_OPEN, false);
|
this.$store.commit(StoreKeys.SET_MODAL_OPEN, false);
|
||||||
},
|
},
|
||||||
tooltip(content) {
|
tooltip(content) {
|
||||||
return {
|
return { content, popperClass: 'in-modal-tt' };
|
||||||
content, popperClass: 'in-modal-tt',
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -117,45 +310,168 @@ export default {
|
|||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@import '@/styles/style-helpers.scss';
|
@import '@/styles/style-helpers.scss';
|
||||||
@import '@/styles/media-queries.scss';
|
@import '@/styles/media-queries.scss';
|
||||||
|
|
||||||
.tooltip { z-index: 99; }
|
.tooltip { z-index: 99; }
|
||||||
|
|
||||||
.export-config-inner {
|
.export-config-inner {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: var(--interactive-editor-background);
|
|
||||||
color: var(--interactive-editor-color);
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
background: var(--interactive-editor-background);
|
||||||
|
color: var(--interactive-editor-color);
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 1rem 0;
|
margin: 0.5rem 0 1rem 0;
|
||||||
}
|
}
|
||||||
|
section + section {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px dashed var(--interactive-editor-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-config {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
h3 { margin: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-path {
|
||||||
|
font-family: var(--font-monospace);
|
||||||
|
margin: 0;
|
||||||
|
a {
|
||||||
|
color: var(--primary);
|
||||||
|
opacity: 0.85;
|
||||||
|
text-decoration: none;
|
||||||
|
&:hover, &:focus-visible { text-decoration: underline; opacity: 1; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.download-button-container {
|
.download-button-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-wrap: wrap;
|
||||||
padding: 0 0.5rem 1rem;
|
gap: 0.5rem;
|
||||||
border-bottom: 1px dashed var(--interactive-editor-color);
|
max-width: 50rem;
|
||||||
button { margin: 0 1rem; }
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
button { flex: 1 1 10rem; margin: 0; }
|
||||||
|
@include tablet-down { flex-direction: column; }
|
||||||
}
|
}
|
||||||
.config-path-info {
|
|
||||||
p, a {
|
.collapsible-header {
|
||||||
color: var(--interactive-editor-color);
|
display: flex;
|
||||||
font-size: 1.2rem;
|
justify-content: space-between;
|
||||||
}
|
align-items: center;
|
||||||
border-bottom: 1px dashed var(--interactive-editor-color);
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
background: var(--interactive-editor-background-darker);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--curve-factor);
|
||||||
}
|
}
|
||||||
.config-tree-view {
|
|
||||||
padding: 0.5rem;
|
.issue-list {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--interactive-editor-background-darker);
|
||||||
|
border-left: 3px solid var(--warning);
|
||||||
|
border-radius: var(--curve-factor);
|
||||||
|
h4 { margin: 0 0 0.25rem; font-size: 0.95rem; }
|
||||||
|
ul { margin: 0; padding-left: 1.25rem; font-size: 0.85rem; }
|
||||||
|
li { font-family: var(--font-monospace); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.yaml-preview {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 280px;
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: auto;
|
||||||
font-family: var(--font-monospace);
|
font-family: var(--font-monospace);
|
||||||
color: var(--interactive-editor-color);
|
|
||||||
background: var(--interactive-editor-background-darker);
|
background: var(--interactive-editor-background-darker);
|
||||||
border-radius: var(--curve-factor);
|
border-radius: var(--curve-factor);
|
||||||
box-shadow: 0px 0px 3px var(--interactive-editor-color);
|
white-space: pre;
|
||||||
margin-bottom: 1.5rem;
|
font-size: 0.85rem;
|
||||||
span {
|
}
|
||||||
font-family: var(--font-monospace);
|
|
||||||
|
.config-list {
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
table-layout: fixed;
|
||||||
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
.col-title { width: 20%; }
|
||||||
|
.col-path { width: 20%; }
|
||||||
|
.col-content { width: 30%; }
|
||||||
|
.col-status { width: 15%; }
|
||||||
|
.col-actions { width: 15%; }
|
||||||
|
th, td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--interactive-editor-color);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cell-truncate .link-cell, .cell-truncate a {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--primary);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
&:hover, &:focus-visible { text-decoration: underline; }
|
||||||
|
}
|
||||||
|
.mono, .mono * {
|
||||||
|
font-family: var(--font-monospace);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
color: var(--black);
|
||||||
|
border-radius: var(--curve-factor);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-family: var(--font-monospace);
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
&.status-loading { background: var(--info); }
|
||||||
|
&.status-valid { background: var(--success); }
|
||||||
|
&.status-warnings { background: var(--warning); }
|
||||||
|
&.status-error { background: var(--danger); color: var(--white); }
|
||||||
|
&.status-unknown { background: var(--neutral); color: var(--white); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
white-space: nowrap;
|
||||||
|
button {
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--primary);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: var(--dimming-factor);
|
||||||
|
}
|
||||||
|
svg { width: 1rem; height: 1rem; }
|
||||||
|
&.arr {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.row-preview td { padding: 0; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-modal {
|
.export-modal {
|
||||||
background: var(--interactive-editor-background);
|
background: var(--interactive-editor-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal containing all the configuration options -->
|
<!-- Modal containing all the configuration options -->
|
||||||
<modal :name="modalNames.CONF_EDITOR" :resizable="true" width="60%" height="85%"
|
<modal :name="modalNames.CONF_EDITOR" :resizable="true" width="80%" height="85%"
|
||||||
@closed="editorClosed" classes="dashy-modal">
|
@closed="editorClosed" classes="dashy-modal">
|
||||||
<ConfigContainer :config="combineConfig()" />
|
<ConfigContainer :config="combineConfig()" />
|
||||||
</modal>
|
</modal>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
<modal
|
<modal
|
||||||
:name="modalNames.CONF_EDITOR"
|
:name="modalNames.CONF_EDITOR"
|
||||||
:resizable="true"
|
:resizable="true"
|
||||||
width="60%"
|
width="80%"
|
||||||
height="85%"
|
height="85%"
|
||||||
classes="dashy-modal"
|
classes="dashy-modal"
|
||||||
@closed="onConfigClosed"
|
@closed="onConfigClosed"
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { createApp } from 'vue';
|
|||||||
// Import component Vue plugins, used throughout the app
|
// Import component Vue plugins, used throughout the app
|
||||||
import VModal from '@febe95/vue-js-modal'; // Modal component (Vue 3 fork)
|
import VModal from '@febe95/vue-js-modal'; // Modal component (Vue 3 fork)
|
||||||
import VSelect from 'vue-select'; // Select dropdown component
|
import VSelect from 'vue-select'; // Select dropdown component
|
||||||
import JsonViewer from 'vue3-json-viewer'; // JSON tree viewer
|
|
||||||
|
|
||||||
// Import base Dashy components and utils
|
// Import base Dashy components and utils
|
||||||
import Dashy from '@/App.vue'; // Main Dashy Vue app
|
import Dashy from '@/App.vue'; // Main Dashy Vue app
|
||||||
@@ -32,7 +31,6 @@ app.use(store);
|
|||||||
app.use(router);
|
app.use(router);
|
||||||
app.use(i18n);
|
app.use(i18n);
|
||||||
app.use(VModal);
|
app.use(VModal);
|
||||||
app.use(JsonViewer);
|
|
||||||
app.use(Toast);
|
app.use(Toast);
|
||||||
|
|
||||||
// Register global components and directives
|
// Register global components and directives
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
--primary: #5cabca; // Main accent color
|
--primary: #5cabca; // Main accent color
|
||||||
--background: #0b1021; // Page background
|
--background: #0b1021; // Page background
|
||||||
--background-darker: #05070e; // Used for navigation bar, footer and fills
|
--background-darker: #05070e; // Used for navigation bar, footer and fills
|
||||||
|
--foreground: var(--primary); // Default text color
|
||||||
|
|
||||||
--primary-transparent-60: color-mix(in srgb, var(--primary), transparent 60%);
|
--primary-transparent-60: color-mix(in srgb, var(--primary), transparent 60%);
|
||||||
|
|
||||||
|
|||||||
@@ -7,78 +7,12 @@
|
|||||||
*/
|
*/
|
||||||
import jsYaml from 'js-yaml';
|
import jsYaml from 'js-yaml';
|
||||||
import { parseDocument } from 'yaml';
|
import { parseDocument } from 'yaml';
|
||||||
import Ajv from 'ajv';
|
|
||||||
import addFormats from 'ajv-formats';
|
|
||||||
|
|
||||||
import schema from './ConfigSchema.json';
|
import { compiledValidator as validate, formatIssue } from './validateConfig';
|
||||||
import { pointerToPath, yamlNodeAt, pairRange } from './schemaPath';
|
import { pointerToPath, yamlNodeAt, pairRange } from './schemaPath';
|
||||||
|
|
||||||
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
||||||
addFormats(ajv);
|
|
||||||
const validate = ajv.compile(schema);
|
|
||||||
|
|
||||||
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
||||||
|
|
||||||
// Turn Ajv's raw errors into user readable strings
|
|
||||||
const formatError = (err) => {
|
|
||||||
const { keyword, params, message } = err;
|
|
||||||
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
||||||
switch (keyword) {
|
|
||||||
case 'enum':
|
|
||||||
return `must be one of: ${(params.allowedValues || []).join(', ')}`;
|
|
||||||
case 'const':
|
|
||||||
return `must be: ${JSON.stringify(params.allowedValue)}`;
|
|
||||||
case 'required':
|
|
||||||
return `missing required field: ${params.missingProperty}`;
|
|
||||||
case 'additionalProperties':
|
|
||||||
return `unknown property: ${params.additionalProperty}`;
|
|
||||||
case 'propertyNames':
|
|
||||||
return `invalid property name: ${params.propertyName}`;
|
|
||||||
case 'dependencies':
|
|
||||||
case 'dependentRequired':
|
|
||||||
return `missing required field: ${params.missingProperty} (needed when ${params.property} is set)`;
|
|
||||||
case 'type':
|
|
||||||
return `must be ${Array.isArray(params.type) ? params.type.join(' or ') : params.type}`;
|
|
||||||
case 'minLength':
|
|
||||||
return `must be at least ${plural(params.limit, 'character')}`;
|
|
||||||
case 'maxLength':
|
|
||||||
return `must be at most ${plural(params.limit, 'character')}`;
|
|
||||||
case 'minimum':
|
|
||||||
case 'maximum':
|
|
||||||
return `must be ${keyword === 'minimum' ? '≥' : '≤'} ${params.limit}`;
|
|
||||||
case 'exclusiveMinimum':
|
|
||||||
case 'exclusiveMaximum':
|
|
||||||
return `must be ${keyword === 'exclusiveMinimum' ? '>' : '<'} ${params.limit}`;
|
|
||||||
case 'multipleOf':
|
|
||||||
return `must be a multiple of ${params.multipleOf}`;
|
|
||||||
case 'minItems':
|
|
||||||
return `must have at least ${plural(params.limit, 'item')}`;
|
|
||||||
case 'maxItems':
|
|
||||||
return `must have at most ${plural(params.limit, 'item')}`;
|
|
||||||
case 'uniqueItems':
|
|
||||||
return 'must not contain duplicate items';
|
|
||||||
case 'minProperties':
|
|
||||||
return `must have at least ${plural(params.limit, 'property')}`;
|
|
||||||
case 'maxProperties':
|
|
||||||
return `must have at most ${plural(params.limit, 'property')}`;
|
|
||||||
case 'pattern':
|
|
||||||
return `must match pattern ${params.pattern}`;
|
|
||||||
case 'format':
|
|
||||||
return `must be a valid ${params.format}`;
|
|
||||||
case 'anyOf':
|
|
||||||
case 'oneOf':
|
|
||||||
return 'must match one of the allowed shapes for this field';
|
|
||||||
case 'not':
|
|
||||||
return 'must not match the disallowed shape for this field';
|
|
||||||
case 'if':
|
|
||||||
return 'does not match the conditional schema for this field';
|
|
||||||
default:
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const prefix = (instancePath) => (instancePath ? `${instancePath} ` : '');
|
|
||||||
|
|
||||||
export function schemaLinter(view) {
|
export function schemaLinter(view) {
|
||||||
const text = view.state.doc.toString();
|
const text = view.state.doc.toString();
|
||||||
if (!text.trim()) return [];
|
if (!text.trim()) return [];
|
||||||
@@ -112,7 +46,7 @@ export function schemaLinter(view) {
|
|||||||
to,
|
to,
|
||||||
severity: 'warning',
|
severity: 'warning',
|
||||||
source: 'schema',
|
source: 'schema',
|
||||||
message: `${prefix(err.instancePath)}${formatError(err)}`,
|
message: formatIssue(err),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
62
src/utils/config/validateConfig.js
Normal file
62
src/utils/config/validateConfig.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Single shared AJV validator for Dashy's ConfigSchema.json.
|
||||||
|
* Used by:
|
||||||
|
* - schemaLinter.js (CodeMirror diagnostics in the JSON editor)
|
||||||
|
* - ExportConfigMenu (status badges per config in the export dialog)
|
||||||
|
* Note: SchemaForm.vue keeps its own AJV instance because JSONForms manages it.
|
||||||
|
*/
|
||||||
|
import Ajv from 'ajv';
|
||||||
|
import addFormats from 'ajv-formats';
|
||||||
|
import schema from './ConfigSchema.json';
|
||||||
|
|
||||||
|
const ajv = new Ajv({ allErrors: true, strict: false });
|
||||||
|
addFormats(ajv);
|
||||||
|
|
||||||
|
/* Raw compiled validator: call as a fn, then read .errors. Prefer validateConfig() unless you need positions. */
|
||||||
|
export const compiledValidator = ajv.compile(schema);
|
||||||
|
|
||||||
|
/* Convenience wrapper returning { valid, errors }. */
|
||||||
|
export function validateConfig(data) {
|
||||||
|
const valid = compiledValidator(data);
|
||||||
|
return { valid, errors: valid ? [] : (compiledValidator.errors || []) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Turn a raw Ajv error into a user-readable message (no path). */
|
||||||
|
export function formatError(err) {
|
||||||
|
const { keyword, params, message } = err;
|
||||||
|
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
||||||
|
switch (keyword) {
|
||||||
|
case 'enum': return `must be one of: ${(params.allowedValues || []).join(', ')}`;
|
||||||
|
case 'const': return `must be: ${JSON.stringify(params.allowedValue)}`;
|
||||||
|
case 'required': return `missing required field: ${params.missingProperty}`;
|
||||||
|
case 'additionalProperties': return `unknown property: ${params.additionalProperty}`;
|
||||||
|
case 'propertyNames': return `invalid property name: ${params.propertyName}`;
|
||||||
|
case 'dependencies':
|
||||||
|
case 'dependentRequired': return `missing required field: ${params.missingProperty} (needed when ${params.property} is set)`;
|
||||||
|
case 'type': return `must be ${Array.isArray(params.type) ? params.type.join(' or ') : params.type}`;
|
||||||
|
case 'minLength': return `must be at least ${plural(params.limit, 'character')}`;
|
||||||
|
case 'maxLength': return `must be at most ${plural(params.limit, 'character')}`;
|
||||||
|
case 'minimum':
|
||||||
|
case 'maximum': return `must be ${keyword === 'minimum' ? '≥' : '≤'} ${params.limit}`;
|
||||||
|
case 'exclusiveMinimum':
|
||||||
|
case 'exclusiveMaximum': return `must be ${keyword === 'exclusiveMinimum' ? '>' : '<'} ${params.limit}`;
|
||||||
|
case 'multipleOf': return `must be a multiple of ${params.multipleOf}`;
|
||||||
|
case 'minItems': return `must have at least ${plural(params.limit, 'item')}`;
|
||||||
|
case 'maxItems': return `must have at most ${plural(params.limit, 'item')}`;
|
||||||
|
case 'uniqueItems': return 'must not contain duplicate items';
|
||||||
|
case 'minProperties': return `must have at least ${plural(params.limit, 'property')}`;
|
||||||
|
case 'maxProperties': return `must have at most ${plural(params.limit, 'property')}`;
|
||||||
|
case 'pattern': return `must match pattern ${params.pattern}`;
|
||||||
|
case 'format': return `must be a valid ${params.format}`;
|
||||||
|
case 'anyOf':
|
||||||
|
case 'oneOf': return 'must match one of the allowed shapes for this field';
|
||||||
|
case 'not': return 'must not match the disallowed shape for this field';
|
||||||
|
case 'if': return 'does not match the conditional schema for this field';
|
||||||
|
default: return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Combined "<path> <message>" with optional path prefix. */
|
||||||
|
export function formatIssue(err) {
|
||||||
|
return `${err.instancePath ? `${err.instancePath} ` : ''}${formatError(err)}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user