Merge pull request #43 from Lissy93/feature_write-config-to-file-system

[FEATURE] Adds ability for config file to be written to directly from the UI
This commit is contained in:
Alicia Sykes 2021-06-21 13:52:57 +01:00 committed by GitHub
commit 6e0449f615
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 836 additions and 186 deletions

View File

@ -4,6 +4,7 @@ FROM node:lts-alpine
ENV PORT 80
ENV DIRECTORY /app
ENV IS_DOCKER true
ENV NODE_ENV production
# Create and set the working directory
WORKDIR ${DIRECTORY}

View File

@ -93,10 +93,12 @@ Dashy supports 1-Click deployments on several popular cloud platforms (with more
Dashy is configured with a single [YAML](https://yaml.org/) file, located at `./public/conf.yml` (or `./app/public/conf.yml` for Docker). Any other optional user-customizable assets are also located in the `./public/` directory, e.g. `favicon.ico`, `manifest.json`, `robots.txt` and `web-icons/*`. If you are using Docker, the easiest way to method is to mount a Docker volume (e.g. `-v /root/my-local-conf.yml:/app/public/conf.yml`)
In the production environment, the app needs to be rebuilt in order for changes to take effect. This can be done with `yarn build`, or `docker exec -it [container-id] yarn build` if you are using Docker (where container ID can be found by running `docker ps`).
In the production environment, the app needs to be rebuilt in order for changes to take effect. This should happen automatically, but can also be triggered by running `yarn build`, or `docker exec -it [container-id] yarn build` if you are using Docker (where container ID can be found by running `docker ps`).
You can check that your config matches Dashy's [schema](https://github.com/Lissy93/dashy/blob/master/src/utils/ConfigSchema.json) before deploying, by running `yarn validate-config.`
It is now possible to update Dashy's config directly through the UI, and have changes written to disk. You can disable this feature by setting: `appConfig.allowConfigEdit: false`. If you are using users within Dashy, then you need to be logged in to a user of `type: admin` in order to modify the configuration globally. You can also trigger a rebuild of the app through the UI (Settings --> Rebuild). The current theme, and other visual preferences are only stored locally, unless otherwise specified in the config file. The option to only apply config changes locally is still available, and can be used in conjunction with the cloud backup feature to sync data between instances.
You may find these [example config](https://gist.github.com/Lissy93/000f712a5ce98f212817d20bc16bab10) helpful for getting you started
**[⬆️ Back to Top](#dashy)**

View File

@ -3,6 +3,9 @@
version: "3.8"
services:
dashy:
# Set any environmental variables
environment:
NODE_ENV: production
# To build from source, replace 'image: lissy93/dashy' with 'build: .'
# build: .
image: lissy93/dashy
@ -16,10 +19,12 @@ services:
# environment:
# - UID=1000
# - GID=1000
# Specify restart policy
restart: unless-stopped
# Configure healthchecks
healthcheck:
test: ['CMD', 'node', '/app/services/healthcheck']
interval: 1m30s
timeout: 10s
retries: 3
start_period: 40s
start_period: 40s

View File

@ -7,9 +7,17 @@ If you're new to YAML, it's pretty straight-forward. The format is exactly the s
You may find it helpful to look at some sample config files to get you started, a collection of which can be found [here](https://gist.github.com/Lissy93/000f712a5ce98f212817d20bc16bab10).
There's a couple of things to remember, before getting started:
- After modifying your config, you will need to run `yarn build` to recompile the application
- After modifying your config, the app needs to be recompiled, run `yarn build` (this happens automatically in newer versions)
- You can check that your config file fits the schema, by running `yarn validate-config`
- Any changes made locally through the UI need to be exported into this file, in order for them to persist across devices
- Any which are only saved locally through the UI need to be exported into this file, in order for them to persist across devices
#### Config Saving Methods
When updating the config through the JSON editor in the UI, you have two save options: **Local** or **Write to Disk**. Changes saved locally will only be applied to the current user through the browser, and to apply to other instances, you either need to use the cloud sync feature, or manually update the conf.yml file. On the other-hand, if you choose to write changes to disk, then your main `conf.yml` file will be updated, and changes will be applied to all users, and visible across all devices.
#### Preventing Changes being Written to Disk
To disallow any changes from being written to disk, then set `appConfig.allowConfigEdit: false`. If you are using users, and have setup `auth` within Dashy, then only users with `type: admin` will be able to write config changes to disk.
It is recommended to make a backup of your config file.
All fields are optional, unless otherwise stated.
@ -58,6 +66,7 @@ All fields are optional, unless otherwise stated.
**`customCss`** | `string` | _Optional_ | Raw CSS that will be applied to the page. This can also be set from the UI. Please minify it first.
**`showSplashScreen`** | `boolean` | _Optional_ | Should display a splash screen while the app is loading. Defaults to false, except on first load
**`auth`** | `array` | _Optional_ | An array of objects containing usernames and hashed passwords. If this is not provided, then authentication will be off by default, and you will not need any credentials to access the app. Note authentication is done on the client side, and so if your instance of Dashy is exposed to the internet, it is recommend to configure your web server to handle this. See [`auth`](#appconfigauth-optional)
**`allowConfigEdit`** | `boolean` | _Optional_ | Should prevent / allow the user to write configuration changes to the conf.yml from the UI. When set to `false`, the user can only apply changes locally using the config editor within the app, whereas if set to `true` then changes can be written to disk directly through the UI. Defaults to `true`. Note that if authentication is enabled, the user must be of type `admin` in order to apply changes globally.
**[⬆️ Back to Top](#configuring)**

View File

@ -83,6 +83,9 @@ on how to create a pull request..
8. [Open a Pull Request](https://help.github.com/articles/using-pull-requests/)
with a clear title and description.
You can use emojis in your commit message, to indicate the category of the task.
For a reference of what each emoji means in the context of commits, see [gitmoji.dev](https://gitmoji.dev/).
#### Testing the Production App
For larger pull requests, please also check that it works as expected in a production environment.

View File

@ -269,6 +269,12 @@ Dashy ships with a pre-configured Node.js server, in [`server.js`](https://githu
If you wish to run Dashy from a sub page (e.g. `example.com/dashy`), then just set the `BASE_URL` environmental variable to that page name (in this example, `/dashy`), before building the app, and the path to all assets will then resolve to the new path, instead of `./`.
However, since Dashy is just a static web application, it can be served with whatever server you like. The following section outlines how you can configure a web server.
Note, that if you choose not to use `server.js` to serve up the app, you will loose access to the following features:
- Loading page, while the app is building
- Writing config file to disk from the UI
- Website status indicators, and ping checks
### NGINX
Create a new file in `/etc/nginx/sites-enabled/dashy`

View File

@ -48,8 +48,8 @@ Note:
- If you are using Docker, precede each command with `docker exec -it [container-id]`. Container ID can be found by running `docker ps`
### Environmental Variables
- `PORT` - The port in which the application will run (defaults to `4000` for the Node.js server, and `80` within the Docker container)
- `NODE_ENV` - Which environment to use, either `production`, `development` or `test`
- `VUE_APP_DOMAIN` - The URL where Dashy is going to be accessible from. This should include the protocol, hostname and (if not 80 or 443), then the port too, e.g. `https://localhost:3000`, `http://192.168.1.2:4002` or `https://dashy.mydomain.com`
All environmental variables are optional. Currently there are not many environmental variables used, as most of the user preferences are stored under `appConfig` in the `conf.yml` file.
@ -58,6 +58,15 @@ If you do add new variables, ensure that there is always a fallback (define it i
Any environmental variables used by the frontend are preceded with `VUE_APP_`. Vue will merge the contents of your `.env` file into the app in a similar way to the ['dotenv'](https://github.com/motdotla/dotenv) package, where any variables that you set on your system will always take preference over the contents of any `.env` file.
### Environment Modes
Both the Node app and Vue app supports several environments: `production`, `development` and `test`. You can set the environment using the `NODE_ENV` variable (either with your OS, in the Docker script or in an `.env` file - see [Environmental Variables](#environmental-variables) above).
The production environment will build the app in full, minifying and streamlining all assets. This means that building takes longer, but the app will then run faster. Whereas the dev environment creates a webpack configuration which enables HMR, doesn't hash assets or create vendor bundles in order to allow for fast re-builds when running a dev server. It supports sourcemaps and other debugging tools, re-compiles and reloads quickly but is not optimized, and so the app will not be as snappy as it could be. The test environment is intended for test running servers, it ignores assets that aren't needed for testing, and focuses on running all the E2E, regression and unit tests. For more information, see [Vue CLI Environment Modes](https://cli.vuejs.org/guide/mode-and-env.html#modes).
By default:
- `production` is used by `yarn build` (or `vue-cli-service build`) and `yarn build-and-start` and `yarn pm2-start`
- `development` is used by `yarn dev` (or `vue-cli-service serve`)
- `test` is used by `yarn test` (or `vue-cli-service test:unit`)
### Resources for Beginners
New to Web Development? Glad you're here! Dashy is a pretty simple app, so it should make a good candidate for your first PR. Presuming that you already have a basic knowledge of JavaScript, the following articles should point you in the right direction for getting up to speed with the technologies used in this project:
- [Introduction to Vue.js](https://v3.vuejs.org/guide/introduction.html)

View File

@ -118,6 +118,10 @@ You can target specific elements on the UI with these variables. All are optiona
- `--side-bar-color` - Color of icons and text within the sidebar. Defaults to `--primary`
- `--status-check-tooltip-background` - Background color for status check tooltips. Defaults to `--background-darker`
- `--status-check-tooltip-color` - Text color for the status check tooltips. Defaults to `--primary`
- `--code-editor-color` - Text color used within raw code editors. Defaults to `--black`
- `--code-editor-background` - Background color for raw code editors. Defaults to `--white`
#### Non-Color Variables
- `--outline-color` - Used to outline focused or selected elements

View File

@ -7,16 +7,17 @@
"start": "node server",
"dev": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint --fix",
"lint": "vue-cli-service lint",
"pm2-start": "npx pm2 start server.js",
"build-watch": "vue-cli-service build --watch",
"build-and-start": "npm-run-all --parallel build start",
"build-watch": "vue-cli-service build --watch --mode production",
"build-and-start": "npm-run-all --parallel build-watch start",
"validate-config": "node src/utils/ConfigValidator",
"health-check": "node services/healthcheck"
},
"dependencies": {
"ajv": "^8.5.0",
"axios": "^0.21.1",
"body-parser": "^1.19.0",
"connect": "^3.7.0",
"crypto-js": "^4.0.0",
"highlight.js": "^11.0.0",

110
server.js
View File

@ -1,84 +1,92 @@
/* eslint-disable no-console */
/* This is a simple Node.js http server, that is used to serve up the contents of ./dist */
const connect = require('connect');
const serveStatic = require('serve-static');
/**
* Note: The app must first be built (yarn build) before this script is run
* This is the main entry point for the application, a simple server that
* runs some checks, and then serves up the app from the ./dist directory
* Also includes some routes for status checks/ ping and config saving
* */
/* Include required node dependencies */
const serveStatic = require('serve-static');
const connect = require('connect');
const util = require('util');
const dns = require('dns');
const os = require('os');
const bodyParser = require('body-parser');
require('./src/utils/ConfigValidator');
const pingUrl = require('./services/ping');
/* Include helper functions and route handlers */
const pingUrl = require('./services/ping'); // Used by the status check feature, to ping services
const saveConfig = require('./services/save-config'); // Saves users new conf.yml to file-system
const printMessage = require('./services/print-message'); // Function to print welcome msg on start
const rebuild = require('./services/rebuild-app'); // A script to programmatically trigger a build
require('./src/utils/ConfigValidator'); // Include and kicks off the config file validation script
/* Checks if app is running within a container, from env var */
const isDocker = !!process.env.IS_DOCKER;
/* Checks env var for port. If undefined, will use Port 80 for Docker, or 4000 for metal */
const port = process.env.PORT || (isDocker ? 80 : 4000);
/* Attempts to get the users local IP, used as part of welcome message */
const getLocalIp = () => {
const dnsLookup = util.promisify(dns.lookup);
return dnsLookup(os.hostname());
};
const overComplicatedMessage = (ip) => {
let msg = '';
const chars = {
RESET: '\x1b[0m',
CYAN: '\x1b[36m',
GREEN: '\x1b[32m',
BLUE: '\x1b[34m',
BRIGHT: '\x1b[1m',
BR: '\n',
};
const stars = (count) => new Array(count).fill('*').join('');
const line = (count) => new Array(count).fill('━').join('');
const blanks = (count) => new Array(count).fill(' ').join('');
if (isDocker) {
const containerId = process.env.HOSTNAME || undefined;
msg = `${chars.BLUE}${stars(91)}${chars.BR}${chars.RESET}`
+ `${chars.CYAN}Welcome to Dashy! 🚀${chars.RESET}${chars.BR}`
+ `${chars.GREEN}Your new dashboard is now up and running `
+ `${containerId ? `in container ID ${containerId}` : 'with Docker'}${chars.BR}`
+ `${chars.GREEN}After updating your config file, run `
+ `'${chars.BRIGHT}docker exec -it ${containerId || '[container-id]'} yarn build`
+ `${chars.RESET}${chars.GREEN}' to rebuild${chars.BR}`
+ `${chars.BLUE}${stars(91)}${chars.BR}${chars.RESET}`;
} else {
msg = `${chars.GREEN}${line(75)}${chars.BR}`
+ `${chars.CYAN}Welcome to Dashy! 🚀${blanks(55)}${chars.GREEN}${chars.BR}`
+ `${chars.CYAN}Your new dashboard is now up and running at ${chars.BRIGHT}`
+ `http://${ip}:${port}${chars.RESET}${blanks(18 - ip.length)}${chars.GREEN}${chars.BR}`
+ `${chars.CYAN}After updating your config file, run '${chars.BRIGHT}yarn build`
+ `${chars.RESET}${chars.CYAN}' to rebuild the app${blanks(6)}${chars.GREEN}${chars.BR}`
+ `${line(75)}${chars.BR}${chars.BR}`;
}
return msg;
};
/* eslint no-console: 0 */
/* Gets the users local IP and port, then calls to print welcome message */
const printWelcomeMessage = () => {
getLocalIp().then(({ address }) => {
const ip = address || 'localhost';
console.log(overComplicatedMessage(ip));
console.log(printMessage(ip, port, isDocker)); // eslint-disable-line no-console
});
};
/* Just console.warns an error */
const printWarning = (msg, error) => {
console.warn(`\x1b[103m\x1b[34m${msg}\x1b[0m\n`, error || ''); // eslint-disable-line no-console
};
/* A middleware function for Connect, that filters requests based on method type */
const method = (m, mw) => (req, res, next) => (req.method === m ? mw(req, res, next) : next());
try {
connect()
.use(serveStatic(`${__dirname}/dist`)) /* Serves up the main built application to the root */
.use(serveStatic(`${__dirname}/public`, { index: 'default.html' })) /* During build, a custom page will be served */
.use('/ping', (req, res) => { /* This root returns the status of a given service - used for uptime monitoring */
.use(bodyParser.json())
// Serves up the main built application to the root
.use(serveStatic(`${__dirname}/dist`))
// During build, a custom page will be served before the app is available
.use(serveStatic(`${__dirname}/public`, { index: 'default.html' }))
// This root returns the status of a given service - used for uptime monitoring
.use('/ping', (req, res) => {
try {
pingUrl(req.url, async (results) => {
await res.end(results);
});
// next();
} catch (e) { console.warn(`Error running ping check for ${req.url}\n`, e); }
} catch (e) {
printWarning(`Error running ping check for ${req.url}\n`, e);
}
})
// POST Endpoint used to save config, by writing conf.yml to disk
.use('/config-manager/save', method('POST', (req, res) => {
try {
saveConfig(req.body, (results) => {
res.end(results);
});
} catch (e) {
res.end(JSON.stringify({ success: false, message: e }));
}
}))
// GET endpoint to trigger a build, and respond with success status and output
.use('/config-manager/rebuild', (req, res) => {
rebuild().then((response) => {
res.end(JSON.stringify(response));
}).catch((response) => {
res.end(JSON.stringify(response));
});
})
// Finally, initialize the server then print welcome message
.listen(port, () => {
try { printWelcomeMessage(); } catch (e) { console.log('Dashy is Starting...'); }
try { printWelcomeMessage(); } catch (e) { printWarning('Dashy is Starting...'); }
});
} catch (error) {
console.log('Sorry, an error occurred ', error);
printWarning('Sorry, a critical error occurred ', error);
}

View File

@ -1,66 +1,66 @@
/**
* This file contains the Node.js code, used for the optional status check feature
* It accepts a single url parameter, and will make an empty GET request to that
* endpoint, and then resolve the response status code, time taken, and short message
*/
const axios = require('axios').default;
/* Determines if successful from the HTTP response code */
const getResponseType = (code) => {
if (Number.isNaN(code)) return false;
const numericCode = parseInt(code, 10);
return (numericCode >= 200 && numericCode <= 302);
};
/* Makes human-readable response text for successful check */
const makeMessageText = (data) => `${data.successStatus ? '✅' : '⚠️'} `
+ `${data.serverName || 'Server'} responded with `
+ `${data.statusCode} - ${data.statusText}. `
+ `\nTook ${data.timeTaken} ms`;
/* Makes human-readable response text for failed check */
const makeErrorMessage = (data) => `❌ Service Unavailable: ${data.hostname || 'Server'} `
+ `resulted in ${data.code || 'a fatal error'} ${data.errno ? `(${data.errno})` : ''}`;
const makeErrorMessage2 = (data) => `❌ Service Error - `
+ `${data.status} - ${data.statusText}`;
/* Kicks of a HTTP request, then formats and renders results */
const makeRequest = (url, render) => {
const startTime = new Date();
axios.get(url)
.then((response) => {
const statusCode = response.status;
const { statusText } = response;
const successStatus = getResponseType(statusCode);
const serverName = response.request.socket.servername;
const timeTaken = (new Date() - startTime);
const results = {
statusCode, statusText, serverName, successStatus, timeTaken,
};
const messageText = makeMessageText(results);
results.message = messageText;
return results;
})
.catch((error) => {
render(JSON.stringify({
successStatus: false,
message: error.response ? makeErrorMessage2(error.response) : makeErrorMessage(error),
}));
}).then((results) => {
render(JSON.stringify(results));
});
};
/* Main function, will check if a URL present, and call function */
module.exports = (params, render) => {
if (!params || !params.includes('=')) {
render(JSON.stringify({
success: false,
message: '❌ Malformed URL',
}));
} else {
const url = params.split('=')[1];
makeRequest(url, render);
}
};
/**
* This file contains the Node.js code, used for the optional status check feature
* It accepts a single url parameter, and will make an empty GET request to that
* endpoint, and then resolve the response status code, time taken, and short message
*/
const axios = require('axios').default;
/* Determines if successful from the HTTP response code */
const getResponseType = (code) => {
if (Number.isNaN(code)) return false;
const numericCode = parseInt(code, 10);
return (numericCode >= 200 && numericCode <= 302);
};
/* Makes human-readable response text for successful check */
const makeMessageText = (data) => `${data.successStatus ? '✅' : '⚠️'} `
+ `${data.serverName || 'Server'} responded with `
+ `${data.statusCode} - ${data.statusText}. `
+ `\nTook ${data.timeTaken} ms`;
/* Makes human-readable response text for failed check */
const makeErrorMessage = (data) => `❌ Service Unavailable: ${data.hostname || 'Server'} `
+ `resulted in ${data.code || 'a fatal error'} ${data.errno ? `(${data.errno})` : ''}`;
const makeErrorMessage2 = (data) => '❌ Service Error - '
+ `${data.status} - ${data.statusText}`;
/* Kicks of a HTTP request, then formats and renders results */
const makeRequest = (url, render) => {
const startTime = new Date();
axios.get(url)
.then((response) => {
const statusCode = response.status;
const { statusText } = response;
const successStatus = getResponseType(statusCode);
const serverName = response.request.socket.servername;
const timeTaken = (new Date() - startTime);
const results = {
statusCode, statusText, serverName, successStatus, timeTaken,
};
const messageText = makeMessageText(results);
results.message = messageText;
return results;
})
.catch((error) => {
render(JSON.stringify({
successStatus: false,
message: error.response ? makeErrorMessage2(error.response) : makeErrorMessage(error),
}));
}).then((results) => {
render(JSON.stringify(results));
});
};
/* Main function, will check if a URL present, and call function */
module.exports = (params, render) => {
if (!params || !params.includes('=')) {
render(JSON.stringify({
success: false,
message: '❌ Malformed URL',
}));
} else {
const url = params.split('=')[1];
makeRequest(url, render);
}
};

42
services/print-message.js Normal file
View File

@ -0,0 +1,42 @@
/**
* A function that prints a welcome message to the user when they start the app
* Contains essential info about restarting and managing the container or service
* @param String ip: The users local IP address
* @param Integer port: the port number the app is running at
* @param Boolean isDocker: whether or not the app is being run within a container
* @returns A string formatted for the terminal
*/
module.exports = (ip, port, isDocker) => {
let msg = '';
const chars = {
RESET: '\x1b[0m',
CYAN: '\x1b[36m',
GREEN: '\x1b[32m',
BLUE: '\x1b[34m',
BRIGHT: '\x1b[1m',
BR: '\n',
};
const stars = (count) => new Array(count).fill('*').join('');
const line = (count) => new Array(count).fill('━').join('');
const blanks = (count) => new Array(count).fill(' ').join('');
if (isDocker) {
const containerId = process.env.HOSTNAME || undefined;
msg = `${chars.BLUE}${stars(91)}${chars.BR}${chars.RESET}`
+ `${chars.CYAN}Welcome to Dashy! 🚀${chars.RESET}${chars.BR}`
+ `${chars.GREEN}Your new dashboard is now up and running `
+ `${containerId ? `in container ID ${containerId}` : 'with Docker'}${chars.BR}`
+ `${chars.GREEN}After updating your config file, run `
+ `'${chars.BRIGHT}docker exec -it ${containerId || '[container-id]'} yarn build`
+ `${chars.RESET}${chars.GREEN}' to rebuild${chars.BR}`
+ `${chars.BLUE}${stars(91)}${chars.BR}${chars.RESET}`;
} else {
msg = `${chars.GREEN}${line(75)}${chars.BR}`
+ `${chars.CYAN}Welcome to Dashy! 🚀${blanks(55)}${chars.GREEN}${chars.BR}`
+ `${chars.CYAN}Your new dashboard is now up and running at ${chars.BRIGHT}`
+ `http://${ip}:${port}${chars.RESET}${blanks(18 - ip.length)}${chars.GREEN}${chars.BR}`
+ `${chars.CYAN}After updating your config file, run '${chars.BRIGHT}yarn build`
+ `${chars.RESET}${chars.CYAN}' to rebuild the app${blanks(6)}${chars.GREEN}${chars.BR}`
+ `${line(75)}${chars.BR}${chars.BR}${chars.RESET}`;
}
return msg;
};

31
services/rebuild-app.js Normal file
View File

@ -0,0 +1,31 @@
/**
* This script programmatically triggers a production build
* and responds with the status, message and full output
*/
const { exec } = require('child_process');
module.exports = () => new Promise((resolve, reject) => {
const buildProcess = exec('npm run build');
let output = '';
buildProcess.stdout.on('data', (data) => {
process.stdout.write(data);
output += data;
});
buildProcess.on('error', (error) => {
reject(Error({
success: false,
error,
output,
}));
});
buildProcess.on('exit', (response) => {
const success = response === 0;
const message = `Build process exited with ${response}: `
+ `${success ? 'Success' : 'Possible Error'}`;
resolve({ success, message, output });
});
});

52
services/save-config.js Normal file
View File

@ -0,0 +1,52 @@
/**
* This file exports a function, used by the write config endpoint.
* It will make a backup of the users conf.yml file
* and then write their new config into the main conf.yml file.
* Finally, it will call a function with the status message
*/
const fsPromises = require('fs').promises;
module.exports = async (newConfig, render) => {
// Define constants for the config file
const settings = {
defaultLocation: './public/',
defaultFile: 'conf.yml',
filename: 'conf',
backupDenominator: '.backup.yml',
};
// Make the full file name and path to save the backup config file
const backupFilePath = `${settings.defaultLocation}${settings.filename}-`
+ `${Math.round(new Date() / 1000)}${settings.backupDenominator}`;
// The path where the main conf.yml should be read and saved to
const defaultFilePath = settings.defaultLocation + settings.defaultFile;
// Returns a string confirming successful job
const getSuccessMessage = () => `Successfully backed up ${settings.defaultFile} to`
+ ` ${backupFilePath}, and updated the contents of ${defaultFilePath}`;
// Encoding options for writing to conf file
const writeFileOptions = { encoding: 'utf8' };
// Prepare the response returned by the API
const getRenderMessage = (success, errorMsg) => JSON.stringify({
success,
message: !success ? errorMsg : getSuccessMessage(),
});
// Makes a backup of the existing config file
await fsPromises.copyFile(defaultFilePath, backupFilePath)
.catch((error) => {
render(getRenderMessage(false, `Unable to backup conf.yml: ${error}`));
});
// Writes the new content to the conf.yml file
await fsPromises.writeFile(defaultFilePath, newConfig.config.toString(), writeFileOptions)
.catch((error) => {
render(getRenderMessage(false, `Unable to write changes to conf.yml: ${error}`));
});
// If successful, then render hasn't yet been called- call it
await render(getRenderMessage(true));
};

View File

@ -1,9 +1,9 @@
<template>
<div id="dashy">
<LoadingScreen :isLoading="isLoading" v-if="shouldShowSplash()" />
<Header :pageInfo="pageInfo" />
<Header :pageInfo="pageInfo" v-if="!shouldHidePageComponents()" />
<router-view />
<Footer v-if="showFooter" :text="getFooterText()" />
<Footer v-if="showFooter && !shouldHidePageComponents()" :text="getFooterText()" />
</div>
</template>
<script>
@ -48,12 +48,20 @@ export default {
return this.appConfig.showSplashScreen || !localStorage[localStorageKeys.HIDE_WELCOME_BANNER];
},
hideSplash() {
if (this.shouldShowSplash()) {
if (this.shouldShowSplash() && !this.shouldHidePageComponents()) {
setTimeout(() => { this.isLoading = false; }, splashScreenTime || 2000);
} else {
this.isLoading = false;
}
},
shouldHidePageComponents() {
return (['download'].includes(this.$route.name));
},
},
computed: {
currentRouteName() {
return this.$route.name;
},
},
mounted() {
this.hideSplash();

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="hammer" class="svg-inline--fa fa-hammer fa-w-18" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path fill="currentColor" d="M571.31 193.94l-22.63-22.63c-6.25-6.25-16.38-6.25-22.63 0l-11.31 11.31-28.9-28.9c5.63-21.31.36-44.9-16.35-61.61l-45.25-45.25c-62.48-62.48-163.79-62.48-226.28 0l90.51 45.25v18.75c0 16.97 6.74 33.25 18.75 45.25l49.14 49.14c16.71 16.71 40.3 21.98 61.61 16.35l28.9 28.9-11.31 11.31c-6.25 6.25-6.25 16.38 0 22.63l22.63 22.63c6.25 6.25 16.38 6.25 22.63 0l90.51-90.51c6.23-6.24 6.23-16.37-.02-22.62zm-286.72-15.2c-3.7-3.7-6.84-7.79-9.85-11.95L19.64 404.96c-25.57 23.88-26.26 64.19-1.53 88.93s65.05 24.05 88.93-1.53l238.13-255.07c-3.96-2.91-7.9-5.87-11.44-9.41l-49.14-49.14z"></path></svg>

After

Width:  |  Height:  |  Size: 798 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="sync" class="svg-inline--fa fa-sync fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M440.65 12.57l4 82.77A247.16 247.16 0 0 0 255.83 8C134.73 8 33.91 94.92 12.29 209.82A12 12 0 0 0 24.09 224h49.05a12 12 0 0 0 11.67-9.26 175.91 175.91 0 0 1 317-56.94l-101.46-4.86a12 12 0 0 0-12.57 12v47.41a12 12 0 0 0 12 12H500a12 12 0 0 0 12-12V12a12 12 0 0 0-12-12h-47.37a12 12 0 0 0-11.98 12.57zM255.83 432a175.61 175.61 0 0 1-146-77.8l101.8 4.87a12 12 0 0 0 12.57-12v-47.4a12 12 0 0 0-12-12H12a12 12 0 0 0-12 12V500a12 12 0 0 0 12 12h47.35a12 12 0 0 0 12-12.6l-4.15-82.57A247.17 247.17 0 0 0 255.83 504c121.11 0 221.93-86.92 243.55-201.82a12 12 0 0 0-11.8-14.18h-49.05a12 12 0 0 0-11.67 9.26A175.86 175.86 0 0 1 255.83 432z"></path></svg>

After

Width:  |  Height:  |  Size: 855 B

View File

@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="100px" height="100px" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid">
<defs>
<clipPath id="ldio-owbkoh4un5-cp">
<rect x="20" y="0" width="60" height="100"></rect>
</clipPath>
</defs>
<path
fill="none"
stroke="var(--primary, #00af87)"
stroke-width="6"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="10"
clip-path="url(#ldio-owbkoh4un5-cp)"
d="M90,76.7V28.3c0-2.7-2.2-5-5-5h-3.4c-2.7,0-5,2.2-5,5v43.4c0,2.7-2.2,5-5,5h-3.4c-2.7,0-5-2.2-5-5V28.3c0-2.7-2.2-5-5-5H55 c-2.7,0-5,2.2-5,5v43.4c0,2.7-2.2,5-5,5h-3.4c-2.7,0-5-2.2-5-5V28.3c0-2.7-2.2-5-5-5h-3.4c-2.7,0-5,2.2-5,5v43.4c0,2.7-2.2,5-5,5H15 c-2.7,0-5-2.2-5-5V23.3"
>
<animateTransform
attributeName="transform"
type="translate"
repeatCount="indefinite"
dur="1.4925373134328357s"
values="-20 0;7 0"
keyTimes="0;1"
></animateTransform>
<animate
attributeName="stroke-dasharray"
repeatCount="indefinite"
dur="1.4925373134328357s"
values="0 72 125 232;0 197 125 233"
keyTimes="0;1"></animate>
</path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -11,7 +11,7 @@
</a>
<button class="config-button center" @click="goToEdit()">
<EditIcon class="button-icon"/>
Edit Sections
Edit Config
</button>
<button class="config-button center" @click="goToMetaEdit()">
<MetaDataIcon class="button-icon"/>
@ -25,6 +25,10 @@
<CloudIcon class="button-icon"/>
{{backupId ? 'Edit Cloud Sync' : 'Enable Cloud Sync'}}
</button>
<button class="config-button center" @click="openRebuildAppModal()">
<RebuildIcon class="button-icon"/>
Rebuild Application
</button>
<button class="config-button center" @click="resetLocalSettings()">
<DeleteIcon class="button-icon"/>
Reset Local Settings
@ -33,24 +37,26 @@
You are using a very small screen, and some screens in this menu may not be optimal
</p>
<div class="config-note">
<p class="sub-title">Note:</p>
<span>
All changes made here are stored locally. To apply globally, either export your config
into your conf.yml file, or use the cloud backup/ restore feature.
It is recommend to make a backup of your conf.yml file, before making any changes.
</span>
</div>
</div>
<!-- Rebuild App Modal -->
<RebuildApp />
</TabItem>
<TabItem name="Backup Config" class="code-container">
<pre id="conf-yaml">{{this.jsonParser(this.config)}}</pre>
<TabItem name="View Config" class="code-container">
<pre id="conf-yaml">{{yaml}}</pre>
<div class="yaml-action-buttons">
<h2>Actions</h2>
<a class="yaml-button download" href="/conf.yml" download>Download Config</a>
<a class="yaml-button download" @click="downloadConfigFile('conf.yml', yaml)">
Download Config
</a>
<a class="yaml-button copy" @click="copyConfigToClipboard()">Copy Config</a>
<a class="yaml-button reset" @click="resetLocalSettings()">Reset Config</a>
</div>
</TabItem>
<TabItem name="Edit Sections">
<TabItem name="Edit Config">
<JsonEditor :config="config" />
</TabItem>
<TabItem name="Edit Site Meta">
@ -73,12 +79,15 @@ import { localStorageKeys, modalNames } from '@/utils/defaults';
import EditSiteMeta from '@/components/Configuration/EditSiteMeta';
import JsonEditor from '@/components/Configuration/JsonEditor';
import CustomCssEditor from '@/components/Configuration/CustomCss';
import RebuildApp from '@/components/Configuration/RebuildApp';
import DownloadIcon from '@/assets/interface-icons/config-download-file.svg';
import DeleteIcon from '@/assets/interface-icons/config-delete-local.svg';
import EditIcon from '@/assets/interface-icons/config-edit-json.svg';
import MetaDataIcon from '@/assets/interface-icons/config-meta-data.svg';
import CustomCssIcon from '@/assets/interface-icons/config-custom-css.svg';
import CloudIcon from '@/assets/interface-icons/cloud-backup-restore.svg';
import RebuildIcon from '@/assets/interface-icons/application-rebuild.svg';
export default {
name: 'ConfigContainer',
@ -95,17 +104,22 @@ export default {
sections: function getSections() {
return this.config.sections;
},
yaml() {
return this.jsonParser(this.config);
},
},
components: {
EditSiteMeta,
JsonEditor,
CustomCssEditor,
RebuildApp,
DownloadIcon,
DeleteIcon,
EditIcon,
CloudIcon,
MetaDataIcon,
CustomCssIcon,
RebuildIcon,
},
methods: {
/* Seletcs the edit tab of the tab view */
@ -121,6 +135,9 @@ export default {
const itemToSelect = this.$refs.tabView.navItems[4];
this.$refs.tabView.activeTabItem({ tabItem: itemToSelect, byUser: true });
},
openRebuildAppModal() {
this.$modal.show(modalNames.REBUILD_APP);
},
openCloudSync() {
this.$modal.show(modalNames.CLOUD_BACKUP);
},
@ -143,6 +160,16 @@ export default {
}, 1900);
}
},
/* Generates a new file, with the YAML contents, and triggers a download */
downloadConfigFile(filename, filecontents) {
const element = document.createElement('a');
element.setAttribute('href', `data:text/plain;charset=utf-8, ${encodeURIComponent(filecontents)}`);
element.setAttribute('download', filename);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
},
},
mounted() {
hljs.registerLanguage('yaml', yaml);
@ -274,14 +301,16 @@ a.hyperlink-wrapper {
border: 1px dashed var(--config-settings-color);
border-radius: var(--curve-factor);
text-align: left;
opacity: 0.95;
opacity: var(--dimming-factor);
color: var(--config-settings-color);
background: var(--config-settings-background);
cursor: default;
p.sub-title {
font-weight: bold;
margin: 0;
display: inline;
}
&:hover { opacity: 1; }
display: none;
@include tablet-up { display: block; }
}

View File

@ -1,11 +1,27 @@
<template>
<div class="json-editor-outer">
<!-- Main JSON editor -->
<v-jsoneditor
v-model="jsonData"
:options="options"
height="580px"
height="500px"
/>
<!-- Options raido, and save button -->
<div class="save-options">
<span class="save-option-title">Save Location:</span>
<div class="option">
<input type="radio" id="local" value="local"
v-model="saveMode" class="radio-option" :disabled="!allowWriteToDisk" />
<label for="local" class="save-option-label">Apply Locally</label>
</div>
<div class="option">
<input type="radio" id="file" value="file" v-model="saveMode" class="radio-option"
:disabled="!allowWriteToDisk" />
<label for="file" class="save-option-label">Write Changes to Config File</label>
</div>
</div>
<button :class="`save-button ${!isValid ? 'err' : ''}`" @click="save()">Save Changes</button>
<!-- List validation warnings -->
<p class="errors">
<ul>
<li v-for="(error, index) in errorMessages" :key="index" :class="`type-${error.type}`">
@ -16,11 +32,18 @@
</li>
</ul>
</p>
<!-- Information notes -->
<p v-if="saveSuccess !== undefined"
:class="`response-output status-${saveSuccess ? 'success' : 'fail'}`">
{{saveSuccess ? 'Task Complete' : 'Task Failed'}}
</p>
<p class="response-output">{{ responseText }}</p>
<p v-if="saveSuccess" class="response-output">
The app should rebuild automatically.
You will need to refresh the page for changes to take effect.
</p>
<p class="note">
It is recommend to backup your existing confiruration before making any changes.
<br>
Remember that these changes are only applied locally,
and will need to be exported to your conf.yml
</p>
</div>
</template>
@ -30,6 +53,9 @@
import VJsoneditor from 'v-jsoneditor';
import { localStorageKeys } from '@/utils/defaults';
import configSchema from '@/utils/ConfigSchema.json';
import JsonToYaml from '@/utils/JsonToYaml';
import { isUserAdmin } from '@/utils/Auth';
import axios from 'axios';
export default {
name: 'JsonEditor',
@ -43,6 +69,7 @@ export default {
return {
jsonData: this.config,
errorMessages: [],
saveMode: 'file',
options: {
schema: configSchema,
mode: 'tree',
@ -50,6 +77,10 @@ export default {
name: 'config',
onValidationError: this.validationErrors,
},
jsonParser: JsonToYaml,
responseText: '',
saveSuccess: undefined,
allowWriteToDisk: this.shouldAllowWriteToDisk(),
};
},
computed: {
@ -57,8 +88,50 @@ export default {
return this.errorMessages.length < 1;
},
},
mounted() {
if (!this.allowWriteToDisk) this.saveMode = 'local';
},
methods: {
shouldAllowWriteToDisk() {
const { appConfig } = this.config;
return appConfig.allowConfigEdit !== false && isUserAdmin(appConfig.auth);
},
save() {
if (this.saveMode === 'local' || !this.allowWriteToDisk) {
this.saveConfigLocally();
} else if (this.saveMode === 'file') {
this.writeConfigToDisk();
} else {
this.$toasted.show('Please select a Save Mode: Local or File');
}
},
writeConfigToDisk() {
// 1. Convert JSON into YAML
const yaml = this.jsonParser(this.jsonData);
// 2. Prepare the request
const baseUrl = process.env.VUE_APP_DOMAIN || window.location.origin;
const endpoint = `${baseUrl}/config-manager/save`;
const headers = { 'Content-Type': 'text/plain' };
const body = { config: yaml, timestamp: new Date() };
const request = axios.post(endpoint, body, headers);
// 3. Make the request, and handle response
request.then((response) => {
this.saveSuccess = response.data.success || false;
this.responseText = response.data.message;
if (this.saveSuccess) {
this.carefullyClearLocalStorage();
this.showToast('Config file written to disk succesfully', true);
} else {
this.showToast('An error occurred saving config', false);
}
})
.catch((error) => {
this.saveSuccess = false;
this.responseText = error;
this.showToast(error, false);
});
},
saveConfigLocally() {
const data = this.jsonData;
if (data.sections) {
localStorage.setItem(localStorageKeys.CONF_SECTIONS, JSON.stringify(data.sections));
@ -72,7 +145,12 @@ export default {
if (data.appConfig.theme) {
localStorage.setItem(localStorageKeys.THEME, data.appConfig.theme);
}
this.$toasted.show('Changes saved succesfully');
this.showToast('Changes saved succesfully', true);
},
carefullyClearLocalStorage() {
localStorage.removeItem(localStorageKeys.PAGE_INFO);
localStorage.removeItem(localStorageKeys.APP_CONFIG);
localStorage.removeItem(localStorageKeys.CONF_SECTIONS);
},
validationErrors(errors) {
const errorMessages = [];
@ -100,11 +178,15 @@ export default {
});
this.errorMessages = errorMessages;
},
showToast(message, success) {
this.$toasted.show(message, { className: `toast-${success ? 'success' : 'error'}` });
},
},
};
</script>
<style lang="scss">
@import '@/styles/media-queries.scss';
.json-editor-outer {
text-align: center;
@ -138,6 +220,16 @@ p.errors {
}
}
}
p.response-output {
font-size: 0.8rem;
text-align: left;
margin: 0.5rem auto;
width: 95%;
color: var(--config-settings-color);
&.status-success { color: var(--success); }
&.status-fail { color: var(--danger); }
}
button.save-button {
padding: 0.5rem 1rem;
margin: 0.25rem auto;
@ -163,6 +255,37 @@ button.save-button {
}
}
div.save-options {
display: flex;
align-items: flex-start;
justify-content: center;
padding: 0.5rem;
margin-bottom: 0.5rem;
background: var(--code-editor-background);
color: var(--code-editor-color);
border-top: 2px solid var(--config-settings-background);
@include tablet-down { flex-direction: column; }
.option {
@include tablet-up { margin-left: 2rem; }
}
span.save-option-title {
cursor: default;
}
input.radio-option {
cursor: pointer;
}
label.save-option-label {
cursor: pointer;
}
}
.jsoneditor, .jsoneditor-menu {
border-color: var(--primary);
}
.jsoneditor {
border-bottom: none;
}
.jsoneditor-menu, .pico-modal-header {
background: var(--config-settings-background) !important;
color: var(--config-settings-color) !important;
@ -182,7 +305,7 @@ div.jsoneditor-search div.jsoneditor-frame {
display: none;
}
.jsoneditor-tree, pre.jsoneditor-preview {
background: #fff;
background: var(--code-editor-background);
text-align: left;
}

View File

@ -0,0 +1,169 @@
<template>
<modal :name="modalName" :resizable="true" width="50%" height="60%" classes="dashy-modal">
<div class="rebuild-app-container">
<!-- Title, intro and start button -->
<h3 class="rebuild-app-title">Rebuild Application</h3>
<p>
A rebuild is required for changes written to the conf.yml file to take effect.
This should happen automatically, but if it hasn't, you can manually trigger it here.<br>
This is not required for modifications stored locally.
</p>
<Button :click="startBuild" :disabled="loading">
<template v-slot:text>{{ loading ? 'Building...' : 'Start Build' }}</template>
<template v-slot:icon><RebuildIcon /></template>
</Button>
<!-- Loading animation and text (shown while build is happening) -->
<div v-if="loading" class="loader-info">
<LoadingAnimation class="loader" />
<p class="loading-message">This may take a few minutes...</p>
</div>
<!-- Build response, and next actions (shown after build is done) -->
<div class="rebuild-response" v-if="success !== undefined">
<p v-if="success" class="response-status success"> Build completed succesfully</p>
<p v-else class="response-status failure"> Build operation failed</p>
<pre class="output"><code>{{ output || error }}</code></pre>
<p class="rebuild-message">{{ message }}</p>
<p v-if="success" class="rebuild-message">
A page reload is now required for changes to take effect
</p>
<Button :click="refreshPage" v-if="success">
<template v-slot:text>Reload Page</template>
<template v-slot:icon><ReloadIcon /></template>
</Button>
</div>
</div>
</modal>
</template>
<script>
import axios from 'axios';
import Button from '@/components/FormElements/Button';
import { modalNames } from '@/utils/defaults';
import RebuildIcon from '@/assets/interface-icons/application-rebuild.svg';
import ReloadIcon from '@/assets/interface-icons/application-reload.svg';
import LoadingAnimation from '@/assets/interface-icons/loader.svg';
export default {
name: 'RebuildApp',
components: {
Button,
RebuildIcon,
ReloadIcon,
LoadingAnimation,
},
data: () => ({
modalName: modalNames.REBUILD_APP,
loading: false,
success: undefined,
error: '',
output: '',
message: '',
}),
methods: {
startBuild() {
const baseUrl = process.env.VUE_APP_DOMAIN || window.location.origin;
const endpoint = `${baseUrl}/config-manager/rebuild`;
this.loading = true;
axios.get(endpoint)
.then((response) => {
this.finished(response.data || false);
})
.catch((error) => {
this.finished({ success: false, error });
});
},
finished(responseData) {
this.loading = false;
if (responseData) {
const {
success, output, error, message,
} = responseData;
this.success = success;
this.output = output;
this.message = message;
this.error = error;
}
this.$toasted.show(
(this.success ? '✅ Build Completed Succesfully' : '❌ Build Failed'),
{ className: `toast-${this.success ? 'success' : 'error'}` },
);
},
refreshPage() {
location.reload(); // eslint-disable-line no-restricted-globals
},
},
};
</script>
<style scoped lang="scss">
.rebuild-app-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 1rem;
color: var(--config-settings-color);
background: var(--config-settings-background);
overflow: auto;
button {
background: var(--config-settings-background);
color: var(--config-settings-color);
}
h3.rebuild-app-title {
text-align: center;
font-size: 2rem;
margin: 1rem;
}
div.loader-info {
margin: 0.2rem auto;
text-align: center;
svg.loader {
width: 100px;
}
p.loading-message {
margin: 0;
font-size: 0.8rem;
opacity: var(--dimming-factor);
animation: 3s fadeIn;
animation-fill-mode: forwards;
opacity: 0;
@keyframes fadeIn {
90% { opacity: 0; }
95% { opacity: 0.8; }
100% { opacity: 1; }
}
}
}
div.rebuild-response {
width: 80%;
margin: 0 auto 4rem auto;
text-align: center;
p.response-status {
font-size: 1rem;
text-align: left;
&.success {
color: var(--success);
}
&.failure {
color: var(--danger);
}
}
pre.output {
padding: 1rem;
font-size: 0.75rem;
border-radius: var(--curve-factor-small);
text-align: left;
color: var(--white);
background: var(--black);
white-space: pre-wrap;
}
p.rebuild-message {
font-size: 1rem;
text-align: left;
margin: 0.8rem 0;
color: var(--config-settings-color);
}
}
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<button @click="click()">
<button @click="click()" :disabled="disabled">
<slot></slot>
<slot name="text"></slot>
<slot name="icon"></slot>
@ -13,6 +13,7 @@ export default {
props: {
text: String,
click: Function,
disabled: Boolean,
},
};
</script>
@ -45,10 +46,14 @@ button {
background: var(--background);
border: 1px solid var(--primary);
border-radius: var(--curve-factor);
&:hover {
&:hover:not(:disabled) {
color: var(--background);
background: var(--primary);
border-color: var(--background);
}
&:disabled {
cursor: progress;
opacity: var(--dimming-factor);
}
}
</style>

View File

@ -1,5 +1,6 @@
<template>
<modal :name="name" :resizable="true" width="80%" height="80%" @closed="modalClosed()">
<modal :name="name" :resizable="true" width="80%" height="80%" @closed="modalClosed()"
classes="dashy-modal">
<div slot="top-right" @click="hide()">Close</div>
<a @click="hide()" class="close-button" title="Close">x</a>
<iframe v-if="url" :src="url" @keydown.esc="close" class="frame"/>
@ -17,12 +18,12 @@ export default {
url: '#',
}),
methods: {
show: function show(url) {
show(url) {
this.url = url;
this.$modal.show(this.name);
this.$emit('modalChanged', true);
},
hide: function hide() {
hide() {
this.$modal.hide(this.name);
},
modalClosed() {

View File

@ -10,14 +10,14 @@
</div>
<!-- Modal containing all the configuration options -->
<modal :name="modalNames.CONF_EDITOR" :resizable="true" width="60%" height="80%"
@closed="$emit('modalChanged', false)">
<modal :name="modalNames.CONF_EDITOR" :resizable="true" width="60%" height="85%"
@closed="$emit('modalChanged', false)" classes="dashy-modal">
<ConfigContainer :config="combineConfig()" />
</modal>
<!-- Modal for cloud backup and restore options -->
<modal :name="modalNames.CLOUD_BACKUP" :resizable="true" width="65%" height="60%"
@closed="$emit('modalChanged', false)">
@closed="$emit('modalChanged', false)" classes="dashy-modal">
<CloudBackupRestore :config="combineConfig()" />
</modal>
</div>
@ -100,14 +100,3 @@ export default {
}
}
</style>
<style lang="scss">
.vm--modal {
box-shadow: 0 40px 70px -2px hsl(0deg 0% 0% / 60%), 1px 1px 6px var(--primary);
min-width: 350px;
min-height: 600px;
}
.vm--overlay {
background: #00000080;
}
</style>

View File

@ -105,15 +105,19 @@ export default {
}
}
.clear-search {
position: absolute;
//position: absolute;
color: var(--settings-text-color);
margin: 0.55rem 0 0 -2.2rem;
padding: 0 0.4rem;
font-style: normal;
font-size: 1.5rem;
font-size: 1rem;
opacity: var(--dimming-factor);
border-radius: 50px;
cursor: pointer;
right: 0.5rem;
top: 1rem;
border: 1px solid var(--settings-text-color);
font-size: 1rem;
margin: 0.5rem;
&:hover {
opacity: 1;
background: var(--background-darker);

View File

@ -4,6 +4,7 @@ import Router from 'vue-router';
import Home from '@/views/Home.vue';
import Login from '@/views/Login.vue';
import Workspace from '@/views/Workspace.vue';
import DownloadConfig from '@/views/DownloadConfig.vue';
import { isLoggedIn } from '@/utils/Auth';
import { appConfig, pageInfo, sections } from '@/utils/ConfigAccumalator';
import { metaTagData } from '@/utils/defaults';
@ -58,6 +59,16 @@ const router = new Router({
name: 'about',
component: () => import(/* webpackChunkName: "about" */ './views/About.vue'),
},
{
path: '/download',
name: 'download',
component: DownloadConfig,
props: { appConfig, pageInfo, sections },
meta: {
title: pageInfo.title || 'Download Dashy Config',
metaTags: metaTagData,
},
},
],
});

View File

@ -76,4 +76,6 @@
--side-bar-color: var(--primary);
--status-check-tooltip-background: var(--background-darker);
--status-check-tooltip-color: var(--primary);
--code-editor-color: var(--black);
--code-editor-background: var(--white);
}

View File

@ -26,6 +26,7 @@ html[data-theme='thebe'] {
html[data-theme='dracula'] {
--font-headings: 'Allerta Stencil', sans-serif;
--primary: #6272a4;
--background: #44475a;
--background-darker: #282a36;
--item-group-background: #282a36;
@ -34,7 +35,7 @@ html[data-theme='dracula'] {
--item-shadow: none;
--item-hover-shadow: none;
--settings-text-color: #98ace9;
--primary: #6272a4;
--config-settings-color: #98ace9;
.collapsable:nth-child(1n) { background: #8be9fd; .item { border: 1px solid #8be9fd; color: #8be9fd; }}
.collapsable:nth-child(2n) { background: #50fa7b; .item { border: 1px solid #50fa7b; color: #50fa7b; }}
.collapsable:nth-child(3n) { background: #ffb86c; .item { border: 1px solid #ffb86c; color: #ffb86c; }}

View File

@ -4,6 +4,7 @@
--outline-color: none;
--curve-factor: 5px; // Border radius for most components
--curve-factor-navbar: 16px; // Border radius for header
--curve-factor-small: 2px; // Subtle border radius for util components
--dimming-factor: 0.7; // Opacity for semi-transparent components
/* Basic Page Components */

View File

@ -13,3 +13,32 @@ html {
cursor: pointer;
}
}
/* Overriding styles for the modal component */
.vm--modal, .dashy-modal {
box-shadow: 0 40px 70px -2px hsl(0deg 0% 0% / 60%), 1px 1px 6px var(--primary) !important;
min-width: 300px;
min-height: 500px;
}
.vm--overlay {
background: #00000080;
}
/* Overiding styles for the global toast component */
.toast-message {
background: var(--toast-background) !important;
color: var(--toast-color) !important;
border: 1px solid var(--toast-color) !important;
border-radius: var(--curve-factor) !important;
font-size: 1.25rem !important;
}
.toast-error {
background: var(--danger) !important;
color: var(--white) !important;
font-size: 1.25rem !important;
}
.toast-success {
background: var(--success) !important;
color: var(--white) !important;
font-size: 1.25rem !important;
}

View File

@ -65,3 +65,9 @@ $extra-large: 2800px;
@content;
}
}
@mixin tablet-down {
@media (max-width: #{$small - 1px}) {
@content;
}
}

View File

@ -37,21 +37,3 @@
.horizontal-center { margin: 0 auto; }
.border-box { box-sizing: border-box; }
/* Overiding styles for the global toast component */
.toast-message {
background: var(--toast-background) !important;
color: var(--toast-color) !important;
border: 1px solid var(--toast-color) !important;
border-radius: var(--curve-factor) !important;
font-size: 1.25rem !important;
}
.toast-error {
background: var(--danger) !important;
color: var(--white) !important;
font-size: 1.25rem !important;
}
.toast-success {
background: var(--success) !important;
color: var(--white) !important;
font-size: 1.25rem !important;
}

View File

@ -50,3 +50,25 @@ export const logout = () => {
document.cookie = 'authenticationToken=null';
localStorage.removeItem(localStorageKeys.USERNAME);
};
/**
* Checks if the current user has admin privileges.
* If no users are setup, then function will always return true
* But if auth is configured, then will verify user is correctly
* logged in and then check weather they are of type admin, and
* return false if any conditions fail
* @param users[] : Array of users
* @returns Boolean : True if admin privileges
*/
export const isUserAdmin = (users) => {
if (!users || users.length === 0) return true; // Authentication not setup
if (!isLoggedIn(users)) return false; // Auth setup, but not signed in as a valid user
const currentUser = localStorage[localStorageKeys.USERNAME];
let isAdmin = false;
users.forEach((user) => {
if (user.user === currentUser) {
if (user.type === 'admin') isAdmin = true;
}
});
return isAdmin;
};

View File

@ -2,11 +2,14 @@
* Reads the users config from `conf.yml`, and combines it with any local preferences
* Also ensures that any missing attributes are populated with defaults, and the
* object is structurally sound, to avoid any error if the user is missing something
* The main config object is make up of three parts: appConfig, pageInfo and sections
* The main config object is made up of three parts: appConfig, pageInfo and sections
*/
import Defaults, { localStorageKeys } from '@/utils/defaults';
import conf from '../../public/conf.yml';
/**
* Returns the appConfig section, as JSON
*/
export const appConfig = (() => {
let usersAppConfig = Defaults.appConfig;
if (localStorage[localStorageKeys.APP_CONFIG]) {
@ -21,6 +24,9 @@ export const appConfig = (() => {
return usersAppConfig;
})();
/**
* Returns the pageInfo section, as JSON
*/
export const pageInfo = (() => {
const defaults = Defaults.pageInfo;
let localPageInfo;
@ -37,6 +43,9 @@ export const pageInfo = (() => {
return pi;
})();
/**
* Returns the sections section, as an array of JSON objects
*/
export const sections = (() => {
// If the user has stored sections in local storage, return those
const localSections = localStorage[localStorageKeys.CONF_SECTIONS];
@ -52,6 +61,9 @@ export const sections = (() => {
return conf.sections;
})();
/**
* Returns the complete configuration, as JSON
*/
export const config = (() => {
const result = {
appConfig,

View File

@ -150,6 +150,11 @@
}
}
}
},
"allowConfigEdit": {
"type": "boolean",
"default": true,
"description": "Can user write changes to conf.yml file from the UI. If set to false, preferences are only stored locally"
}
},
"additionalProperties": false

View File

@ -62,6 +62,7 @@ module.exports = {
modalNames: {
CONF_EDITOR: 'CONF_EDITOR',
CLOUD_BACKUP: 'CLOUD_BACKUP',
REBUILD_APP: 'REBUILD_APP',
},
topLevelConfKeys: {
PAGE_INFO: 'pageInfo',

View File

@ -0,0 +1,35 @@
<template>
<pre><code>{{ jsonParser(config) }}</code></pre>
</template>
<script>
import JsonToYaml from '@/utils/JsonToYaml';
export default {
name: 'DownloadConfig',
props: {
sections: Array,
appConfig: Object,
pageInfo: Object,
},
data() {
return {
config: {
appConfig: this.appConfig,
pageInfo: this.pageInfo,
sections: this.sections,
},
jsonParser: JsonToYaml,
};
},
};
</script>
<style scoped lang="scss">
pre {
background: var(--code-editor-background);
color: var(--code-editor-color);
padding: 1rem;
}
</style>

View File

@ -18,4 +18,11 @@ module.exports = {
msTileColor: '#0b1021',
manifestCrossorigin: 'use-credentials',
},
pages: {
dashy: {
entry: 'src/main.js',
filename: 'index.html',
},
},
};

View File

@ -2112,7 +2112,7 @@ bn.js@^5.0.0, bn.js@^5.1.1:
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.0.tgz#358860674396c6997771a9d051fcc1b57d4ae002"
integrity sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw==
body-parser@1.19.0:
body-parser@1.19.0, body-parser@^1.19.0:
version "1.19.0"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==