Apply multiple improvements

* Added device-view.html for viewing and editing device details.
* Fixed and improved devices.html.
* Modified DeviceManagementController so that the '/monitor/device'
  endpoint returns the devices of the current user,
  and also plain users can retrieve info for the devices they own.

Change-Id: I44a2786d7e1b26a4714353c8df32e1b2633be7aa
This commit is contained in:
ipatini 2023-10-06 22:59:38 +03:00 committed by Radosław Piliszek
parent 5cdb719873
commit 5b7ca47065
4 changed files with 532 additions and 19 deletions

View File

@ -2,14 +2,14 @@ package eu.nebulous.resource.discovery.monitor.controller;
import eu.nebulous.resource.discovery.monitor.model.Device;
import eu.nebulous.resource.discovery.monitor.model.DeviceException;
import eu.nebulous.resource.discovery.monitor.service.DeviceConversionService;
import eu.nebulous.resource.discovery.monitor.service.DeviceManagementService;
import eu.nebulous.resource.discovery.registration.IRegistrationRequestProcessor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@ -21,10 +21,29 @@ import java.util.List;
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
public class DeviceManagementController {
private final DeviceManagementService deviceService;
private final DeviceConversionService deviceConversionService;
private final IRegistrationRequestProcessor deviceRequestProcessor;
@GetMapping(value = { "/device", "/device/all" }, produces = MediaType.APPLICATION_JSON_VALUE)
private boolean isAuthenticated(Authentication authentication) {
return authentication!=null && StringUtils.isNotBlank(authentication.getName());
}
private boolean isAdmin(Authentication authentication) {
if (isAuthenticated(authentication)) {
return authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch("ROLE_ADMIN"::equals);
}
return false;
}
@PreAuthorize("hasAuthority('ROLE_ADMIN') || hasAuthority('ROLE_USER')")
@GetMapping(value = "/device", produces = MediaType.APPLICATION_JSON_VALUE)
public List<Device> listDevicesUser(Authentication authentication) {
return isAuthenticated(authentication)
? deviceService.getByOwner(authentication.getName().trim())
: listDevicesAll();
}
@GetMapping(value = "/device/all", produces = MediaType.APPLICATION_JSON_VALUE)
public List<Device> listDevicesAll() {
return deviceService.getAll();
}
@ -34,10 +53,16 @@ public class DeviceManagementController {
return deviceService.getByOwner(owner);
}
@PreAuthorize("hasAuthority('ROLE_ADMIN') || hasAuthority('ROLE_USER')")
@GetMapping(value = "/device/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
public Device getDevice(@PathVariable String id) {
return deviceService.getById(id)
.orElseThrow(() -> new DeviceException("Not found device with id: "+id));
public Device getDevice(@PathVariable String id, Authentication authentication) {
Device device = deviceService.getById(id)
.orElseThrow(() -> new DeviceException("Not found device with id: " + id));
if (isAuthenticated(authentication)
&& ! authentication.getName().trim().equals(device.getOwner())
&& ! isAdmin(authentication))
throw new DeviceException("Cannot retrieve device with id: " + id);
return device;
}
@GetMapping(value = "/device/ipaddress/{ipAddress}", produces = MediaType.APPLICATION_JSON_VALUE)

View File

@ -0,0 +1,470 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="author" content="Firmbee.com - Free Project Management Platform for remote teams">
<title>NebulOuS Resource Discovery - Management page</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nunito+Sans:wght@400;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-KyZXEAg3QhqLMpG8r+8fhAXLRk2vvoC2f3B09zVXn8CA5QIVfZOJ3BCsw2P0p/We" crossorigin="anonymous">
<link rel="stylesheet" href="css/style.css">
<script src="https://kit.fontawesome.com/0e035b9984.js" crossorigin="anonymous"></script>
<script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-U1DAWAznBHeqEIlVSCgzq+c9gqGAJn5c/t99JyeKa9xxaYpSvHU5awsuZVVFIhvj" crossorigin="anonymous"></script>
<script src="js/addshadow.js"></script>
<script>
$(function() {
const urlParams = new URLSearchParams(window.location.search);
const devId = urlParams.get('id') ?? '';
const edit = urlParams.get('edit') ?? 'false';
deviceId = devId;
isReadonly = ! (edit.trim().toLowerCase()==='true');
if (! isReadonly)
makeFormEditable();
if (devId!=='')
refreshDeviceInfo(devId);
checkSameUser();
});
var isAdmin = false;
var isReadonly = true;
var username = '';
var deviceId;
var owner = '';
function makeFormEditable() {
// Get form fields
var q1 = $('input[id^="device#"]');
var q2 = $('textarea[id^="device#"]');
var all = $.merge(q1, q2);
//console.log('makeFormEditable: form fields: ', all);
// Make form fields editable
all.each((index, item) => {
var it = $(item);
//console.log('makeFormEditable: each: ', it.attr('id'), it.val());
it.prop('readonly', false);
it.removeClass('form-control-plaintext');
it.addClass('form-control');
});
// Show Save button
$('#btn-save').removeClass('d-none');
}
function refreshDeviceInfo(id) {
// show loading spinner
$('#loading-spinner').toggleClass('d-none');
$('#main-page').toggleClass('d-none');
// retrieve device info
$.ajax({
url: '/monitor/device/'+id,
dataType: 'json'
})
.done(function(data, status) {
// console.log('refreshDeviceInfo: OK: ', data);
var devId = data.id;
if (devId!=='')
$('#page_title').html( 'Device '+devId );
else
$('#page_title').html( $(`<div class="text-warning bg-danger">Error: ${status}: ${error}</div>`) );
deviceId = data.id;
owner = data.owner;
checkSameUser();
updateFormData(data);
})
.fail(function(xhr, status, error) {
console.error('refreshDeviceInfo: ERROR: ', status, error);
$('#page_title').html(
$(`<div class="text-warning bg-danger">Error: ${status}: ${error}</div>`)
);
})
.always(function(data, status) {
// hide loading spinner
$('#loading-spinner').toggleClass('d-none');
$('#main-page').toggleClass('d-none');
})
;
}
function checkSameUser() {
if (username!=='' && owner!='' && username===owner) {
$('.sameUser').addClass('d-none');
} else {
$('.sameUser').removeClass('d-none');
}
}
function updateFormData(data) {
// Prepare data for processing
var device = {
device: data
};
//console.log('updateFormData: device: ', device);
// Flatten data map
var keyValuesMap = flattenObject(device);
//console.log('updateFormData: flattenObject: ', keyValuesMap);
// Update form fields
Object.entries(keyValuesMap).forEach((entry) => {
//console.log('updateFormData: Form Update: ', entry[0], entry[1]);
$(`[id="${entry[0]}"]`).val( entry[1] );
});
// Update device info field
if (data.deviceInfo) {
var valStr = JSON.stringify(data.deviceInfo, null, 2);
var rows = valStr.split(/\r\n|\r|\n/).length;
if (rows<2) rows = 1;
if (rows>50) rows = 50;
$(`[id="device#deviceInfo"]`).val( valStr ).attr( 'rows', rows );
} else {
$(`[id="device#deviceInfo"]`).val( '' ).attr( 'rows', 1 );
}
// Update messages field
if (data.messages) {
var valStr = data.messages.join('\n').trim();
var rows = data.messages.length;
if (rows<2) rows = 1;
if (rows>50) rows = 50;
$(`[id="device#messages"]`).val( valStr ).attr( 'rows', rows );
} else {
$(`[id="device#messages"]`).val( '' ).attr( 'rows', 1 );
}
}
function flattenObject(ob) {
var toReturn = {};
for (var i in ob) {
if (!ob.hasOwnProperty(i)) continue;
if ((typeof ob[i]) == 'object' && ob[i] !== null) {
var flatObject = flattenObject(ob[i]);
for (var x in flatObject) {
if (!flatObject.hasOwnProperty(x)) continue;
toReturn[i + '#' + x] = flatObject[x];
}
} else {
toReturn[i] = ob[i];
}
}
return toReturn;
}
function saveDeviceInfo() {
// Confirm change
if (!confirm('Update device data?'))
return;
// Get form fields
var q1 = $('input[id^="device#"]');
var q2 = $('textarea[id^="device#"]');
var all = $.merge(q1, q2);
//console.log('saveDeviceInfo: form fields: ', all);
// Collect values
var keyValuesMap = {};
all.each((index, item) => {
var it = $(item);
//console.log('saveDeviceInfo: each: ', it.attr('id'), it.val());
keyValuesMap[ it.attr('id') ] = it.val();
});
//console.log('saveDeviceInfo: keyValuesMap: ', keyValuesMap);
// Convert to object graph
var root = {};
Object.entries(keyValuesMap).forEach((entry) => {
//console.log('saveDeviceInfo: KVM-each: ', entry);
var keyPart = entry[0].split("#");
var p = root;
keyPart.forEach((item, index) => {
//console.log('saveDeviceInfo: KVM-keyPart-each: ', item, p);
if (! p[item]) p[item] = index+1<keyPart.length ? {} : entry[1];
p = p[item];
});
});
root = root['device'];
//console.log('saveDeviceInfo: root: ', root);
// Fix device info
var deviceInfo = $(`[id="device#deviceInfo"]`).val().trim();
if (deviceInfo==='') deviceInfo = '{}';
root['deviceInfo'] = JSON.parse( deviceInfo );
// Fix messages
var messages = $(`[id="device#messages"]`).val().trim();
if (messages==='') messages = '';
root['messages'] = messages.split(/\r\n|\r|\n/);
// Check device Id
if (deviceId!=='' && deviceId!==root.id) {
alert('Device id has been modified!');
return;
}
console.log('saveDeviceInfo: root: ', root);
sendDeviceData(root);
}
function sendDeviceData(deviceData) {
// show loading spinner
$('#loading-spinner').toggleClass('d-none');
$('#main-page').toggleClass('d-none');
// retrieve device info
$.ajax({
url: deviceId!=='' ? '/monitor/device/'+deviceId : '/monitor/device',
method: deviceId!=='' ? 'post' : 'put',
contentType: "application/json; charset=utf-8",
dataType: 'json',
data: JSON.stringify(deviceData)
})
.done(function(data, status) {
//console.log('sendDeviceData: OK: ', data);
deviceId = data.id;
refreshDeviceInfo(deviceId);
})
.fail(function(xhr, status, error) {
console.error('sendDeviceData: ERROR: ', status, error);
$('#page_title').html(
$(`<div class="text-warning bg-danger">Error: ${status}: ${error}</div>`)
);
})
.always(function(data, status) {
// hide loading spinner
$('#loading-spinner').toggleClass('d-none');
$('#main-page').toggleClass('d-none');
})
;
}
</script>
</head>
<body>
<main>
<div style="position: absolute; left: 10px; top: 10px;">
<a href="index.html"><img src="img/nebulous-logo-basic.png" width="155px" height="155px" alt=""></a>
</div>
<div class="text-end text-secondary nowrap">
<img src="img/user-icon.png" width="24" height="auto">
<span id="whoami"><i class="fas fa-spinner fa-spin"></i></span>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<span onClick="document.location = '/logout';">
<i class="fas fa-sign-out-alt"></i>
</span>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<script>
$(function() {
$.ajax({ url: '/discovery/whoami', dataType: 'json' })
.done(function(data) {
isAdmin = data.admin;
username = data.user;
data.admin ? $('#whoami').html( $(`<span class="text-primary fw-bold">${data.user}</span>`) ) : $('#whoami').html( data.user );
if (isAdmin) $('.adminOnly').toggleClass('d-none');
checkSameUser();
})
.fail(function(xhr, status, error) { $('#whoami').html( $(`Error: ${status} ${JSON.stringify(error)}`) ); });
});
</script>
</div>
<section class="light-section">
<div class="container">
<div id="loading-spinner" class="fa-5x text-secondary text-center d-none">
<i class="fas fa-sync fa-spin fa-5x"></i>
</div>
<div id="main-page" class="text-center">
<h1 class="adminOnly sameUser d-none text-danger fw-bold">* * * CAUTION: YOU'RE VIEWING A DEVICE YOU DON'T OWN * * *</h1>
<h2 id="page_title">Device ---</h2>
<!--<p class="sub-header">Device details</p>-->
<button type="button" class="btn btn-primary" onClick="document.location = 'index.html';">
<i class="fa fa-home"></i>
</button>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<button type="button" class="btn btn-primary" onClick="document.location = 'devices.html' + (isReadonly ? '' : '?edit=true');">
<i class="fa fa-arrow-left"></i>
</button>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<button type="button" class="btn btn-primary" onClick="refreshDeviceInfo(deviceId);">
<i class="fa fa-refresh"></i>
</button>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<button type="button" class="btn btn-success d-none" id="btn-save" onClick="saveDeviceInfo();">
<i class="fa fa-save"></i>
</button>
<p>&nbsp;</p>
<form>
<div class="form-group row text-center bg-dark bg-opacity-25">
<h5>Device details</h5>
</div>
<!-- Device id -->
<div class="form-group row">
<label for="device#id" class="col-sm-2 col-form-label"><b>Device Id</b></label>
<div class="col-sm-10">
<input type="text" readonly class="form-control-plaintext" id="device#id" value="">
</div>
</div>
<!-- Device owner -->
<div class="form-group row">
<label for="device#owner" class="col-sm-2 col-form-label"><b>Owner</b></label>
<div class="col-sm-10">
<input type="text" readonly class="form-control-plaintext" id="device#owner" value="">
</div>
</div>
<!-- Device Name -->
<div class="form-group row">
<label for="device#name" class="col-sm-2 col-form-label"><b>Device Name</b></label>
<div class="col-sm-10">
<input type="text" readonly class="form-control-plaintext" id="device#name" value="" placeholder="Device name">
</div>
</div>
<!-- Device OS -->
<div class="form-group row">
<label for="device#os" class="col-sm-2 col-form-label"><b>Device OS</b></label>
<div class="col-sm-10">
<input type="text" readonly class="form-control-plaintext" id="device#os" value="LINUX" placeholder="Device OS">
</div>
</div>
<!-- Device IP Address -->
<div class="form-group row">
<label for="device#ipAddress" class="col-sm-2 col-form-label"><b>IP address</b></label>
<div class="col-sm-10">
<input type="text" readonly class="form-control-plaintext" id="device#ipAddress" value="" placeholder="Device IP address">
</div>
</div>
<!-- Device Username -->
<div class="form-group row">
<label for="device#username" class="col-sm-2 col-form-label"><b>SSH Username</b></label>
<div class="col-sm-10">
<input type="text" readonly class="form-control-plaintext" id="device#username" value="" placeholder="SSH username">
</div>
</div>
<!-- Device Password -->
<div class="form-group row">
<label for="device#password" class="col-sm-2 col-form-label"><b>SSH Password</b></label>
<div class="col-sm-10">
<input type="password" readonly class="form-control-plaintext" id="device#password" value="" placeholder="*** SSH password - Not exposed ***">
</div>
</div>
<!-- Device Public Key -->
<div class="form-group row">
<label for="device#publicKey" class="col-sm-2 col-form-label"><b>SSH Public Key</b></label>
<div class="col-sm-10">
<textarea readonly class="form-control-plaintext" id="device#publicKey" placeholder="*** SSH public key - Not exposed ***"></textarea>
</div>
</div>
<!-- Node reference (available after onboarding) -->
<div class="form-group row">
<label for="device#nodeReference" class="col-sm-2 col-form-label"><b>Node Reference</b></label>
<div class="col-sm-10">
<input type="text" readonly class="form-control-plaintext" id="device#nodeReference" value="">
</div>
</div>
<!-- Device status -->
<div class="form-group row">
<label for="device#status" class="col-sm-2 col-form-label"><b>Device Status</b></label>
<div class="col-sm-10">
<input type="text" readonly class="form-control-plaintext" id="device#status" value="NEW_DEVICE">
</div>
</div>
<!-- Device creation data -->
<div class="form-group row">
<label for="device#creationDate" class="col-sm-2 col-form-label"><b>Creation Date</b></label>
<div class="col-sm-10">
<input type="text" readonly class="form-control-plaintext" id="device#creationDate" value="">
</div>
</div>
<!-- Device last update date -->
<div class="form-group row">
<label for="device#lastUpdateDate" class="col-sm-2 col-form-label"><b>Last Updated</b></label>
<div class="col-sm-10">
<input type="text" readonly class="form-control-plaintext" id="device#lastUpdateDate" value="">
</div>
</div>
<!-- Device messages -->
<div class="form-group row">
<label for="device#messages" class="col-sm-2 col-form-label"><b>Messages</b></label>
<div class="col-sm-10">
<textarea readonly class="form-control-plaintext" id="device#messages"></textarea>
</div>
</div>
<!-- Device Additional Info -->
<div class="form-group row">
<label for="device#deviceInfo" class="col-sm-2 col-form-label"><b>Additional Info</b></label>
<div class="col-sm-10">
<textarea readonly class="form-control-plaintext" id="device#deviceInfo" placeholder="Additional device info"></textarea>
</div>
</div>
<div class="form-group row text-center bg-dark bg-opacity-25">
<h5>Device metrics</h5>
</div>
++++ TODO ++++
<!--<div class="text-center">
<p>&nbsp;</p>
<input class="btn btn-primary" type="submit" value="Submit">
<input class="btn btn-primary" type="reset" value="Reset">
</div>-->
</form>
</div>
</div>
</section>
<footer class="py-5">
<div class="container">
<div class="row">
<div class="footer-item col-md-8">
<p class="footer-item-title">Links</p>
<a href="">About Us</a>
<a href="">Portfolio</a>
<a href="">Blog</a>
<a href="">Sing In</a>
</div>
<div class="footer-item col-md-4">
<p class="footer-item-title">Get In Touch</p>
<form>
<div class="mb-3 pb-3">
<label for="exampleInputEmail1" class="form-label pb-3">Enter your email and we'll send you more information.</label>
<input type="email" placeholder="Your Email" class="form-control" id="exampleInputEmail1" aria-describedby="emailHelp">
</div>
<button type="submit" class="btn btn-primary">Subscribe</button>
</form>
</div>
<div class="copyright pt-4 text-center text-muted">
<p>&copy; 2022 YOUR-DOMAIN | Created by <a href="https://firmbee.com/solutions/to-do-list/" title="Firmbee - Free To-do list App" target="_blank">Firmbee.com</a></p>
<!--
This template is licenced under Attribution 3.0 (CC BY 3.0 PL),
You are free to: Share and Adapt. You must give appropriate credit, you may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use.
-->
</div>
</div>
</footer>
</main>
<div class="fb2022-copy">Fbee 2022 copyright</div>
</body>
</html>

View File

@ -19,12 +19,23 @@
<script>
$(function() {
const urlParams = new URLSearchParams(window.location.search);
var edit = urlParams.get('edit') ?? 'false';
edit = (edit.trim().toLowerCase()==='true');
if (edit) {
// Show New button
$('#btn-new').removeClass('d-none');
urlAppend = '&edit=true';
}
updateDevicesList(false);
setInterval(() => updateDevicesList(), 5000);
});
var isAdmin = false;
var lastUpdateAsAdmin;
var urlAppend = '';
function updateDevicesList(asAdmin) {
if (asAdmin === undefined) asAdmin = lastUpdateAsAdmin;
@ -42,13 +53,22 @@ function updateDevicesList(asAdmin) {
data.forEach(item => {
var devId = item.id;
var owner = item.owner;
var devName = item.name;
var devName = (item.name && item.name.trim()!=='') ? item.name.trim() : `(No name - Id ${devId})`;
var ipAddress = item.ipAddress;
var load = 'TODO';
var status = item.status;
var color = getStatusColor(status);
var adminActions = (isAdmin) ? `
<button type="button" class="btn btn-primary btn-sm" onClick="if (confirm('Onboard Device again?')) manageDevice('${devId}', 'onboard');">
<i class="fas fa-redo"></i>
</button>
<button type="button" class="btn btn-danger btn-sm" onClick="if (confirm('Remove Device?')) manageDevice('${devId}', 'offboard');">
<i class="fas fa-ban"></i>
</button>
` : '';
ii++;
tbody.append( xx=$(`
tbody.append( $(`
<tr class="${color}">
<th scope="row">${ii}</th>
<td>${owner}</td>
@ -57,10 +77,8 @@ function updateDevicesList(asAdmin) {
<td>${load}</td>
<td>${status}</td>
<td>
<button type="button" class="btn btn-primary btn-sm" onClick="if (confirm('Remove Device?')) removeDevice('${devId}');">
<i class="fas fa-trash"></i>
</button>
<button type="button" class="btn btn-success btn-sm" onClick="document.location='/device-view.html?id=${devId}'; ">
${adminActions}
<button type="button" class="btn btn-success btn-sm" onClick="document.location='/device-view.html?id=${devId}${urlAppend}'; ">
<i class="fas fa-eye"></i>
</button>
</td>
@ -84,9 +102,9 @@ function getStatusColor(status) {
return 'table-info';
}
function removeDevice(id) {
function manageDevice(id, action) {
$.ajax({
url: '/monitor/device/'+id+'/offboard',
url: '/monitor/device/'+id+'/'+action,
method: 'GET',
async: 'true'
@ -150,7 +168,7 @@ function removeDevice(id) {
<i class="fa fa-refresh"></i>
</button>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<button type="button" class="btn btn-success" onClick="document.location = '/device-view.html'; ">
<button type="button" class="btn btn-success d-none" id="btn-new" onClick="document.location = '/device-view.html?x' + urlAppend; ">
<i class="fa fa-plus"></i>
</button>
</div>
@ -169,7 +187,7 @@ function removeDevice(id) {
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="devicesTable">
<tbody id="devicesTable-tbody">
</tbody>
</table>
</div>

View File

@ -52,7 +52,7 @@ function makeFormReadonly() {
// Make form fields readonly
all.each((index, item) => {
var it = $(item);
console.log('makeFormReadonly: each: ', it.attr('id'), it.val());
//console.log('makeFormReadonly: each: ', it.attr('id'), it.val());
it.prop('readonly', true);
it.removeClass('form-control');
it.addClass('form-control-plaintext');