Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix broken web interface features #18

Merged
merged 5 commits into from
Mar 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ module.exports = {
"comma-style": ["error", "last"],
"complexity": "error",
"computed-property-spacing": ["error", "never"],
"consistent-return": "error",
"consistent-return": "off",
"consistent-this": "error",
"curly": "error",
"default-case": "error",
Expand All @@ -124,7 +124,7 @@ module.exports = {
"function-paren-newline": "off",
"generator-star-spacing": ["error", { "before": false, "after": true }],
"grouped-accessor-pairs": "error",
"guard-for-in": "error",
"guard-for-in": "off",
"id-blacklist": "error",
"id-length": "off",
"id-match": "error",
Expand Down
18 changes: 16 additions & 2 deletions src/routes/tileserver.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,29 @@
"use strict";
const fs = require("fs-extra");
const path = require("path");
const sharp = require("sharp");

module.exports = async function registerTileServer(app, tilesPath) {
// Create single color PNG for missing tiles using Sharp
const blackImage = await sharp({
create: {
width: 256,
height: 256,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 1 },
// background: { r: 65, g: 57, b: 18, alpha: 1 }, // Forest green
},
})
.png()
.toBuffer();

module.exports = function registerTileServer(app, tilesPath) {
// Serve tiles
app.get("/api/gridworld/tiles/:z/:x/:y.png", async (req, res) => {
try {
let file = await fs.readFile(path.resolve(tilesPath, `z${req.params.z}x${req.params.x}y${req.params.y}.png`));
res.send(file);
} catch (e) {
res.status(404).send("Tile not found");
res.send(blackImage);
}
});
};
24 changes: 17 additions & 7 deletions web/components/GridVisualizer.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useContext, useState } from "react";
import React, { useContext, useState, useEffect } from "react";
import { Row, Col } from "antd";
import { MapContainer, Polyline, Rectangle, Tooltip, TileLayer, SVGOverlay, Marker, Popup, Circle } from "react-leaflet";
import { MapContainer, Polyline, Rectangle, Tooltip, SVGOverlay, Popup, Circle } from "react-leaflet";
import { TileLayer as TileLayerCustom } from "./leaflet/TileLayerCustomReact";

import { ControlContext, useInstance, statusColors } from "@clusterio/web_ui";
import { useMapData } from "../model/mapData";
Expand All @@ -23,6 +24,14 @@ export default function GridVisualizer(props) {
const playerPositions = usePlayerPosition(control);
const [mapData] = useMapData();
const [activeInstance, setActiveInstance] = useState();
const [refreshTiles, setRefreshTiles] = useState("1");

useEffect(() => {
const interval = setInterval(() => {
setRefreshTiles(Math.floor(Math.random() * 10000).toString());
}, 2500);
return () => clearInterval(interval);
});

return <>
<div className="grid-visualizer">
Expand All @@ -34,7 +43,6 @@ export default function GridVisualizer(props) {
// eslint-disable-next-line max-len
integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUrBc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA=="
crossOrigin=""></script>

<Row>
<Col lg={24} xl={12}>
{mapData.map_data?.length ? <MapContainer
Expand All @@ -50,8 +58,8 @@ export default function GridVisualizer(props) {
maxZoom={18}
crs={L.CRS.Simple}
>
<TileLayer
url={`${document.location.origin}/api/gridworld/tiles/{z}/{x}/{y}.png`}
<TileLayerCustom
url={`${document.location.origin}/api/gridworld/tiles/{z}/{x}/{y}.png?refresh=${refreshTiles}`}
maxNativeZoom={10} // 10 max
minNativeZoom={7} // 7 min
/>
Expand Down Expand Up @@ -110,8 +118,10 @@ function InstanceRender(props) {
-1 * position[1] / scaleFactor,
position[0] / scaleFactor,
]))}
onclick={() => {
props.setActiveInstance(props.instance.instance_id);
eventHandlers={{
click: () => {
props.setActiveInstance(props.instance.instance_id);
},
}}
color={props.instance.instance_id === props.activeInstance ? "#ffff00" : "#3388ff"}
opacity={0.5}
Expand Down
56 changes: 29 additions & 27 deletions web/components/InstanceModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,13 @@ import MigrateInstanceButton from "./MigrateInstanceButton";
function InstanceModal(props) {
let control = useContext(ControlContext);
let [instance] = useInstance(props.instance_id);
let [host] = useHost(instance?.["assigned_host"]);
let [host] = useHost(instance?.assignedHost);
let account = useAccount();
let navigate = useNavigate();

return <>
{props.instance_id && <>
<Descriptions
loading={props.instance_id !== instance.id}
bordered
size="small"
title={instance["name"]}
Expand Down Expand Up @@ -62,38 +61,41 @@ function InstanceModal(props) {
</Space>}
>
<Descriptions.Item label="Host">
{!instance.assigned_host
{!instance.assignedHost
? <em>Unassigned</em>
: host["name"] || instance["assigned_host"]
: host.name || instance.assignedHost
}
</Descriptions.Item>
{instance["status"] && <Descriptions.Item label="Status">
<InstanceStatusTag status={instance["status"]} />
</Descriptions.Item>}
</Descriptions>
<Tabs defaultActiveKey="1">
{
account.hasAllPermission("core.instance.save.list", "core.instance.save.list_subscribe")
&& <Tabs.TabPane tab="Saves" key="saves">
<SavesList instance={instance} />
</Tabs.TabPane>
}
{
account.hasAnyPermission("core.log.follow", "core.instance.send_rcon")
&& <Tabs.TabPane tab="Console" key="console">
<Typography.Title level={5} style={{ marginTop: 16 }}>Console</Typography.Title>
{account.hasPermission("core.log.follow") && <LogConsole instances={[props.instance_id]} />}
{account.hasPermission("core.instance.send_rcon")
&& <InstanceRcon id={props.instance_id} disabled={instance["status"] !== "running"} />}
</Tabs.TabPane>
}
{
account.hasPermission("core.instance.get_config")
&& <Tabs.TabPane tab="Config" key="config">
<InstanceConfigTree id={props.instance_id} />
</Tabs.TabPane>
}
</Tabs>
<Tabs
defaultActiveKey="console"
items={[
{
disabled: !account.hasAllPermission("core.instance.save.list", "core.instance.save.subscribe"),
label: "Saves",
key: "saves",
children: <SavesList instance={instance} />,
}, {
disabled: !account.hasAnyPermission("core.log.follow", "core.instance.send_rcon"),
label: "Console",
key: "console",
children: <div>
<Typography.Title level={5} style={{ marginTop: 16 }}>Console</Typography.Title>
{account.hasPermission("core.log.follow") && <LogConsole instances={[props.instance_id]} />}
{account.hasPermission("core.instance.send_rcon")
&& <InstanceRcon id={props.instance_id} disabled={instance["status"] !== "running"} />}
</div>,
}, {
disabled: !account.hasPermission("core.instance.get_config"),
label: "Config",
key: "config",
children: <InstanceConfigTree id={props.instance_id} />,
},
]}
/>
</>}
</>;
}
Expand Down
8 changes: 4 additions & 4 deletions web/components/MigrateInstanceModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ export default function MigrateInstanceModal(props) {
</p>
<Form form={form} initialValues={{ host: props.hostId }}>
<Form.Item name="host" label="Target host" rules={[{ required: true, message: "Please select a host" }]}>
<Select showSearch placeholder="Select a host" optionFilterProp="children">
{hostList.map(host => <Select.Option
key={host.id}
value={host.id}>
<Select showSearch placeholder="Select a host" optionFilterProp="children" key="Select">
{Array.from(hostList).map(host => <Select.Option
key={`key${host.id}`}
value={`key${host.id}`}>
{host.name}
{!host.connected && " (offline)"}
</Select.Option>)}
Expand Down
35 changes: 35 additions & 0 deletions web/components/leaflet/TileLayerCustom.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/* eslint-disable guard-for-in */
import L from "leaflet";

/**
* Custom TileLayer that allows for in-place replacement of tiles without flickering.
* The original TileLayer implementation gets flickering from 2 sources:
* - Delay while downloading tiles
* - 250ms fade-in animation for tiles
* The fade in animation is disabled with CSS opacity: 1 !important;
*/

export const TileLayer = L.TileLayer.extend({
_refreshTileUrl: function (tile, url) {
// use a image in background, so that only replace the actual tile, once image is loaded in cache!
let img = new Image();
img.onload = function () {
L.Util.requestAnimFrame(() => {
tile.el.src = url;
});
};
img.src = url;
},
refresh: function () {
for (let key in this._tiles) {
let tile = this._tiles[key];
if (tile.current && tile.active) {
let oldsrc = tile.el.src;
let newsrc = this.getTileUrl(tile.coords);
if (oldsrc !== newsrc) {
this._refreshTileUrl(tile, newsrc);
}
}
}
},
});
24 changes: 24 additions & 0 deletions web/components/leaflet/TileLayerCustomReact.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/* eslint-disable prefer-arrow-callback */
import {
createElementObject,
createTileLayerComponent,
updateGridLayer,
withPane,
} from "@react-leaflet/core";
import { TileLayer as TileLayerCustom } from "./TileLayerCustom";

export const TileLayer = createTileLayerComponent(
function createTileLayer({ url, ...options }, context) {
const layer = new TileLayerCustom(url, withPane(options, context));
return createElementObject(layer, context);
},
function updateTileLayer(layer, props, prevProps) {
updateGridLayer(layer, props, prevProps);

const { url } = props;
if (url !== null && url !== prevProps.url) {
layer.setUrl(url, true);
layer.refresh();
}
},
);
Loading