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' apply plugin: 'java-library'
dependencies { dependencies {
implementation 'se.koc:zutil:1.0.314' //implementation 'se.koc:zutil:1.0.314'
//implementation 'se.koc:zutil:1.0.0-SNAPSHOT' implementation 'se.koc:zutil:1.0.0-SNAPSHOT'
testImplementation 'junit:junit:4.12' testImplementation 'junit:junit:4.12'
testImplementation 'org.hamcrest:hamcrest-core:2.2' 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; return null;
} }
var obj = {}; let obj = {};
$.each(this[0].attributes, function() { $.each(this[0].attributes, function() {
if(this.specified) { if(this.specified) {
obj[this.name] = this.value; obj[this.name] = this.value;
@ -42,26 +42,32 @@ $(function(){
// Converts all timestamps to human readable time and date // Converts all timestamps to human readable time and date
$.fn.relTimestamp = function() { $.fn.relTimestamp = function() {
return this.each(function() { return this.each(function() {
var timestamp = parseInt($(this).text()); let timestamp = parseInt($(this).text());
var timestampNow = Date.now();
var timeDiff = timestampNow - timestamp;
if(timeDiff < 10 * 60 * 1000) // less than 10 min $(this).text(getRelTimestamp(timestamp));
$(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"));
return this; 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 // Chart functions
// -------------------------------------------------------- // --------------------------------------------------------
function createChart(elementId, url, updateTime=-1){ function createChart(elementId, url, updateTime=-1){
var tickConf = {count: 20}; let tickConf = {count: 20};
if (updateTime < 60*60*1000) if (updateTime < 60*60*1000)
tickConf['format'] = '%H:%M'; tickConf['format'] = '%H:%M';
else if (updateTime < 24*60*60*1000) else if (updateTime < 24*60*60*1000)
@ -116,10 +122,10 @@ function updateChart(chart, url, updateTime=-1){
} }
} }
function getChartData(json){ function getChartData(json){
var dataXaxis = {}; let dataXaxis = {};
var dataYaxis = {}; let dataYaxis = {};
var data = []; let data = [];
var labels = []; let labels = [];
json.forEach(function(sensor, i) { json.forEach(function(sensor, i) {
var index = 'data' + i; var index = 'data' + i;
@ -166,8 +172,8 @@ function initDynamicModalForm(modalId, formTemplateId = null, templateID = null)
// click event // click event
$("#" + modalId).on('show.bs.modal', function (event) { $("#" + modalId).on('show.bs.modal', function (event) {
var button = $(event.relatedTarget); let button = $(event.relatedTarget);
var modal = $(this); let modal = $(this);
modal.find(" input, select").val('').change(); // Reset all inputs 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/bootstrap-switch.min.js"></script>
<script src="js/lib/moment.js"></script> <script src="js/lib/moment.js"></script>
<script src="js/hal.js"></script> <script src="js/hal.js"></script>
<script src="js/hal_alert.js"></script> <!-- <script src="js/hal_alert.js"></script> -->
<!-- charts --> <!-- charts -->
<script src="js/lib/d3.js"></script> <script src="js/lib/d3.min.js"></script>
<script src="js/lib/c3.js"></script> <script src="js/lib/force-graph.min.js"></script>
<script src="js/lib/c3.min.js"></script>
</head> </head>
@ -39,10 +40,52 @@
{{/side_navigation}} {{/side_navigation}}
{{^side_navigation}}<div class="main">{{/side_navigation}} {{^side_navigation}}<div class="main">{{/side_navigation}}
<div id="alert-container"></div> <div id="alert-container"></div>
<div id="app"></div>
{{content}} {{content}}
</div> </div>
</div> </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> </body>
</html> </html>

View file

@ -4,6 +4,7 @@ package se.hal;
import se.hal.daemon.HalExternalWebDaemon; import se.hal.daemon.HalExternalWebDaemon;
import se.hal.intf.*; import se.hal.intf.*;
import se.hal.intf.HalJavascriptModule.HalJsModule; import se.hal.intf.HalJavascriptModule.HalJsModule;
import se.hal.page.EmptyWebPage;
import se.hal.page.StartupWebPage; import se.hal.page.StartupWebPage;
import se.hal.struct.PluginConfig; import se.hal.struct.PluginConfig;
import zutil.db.DBConnection; import zutil.db.DBConnection;
@ -138,18 +139,22 @@ public class HalServer {
http.setDefaultPage(filePage); http.setDefaultPage(filePage);
http.setPage("/", new HttpRedirectPage("/map")); 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(); ) { for (Iterator<HalJavascriptModule> it = pluginManager.getSingletonIterator(HalJavascriptModule.class); it.hasNext(); ) {
HalJsModule[] jsModules = it.next().getJavascriptModules(); HalJsModule[] jsModules = it.next().getJavascriptModules();
if (jsModules != null) { if (jsModules != null) {
for (HalJsModule module : jsModules) for (HalJsModule module : jsModules) {
HalWebPage.addJavascriptModule(module); 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) { } 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. * Registers the given page with the intranet Hal web server.

View file

@ -19,7 +19,7 @@ import java.util.List;
import java.util.Map; 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_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_NAVIGATION = HalContext.RESOURCE_WEB_ROOT + "/main_nav.tmpl";
private static final String TEMPLATE_SIDE_NAVIGATION = HalContext.RESOURCE_WEB_ROOT + "/main_nav_side.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("side_navigation", subNavigationTemplate);
main.set("javascriptModules", jsModules); main.set("javascriptModules", jsModules);
main.set("javascriptPages", jsPages); 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()); out.print(main.compile());
} catch (Exception e) { } 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; package se.hal.page;
import se.hal.intf.HalJavascriptModule; import se.hal.intf.HalJavascriptModule;
import se.hal.intf.HalWebPage;
public class JavascriptModules implements HalJavascriptModule { public class JavascriptModules implements HalJavascriptModule {
@Override @Override
public HalJsModule[] getJavascriptModules() { public HalJsModule[] getJavascriptModules() {
HalWebPage.getRootNav().createSubNav("Events").createSubNav("/event_overview", "Overview");
return new HalJsModule[] { return new HalJsModule[] {
new HalJsModule("AlertStore", "./js/vue/stores/AlertStore.js"), new HalJsModule("AlertStore", "./js/vue/stores/AlertStore.js"),
new HalJsModule("EventStore", "./js/vue/stores/EventStore.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.RoomApiEndpoint"},
{"se.hal.intf.HalApiEndpoint": "se.hal.page.api.SensorApiEndpoint"}, {"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.EventConfigWebPage"},
{"se.hal.intf.HalWebPage": "se.hal.page.PropertyConfigWebPage"}, {"se.hal.intf.HalWebPage": "se.hal.page.PropertyConfigWebPage"},
{"se.hal.intf.HalWebPage": "se.hal.page.PluginConfigWebPage"}, {"se.hal.intf.HalWebPage": "se.hal.page.PluginConfigWebPage"},