Introduction of JS based pages

This commit is contained in:
Ziver Koc 2024-03-15 00:17:15 +01:00
parent c4cb9ff458
commit 7d64788154
24 changed files with 24446 additions and 30 deletions

View file

@ -18,8 +18,8 @@ subprojects {
apply plugin: 'java-library'
dependencies {
implementation 'se.koc:zutil:1.0.314'
//implementation 'se.koc:zutil:1.0.0-SNAPSHOT'
//implementation 'se.koc:zutil:1.0.314'
implementation 'se.koc:zutil:1.0.0-SNAPSHOT'
testImplementation 'junit:junit:4.12'
testImplementation 'org.hamcrest:hamcrest-core:2.2'

0
gradlew vendored Executable file → Normal file
View file

View file

@ -22,7 +22,7 @@ $(function(){
return null;
}
var obj = {};
let obj = {};
$.each(this[0].attributes, function() {
if(this.specified) {
obj[this.name] = this.value;
@ -42,26 +42,32 @@ $(function(){
// Converts all timestamps to human readable time and date
$.fn.relTimestamp = function() {
return this.each(function() {
var timestamp = parseInt($(this).text());
var timestampNow = Date.now();
var timeDiff = timestampNow - timestamp;
let timestamp = parseInt($(this).text());
if(timeDiff < 10 * 60 * 1000) // less than 10 min
$(this).text(moment(timestamp).fromNow());
else if(timeDiff < 24 * 60 * 60 * 1000) // less than 24 hours
$(this).text(moment(timestamp).fromNow() + " ("+moment(timestamp).format("HH:mm")+")");
else
$(this).text(moment(timestamp).format("YYYY-MM-DD HH:mm"));
$(this).text(getRelTimestamp(timestamp));
return this;
});
};
// Converts all timestamps to human readable time and date
function getRelTimestamp(timestamp) {
let timestampNow = Date.now();
let timeDiff = timestampNow - timestamp;
if(timeDiff < 10 * 60 * 1000) // less than 10 min
return moment(timestamp).fromNow();
else if(timeDiff < 24 * 60 * 60 * 1000) // less than 24 hours
return moment(timestamp).fromNow() + " ("+moment(timestamp).format("HH:mm")+")";
else
return moment(timestamp).format("YYYY-MM-DD HH:mm");
}
// --------------------------------------------------------
// Chart functions
// --------------------------------------------------------
function createChart(elementId, url, updateTime=-1){
var tickConf = {count: 20};
let tickConf = {count: 20};
if (updateTime < 60*60*1000)
tickConf['format'] = '%H:%M';
else if (updateTime < 24*60*60*1000)
@ -116,10 +122,10 @@ function updateChart(chart, url, updateTime=-1){
}
}
function getChartData(json){
var dataXaxis = {};
var dataYaxis = {};
var data = [];
var labels = [];
let dataXaxis = {};
let dataYaxis = {};
let data = [];
let labels = [];
json.forEach(function(sensor, i) {
var index = 'data' + i;
@ -166,8 +172,8 @@ function initDynamicModalForm(modalId, formTemplateId = null, templateID = null)
// click event
$("#" + modalId).on('show.bs.modal', function (event) {
var button = $(event.relatedTarget);
var modal = $(this);
let button = $(event.relatedTarget);
let modal = $(this);
modal.find(" input, select").val('').change(); // Reset all inputs

View file

@ -0,0 +1,39 @@
export default {
props: [
"level", // ERROR, WARNING, SUCCESS, INFO
"message"
],
data() {
return { }
},
computed: {
levelClass() {
switch(this.level) {
case "ERROR": return "alert-danger";
case "WARNING": return "alert-warning";
case "SUCCESS": return "alert-success";
case "INFO":
default: return "alert-info";
}
},
levelIcon() {
switch(this.level) {
case "ERROR": return "glyphicon-minus-sign";
case "WARNING": return "glyphicon-warning-sign";
case "SUCCESS": return "glyphicon-ok-circle";
case "INFO":
default: return "glyphicon-info-sign";
}
}
},
template: `
<div id="" data-alert-id="" class="alert alert-dismissible fade in" :class="levelClass">
<button type="button" class="close" data-dismiss="alert">
<span>&times;</span>
</button>
<span class="glyphicon" :class="levelIcon"></span>
<strong class="alert-title">{{message}}</strong> &nbsp;
<span class="alert-description"></span>
</div>
`
}

View file

@ -0,0 +1,29 @@
import Alert from 'AlertComponent'
import { alertStore } from 'AlertStore'
export default {
data() {
return {
alertStore
}
},
components: {
Alert,
},
mounted() {
alertStore.enableAutoLoad(true);
},
template: `
<div v-if="alertStore.loading" class="modal fade in" tabindex="-1" role="dialog" aria-hidden="true" data-backdrop="false">
<div class="modal-dialog modal-sm">
<div class="modal-content modal-body">
<i class="glyphicon glyphicon-refresh glyphicon-refresh-animate"></i>
Loading...
</div>
</div>
</div>
<div id="alert-container">
<Alert v-for="a in alertStore.alerts" v-bind="a" />
</div>`
}

View file

@ -0,0 +1,17 @@
import AlertList from 'AlertListComponent'
export default {
data() {
return {
}
},
components: {
AlertList,
},
template: `
<div class="container col-md-12">
<AlertList />
<router-view></router-view>
</div>`
}

View file

@ -0,0 +1,35 @@
import {eventStore} from 'EventStore'
export default {
props: {
'id': {default: 0},
},
data() {
let internalEvent = eventStore.getEvent(this.id);
return {
event: internalEvent,
checkboxChecked: internalEvent.data?.valueStr == 'ON',
}
},
template: `
<form method="POST">
<input type="hidden" name="action" value="modify">
<input type="hidden" name="action-id" :value="event.id">
<div class="btn-toolbar pull-right">
<template v-if="event.dataType === 'ColorEventData'">
<input type="hidden" name="type" value="color">
<input type="color" name="data" onchange="this.form.submit()" :value="event.data?.valueStr">
</template>
<template v-else-if="event.dataType === 'LevelEventData'">
<input type="hidden" name="type" value="level">
<input class="styled-slider slider-progress" type="range" name="data" min="0" max="100" step="10" onchange="this.form.submit()" :value="event.data?.value * 100">
</template>
<template v-else-if="event.dataType === 'OnOffEventData'">
<input type="hidden" name="type" value="on-off">
<input class="switch" type="checkbox" name="data" onchange="this.form.submit()" v-model="checkboxChecked">
</template>
</div>
</form>
`
}

View file

@ -0,0 +1,88 @@
import {eventStore} from 'EventStore'
export default {
data() {
var id = this.$route.params.id;
return {
id: id,
event: eventStore.getEvent(id),
}
},
template: `
<h1 class="page-header">Details for <a href="?id={{event.id}}">{{event.name}}</a></h1>
<div class="col-md-5">
<div class="panel panel-default drop-shadow">
<div class="panel-heading">Configuration</div>
<div class="panel-body">
<table class="table table-hover table-condensed">
<thead>
<tr>
<th class="text-right">Event ID:</th>
<th>{{event.id}</th>
</tr>
<tr>
<th class="text-right">Name:</th>
<th>{{event.name}}</th>
</tr>
</thead>
<tr>
<th class="text-right">Type:</th>
<td>{{event.configType}}</td>
</tr>
<tr>
<th class="text-right">Owner:</th>
<td>{{event.owner}} <p></td>
</tr>
<tr>
<th class="text-right">State:</th>
<td>
<form method="POST">
<input type="hidden" name="action" value="modify">
<input type="hidden" name="action-id" value="{{event.id}}">
<div class="btn-toolbar pull-left">
<input class="toggle-switch" type="checkbox" name="enabled"
data-on-color="danger">
</div>
</form>
</td>
</tr>
<tr v-for="(confName, confValue) in event.config">
<th class="text-right">{{name}}:</th>
<td>{{confValue}}</td>
</tr>
</table>
</div>
</div>
</div>
<div class="col-md-7">
<div class="panel panel-default drop-shadow">
<div class="panel-heading">History data</div>
<div class="panel-body">
<table class="table table-hover table-condensed">
<thead>
<th class="col-md-6">Timestamp</th>
<th class="col-md-2">Raw Data</th>
</thead>
<tr>
<td><span class="timestamp">{{event.data.timestamp}}</span></td>
<td>{{event.data.value}}</td>
</tr>
</table>
</div>
</div>
</div>
<script>
$(function (){
$(".toggle-switch").on("switchChange.bootstrapSwitch", function (event, state) {
$(this).closest('form').submit();
});
});
</script>
`
}

View file

@ -0,0 +1,21 @@
import EventTable from 'EventTableComponent'
export default {
props: {
},
components: {
EventTable,
},
template: `
<h1 class="page-header">Event Overview</h1>
<div class="col-md-12">
<div class="panel panel-default drop-shadow">
<div class="panel-heading">Local Events</div>
<div class="panel-body">
<EventTable />
</div>
</div>
</div>
`
}

View file

@ -0,0 +1,34 @@
import {eventStore} from 'EventStore'
import EventTableRow from 'EventTableRowComponent'
export default {
data() {
return {
eventStore,
}
},
components: {
EventTableRow,
},
mounted() {
eventStore.enableAutoLoad(true);
},
unmounted() {
eventStore.enableAutoLoad(false);
},
template: `
<table id="event-device-table2" class="table table-hover table-condensed">
<thead>
<tr>
<th class="col-md-4">Name</th>
<th class="col-md-3">Type</th>
<th class="col-md-1">Data</th>
<th class="col-md-2">Last Update</th>
<th class="col-md-2 text-right">Actions</th>
</tr>
<EventTableRow v-for="e in eventStore.events" :key="e.id" :id="e.id" />
</thead>
</table>
`
}

View file

@ -0,0 +1,29 @@
import {eventStore} from 'EventStore'
import EventAction from 'EventActionComponent'
export default {
props: {
'id': {default: 0},
},
data() {
let event = eventStore.getEvent(this.id)
return {
event: event,
timestamp: getRelTimestamp(event.data.timestamp)
}
},
components: {
EventAction
},
template: `
<tr :data-device-id="event.id">
<td><a :href="'?id=' + event.id">{{ event.name }}</a></td>
<td>{{ event.configType }}</td>
<td>{{ event.data.valueStr }}</td>
<td class="timestamp">{{ timestamp }}</td>
<td>
<EventAction :id="event.id"/>
</td>
</tr>
`
}

View file

@ -0,0 +1,46 @@
import { reactive } from 'vue'
export const alertStore = reactive({
loading: false,
alerts: [],
pollTimer: null,
enableAutoLoad(enabled = true) {
if (enabled && this.pollTimer !== null) {
this.pollTimer = setInterval(function() {
load();
}, 3000);
} else {
clearInterval(this.timer);
this.pollTimer == null;
}
},
load() {
fetch('/api/alert?action=poll')
.then(response => response.json())
.then(data => {
data.forEach(alert => {
alert.source = "server";
alertStore.alerts.push(alert);
});
});
},
setLoading(l = true) {
this.loading = l;
},
addAlertInfo(message) {
this.alerts.push({source: "local", level: "info", message: message});
},
addAlertSuccess(message) {
this.alerts.push({source: "local", level: "success", message: message});
},
addAlertWarning(message) {
this.alerts.push({source: "local", level: "warning", message: message});
},
AddAlertError(message) {
this.alerts.push({source: "local", level: "danger", message: message});
},
});

View file

@ -0,0 +1,49 @@
import { reactive } from 'vue'
import { alertStore } from 'AlertStore'
export const eventStore = reactive({
events: [],
pollTimerId: null,
pollInterval: 10000, // 10 sec
enableAutoLoad(enabled = true) {
if (enabled) {
if (this.pollTimerId !== null)
return; // Timer already initialized
eventStore.load();
this.pollTimerId = setInterval(function() {
eventStore.load();
}, this.pollInterval);
} else {
clearInterval(this.pollTimerId);
this.pollTimerId == null;
}
},
load() {
alertStore.setLoading(true);
fetch('/api/event', {
method: 'GET',
headers: {
'Accept': 'application/json',
},
})
.then(response => response.json())
.then(json => {
if (json['error'] != null) {
alertStore.alertError(json['error']);
return;
}
eventStore.events = json;
alertStore.setLoading(false);
})
},
getEvent(id) {
let event = eventStore.events.find(event => id == event.id);
return event;
}
});

View file

@ -0,0 +1,44 @@
import { reactive } from 'vue'
import { alertStore } from 'AlertStore'
export const sensorStore = reactive({
sensors: {},
pollTimerId: null,
pollInterval: 10000, // 10 sec
enableAutoLoad(enabled = true) {
if (enabled) {
if (this.pollTimerId !== null)
return; // Timer already initialized
sensorStore.load();
this.pollTimerId = setInterval(function() {
sensorStore.load();
}, this.pollInterval);
} else {
clearInterval(this.pollTimerId);
this.pollTimerId == null;
}
},
load() {
alertStore.setLoading(true);
fetch('/api/sensor', {
method: 'GET',
headers: {
'Accept': 'application/json',
},
})
.then(response => response.json())
.then(json => {
if (json['error'] != null) {
alertStore.alertError(json['error']);
return;
}
sensorStore.sensors = json;
alertStore.setLoading(false);
})
},
});

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -19,11 +19,12 @@
<script src="js/lib/bootstrap-switch.min.js"></script>
<script src="js/lib/moment.js"></script>
<script src="js/hal.js"></script>
<script src="js/hal_alert.js"></script>
<!-- <script src="js/hal_alert.js"></script> -->
<!-- charts -->
<script src="js/lib/d3.js"></script>
<script src="js/lib/c3.js"></script>
<script src="js/lib/d3.min.js"></script>
<script src="js/lib/force-graph.min.js"></script>
<script src="js/lib/c3.min.js"></script>
</head>
@ -39,10 +40,52 @@
{{/side_navigation}}
{{^side_navigation}}<div class="main">{{/side_navigation}}
<div id="alert-container"></div>
<div id="app"></div>
{{content}}
</div>
</div>
</div>
<script type="importmap">
{
"imports": {
"vue": "./js/vue/vue.esm-browser.js",
"vue-router": "./js/vue/vue-router.esm-browser.js",
"@vue/devtools-api": "https://unpkg.com/@vue/devtools-api@6.4.5/lib/esm/index.js",
{{#javascriptModules}}"{{.getModuleName()}}": "{{.getScriptPath()}}",
{{/javascriptModules}}
"App": "./js/vue/components/App.js"
}
}
</script>
<script type="module">
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
{{#javascriptPages}}import {{.getModuleName()}} from '{{.getModuleName()}}'
{{/javascriptPages}}
import App from 'App'
const app = createApp(App);
const router = createRouter({
history: createWebHistory(),
routes: [
{{#javascriptPages}}{ name: "{{.getModuleName()}}", path: "{{.getPage()}}", component: {{.getModuleName()}} },
{{/javascriptPages}}
]
});
app.use(router);
app.mount('#app');
</script>
</body>
</html>

View file

@ -4,6 +4,7 @@ package se.hal;
import se.hal.daemon.HalExternalWebDaemon;
import se.hal.intf.*;
import se.hal.intf.HalJavascriptModule.HalJsModule;
import se.hal.page.EmptyWebPage;
import se.hal.page.StartupWebPage;
import se.hal.struct.PluginConfig;
import zutil.db.DBConnection;
@ -138,18 +139,22 @@ public class HalServer {
http.setDefaultPage(filePage);
http.setPage("/", new HttpRedirectPage("/map"));
for (Iterator<HalWebPage> it = pluginManager.getSingletonIterator(HalApiEndpoint.class); it.hasNext(); )
registerPage(it.next());
for (Iterator<HalWebPage> it = pluginManager.getSingletonIterator(HalWebPage.class); it.hasNext(); )
registerPage(it.next());
for (Iterator<HalJavascriptModule> it = pluginManager.getSingletonIterator(HalJavascriptModule.class); it.hasNext(); ) {
HalJsModule[] jsModules = it.next().getJavascriptModules();
if (jsModules != null) {
for (HalJsModule module : jsModules)
for (HalJsModule module : jsModules) {
HalWebPage.addJavascriptModule(module);
if (module instanceof HalJavascriptModule.HalJsModulePage)
registerPage(((HalJavascriptModule.HalJsModulePage) module).getPage(), new EmptyWebPage());
}
}
}
for (Iterator<HalWebPage> it = pluginManager.getSingletonIterator(HalApiEndpoint.class); it.hasNext(); )
registerPage(it.next());
for (Iterator<HalWebPage> it = pluginManager.getSingletonIterator(HalWebPage.class); it.hasNext(); )
registerPage(it.next());
} catch (Exception e) {
@ -202,6 +207,10 @@ public class HalServer {
}
}
public static List<HalDaemon> getAllDaemons() {
return daemons;
}
/**
* Registers the given page with the intranet Hal web server.

View file

@ -19,7 +19,7 @@ import java.util.List;
import java.util.Map;
public abstract class HalWebPage implements HttpPage{
public abstract class HalWebPage implements HttpPage {
private static final String TEMPLATE_MAIN = HalContext.RESOURCE_WEB_ROOT + "/main_index.tmpl";
private static final String TEMPLATE_NAVIGATION = HalContext.RESOURCE_WEB_ROOT + "/main_nav.tmpl";
private static final String TEMPLATE_SIDE_NAVIGATION = HalContext.RESOURCE_WEB_ROOT + "/main_nav_side.tmpl";
@ -83,7 +83,12 @@ public abstract class HalWebPage implements HttpPage{
main.set("side_navigation", subNavigationTemplate);
main.set("javascriptModules", jsModules);
main.set("javascriptPages", jsPages);
main.set("content", httpRespond(session, cookie, request));
Templator body = httpRespond(session, cookie, request);
if (body == null)
main.set("content", "");
else
main.set("content", body);
out.print(main.compile());
} catch (Exception e) {

View file

@ -0,0 +1,44 @@
package se.hal.page;
import se.hal.EventControllerManager;
import se.hal.HalContext;
import se.hal.intf.HalEventData;
import se.hal.intf.HalWebPage;
import se.hal.struct.Event;
import se.hal.struct.devicedata.ColorEventData;
import se.hal.struct.devicedata.LevelEventData;
import se.hal.struct.devicedata.OnOffEventData;
import se.hal.util.DeviceNameComparator;
import se.hal.util.HistoryDataListSqlResult;
import se.hal.util.HistoryDataListSqlResult.HistoryData;
import zutil.ObjectUtil;
import zutil.db.DBConnection;
import zutil.io.file.FileUtil;
import zutil.log.LogUtil;
import zutil.parser.Templator;
import java.sql.PreparedStatement;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;
/**
* A empty page with an empty body content generated from server side.
*/
public class EmptyWebPage extends HalWebPage {
public EmptyWebPage() {
super("empty");
}
@Override
public Templator httpRespond(
Map<String, Object> session,
Map<String, String> cookie,
Map<String, String> request)
throws Exception{
return null;
}
}

View file

@ -1,11 +1,14 @@
package se.hal.page;
import se.hal.intf.HalJavascriptModule;
import se.hal.intf.HalWebPage;
public class JavascriptModules implements HalJavascriptModule {
@Override
public HalJsModule[] getJavascriptModules() {
HalWebPage.getRootNav().createSubNav("Events").createSubNav("/event_overview", "Overview");
return new HalJsModule[] {
new HalJsModule("AlertStore", "./js/vue/stores/AlertStore.js"),
new HalJsModule("EventStore", "./js/vue/stores/EventStore.js"),

View file

@ -18,7 +18,8 @@
{"se.hal.intf.HalApiEndpoint": "se.hal.page.api.RoomApiEndpoint"},
{"se.hal.intf.HalApiEndpoint": "se.hal.page.api.SensorApiEndpoint"},
{"se.hal.intf.HalWebPage": "se.hal.page.EventOverviewWebPage"},
{"se.hal.intf.HalJavascriptModule": "se.hal.page.JavascriptModules"},
{"DISABLEDse.hal.intf.HalWebPage": "se.hal.page.EventOverviewWebPage"},
{"se.hal.intf.HalWebPage": "se.hal.page.EventConfigWebPage"},
{"se.hal.intf.HalWebPage": "se.hal.page.PropertyConfigWebPage"},
{"se.hal.intf.HalWebPage": "se.hal.page.PluginConfigWebPage"},