Compare commits
17 Commits
b0837c7424
...
dev
Author | SHA1 | Date | |
---|---|---|---|
824d7c59e7 | |||
1be8a3f592 | |||
8100a19e76 | |||
b6dfad0515 | |||
aa4094cd05 | |||
d45c8a7e9a | |||
2ec9a9fe4d | |||
c3a11f1f3c | |||
6e9d634f74 | |||
4174e26496 | |||
cf5c17bf59 | |||
ca305dff73 | |||
9d91740f6e | |||
1a75f8c242 | |||
ca37e38800 | |||
f57444a3d1 | |||
19904bd0ec |
2
.gitignore
vendored
2
.gitignore
vendored
@ -21,3 +21,5 @@ pnpm-debug.log*
|
||||
*.sln
|
||||
*.sw?
|
||||
*.sqlite
|
||||
|
||||
!/data/test.sqlite
|
||||
|
BIN
data/test.sqlite
Normal file
BIN
data/test.sqlite
Normal file
Binary file not shown.
@ -11,6 +11,7 @@
|
||||
|
||||
const knex = require ('knex');
|
||||
const fs = require ('fs');
|
||||
const job = require ('./job');
|
||||
|
||||
let db = null;
|
||||
|
||||
@ -18,8 +19,10 @@ async function init (use_fake_seed) {
|
||||
if (!fs.existsSync ('data'))
|
||||
fs.mkdirSync ('data');
|
||||
|
||||
if (use_fake_seed && fs.existsSync ('data/db.sqlite'))
|
||||
if (use_fake_seed && fs.existsSync ('data/db.sqlite')) {
|
||||
fs.unlinkSync ('data/db.sqlite');
|
||||
fs.copyFileSync ('data/test.sqlite', 'data/db.sqlite');
|
||||
}
|
||||
|
||||
db = knex ({
|
||||
client: 'sqlite',
|
||||
@ -30,10 +33,14 @@ async function init (use_fake_seed) {
|
||||
});
|
||||
|
||||
await db.migrate.latest ();
|
||||
if (use_fake_seed)
|
||||
await db.seed.run ({ specific: 'fake.js' });
|
||||
else
|
||||
await db.seed.run ({ specific: 'prod.js' });
|
||||
|
||||
await db.seed.run ({ specific: 'prod.js' });
|
||||
|
||||
// await db.seed.run ({ specific: 'fake.js' });
|
||||
await job (db);
|
||||
setInterval (() => {
|
||||
job (db);
|
||||
}, 3600e3);
|
||||
}
|
||||
|
||||
function get_db () {
|
||||
|
87
lib/db/job.js
Normal file
87
lib/db/job.js
Normal 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`);
|
||||
}
|
||||
};
|
23
lib/defaults.js
Normal file
23
lib/defaults.js
Normal 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
|
||||
])
|
||||
}
|
||||
};
|
22
migrations/00001-reduction.js
Normal file
22
migrations/00001-reduction.js
Normal 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 };
|
10
package.json
10
package.json
@ -18,14 +18,15 @@
|
||||
"core-js": "^3.6.5",
|
||||
"express": "^4.17.1",
|
||||
"express-http-proxy": "^1.6.2",
|
||||
"faker": "^4.1.0",
|
||||
"faker": "^5.1.0",
|
||||
"knex": "^0.21.2",
|
||||
"lodash.chunk": "^4.2.0",
|
||||
"sqlite3": "^5.0.0",
|
||||
"vue": "^2.6.11",
|
||||
"vue-chartjs": "^3.5.0",
|
||||
"vue-router": "^3.2.0",
|
||||
"vuex": "^3.4.0",
|
||||
"yargs": "^15.4.1"
|
||||
"yargs": "^16.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sapphirecode/eslint-config": "^2.1.16",
|
||||
@ -37,7 +38,7 @@
|
||||
"@vue/cli-service": "^4.4.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"eslint": "^7.5.0",
|
||||
"eslint-plugin-vue": "^6.2.2",
|
||||
"eslint-plugin-vue": "^7.1.0",
|
||||
"vue-template-compiler": "^2.6.11"
|
||||
},
|
||||
"license": "GPL-3.0-or-later",
|
||||
@ -45,5 +46,8 @@
|
||||
"author": {
|
||||
"name": "Timo Hocker",
|
||||
"email": "timo@scode.ovh"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
}
|
@ -9,16 +9,6 @@
|
||||
|
||||
const faker = require ('faker');
|
||||
|
||||
const apps = [];
|
||||
|
||||
async function create_app (knex) {
|
||||
const [ id ] = await knex ('app')
|
||||
.insert (
|
||||
{ name: faker.random.word () }
|
||||
);
|
||||
apps.push (id);
|
||||
}
|
||||
|
||||
let last_t = 0;
|
||||
let last_h = 0;
|
||||
|
||||
@ -32,7 +22,7 @@ function create_log (timestamp) {
|
||||
humidity: last_h
|
||||
};
|
||||
return {
|
||||
app_id: faker.random.arrayElement (apps),
|
||||
app_id: 1,
|
||||
message: faker.random.words (),
|
||||
data: JSON.stringify (data),
|
||||
timestamp
|
||||
@ -40,15 +30,14 @@ function create_log (timestamp) {
|
||||
}
|
||||
|
||||
async function seed (knex) {
|
||||
await knex ('log')
|
||||
.del ();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log ('creating seeds');
|
||||
for (let i = 0; i < 5; i++)
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await create_app (knex);
|
||||
|
||||
const log = (Array (1000))
|
||||
const log = (Array (10000))
|
||||
.fill (() => null)
|
||||
.map (() => faker.date.recent (30))
|
||||
.map (() => faker.date.recent (60))
|
||||
.sort ()
|
||||
.map ((t) => create_log (t));
|
||||
|
||||
|
28
spec/api.json
Normal file
28
spec/api.json
Normal 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": {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
27
src/App.vue
27
src/App.vue
@ -1,34 +1,15 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<p
|
||||
id="build-info"
|
||||
v-text="version"
|
||||
/>
|
||||
<Style />
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import version from '../version';
|
||||
import Style from './Style.vue';
|
||||
|
||||
export default {
|
||||
data () {
|
||||
return { version: `build: ${version}` };
|
||||
}
|
||||
};
|
||||
export default { components: { Style } };
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-align: center;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
#build-info {
|
||||
font-size: 10pt;
|
||||
text-align: left;
|
||||
}
|
||||
<style scoped>
|
||||
</style>
|
||||
|
93
src/Style.vue
Normal file
93
src/Style.vue
Normal 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>
|
@ -1,42 +1,63 @@
|
||||
<template>
|
||||
<div
|
||||
class="grid"
|
||||
>
|
||||
<ConfigEditor
|
||||
v-model="config"
|
||||
:template="template"
|
||||
/>
|
||||
<ViewComponent
|
||||
v-for="(item,key) of saved_config.displays"
|
||||
:key="key"
|
||||
:config="item"
|
||||
:data="log(item.source)"
|
||||
<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 ViewComponent from '../components/ViewComponent.vue';
|
||||
import ConfigEditor from '../components/ConfigEditor.vue';
|
||||
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
|
||||
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) {
|
||||
if (ev.key === 's' && ev.ctrlKey && this.editing) {
|
||||
this.save_config ();
|
||||
ev.preventDefault ();
|
||||
return false;
|
||||
@ -52,13 +73,20 @@ export default {
|
||||
});
|
||||
this.saved_config = copy_object (this.config);
|
||||
this.fetch_log ();
|
||||
this.editing = false;
|
||||
},
|
||||
fetch_log () {
|
||||
this.get_log ({
|
||||
app_id: this.$route.params.id,
|
||||
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' })
|
||||
}
|
||||
};
|
||||
@ -73,7 +101,7 @@ export default {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
p {
|
||||
display: inline-block;
|
||||
.spacer {
|
||||
margin: 10px;
|
||||
}
|
||||
</style>
|
@ -17,6 +17,10 @@ export default {
|
||||
yaxis: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
remove_duplicates: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@ -28,10 +32,27 @@ export default {
|
||||
data: [],
|
||||
yAxisID: index,
|
||||
borderColor: y.color,
|
||||
backgroundColor: y.fill
|
||||
backgroundColor: y.fill,
|
||||
spanGaps: true,
|
||||
lineTension: 0
|
||||
};
|
||||
for (const data of this.data)
|
||||
res.data.push (resolve_data (data, y.field));
|
||||
let last = null;
|
||||
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 { datasets, labels };
|
||||
@ -49,7 +70,16 @@ export default {
|
||||
if (typeof y.max_value === 'number')
|
||||
range.suggestedMax = y.max_value;
|
||||
|
||||
return { id: index, ticks: range };
|
||||
return {
|
||||
id: index,
|
||||
ticks: range,
|
||||
scaleLabel: {
|
||||
labelString: y.label,
|
||||
display: true,
|
||||
lineHeight: 1,
|
||||
padding: 0.1
|
||||
}
|
||||
};
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -196,7 +196,7 @@ export default {
|
||||
}
|
||||
|
||||
.editor {
|
||||
border: 1px solid black;
|
||||
border: var(--sub-container-border);
|
||||
margin-left: 10px;
|
||||
grid-column: editor;
|
||||
}
|
||||
|
@ -4,6 +4,13 @@
|
||||
class="table_view"
|
||||
>
|
||||
<table>
|
||||
<tr v-if="headings.length > 0">
|
||||
<th
|
||||
v-for="(heading,key) of headings"
|
||||
:key="key"
|
||||
v-text="heading"
|
||||
/>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="(item,key) of items"
|
||||
:key="key"
|
||||
@ -30,6 +37,10 @@ export default {
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
headings: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
methods: { resolve_data }
|
||||
@ -38,8 +49,7 @@ export default {
|
||||
|
||||
<style>
|
||||
.table_view {
|
||||
overflow: auto;
|
||||
border: 1px solid blue;
|
||||
overflow-y: scoll;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
max-height: 100vh;
|
||||
@ -51,11 +61,12 @@ table {
|
||||
}
|
||||
|
||||
tr:nth-child(odd) {
|
||||
background-color: #ddd;
|
||||
background-color: var(--color-background-1);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
td {
|
||||
border: 1px solid black;
|
||||
margin: 0;
|
||||
td, th {
|
||||
border: var(--sub-container-border);
|
||||
padding: 3px;
|
||||
}
|
||||
</style>
|
||||
|
@ -4,11 +4,13 @@
|
||||
:data="[...data].reverse()"
|
||||
:xaxis="config.x"
|
||||
:yaxis="config.y"
|
||||
:remove_duplicates="config.remove_duplicates"
|
||||
/>
|
||||
<TableView
|
||||
v-else
|
||||
:items="data"
|
||||
:columns="config.columns"
|
||||
:headings="config.headings"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@ -31,6 +33,5 @@ export default {
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
@ -6,24 +6,18 @@
|
||||
*/
|
||||
|
||||
export default {
|
||||
sources: [
|
||||
{
|
||||
name: 'default',
|
||||
limit: 140,
|
||||
offset: 0
|
||||
},
|
||||
{
|
||||
name: 'secondary',
|
||||
limit: 10,
|
||||
offset: 0
|
||||
}
|
||||
sources: [
|
||||
{ name: 'default', limit: 140, offset: 0 },
|
||||
{ name: 'secondary', limit: 10, offset: 0 },
|
||||
{ name: 'version', limit: 1, offset: 0 }
|
||||
],
|
||||
displays: [
|
||||
{
|
||||
source: 'default',
|
||||
type: 'chart',
|
||||
x: 'timestamp',
|
||||
y: [
|
||||
source: 'default',
|
||||
type: 'chart',
|
||||
remove_duplicates: true,
|
||||
x: 'timestamp',
|
||||
y: [
|
||||
{
|
||||
label: 'temperature',
|
||||
field: 'data/temperature',
|
||||
@ -49,12 +43,31 @@ export default {
|
||||
]
|
||||
},
|
||||
{
|
||||
source: 'secondary',
|
||||
type: 'table',
|
||||
source: 'secondary',
|
||||
type: 'table',
|
||||
headings: [
|
||||
'timestamp',
|
||||
'temperature',
|
||||
'humidity',
|
||||
'light',
|
||||
'fan'
|
||||
],
|
||||
columns: [
|
||||
'timestamp',
|
||||
'data'
|
||||
'data/temperature',
|
||||
'data/humidity',
|
||||
'data/light',
|
||||
'data/latch'
|
||||
]
|
||||
},
|
||||
{
|
||||
source: 'version',
|
||||
type: 'table',
|
||||
headings: [ 'hardware version' ],
|
||||
columns: [ 'message' ],
|
||||
x: '',
|
||||
y: [],
|
||||
remove_duplicates: false
|
||||
}
|
||||
]
|
||||
};
|
||||
|
@ -9,7 +9,6 @@
|
||||
|
||||
import Vue from 'vue';
|
||||
import VueRouter from 'vue-router';
|
||||
import AppView from '../views/AppView.vue';
|
||||
import NotFound from '../views/NotFound.vue';
|
||||
import Home from '../views/Home.vue';
|
||||
|
||||
@ -23,8 +22,7 @@ const routes = [
|
||||
},
|
||||
{
|
||||
path: '/app/:id',
|
||||
name: 'AppView',
|
||||
component: AppView
|
||||
redirect: '/'
|
||||
},
|
||||
{
|
||||
path: '/404',
|
||||
|
@ -8,10 +8,12 @@
|
||||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
|
||||
import version from '../../version';
|
||||
|
||||
Vue.use (Vuex);
|
||||
|
||||
export default new Vuex.Store ({
|
||||
state: { log: {} },
|
||||
state: { log: {}, version, theme: '' },
|
||||
mutations: {
|
||||
set_log (state, log) {
|
||||
state.log = log;
|
||||
@ -41,13 +43,11 @@ export default new Vuex.Store ({
|
||||
.then ((res) => res.json ())
|
||||
.then ((json) => json.map ((entry) => {
|
||||
entry.data = JSON.parse (entry.data);
|
||||
const time
|
||||
= (/(?<y>[0-9]+-[0-9]+-[0-9]+)T(?<t>[0-9]+:[0-9]+:[0-9]+)/u)
|
||||
.exec (
|
||||
new Date (entry.timestamp)
|
||||
.toISOString ()
|
||||
);
|
||||
entry.timestamp = `${time.groups.y} ${time.groups.t}`;
|
||||
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;
|
||||
|
@ -47,6 +47,12 @@ export default {
|
||||
'chart'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'headings',
|
||||
type: 'array',
|
||||
child: { type: 'string' },
|
||||
if: { prop: 'type', op: '=', val: 'table' }
|
||||
},
|
||||
{
|
||||
name: 'columns',
|
||||
type: 'array',
|
||||
@ -73,6 +79,11 @@ export default {
|
||||
{ type: 'number', name: 'max_value' }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'remove_duplicates',
|
||||
type: 'boolean',
|
||||
if: { prop: 'type', op: '=', val: 'chart' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -1,21 +1,49 @@
|
||||
<template>
|
||||
<ul>
|
||||
<li
|
||||
v-for="(app,key) in apps"
|
||||
:key="key"
|
||||
>
|
||||
<a
|
||||
:href="'/app/' + app.id"
|
||||
<div class="main_view">
|
||||
<div class="app_list">
|
||||
<div
|
||||
v-for="(app,key) in apps"
|
||||
:key="key"
|
||||
class="app_list_item"
|
||||
@click="active = app.id"
|
||||
v-text="app.name"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<script>
|
||||
import Vuex from 'vuex';
|
||||
import AppView from '../components/AppView.vue';
|
||||
|
||||
export default {
|
||||
components: { AppView },
|
||||
data () {
|
||||
return { apps: [] };
|
||||
return { apps: [], active: null };
|
||||
},
|
||||
computed: {
|
||||
...Vuex.mapState (
|
||||
{ version: ({ version }) => `appreports build: ${version}` }
|
||||
)
|
||||
},
|
||||
async mounted () {
|
||||
this.apps = await fetch ('/app')
|
||||
@ -25,5 +53,53 @@ export default {
|
||||
</script>
|
||||
|
||||
<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>
|
||||
|
Reference in New Issue
Block a user