summaryrefslogtreecommitdiff
path: root/kvmd
diff options
context:
space:
mode:
authorDevaev Maxim <[email protected]>2018-08-24 07:13:40 +0300
committerDevaev Maxim <[email protected]>2018-08-24 07:13:40 +0300
commit52d2b8a3158f6bf8651c07b11a056ecf17f9e49c (patch)
tree02fd818cbac3cea05827bf7e304467df24b9234f /kvmd
parent0468100bba78f89743edf77a095aa24de5406dbb (diff)
configurable stream resolution
Diffstat (limited to 'kvmd')
-rw-r--r--kvmd/configs/kvmd/v1.yaml7
-rw-r--r--kvmd/kvmd/__init__.py3
-rw-r--r--kvmd/kvmd/server.py21
-rw-r--r--kvmd/kvmd/streamer.py34
-rw-r--r--kvmd/testenv/kvmd.yaml9
-rw-r--r--kvmd/web/css/main.css36
-rw-r--r--kvmd/web/css/stream.css23
-rw-r--r--kvmd/web/index.html11
-rw-r--r--kvmd/web/js/stream.js46
-rw-r--r--kvmd/web/svg/select-arrow-inactive.svg78
-rw-r--r--kvmd/web/svg/select-arrow-intensive.svg78
-rw-r--r--kvmd/web/svg/select-arrow-normal.svg78
12 files changed, 383 insertions, 41 deletions
diff --git a/kvmd/configs/kvmd/v1.yaml b/kvmd/configs/kvmd/v1.yaml
index 5d1fc783..712891a0 100644
--- a/kvmd/configs/kvmd/v1.yaml
+++ b/kvmd/configs/kvmd/v1.yaml
@@ -36,14 +36,13 @@ kvmd:
init_restart_after: 1.0
shutdown_delay: 10.0
- size:
- width: 800
- height: 600
+ resolutions:
+ - 800x600 - 720x576
cmd:
- "/usr/bin/mjpg_streamer"
- "-i"
- - "input_uvc.so -d /dev/kvmd-streamer -e 2 -t pal -y -n -r 720x576"
+ - "input_uvc.so -d /dev/kvmd-streamer -e 2 -t pal -y -n -r {resolution}"
- "-o"
- "output_http.so -l localhost -p 8082"
diff --git a/kvmd/kvmd/__init__.py b/kvmd/kvmd/__init__.py
index c1e3dc3d..40eead3b 100644
--- a/kvmd/kvmd/__init__.py
+++ b/kvmd/kvmd/__init__.py
@@ -49,8 +49,7 @@ def main() -> None:
sync_delay=float(config["streamer"]["sync_delay"]),
init_delay=float(config["streamer"]["init_delay"]),
init_restart_after=float(config["streamer"]["init_restart_after"]),
- width=int(config["streamer"]["size"]["width"]),
- height=int(config["streamer"]["size"]["height"]),
+ resolutions=config["streamer"]["resolutions"],
cmd=list(map(str, config["streamer"]["cmd"])),
loop=loop,
)
diff --git a/kvmd/kvmd/server.py b/kvmd/kvmd/server.py
index cc5fdbf0..0f76808f 100644
--- a/kvmd/kvmd/server.py
+++ b/kvmd/kvmd/server.py
@@ -128,6 +128,7 @@ class Server: # pylint: disable=too-many-instance-attributes
self.__system_tasks: List[asyncio.Task] = []
self.__reset_streamer = False
+ self.__streamer_resolution = streamer.get_current_resolution()
def run(self, host: str, port: int) -> None:
self.__hid.start()
@@ -148,6 +149,7 @@ class Server: # pylint: disable=too-many-instance-attributes
app.router.add_post("/msd/write", self.__msd_write_handler)
app.router.add_get("/streamer", self.__streamer_state_handler)
+ app.router.add_post("/streamer/set_params", self.__streamer_set_params_handler)
app.router.add_post("/streamer/reset", self.__streamer_reset_handler)
app.on_shutdown.append(self.__on_shutdown)
@@ -301,6 +303,18 @@ class Server: # pylint: disable=too-many-instance-attributes
async def __streamer_state_handler(self, _: aiohttp.web.Request) -> aiohttp.web.Response:
return _json(self.__streamer.get_state())
+ @_wrap_exceptions_for_web("Can't set stream params")
+ async def __streamer_set_params_handler(self, request: aiohttp.web.Request) -> aiohttp.web.Response:
+ resolution = request.query.get("resolution")
+ if resolution:
+ if resolution in self.__streamer.get_available_resolutions():
+ if resolution != self.__streamer_resolution:
+ self.__streamer_resolution = resolution
+ self.__reset_streamer = True
+ else:
+ raise BadRequest("Unknown resolution %r" % (resolution))
+ return _json()
+
async def __streamer_reset_handler(self, _: aiohttp.web.Request) -> aiohttp.web.Response:
self.__reset_streamer = True
return _json()
@@ -344,17 +358,20 @@ class Server: # pylint: disable=too-many-instance-attributes
cur = len(self.__sockets)
if prev == 0 and cur > 0:
if not self.__streamer.is_running():
- await self.__streamer.start()
+ await self.__streamer.start(self.__streamer_resolution)
+ await self.__broadcast_event("streamer_state", **self.__streamer.get_state())
elif prev > 0 and cur == 0:
shutdown_at = time.time() + self.__streamer_shutdown_delay
elif prev == 0 and cur == 0 and time.time() > shutdown_at:
if self.__streamer.is_running():
await self.__streamer.stop()
+ await self.__broadcast_event("streamer_state", **self.__streamer.get_state())
if self.__reset_streamer:
if self.__streamer.is_running():
await self.__streamer.stop()
- await self.__streamer.start(no_init_restart=True)
+ await self.__streamer.start(self.__streamer_resolution, no_init_restart=True)
+ await self.__broadcast_event("streamer_state", **self.__streamer.get_state())
self.__reset_streamer = False
prev = cur
diff --git a/kvmd/kvmd/streamer.py b/kvmd/kvmd/streamer.py
index 32cc60ab..7e3fd513 100644
--- a/kvmd/kvmd/streamer.py
+++ b/kvmd/kvmd/streamer.py
@@ -1,6 +1,8 @@
import asyncio
import asyncio.subprocess
+from collections import OrderedDict as odict
+
from typing import List
from typing import Dict
from typing import Optional
@@ -20,8 +22,7 @@ class Streamer: # pylint: disable=too-many-instance-attributes
init_delay: float,
init_restart_after: float,
- width: int,
- height: int,
+ resolutions: List[str],
cmd: List[str],
loop: asyncio.AbstractEventLoop,
@@ -33,17 +34,25 @@ class Streamer: # pylint: disable=too-many-instance-attributes
self.__init_delay = init_delay
self.__init_restart_after = init_restart_after
- self.__width = width
- self.__height = height
+ self.__resolutions = odict([
+ (display, (real or display))
+ for (display, real) in [
+ (tuple(map(str.lower, map(str.strip, resolution.split("-", maxsplit=1)))) + ("",))[:2]
+ for resolution in resolutions
+ ]
+ ])
+ self.__resolution = list(self.__resolutions)[0]
self.__cmd = cmd
self.__loop = loop
self.__proc_task: Optional[asyncio.Task] = None
- async def start(self, no_init_restart: bool=False) -> None:
+ async def start(self, resolution: str, no_init_restart: bool=False) -> None:
logger = get_logger()
logger.info("Starting streamer ...")
+ assert resolution in self.__resolutions, (resolution, self.__resolutions)
+ self.__resolution = resolution
await self.__inner_start()
if self.__init_restart_after > 0.0 and not no_init_restart:
logger.info("Stopping streamer to restart ...")
@@ -58,13 +67,22 @@ class Streamer: # pylint: disable=too-many-instance-attributes
def is_running(self) -> bool:
return bool(self.__proc_task)
+ def get_current_resolution(self) -> str:
+ return self.__resolution
+
+ def get_available_resolutions(self) -> List[str]:
+ return list(self.__resolutions)
+
def get_state(self) -> Dict:
+ (width, height) = tuple(map(int, self.__resolution.split("x")))
return {
"is_running": self.is_running(),
"size": {
- "width": self.__width,
- "height": self.__height,
+ "width": width,
+ "height": height,
},
+ "resolution": self.__resolution,
+ "resolutions": list(self.__resolutions),
}
async def cleanup(self) -> None:
@@ -100,7 +118,7 @@ class Streamer: # pylint: disable=too-many-instance-attributes
while True: # pylint: disable=too-many-nested-blocks
proc: Optional[asyncio.subprocess.Process] = None # pylint: disable=no-member
try:
- cmd = [part.format(width=self.__width, height=self.__height) for part in self.__cmd]
+ cmd = [part.format(resolution=self.__resolutions[self.__resolution]) for part in self.__cmd]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
diff --git a/kvmd/testenv/kvmd.yaml b/kvmd/testenv/kvmd.yaml
index e1773847..1ad9e7f5 100644
--- a/kvmd/testenv/kvmd.yaml
+++ b/kvmd/testenv/kvmd.yaml
@@ -36,14 +36,15 @@ kvmd:
init_restart_after: 1.0
shutdown_delay: 10.0
- size:
- width: 800
- height: 600
+ resolutions:
+ - 640x480
+ - 800x600
+ - 1024x768
cmd:
- "/usr/bin/mjpg_streamer"
- "-i"
- - "input_uvc.so -d /dev/kvmd-streamer -e 2 -y -n -r {width}x{height}"
+ - "input_uvc.so -d /dev/kvmd-streamer -e 2 -y -n -r {resolution}"
- "-o"
- "output_http.so -l 0.0.0.0 -p 8082"
diff --git a/kvmd/web/css/main.css b/kvmd/web/css/main.css
index 44ca1ac1..4ff39abe 100644
--- a/kvmd/web/css/main.css
+++ b/kvmd/web/css/main.css
@@ -91,7 +91,7 @@ div.ctl-dropdown-content div.buttons-row {
padding: 0;
font-size: 0;
}
-div.ctl-dropdown-content button {
+div.ctl-dropdown-content button, select {
box-shadow: none;
border: none;
color: var(--fg-color-normal);
@@ -105,26 +105,48 @@ div.ctl-dropdown-content button {
outline: none;
cursor: pointer;
}
-div.ctl-dropdown-content button:enabled:hover {
+div.ctl-dropdown-content button:enabled:hover, select:enabled:hover {
color: var(--fg-color-intensive);
background-color: var(--bg-color-dark) !important;
}
-div.ctl-dropdown-content button:disabled {
+div.ctl-dropdown-content button:disabled, select:disabled {
color: var(--fg-color-inactive);
cursor: default;
}
-div.ctl-dropdown-content button:active {
+div.ctl-dropdown-content button:active, select:active {
color: var(--fg-color-selected) !important;
}
-div.ctl-dropdown-content button.row50 {
+div.ctl-dropdown-content select {
+ -webkit-appearance: button;
+ -moz-appearance: button;
+ appearance: button;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ user-select: none;
+ background-image: url("../svg/select-arrow-normal.svg");
+ background-position: center right;
+ background-repeat: no-repeat;
+}
+div.ctl-dropdown-content select:enabled:hover {
+ background-image: url("../svg/select-arrow-intensive.svg") !important;
+}
+div.ctl-dropdown-content select:disabled {
+ background-image: url("../svg/select-arrow-inactive.svg") !important;
+}
+div.ctl-dropdown-content select:active {
+ color: var(--fg-color-intensive) !important;
+ background-color: var(--bg-color-dark) !important;
+ background-image: url("../svg/select-arrow-intensive.svg") !important;
+}
+div.ctl-dropdown-content .row50 {
display: inline-block;
width: 50%;
}
-div.ctl-dropdown-content button.row25 {
+div.ctl-dropdown-content .row25 {
display: inline-block;
width: 25%;
}
-div.ctl-dropdown-content button.row50:not(:first-child), button.row25:not(:first-child) {
+div.ctl-dropdown-content .row50:not(:first-child), .row25:not(:first-child) {
border-left: var(--dark-border);
}
div.ctl-dropdown-content hr {
diff --git a/kvmd/web/css/stream.css b/kvmd/web/css/stream.css
index c3429630..01fd3cc4 100644
--- a/kvmd/web/css/stream.css
+++ b/kvmd/web/css/stream.css
@@ -35,21 +35,26 @@ div.stream-box-mouse-enabled {
cursor: url("../svg/stream-mouse-cursor.svg"), pointer;
}
-div#stream-size {
+div.stream-params {
-webkit-user-select: text;
-moz-user-select: text;
user-select: text;
font-size: 12px;
margin: 5px 15px 5px 15px;
}
-div#stream-size span#stream-size-counter {
+
+div.stream-params select#stream-resolution-select {
+ margin: 8px 0 8px 0;
+}
+
+div.stream-params span#stream-size-value {
}
-div#stream-size div#stream-size-slider-box {
+div.stream-params div#stream-size-slider-box {
margin-top: 5px;
display: flex;
}
@supports (-webkit-appearance:none) {
- div#stream-size div#stream-size-slider-box input[type=range] {
+ div.stream-params div#stream-size-slider-box input[type=range] {
cursor: pointer;
outline: none;
width: 100%;
@@ -60,7 +65,7 @@ div#stream-size div#stream-size-slider-box {
}
}
@supports not (-webkit-appearance:none) {
- div#stream-size div#stream-size-slider-box input[type=range] {
+ div.stream-params div#stream-size-slider-box input[type=range] {
cursor: pointer;
outline: none;
width: 100%;
@@ -69,12 +74,12 @@ div#stream-size div#stream-size-slider-box {
margin-right: 0;
}
}
-div#stream-size div#stream-size-slider-box input[type=range]::-webkit-slider-runnable-track {
+div.stream-params div#stream-size-slider-box input[type=range]::-webkit-slider-runnable-track {
height: 5px;
background: var(--bg-color-light);
border-radius: 3px;
}
-div#stream-size div#stream-size-slider-box input[type=range]::-webkit-slider-thumb {
+div.stream-params div#stream-size-slider-box input[type=range]::-webkit-slider-thumb {
border: var(--intensive-border);
height: 18px;
width: 18px;
@@ -83,12 +88,12 @@ div#stream-size div#stream-size-slider-box input[type=range]::-webkit-slider-thu
-webkit-appearance: none;
margin-top: -7px;
}
-div#stream-size div#stream-size-slider-box input[type=range]::-moz-range-track {
+div.stream-params div#stream-size-slider-box input[type=range]::-moz-range-track {
height: 5px;
background: var(--bg-color-light);
border-radius: 3px;
}
-div#stream-size div#stream-size-slider-box input[type=range]::-moz-range-thumb {
+div.stream-params div#stream-size-slider-box input[type=range]::-moz-range-thumb {
border: var(--intensive-border);
height: 18px;
width: 18px;
diff --git a/kvmd/web/index.html b/kvmd/web/index.html
index adc024db..8ae5d250 100644
--- a/kvmd/web/index.html
+++ b/kvmd/web/index.html
@@ -70,8 +70,15 @@
<button id="show-stream-button">&bull; Show stream</button>
<button disabled id="stream-reset-button">&bull; Reset stream</button>
<hr>
- <div data-dont-hide-menu id="stream-size">
- Stream size: <span id="stream-size-counter">100%</span>
+ <div data-dont-hide-menu class="stream-params">
+ Resolution:
+ <select disabled data-dont-hide-menu id="stream-resolution-select">
+ <option>640x480</option>
+ </select>
+ </div>
+ <hr>
+ <div data-dont-hide-menu class="stream-params">
+ Stream size: <span id="stream-size-value">100%</span>
<div id="stream-size-slider-box">
<input id="stream-size-slider" type="range" min="50" max="150" value="100" step="10" />
</div>
diff --git a/kvmd/web/js/stream.js b/kvmd/web/js/stream.js
index 8b6fe262..7eb09ee0 100644
--- a/kvmd/web/js/stream.js
+++ b/kvmd/web/js/stream.js
@@ -4,6 +4,10 @@ function Stream(ui) {
/********************************************************************************/
var __prev_state = false;
+
+ var __resolution = "640x480";
+ var __resolutions = ["640x480"];
+
var __normal_size = {width: 640, height: 480};
var __size_factor = 1;
@@ -11,6 +15,7 @@ function Stream(ui) {
$("stream-led").title = "Stream inactive";
$("stream-reset-button").onclick = __clickResetButton;
+ $("stream-resolution-select").onchange = __changeResolution;
$("stream-size-slider").oninput = __resize;
$("stream-size-slider").onchange = __resize;
@@ -19,6 +24,8 @@ function Stream(ui) {
/********************************************************************************/
+ // XXX: In current implementation we don't need this event because Stream() has own state poller
+
var __startPoller = function() {
var http = tools.makeRequest("GET", "/streamer/?action=snapshot", function() {
if (http.readyState === 2 || http.readyState === 4) {
@@ -33,6 +40,7 @@ function Stream(ui) {
$("stream-led").className = "led-off";
$("stream-led").title = "Stream inactive";
$("stream-reset-button").disabled = true;
+ $("stream-resolution-select").disabled = true;
} else if (!__prev_state) {
__refreshImage();
__prev_state = true;
@@ -44,7 +52,7 @@ function Stream(ui) {
}
}
});
- setTimeout(__startPoller, 2000);
+ setTimeout(__startPoller, 1500);
};
var __clickResetButton = function() {
@@ -58,9 +66,23 @@ function Stream(ui) {
});
};
+ var __changeResolution = function() {
+ var resolution = $("stream-resolution-select").value;
+ if (__resolution != resolution) {
+ $("stream-resolution-select").disabled = true;
+ var http = tools.makeRequest("POST", "/kvmd/streamer/set_params?resolution=" + resolution, function() {
+ if (http.readyState === 4) {
+ if (http.status !== 200) {
+ alert("Can't change stream:", http.responseText);
+ }
+ }
+ });
+ }
+ };
+
var __resize = function() {
var percent = $("stream-size-slider").value;
- $("stream-size-counter").innerHTML = percent + "%";
+ $("stream-size-value").innerHTML = percent + "%";
__size_factor = percent / 100;
__applySizeFactor();
};
@@ -75,7 +97,25 @@ function Stream(ui) {
var __refreshImage = function() {
var http = tools.makeRequest("GET", "/kvmd/streamer", function() {
if (http.readyState === 4 && http.status === 200) {
- __normal_size = JSON.parse(http.responseText).result.size;
+ var result = JSON.parse(http.responseText).result;
+
+ if (__resolutions != result.resolutions) {
+ tools.info("Resolutions list changed:", result.resolutions);
+ $("stream-resolution-select").innerHTML = "";
+ result.resolutions.forEach(function(resolution) {
+ $("stream-resolution-select").innerHTML += "<option value=\"" + resolution + "\">" + resolution + "</option>";
+ });
+ $("stream-resolution-select").disabled = (result.resolutions.length == 1);
+ __resolutions = result.resolutions;
+ }
+
+ if (__resolution != result.resolution) {
+ tools.info("Resolution changed:", result.resolution);
+ document.querySelector("#stream-resolution-select [value=\"" + result.resolution + "\"]").selected = true;
+ __resolution = result.resolution;
+ }
+
+ __normal_size = result.size;
__applySizeFactor();
$("stream-image").src = "/streamer/?action=stream&time=" + new Date().getTime();
}
diff --git a/kvmd/web/svg/select-arrow-inactive.svg b/kvmd/web/svg/select-arrow-inactive.svg
new file mode 100644
index 00000000..ba68f05c
--- /dev/null
+++ b/kvmd/web/svg/select-arrow-inactive.svg
@@ -0,0 +1,78 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="24"
+ height="31.999998"
+ viewBox="0 0 6.3500001 8.4666662"
+ version="1.1"
+ id="svg8"
+ inkscape:version="0.92.2 2405546, 2018-03-11"
+ sodipodi:docname="select-arrow-inactive.svg">
+ <defs
+ id="defs2" />
+ <sodipodi:namedview
+ id="base"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageopacity="0.0"
+ inkscape:pageshadow="2"
+ inkscape:zoom="11.2"
+ inkscape:cx="27.151934"
+ inkscape:cy="16.615415"
+ inkscape:document-units="mm"
+ inkscape:current-layer="layer1"
+ showgrid="false"
+ fit-margin-top="0"
+ fit-margin-left="0"
+ fit-margin-right="0"
+ fit-margin-bottom="0"
+ inkscape:window-width="1920"
+ inkscape:window-height="1020"
+ inkscape:window-x="0"
+ inkscape:window-y="30"
+ inkscape:window-maximized="1"
+ units="px" />
+ <metadata
+ id="metadata5">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(-8.8745959,-36.821965)">
+ <path
+ sodipodi:type="star"
+ style="opacity:1;fill:#6c7481;fill-opacity:1;stroke:none;stroke-width:2.64583325;stroke-linecap:square;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:75.59055328;stroke-opacity:1;paint-order:normal"
+ id="path4749"
+ sodipodi:sides="3"
+ sodipodi:cx="12.049596"
+ sodipodi:cy="40.702518"
+ sodipodi:r1="1.411111"
+ sodipodi:r2="0.70555568"
+ sodipodi:arg1="1.5707963"
+ sodipodi:arg2="2.6179939"
+ inkscape:flatsided="false"
+ inkscape:rounded="0"
+ inkscape:randomized="0"
+ d="m 12.049596,42.113629 -0.611029,-1.058333 -0.611029,-1.058333 1.222058,0 1.222058,0 -0.611029,1.058333 z"
+ inkscape:transform-center-y="0.3527758" />
+ </g>
+</svg>
diff --git a/kvmd/web/svg/select-arrow-intensive.svg b/kvmd/web/svg/select-arrow-intensive.svg
new file mode 100644
index 00000000..3223f099
--- /dev/null
+++ b/kvmd/web/svg/select-arrow-intensive.svg
@@ -0,0 +1,78 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="24"
+ height="31.999998"
+ viewBox="0 0 6.3500001 8.4666662"
+ version="1.1"
+ id="svg8"
+ inkscape:version="0.92.2 2405546, 2018-03-11"
+ sodipodi:docname="select-arrow-intensive.svg">
+ <defs
+ id="defs2" />
+ <sodipodi:namedview
+ id="base"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageopacity="0.0"
+ inkscape:pageshadow="2"
+ inkscape:zoom="11.2"
+ inkscape:cx="27.151934"
+ inkscape:cy="16.615415"
+ inkscape:document-units="mm"
+ inkscape:current-layer="layer1"
+ showgrid="false"
+ fit-margin-top="0"
+ fit-margin-left="0"
+ fit-margin-right="0"
+ fit-margin-bottom="0"
+ inkscape:window-width="1920"
+ inkscape:window-height="1020"
+ inkscape:window-x="0"
+ inkscape:window-y="30"
+ inkscape:window-maximized="1"
+ units="px" />
+ <metadata
+ id="metadata5">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(-8.8745959,-36.821965)">
+ <path
+ sodipodi:type="star"
+ style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:2.64583325;stroke-linecap:square;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:75.59055328;stroke-opacity:1;paint-order:normal"
+ id="path4749"
+ sodipodi:sides="3"
+ sodipodi:cx="12.049596"
+ sodipodi:cy="40.702518"
+ sodipodi:r1="1.411111"
+ sodipodi:r2="0.70555568"
+ sodipodi:arg1="1.5707963"
+ sodipodi:arg2="2.6179939"
+ inkscape:flatsided="false"
+ inkscape:rounded="0"
+ inkscape:randomized="0"
+ d="m 12.049596,42.113629 -0.611029,-1.058333 -0.611029,-1.058333 1.222058,0 1.222058,0 -0.611029,1.058333 z"
+ inkscape:transform-center-y="0.3527758" />
+ </g>
+</svg>
diff --git a/kvmd/web/svg/select-arrow-normal.svg b/kvmd/web/svg/select-arrow-normal.svg
new file mode 100644
index 00000000..174663ce
--- /dev/null
+++ b/kvmd/web/svg/select-arrow-normal.svg
@@ -0,0 +1,78 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="24"
+ height="31.999998"
+ viewBox="0 0 6.3500001 8.4666662"
+ version="1.1"
+ id="svg8"
+ inkscape:version="0.92.2 2405546, 2018-03-11"
+ sodipodi:docname="select-arrow-normal.svg">
+ <defs
+ id="defs2" />
+ <sodipodi:namedview
+ id="base"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageopacity="0.0"
+ inkscape:pageshadow="2"
+ inkscape:zoom="11.2"
+ inkscape:cx="27.151934"
+ inkscape:cy="16.615415"
+ inkscape:document-units="mm"
+ inkscape:current-layer="layer1"
+ showgrid="false"
+ fit-margin-top="0"
+ fit-margin-left="0"
+ fit-margin-right="0"
+ fit-margin-bottom="0"
+ inkscape:window-width="1920"
+ inkscape:window-height="1020"
+ inkscape:window-x="0"
+ inkscape:window-y="30"
+ inkscape:window-maximized="1"
+ units="px" />
+ <metadata
+ id="metadata5">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(-8.8745959,-36.821965)">
+ <path
+ sodipodi:type="star"
+ style="opacity:1;fill:#c3c3c3;fill-opacity:1;stroke:none;stroke-width:2.64583325;stroke-linecap:square;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:75.59055328;stroke-opacity:1;paint-order:normal"
+ id="path4749"
+ sodipodi:sides="3"
+ sodipodi:cx="12.049596"
+ sodipodi:cy="40.702518"
+ sodipodi:r1="1.411111"
+ sodipodi:r2="0.70555568"
+ sodipodi:arg1="1.5707963"
+ sodipodi:arg2="2.6179939"
+ inkscape:flatsided="false"
+ inkscape:rounded="0"
+ inkscape:randomized="0"
+ d="m 12.049596,42.113629 -0.611029,-1.058333 -0.611029,-1.058333 1.222058,0 1.222058,0 -0.611029,1.058333 z"
+ inkscape:transform-center-y="0.3527758" />
+ </g>
+</svg>