mirror of
https://github.com/Break27/kvmd.git
synced 2026-02-06 02:16:37 +08:00
feature: remote host control
This commit is contained in:
parent
988a190957
commit
c536a1ac49
1
.gitignore
vendored
1
.gitignore
vendored
@ -19,3 +19,4 @@
|
||||
*.pyc
|
||||
*.swp
|
||||
/venv/
|
||||
/.vscode/
|
||||
|
||||
6
extras/remote/manifest.yaml
Normal file
6
extras/remote/manifest.yaml
Normal file
@ -0,0 +1,6 @@
|
||||
name: Remote
|
||||
description: Remote Hosts Control
|
||||
daemon: kvmd
|
||||
icon: share/svg/ipmi.svg
|
||||
path: remote
|
||||
place: 30
|
||||
@ -31,6 +31,8 @@ import pygments
|
||||
import pygments.lexers.data
|
||||
import pygments.formatters
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .. import tools
|
||||
|
||||
from ..mouse import MouseRange
|
||||
@ -163,6 +165,20 @@ def init(
|
||||
"Make sure you understand exactly what you are doing!"
|
||||
)
|
||||
|
||||
path = Path(config.kvmd.remote.path)
|
||||
config.kvmd.remote.hosts = []
|
||||
|
||||
for file in [x for x in os.listdir(path) if x.endswith(".conf")]:
|
||||
pairs = {'name': file.rsplit('.', 1)[0]}
|
||||
|
||||
with open(path / file) as conf:
|
||||
lines = conf.readlines()
|
||||
for line in lines:
|
||||
key, value = line.rstrip('\n').split("=", 1)
|
||||
pairs[key.strip()] = value.strip()
|
||||
|
||||
config.kvmd.remote.hosts.append(pairs)
|
||||
|
||||
return (parser, remaining, config)
|
||||
|
||||
|
||||
@ -348,6 +364,11 @@ def _get_config_scheme() -> dict:
|
||||
"logging": Option({}),
|
||||
|
||||
"kvmd": {
|
||||
"remote": {
|
||||
"path": Option("/etc/kvmd/hosts.d", type=valid_abs_path),
|
||||
"ssh_key": Option("/etc/kvmd/ssh/id_rsa", type=valid_abs_path),
|
||||
"timeout": Option(10, type=valid_int_f1),
|
||||
},
|
||||
"server": {
|
||||
"unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"),
|
||||
"unix_rm": Option(True, type=valid_bool),
|
||||
|
||||
@ -36,11 +36,12 @@ from .streamer import Streamer
|
||||
from .snapshoter import Snapshoter
|
||||
from .ocr import Ocr
|
||||
from .server import KvmdServer
|
||||
from .remote import RemoteControl
|
||||
|
||||
|
||||
# =====
|
||||
def main(argv: (list[str] | None)=None) -> None:
|
||||
config = init(
|
||||
_, _, config = init(
|
||||
prog="kvmd",
|
||||
description="The main PiKVM daemon",
|
||||
argv=argv,
|
||||
@ -50,7 +51,7 @@ def main(argv: (list[str] | None)=None) -> None:
|
||||
load_atx=True,
|
||||
load_msd=True,
|
||||
load_gpio=True,
|
||||
)[2]
|
||||
)
|
||||
|
||||
msd_kwargs = config.kvmd.msd._unpack(ignore=["type"])
|
||||
if config.kvmd.msd.type == "otg":
|
||||
@ -102,6 +103,12 @@ def main(argv: (list[str] | None)=None) -> None:
|
||||
**config.snapshot._unpack(),
|
||||
),
|
||||
|
||||
remote=RemoteControl.from_dict(
|
||||
hosts=config.remote.hosts,
|
||||
timeout=config.remote.timeout,
|
||||
ssh_key=config.remote.ssh_key,
|
||||
),
|
||||
|
||||
keymap_path=config.hid.keymap,
|
||||
ignore_keys=config.hid.ignore_keys,
|
||||
mouse_x_range=(config.hid.mouse_x_range.min, config.hid.mouse_x_range.max),
|
||||
|
||||
47
kvmd/apps/kvmd/api/remote.py
Normal file
47
kvmd/apps/kvmd/api/remote.py
Normal file
@ -0,0 +1,47 @@
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from aiohttp.web import Request
|
||||
from aiohttp.web import Response
|
||||
|
||||
from ....htserver import UnavailableError
|
||||
from ....htserver import exposed_http
|
||||
from ....htserver import make_json_response
|
||||
|
||||
from ....logging import get_logger
|
||||
|
||||
from ..remote import RemoteControl
|
||||
|
||||
|
||||
# =====
|
||||
class RemoteApi:
|
||||
def __init__(self, remote: RemoteControl) -> None:
|
||||
self.__remote = remote
|
||||
|
||||
# =====
|
||||
|
||||
@exposed_http("POST", "/remote")
|
||||
async def __state_handler(self, _: Request) -> Response:
|
||||
return make_json_response({
|
||||
"hosts": await self.__remote.get_state(),
|
||||
})
|
||||
|
||||
@exposed_http("POST", "/remote/control")
|
||||
async def __remote_control_handler(self, request: Request) -> Response:
|
||||
data = await request.json()
|
||||
try:
|
||||
code, message, error = await self.__remote.perform(
|
||||
data["target"], data["action"]
|
||||
)
|
||||
except Exception:
|
||||
logger = get_logger(0)
|
||||
tb = traceback.format_exception(*sys.exc_info())
|
||||
|
||||
logger.exception(repr(tb))
|
||||
raise UnavailableError()
|
||||
|
||||
return make_json_response({
|
||||
"code": code,
|
||||
"message": message,
|
||||
"error": error,
|
||||
})
|
||||
114
kvmd/apps/kvmd/remote.py
Normal file
114
kvmd/apps/kvmd/remote.py
Normal file
@ -0,0 +1,114 @@
|
||||
import asyncio
|
||||
|
||||
from time import time
|
||||
|
||||
from typing import AsyncGenerator
|
||||
from typing import Tuple
|
||||
|
||||
from ... import aiotools
|
||||
|
||||
|
||||
class RemoteHost:
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
address='',
|
||||
encoding='utf-8',
|
||||
**kwargs
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.address = address
|
||||
self.encoding = encoding
|
||||
self.actions = kwargs
|
||||
|
||||
self.online = False
|
||||
self.last_seen = 0
|
||||
|
||||
def get_state(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"online": self.online,
|
||||
"last_seen": self.last_seen,
|
||||
"actions": self.parse_action(),
|
||||
}
|
||||
|
||||
def parse_action(self, action=None) -> list[str] | str:
|
||||
if not action:
|
||||
return [
|
||||
key[3:].upper() for key, value in self.actions.items()
|
||||
if key.startswith('on_') and value
|
||||
]
|
||||
command = self.actions.get("on_%s" % action.lower())
|
||||
if not command: raise NotImplementedError()
|
||||
return command
|
||||
|
||||
async def ping(self) -> bool:
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
f"ping -i 0.5 -c 2 -w 2 {self.address}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
await process.wait()
|
||||
online = process.returncode == 0
|
||||
|
||||
changed = self.online != online
|
||||
self.online = online
|
||||
|
||||
if self.online:
|
||||
self.last_seen = time()
|
||||
return changed
|
||||
|
||||
|
||||
class RemoteControl:
|
||||
def __init__(self, hosts: list[RemoteHost], timeout: int, ssh_key: str) -> None:
|
||||
self.__hosts = { host.name: host for host in hosts }
|
||||
self.__timeout = timeout
|
||||
self.__ssh_key = ssh_key
|
||||
|
||||
self.__notifier = aiotools.AioNotifier()
|
||||
|
||||
@staticmethod
|
||||
def from_dict(hosts: list[dict], **kwargs):
|
||||
roll = [RemoteHost(**host) for host in hosts]
|
||||
return RemoteControl(roll, **kwargs)
|
||||
|
||||
async def get_state(self) -> list[dict]:
|
||||
return [
|
||||
host.get_state() for host in self.__hosts.values()
|
||||
]
|
||||
|
||||
async def perform(self, hostname, action) -> Tuple[int | None, str, str]:
|
||||
if hostname not in self.__hosts:
|
||||
raise KeyError(hostname)
|
||||
host = self.__hosts.get(hostname)
|
||||
command = host.parse_action(action)
|
||||
|
||||
if command.startswith("ssh"):
|
||||
head, tail = command.split(' ', 1)
|
||||
slices = [head, "-oStrictHostKeyChecking=no", "-i", self.__ssh_key, tail]
|
||||
command = ' '.join(slices)
|
||||
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
await process.wait()
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
message = stdout.decode(host.encoding)
|
||||
error = stderr.decode(host.encoding)
|
||||
return (process.returncode, message, error)
|
||||
|
||||
async def update(self) -> list[dict]:
|
||||
return [
|
||||
host.get_state() for host in self.__hosts.values()
|
||||
if await host.ping()
|
||||
]
|
||||
|
||||
async def poll_state(self) -> AsyncGenerator[list, None]:
|
||||
while True:
|
||||
state = await self.update()
|
||||
if state: yield state
|
||||
await self.__notifier.wait(self.__timeout)
|
||||
@ -70,6 +70,7 @@ from .ugpio import UserGpio
|
||||
from .streamer import Streamer
|
||||
from .snapshoter import Snapshoter
|
||||
from .ocr import Ocr
|
||||
from .remote import RemoteControl
|
||||
|
||||
from .api.auth import AuthApi
|
||||
from .api.auth import check_request_auth
|
||||
@ -83,6 +84,7 @@ from .api.msd import MsdApi
|
||||
from .api.streamer import StreamerApi
|
||||
from .api.export import ExportApi
|
||||
from .api.redfish import RedfishApi
|
||||
from .api.remote import RemoteApi
|
||||
|
||||
|
||||
# =====
|
||||
@ -137,6 +139,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
|
||||
msd: BaseMsd,
|
||||
streamer: Streamer,
|
||||
snapshoter: Snapshoter,
|
||||
remote: RemoteControl,
|
||||
|
||||
keymap_path: str,
|
||||
ignore_keys: List[str],
|
||||
@ -170,6 +173,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
|
||||
_Component("ATX", "atx_state", atx),
|
||||
_Component("MSD", "msd_state", msd),
|
||||
_Component("Streamer", "streamer_state", streamer),
|
||||
_Component("Remote", "remote_state", remote),
|
||||
],
|
||||
]
|
||||
|
||||
@ -187,6 +191,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins
|
||||
self.__streamer_api,
|
||||
ExportApi(info_manager, atx, user_gpio),
|
||||
RedfishApi(info_manager, atx),
|
||||
RemoteApi(remote),
|
||||
]
|
||||
|
||||
self.__streamer_notifier = aiotools.AioNotifier()
|
||||
|
||||
37
web/remote/index.html
Normal file
37
web/remote/index.html
Normal file
@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Remote Control</title>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/share/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/share/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/share/favicon-16x16.png">
|
||||
<link rel="manifest" href="/share/site.webmanifest">
|
||||
<link rel="mask-icon" href="/share/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<meta name="msapplication-TileColor" content="#2b5797">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<link rel="stylesheet" href="/share/css/remote/remote.css">
|
||||
<link rel="stylesheet" href="/share/css/remote/actions.css">
|
||||
<link rel="stylesheet" href="/share/css/vars.css">
|
||||
<link rel="stylesheet" href="/share/css/main.css">
|
||||
<link rel="stylesheet" href="/share/css/start.css">
|
||||
<link rel="stylesheet" href="/share/css/user.css">
|
||||
<script type="module">import { main } from "/share/js/remote/main.js";
|
||||
main();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="start-box">
|
||||
<div class="start">
|
||||
<div id="actions">
|
||||
<a class="link" href="/"> ← [ PiKVM Index ]</a>
|
||||
<div id="rf" class="link">[ Refresh ]<div class="icon">↻</div></div>
|
||||
</div>
|
||||
<hr>
|
||||
<div id="bulletin">
|
||||
<h4>Loading ...</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
57
web/share/css/remote/actions.css
Normal file
57
web/share/css/remote/actions.css
Normal file
@ -0,0 +1,57 @@
|
||||
div.host[state='online'] button.remote-action[action='WAKE'],
|
||||
div.host[state='offline'] button.remote-action[action='SHUTDOWN'],
|
||||
div.host[state='unknown'] button.remote-action {
|
||||
display: none;
|
||||
}
|
||||
|
||||
div.host[state='unknown'] div.remote-actions::before {
|
||||
content: "⏳";
|
||||
}
|
||||
|
||||
/********************************************/
|
||||
|
||||
button.remote-action[action='WAKE']::before {
|
||||
content: "☀️"
|
||||
}
|
||||
|
||||
button.remote-action[action='SHUTDOWN']::before {
|
||||
content: "🌙"
|
||||
}
|
||||
|
||||
button.remote-action[action='RESTART']::before {
|
||||
content: "🔄"
|
||||
}
|
||||
|
||||
/********************************************/
|
||||
|
||||
div.remote-actions {
|
||||
display: inline-flex;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
button.remote-action {
|
||||
border: var(--border-key-thin);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow-micro);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
margin-left: 10px;
|
||||
background-color: var(--cs-control-default-bg);
|
||||
}
|
||||
|
||||
button.remote-action:hover {
|
||||
border-color: var(--cs-corner-bg);
|
||||
}
|
||||
|
||||
button.remote-action:active {
|
||||
background-color: var(--cs-control-pressed-bg);
|
||||
}
|
||||
|
||||
116
web/share/css/remote/remote.css
Normal file
116
web/share/css/remote/remote.css
Normal file
@ -0,0 +1,116 @@
|
||||
h4 {
|
||||
text-align: center;
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
div#rf div.icon {
|
||||
display: inline-flex;
|
||||
margin-left: 12px;
|
||||
margin-right: 12px;
|
||||
font-size: 20px;
|
||||
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
div#rf div.icon.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
div#hosts {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
div#hosts div.host {
|
||||
display: flex;
|
||||
margin-top: 8px;
|
||||
padding: 12px 12px 12px 12px;
|
||||
|
||||
background-color: var(--cs-control-default-bg);
|
||||
border: var(--border-key-thin);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
span.bulb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
div[state='unknown'] span.bulb {
|
||||
background-color: yellow;
|
||||
}
|
||||
|
||||
div[state='online'] span.bulb {
|
||||
background-color: limegreen;
|
||||
}
|
||||
|
||||
div[state='offline'] span.bulb {
|
||||
background-color: grey;
|
||||
}
|
||||
|
||||
span.state {
|
||||
margin-left: 24px;
|
||||
color: grey;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
div[state='unknown'] span.state::before {
|
||||
content: "[ Unknown ]";
|
||||
}
|
||||
|
||||
div[state='online'] span.state::before {
|
||||
content: "[ Online ]";
|
||||
}
|
||||
|
||||
div[state='offline'] span.state::before {
|
||||
content: "[ Offline ]";
|
||||
}
|
||||
|
||||
span.hostname {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
line-height: 28px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
div.link {
|
||||
margin-top: 1.5px;
|
||||
margin-left: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
div#actions {
|
||||
display:flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
div.start {
|
||||
min-width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: inline-flex;
|
||||
margin-top: 4px;
|
||||
color: #5c90bc;
|
||||
text-decoration: none;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 768px) {
|
||||
div.start {
|
||||
min-width: 40%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
192
web/share/js/remote/main.js
Normal file
192
web/share/js/remote/main.js
Normal file
@ -0,0 +1,192 @@
|
||||
"use strict";
|
||||
|
||||
|
||||
import { $, $$$, tools } from "../tools.js";
|
||||
import { fromNow } from "./relativeTime.js";
|
||||
|
||||
|
||||
let prev_state = {};
|
||||
|
||||
let loading = false;
|
||||
|
||||
/********************************************************/
|
||||
|
||||
export function main() {
|
||||
loadRemoteApi(x => makeView(x));
|
||||
setInterval(update, 10000);
|
||||
|
||||
$("rf").addEventListener("click", refresh);
|
||||
}
|
||||
|
||||
function update() {
|
||||
loadRemoteApi(x => x.forEach(y => updateState(y)));
|
||||
updateOfflineTime();
|
||||
}
|
||||
|
||||
function refresh(event) {
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
|
||||
let icon = $$$("#rf div.icon")[0];
|
||||
icon.classList.toggle("spin");
|
||||
|
||||
prev_state = {};
|
||||
loadRemoteApi(x => {
|
||||
x.forEach(y => updateState(y));
|
||||
setTimeout(() => {
|
||||
icon.classList.toggle("spin")
|
||||
loading = false;
|
||||
}, 950);
|
||||
});
|
||||
}
|
||||
|
||||
/********************************************************/
|
||||
|
||||
function guards(http) {
|
||||
if (http.readyState !== 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (http.status === 401 || http.status === 403) {
|
||||
document.location.href = "/login";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (http.status !== 200) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function loadRemoteApi(callback) {
|
||||
let http = tools.makeRequest("POST", "/api/remote", () => {
|
||||
let response = http.responseText;
|
||||
|
||||
if (! guards(http)) return;
|
||||
if (! response) return;
|
||||
|
||||
let hosts = JSON.parse(response).result.hosts;
|
||||
let diff = stateDiff(hosts);
|
||||
|
||||
callback(diff);
|
||||
});
|
||||
}
|
||||
|
||||
function actionPerform(target, action) {
|
||||
let body = JSON.stringify({ target, action });
|
||||
let contentType = "application/json";
|
||||
|
||||
let http = tools.makeRequest("POST", "/api/remote/control", () => {
|
||||
if (! guards(http)) return;
|
||||
|
||||
let response = http.responseText;
|
||||
let result = JSON.parse(response).result;
|
||||
|
||||
if (result.code != 0) {
|
||||
delete prev_state[target];
|
||||
let state = $$$(`.host[name='${target}'] span.state`)[0];
|
||||
|
||||
state.innerHTML = ' ⦻ Failed';
|
||||
setTimeout(update, 3000);
|
||||
}
|
||||
}, body, contentType);
|
||||
}
|
||||
|
||||
/****************************************************/
|
||||
|
||||
function makeView(hosts) {
|
||||
let parent = $("bulletin");
|
||||
|
||||
if (! hosts) {
|
||||
parent.innerHTML = '<h4>Error</h4>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (hosts.length == 0) {
|
||||
parent.innerHTML = '<h4>No Hosts</h4>';
|
||||
return;
|
||||
}
|
||||
|
||||
parent.innerHTML = `
|
||||
<div id="hosts">
|
||||
<div id="separator"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
for (const host of hosts) {
|
||||
let child = document.createElement("div");
|
||||
child.setAttribute("name", host.name);
|
||||
child.classList.add("host");
|
||||
|
||||
child.innerHTML = `
|
||||
<div>
|
||||
<div>
|
||||
<span class="bulb"></span>
|
||||
<span class="hostname">${host.name}</span>
|
||||
</div>
|
||||
<span class="state"></span>
|
||||
</div><div class="remote-actions">
|
||||
</div>
|
||||
`;
|
||||
|
||||
let actions = child.querySelector(".remote-actions");
|
||||
for (const action of host.actions) {
|
||||
let button = document.createElement("button");
|
||||
button.classList.add("remote-action");
|
||||
button.setAttribute("action", action);
|
||||
button.onclick = () => {
|
||||
child.setAttribute("state", "unknown");
|
||||
child.querySelector("span.state")
|
||||
.innerHTML = ' ⇒ '
|
||||
+ action[0] + action.slice(1).toLowerCase();
|
||||
actionPerform(host.name, action);
|
||||
};
|
||||
actions.appendChild(button);
|
||||
}
|
||||
updateState(host, child);
|
||||
}
|
||||
}
|
||||
|
||||
function updateState(host, child) {
|
||||
let parent = $("hosts");
|
||||
let separator = $("separator");
|
||||
let element = child ?? $$$(`div.host[name='${host.name}']`)[0];
|
||||
|
||||
element.setAttribute("state", host.online ? 'online' : 'offline');
|
||||
element.setAttribute("last-seen", host.last_seen);
|
||||
|
||||
if (host.online) {
|
||||
parent.insertBefore(element, separator);
|
||||
element.querySelector("span.state").innerHTML = '';
|
||||
} else {
|
||||
separator.after(element);
|
||||
updateOfflineTime(host.name);
|
||||
}
|
||||
}
|
||||
|
||||
function updateOfflineTime(name) {
|
||||
let attr = name ? `[name='${name}']` : '';
|
||||
|
||||
for (const element of $$$(`div.host${attr}[state='offline']`)) {
|
||||
let timestamp = element.getAttribute("last-seen");
|
||||
let state = element.querySelector("span.state");
|
||||
|
||||
state.innerHTML = ' – ' + fromNow(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
function stateDiff(hosts) {
|
||||
let diff = [];
|
||||
|
||||
for (const host of hosts) {
|
||||
if (! (host.name in prev_state)
|
||||
|| prev_state[host.name].online != host.online)
|
||||
{
|
||||
diff.push(host);
|
||||
}
|
||||
prev_state[host.name] = host;
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
32
web/share/js/remote/relativeTime.js
Normal file
32
web/share/js/remote/relativeTime.js
Normal file
@ -0,0 +1,32 @@
|
||||
"use strict";
|
||||
|
||||
|
||||
const RTF = new Intl.RelativeTimeFormat('en', {
|
||||
numeric: "always",
|
||||
style: "long"
|
||||
});
|
||||
|
||||
const Time = {
|
||||
year: 29030400,
|
||||
month: 2419200,
|
||||
week: 604800,
|
||||
day: 86400,
|
||||
hour: 3600,
|
||||
minute: 60,
|
||||
second: 1
|
||||
}
|
||||
|
||||
|
||||
export function fromNow(timestamp) {
|
||||
if (timestamp == 0) return "never seen";
|
||||
|
||||
let now = Date.now() / 1000;
|
||||
let time = now - timestamp;
|
||||
|
||||
for (const unit in Time) if (time >= Time[unit]) {
|
||||
let value = Math.floor(time / Time[unit]);
|
||||
return RTF.format(-value, unit);
|
||||
}
|
||||
|
||||
return "just now";
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user