mirror of
https://github.com/Break27/kvmd.git
synced 2026-02-06 18:36: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
|
*.pyc
|
||||||
*.swp
|
*.swp
|
||||||
/venv/
|
/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.lexers.data
|
||||||
import pygments.formatters
|
import pygments.formatters
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from .. import tools
|
from .. import tools
|
||||||
|
|
||||||
from ..mouse import MouseRange
|
from ..mouse import MouseRange
|
||||||
@ -163,6 +165,20 @@ def init(
|
|||||||
"Make sure you understand exactly what you are doing!"
|
"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)
|
return (parser, remaining, config)
|
||||||
|
|
||||||
|
|
||||||
@ -348,6 +364,11 @@ def _get_config_scheme() -> dict:
|
|||||||
"logging": Option({}),
|
"logging": Option({}),
|
||||||
|
|
||||||
"kvmd": {
|
"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": {
|
"server": {
|
||||||
"unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"),
|
"unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"),
|
||||||
"unix_rm": Option(True, type=valid_bool),
|
"unix_rm": Option(True, type=valid_bool),
|
||||||
|
|||||||
@ -36,11 +36,12 @@ from .streamer import Streamer
|
|||||||
from .snapshoter import Snapshoter
|
from .snapshoter import Snapshoter
|
||||||
from .ocr import Ocr
|
from .ocr import Ocr
|
||||||
from .server import KvmdServer
|
from .server import KvmdServer
|
||||||
|
from .remote import RemoteControl
|
||||||
|
|
||||||
|
|
||||||
# =====
|
# =====
|
||||||
def main(argv: (list[str] | None)=None) -> None:
|
def main(argv: (list[str] | None)=None) -> None:
|
||||||
config = init(
|
_, _, config = init(
|
||||||
prog="kvmd",
|
prog="kvmd",
|
||||||
description="The main PiKVM daemon",
|
description="The main PiKVM daemon",
|
||||||
argv=argv,
|
argv=argv,
|
||||||
@ -50,7 +51,7 @@ def main(argv: (list[str] | None)=None) -> None:
|
|||||||
load_atx=True,
|
load_atx=True,
|
||||||
load_msd=True,
|
load_msd=True,
|
||||||
load_gpio=True,
|
load_gpio=True,
|
||||||
)[2]
|
)
|
||||||
|
|
||||||
msd_kwargs = config.kvmd.msd._unpack(ignore=["type"])
|
msd_kwargs = config.kvmd.msd._unpack(ignore=["type"])
|
||||||
if config.kvmd.msd.type == "otg":
|
if config.kvmd.msd.type == "otg":
|
||||||
@ -102,6 +103,12 @@ def main(argv: (list[str] | None)=None) -> None:
|
|||||||
**config.snapshot._unpack(),
|
**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,
|
keymap_path=config.hid.keymap,
|
||||||
ignore_keys=config.hid.ignore_keys,
|
ignore_keys=config.hid.ignore_keys,
|
||||||
mouse_x_range=(config.hid.mouse_x_range.min, config.hid.mouse_x_range.max),
|
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 .streamer import Streamer
|
||||||
from .snapshoter import Snapshoter
|
from .snapshoter import Snapshoter
|
||||||
from .ocr import Ocr
|
from .ocr import Ocr
|
||||||
|
from .remote import RemoteControl
|
||||||
|
|
||||||
from .api.auth import AuthApi
|
from .api.auth import AuthApi
|
||||||
from .api.auth import check_request_auth
|
from .api.auth import check_request_auth
|
||||||
@ -83,6 +84,7 @@ from .api.msd import MsdApi
|
|||||||
from .api.streamer import StreamerApi
|
from .api.streamer import StreamerApi
|
||||||
from .api.export import ExportApi
|
from .api.export import ExportApi
|
||||||
from .api.redfish import RedfishApi
|
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,
|
msd: BaseMsd,
|
||||||
streamer: Streamer,
|
streamer: Streamer,
|
||||||
snapshoter: Snapshoter,
|
snapshoter: Snapshoter,
|
||||||
|
remote: RemoteControl,
|
||||||
|
|
||||||
keymap_path: str,
|
keymap_path: str,
|
||||||
ignore_keys: List[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("ATX", "atx_state", atx),
|
||||||
_Component("MSD", "msd_state", msd),
|
_Component("MSD", "msd_state", msd),
|
||||||
_Component("Streamer", "streamer_state", streamer),
|
_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,
|
self.__streamer_api,
|
||||||
ExportApi(info_manager, atx, user_gpio),
|
ExportApi(info_manager, atx, user_gpio),
|
||||||
RedfishApi(info_manager, atx),
|
RedfishApi(info_manager, atx),
|
||||||
|
RemoteApi(remote),
|
||||||
]
|
]
|
||||||
|
|
||||||
self.__streamer_notifier = aiotools.AioNotifier()
|
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