mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-14 15:24:52 +09:00
Add button to delete minimap data
This commit is contained in:
1 parent
264a32431e
commit
ec0c068d44
4 files changed
+428
-20
No files matched your search
@@ -4,14 +4,16 @@ import {
|
||||
TileDataEvent,
|
||||
GetRawTileRequest,
|
||||
GetChartTagsRequest,
|
||||
ChartData,
|
||||
type ChartData,
|
||||
ChartTagDataEvent,
|
||||
ChartTagData,
|
||||
type ChartTagData,
|
||||
RecipeDataEvent,
|
||||
GetRawRecipeTileRequest,
|
||||
PlayerPositionEvent,
|
||||
PlayerData,
|
||||
type PlayerData,
|
||||
GetPlayerPathRequest,
|
||||
ClearMinimapSurfaceDataRequest,
|
||||
ClearMinimapDataRequest,
|
||||
} from "./messages";
|
||||
import * as fs from "fs-extra";
|
||||
import * as path from "path";
|
||||
@@ -24,12 +26,21 @@ import {
|
||||
renderTileToPixels,
|
||||
pixelsToRGBA,
|
||||
parseRecipeTileBinary,
|
||||
ParsedRecipeTile,
|
||||
type ParsedRecipeTile,
|
||||
} from "./utils/tile-utils";
|
||||
import { parsePlayerPositionsBinary } from "./utils/player-utils";
|
||||
|
||||
const CHUNK_SIZE = 32;
|
||||
const inflateAsync = promisify(zlib.inflate);
|
||||
interface TileMetadata {
|
||||
instanceId: number;
|
||||
surface: string;
|
||||
force: string;
|
||||
}
|
||||
|
||||
interface ChartTagMetadata {
|
||||
instanceId: number;
|
||||
surface: string;
|
||||
force: string;
|
||||
}
|
||||
|
||||
interface PixelChange {
|
||||
x: number;
|
||||
@@ -107,6 +118,14 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
this.controller.handle(GetRawRecipeTileRequest, this.handleGetRawRecipeTileRequest.bind(this));
|
||||
this.controller.handle(PlayerPositionEvent, this.handlePlayerPositionEvent.bind(this));
|
||||
this.controller.handle(GetPlayerPathRequest, this.handleGetPlayerPathRequest.bind(this));
|
||||
this.controller.handle(
|
||||
ClearMinimapSurfaceDataRequest,
|
||||
this.handleClearMinimapSurfaceDataRequest.bind(this)
|
||||
);
|
||||
this.controller.handle(
|
||||
ClearMinimapDataRequest,
|
||||
this.handleClearAllMinimapDataRequest.bind(this)
|
||||
);
|
||||
|
||||
// Register events as subscribable for web clients
|
||||
this.controller.subscriptions.handle(TileDataEvent);
|
||||
@@ -132,6 +151,199 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
private parseTileKey(name: string): TileMetadata | null {
|
||||
const trimmed = name.replace(/(\.bin|\.recipes)$/, "");
|
||||
const parts = trimmed.split("_");
|
||||
if (parts.length < 5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
parts.pop(); // tileY
|
||||
parts.pop(); // tileX
|
||||
const force = parts.pop()!;
|
||||
const instancePart = parts.shift();
|
||||
if (!instancePart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const surface = parts.join("_");
|
||||
const instanceId = Number.parseInt(instancePart, 10);
|
||||
if (!Number.isFinite(instanceId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { instanceId, surface, force };
|
||||
}
|
||||
|
||||
private parseChartTagMetadata(name: string): ChartTagMetadata | null {
|
||||
const suffix = "_chart_tags.json";
|
||||
if (!name.endsWith(suffix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = name.slice(0, -suffix.length);
|
||||
const parts = trimmed.split("_");
|
||||
if (parts.length < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const force = parts.pop()!;
|
||||
const instancePart = parts.shift();
|
||||
if (!instancePart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const surface = parts.join("_");
|
||||
const instanceId = Number.parseInt(instancePart, 10);
|
||||
if (!Number.isFinite(instanceId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { instanceId, surface, force };
|
||||
}
|
||||
|
||||
private matchesSurfaceForce(
|
||||
meta: TileMetadata | null,
|
||||
instanceId: number,
|
||||
surface: string,
|
||||
force?: string
|
||||
): meta is TileMetadata {
|
||||
if (!meta) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (meta.instanceId !== instanceId || meta.surface !== surface) {
|
||||
return false;
|
||||
}
|
||||
if (force && meta.force !== force) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private matchesChartTag(
|
||||
meta: ChartTagMetadata | null,
|
||||
instanceId: number,
|
||||
surface: string,
|
||||
force?: string
|
||||
): meta is ChartTagMetadata {
|
||||
if (!meta) {
|
||||
return false;
|
||||
}
|
||||
if (meta.instanceId !== instanceId || meta.surface !== surface) {
|
||||
return false;
|
||||
}
|
||||
if (force && meta.force !== force) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async deleteFilesMatching(dir: string, predicate: (file: string) => boolean) {
|
||||
const files = await fs.readdir(dir);
|
||||
for (const file of files) {
|
||||
if (predicate(file)) {
|
||||
await fs.remove(path.join(dir, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private removeSurfaceQueueEntries(instanceId: number, surface: string, force?: string) {
|
||||
for (const tileName of Array.from(this.chunkSavingQueue.keys())) {
|
||||
if (this.matchesSurfaceForce(this.parseTileKey(tileName), instanceId, surface, force)) {
|
||||
this.chunkSavingQueue.delete(tileName);
|
||||
}
|
||||
}
|
||||
for (const tileName of Array.from(this.recipeSavingQueue.keys())) {
|
||||
if (this.matchesSurfaceForce(this.parseTileKey(tileName), instanceId, surface, force)) {
|
||||
this.recipeSavingQueue.delete(tileName);
|
||||
}
|
||||
}
|
||||
for (const key of Array.from(this.chartTagQueues.keys())) {
|
||||
if (this.matchesChartTag(this.parseChartTagMetadata(key), instanceId, surface, force)) {
|
||||
this.chartTagQueues.delete(key);
|
||||
}
|
||||
}
|
||||
for (const key of Array.from(this.playerPositionQueues.keys())) {
|
||||
if (key === `${instanceId}_${surface}`) {
|
||||
this.playerPositionQueues.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private removeSurfaceCaches(instanceId: number, surface: string, force?: string) {
|
||||
for (const key of Array.from(this.lastSeenTagContent.keys())) {
|
||||
if (this.matchesChartTag(this.parseChartTagMetadata(key), instanceId, surface, force)) {
|
||||
this.lastSeenTagContent.delete(key);
|
||||
}
|
||||
}
|
||||
for (const key of Array.from(this.lastSeenRecipeContent.keys())) {
|
||||
if (this.matchesSurfaceForce(this.parseTileKey(key), instanceId, surface, force)) {
|
||||
this.lastSeenRecipeContent.delete(key);
|
||||
this.recipeDictionaries.delete(key);
|
||||
this.nextRecipeIndex.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
const playerKey = `${instanceId}_${surface}`;
|
||||
this.playerSessions.delete(playerKey);
|
||||
this.nextPlayerIds.delete(playerKey);
|
||||
this.activePlayerSessions.delete(playerKey);
|
||||
}
|
||||
|
||||
private async clearSurfaceData(instanceId: number, surface: string, force?: string) {
|
||||
const matchesTile = (file: string) => this.matchesSurfaceForce(
|
||||
this.parseTileKey(file),
|
||||
instanceId,
|
||||
surface,
|
||||
force
|
||||
);
|
||||
|
||||
await this.deleteFilesMatching(this.tilesPath, matchesTile);
|
||||
await this.deleteFilesMatching(this.recipeTilesPath, matchesTile);
|
||||
|
||||
await this.deleteFilesMatching(this.chartTagsPath, (file) => (
|
||||
this.matchesChartTag(this.parseChartTagMetadata(file), instanceId, surface, force)
|
||||
));
|
||||
|
||||
if (!force) {
|
||||
const positionsFile = `${instanceId}_${surface}.positions`;
|
||||
await fs.remove(path.join(this.playerPositionsPath, positionsFile));
|
||||
}
|
||||
|
||||
this.removeSurfaceQueueEntries(instanceId, surface, force);
|
||||
this.removeSurfaceCaches(instanceId, surface, force);
|
||||
|
||||
this.logger.info(`Cleared minimap data: ${instanceId}, surface ${surface}${force ? `, force ${force}` : ""}`);
|
||||
}
|
||||
|
||||
private async clearAllMinimapData() {
|
||||
await Promise.all([
|
||||
fs.emptyDir(this.tilesPath),
|
||||
fs.emptyDir(this.chartTagsPath),
|
||||
fs.emptyDir(this.recipeTilesPath),
|
||||
fs.emptyDir(this.playerPositionsPath),
|
||||
]);
|
||||
|
||||
this.chunkSavingQueue.clear();
|
||||
this.chartTagQueues.clear();
|
||||
this.recipeSavingQueue.clear();
|
||||
this.playerPositionQueues.clear();
|
||||
|
||||
this.lastSeenTagContent.clear();
|
||||
this.lastSeenRecipeContent.clear();
|
||||
this.recipeDictionaries.clear();
|
||||
this.nextRecipeIndex.clear();
|
||||
|
||||
this.playerSessions.clear();
|
||||
this.nextPlayerIds.clear();
|
||||
this.activePlayerSessions.clear();
|
||||
|
||||
this.logger.info("Cleared all minimap data");
|
||||
}
|
||||
|
||||
|
||||
async onShutdown() {
|
||||
if (this.saveInterval) {
|
||||
clearInterval(this.saveInterval);
|
||||
@@ -1113,6 +1325,16 @@ export class ControllerPlugin extends BaseControllerPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
async handleClearMinimapSurfaceDataRequest(request: ClearMinimapSurfaceDataRequest) {
|
||||
await this.clearSurfaceData(request.instance_id, request.surface, request.force);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async handleClearAllMinimapDataRequest(_request: ClearMinimapDataRequest) {
|
||||
await this.clearAllMinimapData();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async loadExistingPlayerSessions() {
|
||||
try {
|
||||
if (!await fs.pathExists(this.playerPositionsPath)) {
|
||||
|
||||
@@ -18,6 +18,11 @@ lib.definePermission({
|
||||
description: "View the interactive minimap of Factorio instances.",
|
||||
grantByDefault: true,
|
||||
});
|
||||
lib.definePermission({
|
||||
name: "minimap.manage",
|
||||
title: "Manage Minimap Data",
|
||||
description: "Delete cached minimap data through the web interface.",
|
||||
});
|
||||
|
||||
export const plugin: lib.PluginDeclaration = {
|
||||
name: "minimap",
|
||||
|
||||
@@ -256,6 +256,58 @@ export class GetRawTileRequest {
|
||||
}));
|
||||
}
|
||||
|
||||
export class ClearMinimapSurfaceDataRequest {
|
||||
declare ["constructor"]: typeof ClearMinimapSurfaceDataRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static plugin = "minimap" as const;
|
||||
static permission = "minimap.manage";
|
||||
|
||||
constructor(
|
||||
public instance_id: number,
|
||||
public surface: string,
|
||||
public force?: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"instance_id": Type.Number(),
|
||||
"surface": Type.String(),
|
||||
"force": Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof ClearMinimapSurfaceDataRequest.jsonSchema>) {
|
||||
return new this(
|
||||
json.instance_id,
|
||||
json.surface,
|
||||
json.force
|
||||
);
|
||||
}
|
||||
|
||||
static Response = lib.plainJson(Type.Object({
|
||||
"success": Type.Boolean(),
|
||||
}));
|
||||
}
|
||||
|
||||
export class ClearMinimapDataRequest {
|
||||
declare ["constructor"]: typeof ClearMinimapDataRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static plugin = "minimap" as const;
|
||||
static permission = "minimap.manage";
|
||||
|
||||
static jsonSchema = Type.Object({});
|
||||
|
||||
static fromJSON(_: Static<typeof ClearMinimapDataRequest.jsonSchema>) {
|
||||
return new this();
|
||||
}
|
||||
|
||||
static Response = lib.plainJson(Type.Object({
|
||||
"success": Type.Boolean(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* GetChartTagsRequest: Request existing chart tags from controller storage
|
||||
*
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
import React, { useState, useEffect, useContext, useRef, useCallback } from "react";
|
||||
import { Row, Col, Button, Card, Space, Select, Switch, Slider, Typography } from "antd";
|
||||
import { ControlContext, useInstances, useItemMetadata } from "@clusterio/web_ui";
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Button,
|
||||
Card,
|
||||
Space,
|
||||
Select,
|
||||
Switch,
|
||||
Slider,
|
||||
Typography,
|
||||
Dropdown,
|
||||
Tooltip,
|
||||
type MenuProps,
|
||||
} from "antd";
|
||||
import { EllipsisOutlined } from "@ant-design/icons";
|
||||
import { ControlContext, useInstances, useItemMetadata, useAccount, notify } from "@clusterio/web_ui";
|
||||
import {
|
||||
GetRawTileRequest,
|
||||
GetChartTagsRequest,
|
||||
GetRawRecipeTileRequest,
|
||||
GetPlayerPathRequest,
|
||||
TileDataEvent,
|
||||
ChartTagData,
|
||||
ChartTagDataEvent,
|
||||
SignalID,
|
||||
RecipeDataEvent,
|
||||
PlayerPositionEvent,
|
||||
PlayerData,
|
||||
ClearMinimapSurfaceDataRequest,
|
||||
ClearMinimapDataRequest,
|
||||
type TileDataEvent,
|
||||
type ChartTagData,
|
||||
type ChartTagDataEvent,
|
||||
type SignalID,
|
||||
type RecipeDataEvent,
|
||||
type PlayerPositionEvent,
|
||||
type PlayerData,
|
||||
} from "../messages";
|
||||
import * as zlib from "zlib";
|
||||
import {
|
||||
@@ -22,10 +38,10 @@ import {
|
||||
extractAvailableTicks,
|
||||
parseTileData,
|
||||
renderTileIncremental,
|
||||
ParsedTileData,
|
||||
type ParsedTileData,
|
||||
parseRecipeTileBinary,
|
||||
} from "../utils/tile-utils";
|
||||
import { parseAndDeduplicatePlayerPositions, ParsedPlayerPos } from "../utils/player-utils";
|
||||
import { parseAndDeduplicatePlayerPositions, type ParsedPlayerPos } from "../utils/player-utils";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -119,7 +135,10 @@ export default function CanvasMinimapPage() {
|
||||
const [selectedSurface, setSelectedSurface] = useState<string>("nauvis");
|
||||
const [selectedForce, setSelectedForce] = useState<string>("player");
|
||||
const [surfaceForceData, setSurfaceForceData] = useState<SurfaceForceData>({ surfaces: [], forces: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const account = useAccount();
|
||||
const canManageMinimap = account.hasPermission("minimap.manage") === true;
|
||||
const [isClearingSurface, setIsClearingSurface] = useState(false);
|
||||
const [isClearingAll, setIsClearingAll] = useState(false);
|
||||
|
||||
// Timelapse UI states (these need React re-renders for UI)
|
||||
const [isTimelapseMode, setIsTimelapseMode] = useState(false);
|
||||
@@ -179,6 +198,82 @@ export default function CanvasMinimapPage() {
|
||||
// Timelapse playback timer ref
|
||||
const playbackTimerRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
const resetViewerState = () => {
|
||||
virtualTiles.current.clear();
|
||||
tileStateCache.current.clear();
|
||||
chunkCache.current.clear();
|
||||
loadingTiles.current.clear();
|
||||
recipeCache.current.clear();
|
||||
playerPathBufRef.current = null;
|
||||
deduplicatedPlayerPositions.current.clear();
|
||||
setPlayerPositions(new Map());
|
||||
setRawChartTags([]);
|
||||
setMergedChartTags([]);
|
||||
setAvailableTicks([]);
|
||||
setCurrentTick(null);
|
||||
setChartTagTimestamps(0);
|
||||
setIsPlaying(false);
|
||||
setIsTimelapseMode(false);
|
||||
};
|
||||
|
||||
const handleClearSurfaceData = async () => {
|
||||
if (!canManageMinimap) {
|
||||
return;
|
||||
}
|
||||
if (!selectedInstance || !selectedSurface) {
|
||||
notify("Select an instance and surface before clearing minimap data", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsClearingSurface(true);
|
||||
try {
|
||||
await control.send(new ClearMinimapSurfaceDataRequest(
|
||||
selectedInstance,
|
||||
selectedSurface,
|
||||
selectedForce || undefined
|
||||
));
|
||||
|
||||
notify("Cleared minimap surface data", "success");
|
||||
resetViewerState();
|
||||
} catch (err: any) {
|
||||
notify(err instanceof Error ? err : new Error("Failed to clear minimap surface data"), "error");
|
||||
} finally {
|
||||
setIsClearingSurface(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAllData = async () => {
|
||||
if (!canManageMinimap) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsClearingAll(true);
|
||||
try {
|
||||
await control.send(new ClearMinimapDataRequest());
|
||||
notify("Cleared all minimap data", "success");
|
||||
resetViewerState();
|
||||
} catch (err: any) {
|
||||
notify(err instanceof Error ? err : new Error("Failed to clear minimap data"), "error");
|
||||
} finally {
|
||||
setIsClearingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManageDropdownClick: MenuProps["onClick"] = ({ key }) => {
|
||||
if (key === "clear_all") {
|
||||
void handleClearAllData();
|
||||
}
|
||||
};
|
||||
|
||||
const manageDropdownItems: MenuProps["items"] = [
|
||||
{
|
||||
key: "clear_all",
|
||||
label: "Clear all minimap data",
|
||||
danger: true,
|
||||
disabled: !canManageMinimap || isClearingAll,
|
||||
},
|
||||
];
|
||||
|
||||
// Refs for current state access in event handlers
|
||||
const currentStateRef = useRef({
|
||||
selectedInstance: null as number | null,
|
||||
@@ -2112,6 +2207,42 @@ export default function CanvasMinimapPage() {
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
loading={isClearingSurface}
|
||||
onClick={handleClearSurfaceData}
|
||||
disabled={
|
||||
!canManageMinimap || !selectedInstance || !selectedSurface
|
||||
}
|
||||
>
|
||||
Clear surface data
|
||||
</Button>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: manageDropdownItems,
|
||||
onClick: handleManageDropdownClick,
|
||||
}}
|
||||
trigger={["click"]}
|
||||
>
|
||||
<Tooltip
|
||||
title={
|
||||
canManageMinimap
|
||||
? "Danger zone"
|
||||
: "Requires minimap.manage permission"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EllipsisOutlined />}
|
||||
loading={isClearingAll}
|
||||
disabled={!canManageMinimap}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
}
|
||||
styles={{
|
||||
header: { padding: "16px 24px" },
|
||||
body: { padding: "16px 24px" },
|
||||
@@ -2142,7 +2273,6 @@ export default function CanvasMinimapPage() {
|
||||
}
|
||||
}, 100);
|
||||
}}
|
||||
loading={loading}
|
||||
options={[...instances.values()].map(instance => ({
|
||||
value: instance.id,
|
||||
label: instance.name,
|
||||
@@ -2230,7 +2360,6 @@ export default function CanvasMinimapPage() {
|
||||
onChange={(checked) => {
|
||||
toggleTimelapseMode(checked);
|
||||
}}
|
||||
loading={loading}
|
||||
disabled={!selectedInstance}
|
||||
/>
|
||||
<Text
|
||||
|
||||
Reference in new issue
Block a user