Compare commits

...

30 Commits

Author SHA1 Message Date
824d7c59e7 fix
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2020-11-03 12:46:14 +01:00
1be8a3f592 update
All checks were successful
continuous-integration/drone/push Build is passing
2020-11-03 12:36:26 +01:00
8100a19e76 remove bezier curves
All checks were successful
continuous-integration/drone/push Build is passing
2020-11-03 12:30:27 +01:00
b6dfad0515 update
All checks were successful
continuous-integration/drone/push Build is passing
2020-11-02 18:01:57 +01:00
aa4094cd05 place start and end mark
All checks were successful
continuous-integration/drone/push Build is passing
2020-10-13 08:35:38 +02:00
d45c8a7e9a typo
All checks were successful
continuous-integration/drone/push Build is passing
2020-09-04 17:40:29 +02:00
2ec9a9fe4d starting api spec 2020-09-04 17:40:26 +02:00
c3a11f1f3c separate settings
All checks were successful
continuous-integration/drone/push Build is passing
2020-09-02 14:34:20 +02:00
6e9d634f74 table headings
All checks were successful
continuous-integration/drone/push Build is passing
2020-09-02 12:05:48 +02:00
4174e26496 update test database 2020-09-02 12:05:37 +02:00
cf5c17bf59 styling, restructure
All checks were successful
continuous-integration/drone/push Build is passing
2020-08-30 15:28:27 +02:00
ca305dff73 move editor to bottom
All checks were successful
continuous-integration/drone/push Build is passing
2020-08-28 17:42:21 +02:00
9d91740f6e display timestamps in local timezone 2020-08-28 17:40:20 +02:00
1a75f8c242 y axis labelling 2020-08-28 17:40:00 +02:00
ca37e38800 schedule database cleanup 2020-08-28 17:39:47 +02:00
f57444a3d1 reduction job, chart duplicates
All checks were successful
continuous-integration/drone/push Build is passing
2020-08-25 15:24:20 +02:00
19904bd0ec adding a test database 2020-08-25 15:23:54 +02:00
b0837c7424 more visible color for light level
All checks were successful
continuous-integration/drone/push Build is passing
2020-08-24 23:05:11 +02:00
af6bd94678 fix humidity
All checks were successful
continuous-integration/drone/push Build is passing
2020-08-24 21:36:33 +02:00
84c4868720 fix
All checks were successful
continuous-integration/drone/push Build is passing
2020-08-24 21:27:18 +02:00
6d98e379fe fix
Some checks failed
continuous-integration/drone/push Build is failing
2020-08-24 21:25:51 +02:00
36d12a784a polishing chart, build info in top left
Some checks failed
continuous-integration/drone/push Build is failing
2020-08-24 21:20:58 +02:00
d5dc2f933a fix infinite loop
All checks were successful
continuous-integration/drone/push Build is passing
2020-08-23 19:13:14 +02:00
b0b3528ada reverse chart data
All checks were successful
continuous-integration/drone/push Build is passing
2020-08-23 18:56:27 +02:00
04707545f1 fix
Some checks reported errors
continuous-integration/drone/push Build was killed
2020-08-23 18:43:21 +02:00
62bcac28dd fix order
All checks were successful
continuous-integration/drone/push Build is passing
2020-08-23 18:35:43 +02:00
ade5b933d8 better timestamps, adapt default config
All checks were successful
continuous-integration/drone/push Build is passing
2020-08-23 14:31:15 +02:00
714110edb0 fix 2020-08-23 14:18:14 +02:00
b6ecd65748 prevent huge data amounts, separate sources 2020-08-23 14:17:10 +02:00
39f9f17b95 linting, cleanup 2020-08-23 13:08:04 +02:00
32 changed files with 10324 additions and 9757 deletions

View File

@ -2,6 +2,12 @@ kind: pipeline
name: default name: default
steps: steps:
- name: version
image: alpine
commands:
- sed -i 's/module\.exports.*//' version.js
- echo "module.exports = '$(date)';" >> version.js
- name: docker build - name: docker build
image: plugins/docker image: plugins/docker
settings: settings:

2
.gitignore vendored
View File

@ -21,3 +21,5 @@ pnpm-debug.log*
*.sln *.sln
*.sw? *.sw?
*.sqlite *.sqlite
!/data/test.sqlite

BIN
data/test.sqlite Normal file

Binary file not shown.

View File

@ -5,50 +5,29 @@
* Created by Timo Hocker <timo@scode.ovh>, August 2020 * Created by Timo Hocker <timo@scode.ovh>, August 2020
*/ */
/* eslint-disable no-console */
// @ts-nocheck // @ts-nocheck
'use strict'; 'use strict';
const express = require ('express'); const express = require ('express');
const auth = require ('@sapphirecode/auth-server-helper');
const body_parser = require ('body-parser'); const body_parser = require ('body-parser');
const cookie_parser = require ('cookie-parser');
const db = require ('./lib/db'); const db = require ('./lib/db');
const crypto = require ('@sapphirecode/crypto-helper');
const password_helper = require ('@sapphirecode/password-helper');
const api = require ('./lib/api'); const api = require ('./lib/api');
const http_proxy = require ('express-http-proxy'); const http_proxy = require ('express-http-proxy');
const history_fallback = require ('connect-history-api-fallback'); const history_fallback = require ('connect-history-api-fallback');
const fs = require ('fs');
const { argv } = require ('yargs'); const { argv } = require ('yargs');
const version = require ('./version');
const salt = crypto.create_salt ();
const hash = crypto.hash_sha512 ('asd', salt);
const user = {
id: 0,
salt,
password: password_helper.hash (hash)
};
const is_dev = argv.dev; const is_dev = argv.dev;
console.log (`starting appreports build ${version}`);
(async () => { (async () => {
if (fs.existsSync ('db.sqlite'))
fs.unlinkSync ('db.sqlite');
await db.init (is_dev); await db.init (is_dev);
const app = express (); const app = express ();
app.use (cookie_parser ());
app.use (body_parser.json ()); app.use (body_parser.json ());
/*
* app.use (auth ((name) => {
*if (name === 'timo')
* return user;
*return null;
*}));
*/
app.use (api); app.use (api);
app.use (history_fallback ()); app.use (history_fallback ());
if (is_dev) if (is_dev)

View File

@ -1,7 +1,17 @@
module.exports = (req,res,next) => { /*
console.log(req.method, req.originalUrl); * Copyright (C) Sapphirecode - All Rights Reserved
console.log(JSON.stringify(req.headers)); * This file is part of appreports which is released under GPL-3.0-or-later.
console.log(''); * See file 'LICENSE' for full license details.
console.log(JSON.stringify(req.body, null, 2)); * Created by Timo Hocker <timo@scode.ovh>, August 2020
next(); */
};
/* eslint-disable no-console */
'use strict';
module.exports = (req, res, next) => {
console.log (req.method, req.originalUrl);
console.log (JSON.stringify (req.headers));
console.log ('');
console.log (JSON.stringify (req.body, null, 2));
next ();
};

View File

@ -11,15 +11,20 @@ const db = require ('../db');
const { http } = require ('@sapphirecode/consts'); const { http } = require ('@sapphirecode/consts');
module.exports = async (req, res) => { module.exports = async (req, res) => {
if ( const limit = parseInt (req.headers.limit);
typeof req.headers.app_id === 'undefined' const offset = parseInt (req.headers.offset);
|| isNaN (parseInt (req.headers.app_id)) const app_id = parseInt (req.headers.app_id);
) {
if (isNaN (app_id)) {
res.status (http.status_bad_request) res.status (http.status_bad_request)
.end (); .end ('app id not specified');
} return;
else {
res.status (http.status_ok)
.json (await db.log.get_all (parseInt (req.headers.app_id)));
} }
res.status (http.status_ok)
.json (await db.log.get_all (
parseInt (req.headers.app_id),
isNaN (limit) ? 100 : limit,
isNaN (offset) ? 0 : offset
));
}; };

View File

@ -10,7 +10,7 @@
const router = require ('express') const router = require ('express')
.Router (); .Router ();
router.use(require('./dump')); router.use (require ('./dump'));
router.get ('/log', require ('./get-log')); router.get ('/log', require ('./get-log'));
router.get ('/app', require ('./get-app')); router.get ('/app', require ('./get-app'));

View File

@ -16,17 +16,15 @@ module.exports = async (req, res) => {
app_id === 'undefined' app_id === 'undefined'
|| isNaN (parseInt (app_id)) || isNaN (parseInt (app_id))
) { ) {
console.log('bad request, did not receive app id');
res.status (http.status_bad_request) res.status (http.status_bad_request)
.end (); .end ('app id not specified');
return; return;
} }
const log = req.body; const log = req.body;
if (typeof log !== 'object') { if (typeof log !== 'object') {
console.log('bad request, did not receive data in json format');
res.status (http.status_bad_request) res.status (http.status_bad_request)
.end (); .end ('invalid data format');
return; return;
} }

View File

@ -5,26 +5,42 @@
* Created by Timo Hocker <timo@scode.ovh>, August 2020 * Created by Timo Hocker <timo@scode.ovh>, August 2020
*/ */
/* eslint-disable no-sync */
'use strict'; 'use strict';
const knex = require ('knex'); const knex = require ('knex');
const fs = require ('fs');
const job = require ('./job');
let db = null; let db = null;
async function init (use_fake_seed) { async function init (use_fake_seed) {
if (!fs.existsSync ('data'))
fs.mkdirSync ('data');
if (use_fake_seed && fs.existsSync ('data/db.sqlite')) {
fs.unlinkSync ('data/db.sqlite');
fs.copyFileSync ('data/test.sqlite', 'data/db.sqlite');
}
db = knex ({ db = knex ({
client: 'sqlite', client: 'sqlite',
connection: { filename: 'db.sqlite' }, connection: { filename: 'data/db.sqlite' },
migrations: { directory: 'migrations' }, migrations: { directory: 'migrations' },
seeds: { directory: 'seeds' }, seeds: { directory: 'seeds' },
useNullAsDefault: true useNullAsDefault: true
}); });
await db.migrate.latest (); await db.migrate.latest ();
if (use_fake_seed)
await db.seed.run ({ specific: 'fake.js' }); await db.seed.run ({ specific: 'prod.js' });
else
await db.seed.run ({ specific: 'prod.js' }); // await db.seed.run ({ specific: 'fake.js' });
await job (db);
setInterval (() => {
job (db);
}, 3600e3);
} }
function get_db () { function get_db () {

87
lib/db/job.js Normal file
View File

@ -0,0 +1,87 @@
/* eslint-disable no-console */
/* eslint-disable no-await-in-loop */
'use strict';
const chunk = require ('lodash.chunk');
function get_targets (knex, app_id, duration, reduction = null) {
return knex.from ('log')
.where ({ app_id })
.andWhere ('timestamp', '<', Number (new Date) - (duration * 1000))
.andWhere ((builder) => {
if (reduction === null) {
builder.whereNotNull ('id');
}
else {
builder.where ('reduction', '<', reduction)
.orWhere ('reduction', null);
}
});
}
async function batch_delete (knex, ids) {
for (const c of chunk (ids, 100)) {
await knex ('log')
.whereIn ('id', c)
.del ();
}
return ids.length;
}
async function batch_update (knex, ids, data) {
for (const c of chunk (ids, 100)) {
await knex ('log')
.whereIn ('id', c)
.update (data);
}
return ids.length;
}
module.exports = async (knex) => {
console.log ('-- running database cleanup --');
const apps = await knex ('app')
.select ('id', 'name', 'reduction');
for (const app of apps) {
const reduction = JSON.parse (app.reduction);
const duplicates = reduction.shift ();
const end = reduction.pop ();
// delete anything older than now - end
const deleted_old = await get_targets (knex, app.id, end)
.del ();
console.log (`deleted ${deleted_old} old datasets`);
for (const r of reduction) {
const targets = (await get_targets (knex, app.id, r, r)
.orderBy ('timestamp')
.select ('id'))
.map ((v) => v.id);
const even = targets.filter ((v, i) => (i % 2 === 0));
const odd = targets.filter ((v, i) => (i % 2 !== 0));
const deleted_reduction = await batch_delete (knex, even);
console.log (`reduction ${r} deleted ${deleted_reduction}`);
await batch_update (knex, odd, { reduction: r });
}
const deleted_duplicates = await get_targets (knex, app.id, duplicates)
.andWhere ((builder) => {
builder.whereNotIn ('id', (inBuilder) => {
get_targets (inBuilder, app.id, duplicates)
.groupBy ('message', 'data')
.min ({ id: 'id' });
});
})
.del ();
console.log (`deleted ${deleted_duplicates} duplicates`);
}
};

View File

@ -8,7 +8,7 @@
'use strict'; 'use strict';
module.exports = (get_db) => ({ module.exports = (get_db) => ({
get_all (app_id) { get_all (app_id, limit = 100, offset = 0) {
const knex = get_db (); const knex = get_db ();
return knex.select ( return knex.select (
'id', 'id',
@ -17,7 +17,10 @@ module.exports = (get_db) => ({
'timestamp' 'timestamp'
) )
.from ('log') .from ('log')
.where ({ app_id }); .where ({ app_id })
.orderBy ('timestamp', 'desc')
.limit (Math.min (limit, 10000))
.offset (offset);
}, },
insert (app_id, message, data = '{}', timestamp = (new Date)) { insert (app_id, message, data = '{}', timestamp = (new Date)) {
const knex = get_db (); const knex = get_db ();

23
lib/defaults.js Normal file
View File

@ -0,0 +1,23 @@
'use strict';
module.exports = {
app: {
/**
* reduce data after given time durations
* 1. delete duplicates (1 day)
* 2. divide by 2 (1 week)
* ...
* 7. delete all (6 weeks)
*/
reduction: JSON.stringify ([
86400,
604800,
1209600,
1814400,
2419200,
3024000,
3628800
])
}
};

View File

@ -0,0 +1,22 @@
'use strict';
const defaults = require ('../lib/defaults');
async function up (knex) {
await knex.schema.table ('app', (t) => {
t.string ('reduction');
});
await knex.schema.table ('log', (t) => {
t.integer ('reduction');
});
await knex ('app')
.update ({ reduction: defaults.app.reduction });
}
function down () {
// noop
}
module.exports = { up, down };

View File

@ -9,30 +9,24 @@
"serve": "vue-cli-service serve" "serve": "vue-cli-service serve"
}, },
"dependencies": { "dependencies": {
"@sapphirecode/auth-client-helper": "^1.1.1",
"@sapphirecode/auth-server-helper": "^1.1.2",
"@sapphirecode/consts": "^1.1.28", "@sapphirecode/consts": "^1.1.28",
"@sapphirecode/crypto-helper": "^1.1.57", "@sapphirecode/crypto-helper": "^1.1.57",
"@sapphirecode/password-helper": "^1.0.47",
"@sapphirecode/ui-modules": "^0.1.1",
"@sapphirecode/utilities": "^1.8.5", "@sapphirecode/utilities": "^1.8.5",
"body-parser": "^1.19.0", "body-parser": "^1.19.0",
"chart.js": "^2.9.3", "chart.js": "^2.9.3",
"connect-history-api-fallback": "^1.6.0", "connect-history-api-fallback": "^1.6.0",
"cookie-parser": "^1.4.5",
"core-js": "^3.6.5", "core-js": "^3.6.5",
"express": "^4.17.1", "express": "^4.17.1",
"express-http-proxy": "^1.6.2", "express-http-proxy": "^1.6.2",
"faker": "^4.1.0", "faker": "^5.1.0",
"hjson": "^3.2.1",
"knex": "^0.21.2", "knex": "^0.21.2",
"simplex-noise": "^2.4.0", "lodash.chunk": "^4.2.0",
"sqlite3": "^5.0.0", "sqlite3": "^5.0.0",
"vue": "^2.6.11", "vue": "^2.6.11",
"vue-chartjs": "^3.5.0", "vue-chartjs": "^3.5.0",
"vue-router": "^3.2.0", "vue-router": "^3.2.0",
"vuex": "^3.4.0", "vuex": "^3.4.0",
"yargs": "^15.4.1" "yargs": "^16.1.0"
}, },
"devDependencies": { "devDependencies": {
"@sapphirecode/eslint-config": "^2.1.16", "@sapphirecode/eslint-config": "^2.1.16",
@ -44,7 +38,7 @@
"@vue/cli-service": "^4.4.0", "@vue/cli-service": "^4.4.0",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"eslint": "^7.5.0", "eslint": "^7.5.0",
"eslint-plugin-vue": "^6.2.2", "eslint-plugin-vue": "^7.1.0",
"vue-template-compiler": "^2.6.11" "vue-template-compiler": "^2.6.11"
}, },
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
@ -52,5 +46,8 @@
"author": { "author": {
"name": "Timo Hocker", "name": "Timo Hocker",
"email": "timo@scode.ovh" "email": "timo@scode.ovh"
},
"engines": {
"node": ">=10.0.0"
} }
} }

View File

@ -8,43 +8,38 @@
'use strict'; 'use strict';
const faker = require ('faker'); const faker = require ('faker');
const sn = require ('simplex-noise');
const apps = []; let last_t = 0;
let last_h = 0;
async function create_app (knex) { function create_log (timestamp) {
const [ id ] = await knex ('app') last_t = faker.random.number (2) + 22;
.insert ( last_h = faker.random.number (10) + 38;
{ name: faker.random.word () }
);
apps.push (id);
}
function create_log (index, simplex) {
const data = { const data = {
num1: faker.random.number (), light: faker.random.number (10),
num2: simplex.noise2D (index * 0.1, 0), temperature: last_t,
num3: simplex.noise2D (index * 0.1, 1000) humidity: last_h
}; };
return { return {
app_id: faker.random.arrayElement (apps), app_id: 1,
message: faker.random.words (), message: faker.random.words (),
data: JSON.stringify (data), data: JSON.stringify (data),
timestamp: faker.date.recent () timestamp
}; };
} }
async function seed (knex) { async function seed (knex) {
await knex ('log')
.del ();
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log ('creating seeds'); console.log ('creating seeds');
for (let i = 0; i < 5; i++)
// eslint-disable-next-line no-await-in-loop
await create_app (knex);
const simplex = (new sn); const log = (Array (10000))
const log = (Array (20))
.fill (() => null) .fill (() => null)
.map ((a, index) => create_log (index, simplex)); .map (() => faker.date.recent (60))
.sort ()
.map ((t) => create_log (t));
await knex.batchInsert ('log', log, 10); await knex.batchInsert ('log', log, 10);
} }

View File

@ -10,10 +10,16 @@
async function seed (knex) { async function seed (knex) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log ('creating prod seeds'); console.log ('creating prod seeds');
await knex ('app')
.insert ( const apps = await knex ('app')
{ name: 'test app' } .select ();
);
if (apps.length < 1) {
await knex ('app')
.insert (
{ name: 'test app' }
);
}
} }
module.exports = { seed }; module.exports = { seed };

28
spec/api.json Normal file
View File

@ -0,0 +1,28 @@
{
"openapi":"3.0.2",
"info": {
"title":"API Title",
"version":"1.0"
},
"servers": [
{"url":"https://appreports.scode.ovh/v1"}
],
"paths": {
"/app": {
"get": {
"description": "list all apps",
"parameters": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
}
}
}
}
}
}
}
}

View File

@ -1,32 +1,15 @@
<template> <template>
<div id="app"> <div id="app">
<Style />
<router-view /> <router-view />
</div> </div>
</template> </template>
<script> <script>
export default {}; import Style from './Style.vue';
export default { components: { Style } };
</script> </script>
<style> <style scoped>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
#nav {
padding: 30px;
}
#nav a {
font-weight: bold;
color: #2c3e50;
}
#nav a.router-link-exact-active {
color: #42b983;
}
</style> </style>

93
src/Style.vue Normal file
View File

@ -0,0 +1,93 @@
<template>
<div :class="classes" />
</template>
<script>
import Vuex from 'vuex';
export default {
computed: {
classes () {
if (this.theme !== '')
return { [this.theme]: true };
return {};
},
...Vuex.mapState ({ theme: (state) => state.theme })
}
};
</script>
<style>
:root {
/* generated with adobe color (compound) */
--Color-A: #1254B8;
--Color-B: #355585;
--Color-C: #00D8EB;
--Color-D: #F0632F;
--Color-E: #BD311E;
--Color-0: #000;
--Color-1: #222;
--color-background: var(--Color-0);
--color-background-1: var(--Color-1);
--color-foreground: var(--Color-B);
--color-contrast: var(--Color-A);
--color-high-contrast: var(--Color-C);
--color-focus: var(--Color-D);
--color-accent: var(--Color-E);
--background: var(--color-background);
--background-1: var(--color-background-1);
--container-border: 1px solid var(--color-high-contrast);
--sub-container-border: 1px solid var(--color-contrast);
--accent-border: 1px solid var(--color-accent);
--font-family: Avenir, Helvetica, Arial, sans-serif;
}
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: var(--background);
color: var(--color-foreground);
font-family: var(--font-family);
}
button {
border: var(--accent-border);
background: inherit;
color: inherit;
cursor: pointer;
transition:
border-color ease-in-out .1s,
color ease-in-out .1s;
}
button:hover {
border-color: var(--color-focus);
color: var(--color-focus);
}
input {
border: var(--accent-border);
background: inherit;
color: inherit;
}
select {
border: var(--accent-border);
background: inherit;
color: inherit;
}
select > option {
background: var(--background-1);
color: var(--color-foreground);
font-weight: bold;
}
.large_button {
padding: 10px;
}
</style>

107
src/components/AppView.vue Normal file
View File

@ -0,0 +1,107 @@
<template>
<div>
<button
type="button"
class="large_button"
@click="settings_btn_action"
v-text="editing?'Close':'Settings'"
/>
<div
v-if="editing"
class="spacer"
>
<ConfigEditor
v-model="config"
:template="template"
/>
</div>
<div
v-else
class="grid"
>
<ViewComponent
v-for="(item,key) of saved_config.displays"
:key="key"
:config="item"
:data="log(item.source)"
/>
</div>
</div>
</template>
<script>
import Vuex from 'vuex';
import { copy_object } from '@sapphirecode/utilities';
import default_config from '../default';
import default_template from '../template';
import ViewComponent from './ViewComponent.vue';
import ConfigEditor from './ConfigEditor.vue';
export default {
components: { ViewComponent, ConfigEditor },
props: {
app: {
type: Number,
required: true
}
},
data () {
return {
config: copy_object (default_config),
saved_config: copy_object (default_config),
template: default_template,
editing: false
};
},
computed: { ...Vuex.mapGetters ({ log: 'log' }) },
mounted () {
this.fetch_log ();
document.body.addEventListener ('keydown', (ev) => {
if (ev.key === 's' && ev.ctrlKey && this.editing) {
this.save_config ();
ev.preventDefault ();
return false;
}
return true;
});
},
methods: {
save_config () {
fetch ('config', {
method: 'POST',
body: JSON.stringify (this.config)
});
this.saved_config = copy_object (this.config);
this.fetch_log ();
this.editing = false;
},
fetch_log () {
this.get_log ({
app_id: this.app,
sources: this.saved_config.sources
});
},
settings_btn_action () {
if (this.editing)
this.save_config ();
else
this.editing = true;
},
...Vuex.mapActions ({ get_log: 'get_log' })
}
};
</script>
<style scoped>
.grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
width: 100%;
height: 100%;
}
.spacer {
margin: 10px;
}
</style>

View File

@ -17,6 +17,10 @@ export default {
yaxis: { yaxis: {
type: Array, type: Array,
required: true required: true
},
remove_duplicates: {
type: Boolean,
default: false
} }
}, },
computed: { computed: {
@ -28,19 +32,55 @@ export default {
data: [], data: [],
yAxisID: index, yAxisID: index,
borderColor: y.color, borderColor: y.color,
backgroundColor: y.fill backgroundColor: y.fill,
spanGaps: true,
lineTension: 0
}; };
for (const data of this.data) let last = null;
res.data.push (resolve_data (data, y.field)); for (let i = 0; i < this.data.length; i++) {
const data = this.data[i];
const val = resolve_data (data, y.field);
const next = this.data.length - 1 === i
? val
: resolve_data (this.data[i + 1], y.field);
if (
!this.remove_duplicates
|| last !== val || next !== val
|| this.data.length - 1 === i
)
res.data.push (val);
else
res.data.push (null);
last = val;
}
return res; return res;
}); });
return { datasets, labels }; return { datasets, labels };
}, },
chart_options () { chart_options () {
return { return {
scales: { animation: { duration: 0 },
yAxes: this.yaxis.map ( responsiveAnimationDuration: 0,
(y, index) => ({ id: index }) scales: {
yAxes: this.yaxis.map (
(y, index) => {
const range = {};
if (typeof y.min_value === 'number')
range.suggestedMin = y.min_value;
if (typeof y.max_value === 'number')
range.suggestedMax = y.max_value;
return {
id: index,
ticks: range,
scaleLabel: {
labelString: y.label,
display: true,
lineHeight: 1,
padding: 0.1
}
};
}
) )
} }
}; };

View File

@ -7,6 +7,14 @@
class="label" class="label"
v-text="template.name" v-text="template.name"
/> />
<button
v-if="removable"
class="array_remove"
type="button"
@click="$emit('remove')"
>
X
</button>
<!-- editor --> <!-- editor -->
<div <div
v-if="template.type === 'object'" v-if="template.type === 'object'"
@ -26,10 +34,11 @@
> >
<ConfigEditor <ConfigEditor
v-for="(child,key) of config" v-for="(child,key) of config"
:key="key" :key="salt (key)"
v-model="config[key]" v-model="config[key]"
:template="template.child" :template="template.child"
:enabled="is_enabled(template.child.if)" :removable="true"
@remove="config.splice(key, 1)"
/> />
<button <button
type="button" type="button"
@ -83,8 +92,10 @@
</template> </template>
<script> <script>
import { create_salt } from '@sapphirecode/crypto-helper';
import { resolve_data } from '../helper'; import { resolve_data } from '../helper';
export default { export default {
name: 'ConfigEditor', name: 'ConfigEditor',
props: { props: {
@ -98,10 +109,18 @@ export default {
enabled: { enabled: {
type: Boolean, type: Boolean,
default: true default: true
},
removable: {
type: Boolean,
default: false
} }
}, },
data () { data () {
return { temp: this.value }; let val = this.value;
if (typeof val === 'undefined')
val = this.create_default (this.template);
return { temp: val };
}, },
computed: { computed: {
config: { config: {
@ -109,8 +128,14 @@ export default {
return this.temp; return this.temp;
}, },
set (val) { set (val) {
this.$emit ('input', val); let mapped = val;
this.temp = val; if (this.template.type === 'number') {
mapped = parseFloat (val);
if (isNaN (mapped))
mapped = 0;
}
this.$emit ('input', mapped);
this.temp = mapped;
} }
} }
}, },
@ -155,6 +180,9 @@ export default {
return res; return res;
} }
return null; return null;
},
salt () {
return create_salt ();
} }
} }
}; };
@ -168,7 +196,7 @@ export default {
} }
.editor { .editor {
border: 1px solid black; border: var(--sub-container-border);
margin-left: 10px; margin-left: 10px;
grid-column: editor; grid-column: editor;
} }
@ -177,4 +205,11 @@ export default {
display: inline-block; display: inline-block;
grid-column: label; grid-column: label;
} }
.array_remove {
display: inline-block;
width: 20px;
height: 20px;
padding: 0;
}
</style> </style>

View File

@ -4,6 +4,13 @@
class="table_view" class="table_view"
> >
<table> <table>
<tr v-if="headings.length > 0">
<th
v-for="(heading,key) of headings"
:key="key"
v-text="heading"
/>
</tr>
<tr <tr
v-for="(item,key) of items" v-for="(item,key) of items"
:key="key" :key="key"
@ -30,6 +37,10 @@ export default {
columns: { columns: {
type: Array, type: Array,
required: true required: true
},
headings: {
type: Array,
default: () => []
} }
}, },
methods: { resolve_data } methods: { resolve_data }
@ -38,8 +49,7 @@ export default {
<style> <style>
.table_view { .table_view {
overflow: auto; overflow-y: scoll;
border: 1px solid blue;
width: max-content; width: max-content;
max-width: 100%; max-width: 100%;
max-height: 100vh; max-height: 100vh;
@ -51,11 +61,12 @@ table {
} }
tr:nth-child(odd) { tr:nth-child(odd) {
background-color: #ddd; background-color: var(--color-background-1);
color: var(--color-accent);
} }
td { td, th {
border: 1px solid black; border: var(--sub-container-border);
margin: 0; padding: 3px;
} }
</style> </style>

View File

@ -1,14 +1,16 @@
<template> <template>
<ChartView <ChartView
v-if="config.type === 'chart'" v-if="config.type === 'chart'"
:data="data" :data="[...data].reverse()"
:xaxis="config.x" :xaxis="config.x"
:yaxis="config.y" :yaxis="config.y"
:remove_duplicates="config.remove_duplicates"
/> />
<TableView <TableView
v-else v-else
:items="data" :items="data"
:columns="config.columns" :columns="config.columns"
:headings="config.headings"
/> />
</template> </template>
@ -31,6 +33,5 @@ export default {
}; };
</script> </script>
<style> <style scoped>
</style> </style>

View File

@ -5,38 +5,69 @@
* Created by Timo Hocker <timo@scode.ovh>, August 2020 * Created by Timo Hocker <timo@scode.ovh>, August 2020
*/ */
export default [ export default {
{ sources: [
type: 'chart', { name: 'default', limit: 140, offset: 0 },
x: 'timestamp', { name: 'secondary', limit: 10, offset: 0 },
y: [ { name: 'version', limit: 1, offset: 0 }
{ ],
label: 'temperature', displays: [
field: 'data/temperature', {
color: '#ff0000', source: 'default',
fill: '#0000' type: 'chart',
}, remove_duplicates: true,
{ x: 'timestamp',
label: 'humidity', y: [
field: 'data/humidity', {
color: '#0000ff', label: 'temperature',
fill: '#0000' field: 'data/temperature',
}, color: '#ff0000',
{ fill: '#0000',
label: 'light', min_value: 15,
field: 'data/light', max_value: 30
color: '#ffff00', },
fill: '#0000' {
} label: 'humidity',
] field: 'data/humidity',
}, color: '#0000ff',
{ fill: '#0000',
type: 'table', min_value: 20,
columns: [ max_value: 80
'id', },
'message', {
'data', label: 'light',
'timestamp' field: 'data/light',
] color: '#999',
} fill: '#0000'
]; }
]
},
{
source: 'secondary',
type: 'table',
headings: [
'timestamp',
'temperature',
'humidity',
'light',
'fan'
],
columns: [
'timestamp',
'data/temperature',
'data/humidity',
'data/light',
'data/latch'
]
},
{
source: 'version',
type: 'table',
headings: [ 'hardware version' ],
columns: [ 'message' ],
x: '',
y: [],
remove_duplicates: false
}
]
};

View File

@ -9,7 +9,6 @@
import Vue from 'vue'; import Vue from 'vue';
import VueRouter from 'vue-router'; import VueRouter from 'vue-router';
import AppView from '../views/AppView.vue';
import NotFound from '../views/NotFound.vue'; import NotFound from '../views/NotFound.vue';
import Home from '../views/Home.vue'; import Home from '../views/Home.vue';
@ -23,8 +22,7 @@ const routes = [
}, },
{ {
path: '/app/:id', path: '/app/:id',
name: 'AppView', redirect: '/'
component: AppView
}, },
{ {
path: '/404', path: '/404',

View File

@ -8,20 +8,52 @@
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import version from '../../version';
Vue.use (Vuex); Vue.use (Vuex);
export default new Vuex.Store ({ export default new Vuex.Store ({
state: { log: [] }, state: { log: {}, version, theme: '' },
mutations: { mutations: {
set_log (state, log) { set_log (state, log) {
state.log = log; state.log = log;
} }
}, },
getters: {
log (state) {
return (source) => {
if (typeof state.log[source] === 'undefined')
return [];
return state.log[source];
};
}
},
actions: { actions: {
async get_log ({ commit }, { app_id }) { async get_log ({ commit }, { app_id, sources }) {
const log = await fetch ('/log', { headers: { app_id } }) const logs = {};
.then ((res) => res.json ()); for (const source of sources) {
commit ('set_log', log); // eslint-disable-next-line no-await-in-loop
const log = await fetch ('/log', {
headers: {
app_id,
offset: source.offset,
limit: source.limit
}
})
.then ((res) => res.json ())
.then ((json) => json.map ((entry) => {
entry.data = JSON.parse (entry.data);
const time = new Date (entry.timestamp);
time.setMinutes (time.getMinutes () - time.getTimezoneOffset ());
entry.timestamp = time.toISOString ()
.replace ('T', ' ')
.substr (0, 19);
return entry;
}));
logs[source.name] = log;
}
commit ('set_log', logs);
} }
}, },
modules: {} modules: {}

View File

@ -6,43 +6,87 @@
*/ */
export default { export default {
type: 'array', type: 'object',
child: { properties: [
type: 'object', {
properties: [ type: 'array',
{ name: 'sources',
type: 'string', child: {
name: 'type', type: 'object',
choices: [ properties: [
'table', {
'chart' type: 'string',
name: 'name'
},
{
type: 'number',
name: 'limit'
},
{
type: 'number',
name: 'offset'
}
] ]
},
{
name: 'columns',
type: 'array',
child: { type: 'string' },
if: { prop: 'type', op: '=', val: 'table' }
},
{
name: 'x',
type: 'string',
if: { prop: 'type', op: '=', val: 'chart' }
},
{
name: 'y',
type: 'array',
if: { prop: 'type', op: '=', val: 'chart' },
child: {
type: 'object',
properties: [
{ type: 'string', name: 'label' },
{ type: 'string', name: 'field' },
{ type: 'string', name: 'color' },
{ type: 'string', name: 'fill' }
]
}
} }
] },
} {
type: 'array',
name: 'displays',
child: {
type: 'object',
properties: [
{
type: 'string',
name: 'source'
},
{
type: 'string',
name: 'type',
choices: [
'table',
'chart'
]
},
{
name: 'headings',
type: 'array',
child: { type: 'string' },
if: { prop: 'type', op: '=', val: 'table' }
},
{
name: 'columns',
type: 'array',
child: { type: 'string' },
if: { prop: 'type', op: '=', val: 'table' }
},
{
name: 'x',
type: 'string',
if: { prop: 'type', op: '=', val: 'chart' }
},
{
name: 'y',
type: 'array',
if: { prop: 'type', op: '=', val: 'chart' },
child: {
type: 'object',
properties: [
{ type: 'string', name: 'label' },
{ type: 'string', name: 'field' },
{ type: 'string', name: 'color' },
{ type: 'string', name: 'fill' },
{ type: 'number', name: 'min_value' },
{ type: 'number', name: 'max_value' }
]
}
},
{
name: 'remove_duplicates',
type: 'boolean',
if: { prop: 'type', op: '=', val: 'chart' }
}
]
}
}
]
}; };

View File

@ -1,80 +0,0 @@
<template>
<div
class="grid"
>
<ConfigEditor
v-model="config"
:template="template"
/>
<ViewComponent
v-for="(item,key) of saved_config"
:key="key"
:config="item"
:data="parsed_log"
/>
</div>
</template>
<script>
import Vuex from 'vuex';
import { copy_object } from '@sapphirecode/utilities';
import ViewComponent from '../components/ViewComponent.vue';
import ConfigEditor from '../components/ConfigEditor.vue';
import default_config from '../default';
import default_template from '../template';
export default {
components: { ViewComponent, ConfigEditor },
data () {
return {
config: copy_object (default_config),
saved_config: copy_object (default_config),
template: default_template
};
},
computed: {
parsed_log () {
return this.log.map ((l) => {
l.data = JSON.parse (l.data);
return l;
});
},
...Vuex.mapState ({ log: (state) => state.log })
},
mounted () {
this.get_log ({ app_id: this.$route.params.id });
document.body.addEventListener ('keydown', (ev) => {
if (ev.key === 's' && ev.ctrlKey) {
this.save_config ();
ev.preventDefault ();
return false;
}
return true;
});
},
methods: {
save_config () {
fetch ('config', {
method: 'POST',
body: JSON.stringify (this.config)
});
this.saved_config = copy_object (this.config);
},
...Vuex.mapActions ({ get_log: 'get_log' })
}
};
</script>
<style scoped>
.grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
width: 100%;
height: 100%;
}
p {
display: inline-block;
}
</style>

View File

@ -1,21 +1,49 @@
<template> <template>
<ul> <div class="main_view">
<li <div class="app_list">
v-for="(app,key) in apps" <div
:key="key" v-for="(app,key) in apps"
> :key="key"
<a class="app_list_item"
:href="'/app/' + app.id" @click="active = app.id"
v-text="app.name" v-text="app.name"
/> />
</li> </div>
</ul> <div class="data_view">
<AppView
v-if="active !== null"
class="data_view_display"
:app="active"
/>
<p
v-else
class="info"
>
Select an app to view its reports
</p>
</div>
<div class="footer">
<p
class="build_info"
v-text="version"
/>
</div>
</div>
</template> </template>
<script> <script>
import Vuex from 'vuex';
import AppView from '../components/AppView.vue';
export default { export default {
components: { AppView },
data () { data () {
return { apps: [] }; return { apps: [], active: null };
},
computed: {
...Vuex.mapState (
{ version: ({ version }) => `appreports build: ${version}` }
)
}, },
async mounted () { async mounted () {
this.apps = await fetch ('/app') this.apps = await fetch ('/app')
@ -25,5 +53,53 @@ export default {
</script> </script>
<style> <style>
.main_view {
display: grid;
grid-template-areas:
'sidebar main'
'footer footer';
grid-template-columns: minmax(min-content,max-content) 1fr;
grid-template-rows: minmax(80vh, auto) 1fr;
}
.app_list {
grid-area: sidebar;
border: var(--container-border);
}
.app_list_item {
cursor: pointer;
margin: 0;
padding: 5px;
border: var(--sub-container-border);
transition: .1s color ease-in-out;
}
.app_list_item:hover {
color: var(--color-focus);
}
.app_list_item:active {
color: var(--color-accent);
}
.data_view {
grid-area: main;
border: var(--container-border);
padding: 10px;
}
.footer {
grid-area: footer;
}
.info {
color: var(--color-focus);
}
.build_info {
font-size: 10pt;
text-align: left;
}
</style> </style>

10
version.js Normal file
View File

@ -0,0 +1,10 @@
/*
* Copyright (C) Sapphirecode - All Rights Reserved
* This file is part of appreports which is released under GPL-3.0-or-later.
* See file 'LICENSE' for full license details.
* Created by Timo Hocker <timo@scode.ovh>, August 2020
*/
'use strict';
module.exports = 'development';

18919
yarn.lock

File diff suppressed because it is too large Load Diff