New version of farmcontrol-server. Includes electron UI, connects only via WS and authenticates using OTP and then auth code.

This commit is contained in:
Tom Butcher 2025-09-05 23:33:21 +01:00
parent ec0f2e4d16
commit 5db74f2c5c
103 changed files with 13668 additions and 8338 deletions

View File

@ -1,40 +1,18 @@
{
"development": {
"server": {
"port": 8081,
"logLevel": "debug"
},
"auth": {
"enabled": true,
"keycloak": {
"url": "https://auth.tombutcher.work",
"realm": "master",
"clientId": "farmcontrol-client",
"clientSecret": "GPyh59xctRX83yfKWb83ShK6VEwHIvLF"
},
"requiredRoles": []
},
"database": {
"url": "mongodb://localhost:27017/farmcontrol"
"logLevel": "debug",
"url": "http://192.168.68.53:9090",
"host": {
"id": "68a0b5d7c873abe59a995431",
"authCode": "OHHRijUj-PJnsxx6qAb7hesAlB64SdFBpDrJszComy225KIQ3M3uvMMKhdVCeGfB"
}
},
"production": {
"server": {
"port": 8081,
"logLevel": "info"
},
"auth": {
"enabled": true,
"keycloak": {
"url": "https://auth.tombutcher.work",
"realm": "master",
"clientId": "farmcontrol-client",
"clientSecret": "GPyh59xctRX83yfKWb83ShK6VEwHIvLF"
},
"requiredRoles": []
},
"database": {
"url": "mongodb://farmcontrol.tombutcher.local:27017/farmcontrol"
"logLevel": "info",
"url": "192.168.68.53:8001",
"host": {
"id": "",
"authCode": ""
}
}
}

3910
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,25 +7,42 @@
"scripts": {
"start": "node build/index.js",
"dev": "nodemon src/index.js",
"build": "rimraf build && mkdir build && cp -r src/* build/ && cp package.json config.json build/",
"dev:electron": "concurrently \"NODE_ENV=development electron .\" \"vite src/electron --port 5173\"",
"build": "rimraf build && mkdir build && cp -r src/* build/ && cp package.json config.json build/ && npm run build:electron && cp src/electron/preload.js build/electron/ && rm -rf build/electron/App.jsx build/electron/main.jsx build/electron/App.css build/electron/index.css build/electron/FarmControlLogo.jsx build/electron/vite.config.js build/electron/public build/electron/build",
"build:electron": "vite build src/electron --outDir build/electron",
"clean": "rimraf build"
},
"author": "Tom Butcher",
"license": "ISC",
"dependencies": {
"@ant-design/icons": "^6.0.0",
"ant-design": "^1.0.0",
"antd": "^5.27.0",
"axios": "^1.8.4",
"etcd3": "^1.1.2",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
"keycloak-connect": "^26.1.1",
"lodash": "^4.17.21",
"log4js": "^6.9.1",
"mongoose": "^8.13.2",
"prop-types": "^15.8.1",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"ws": "^8.18.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
"concurrently": "^9.2.0",
"electron": "^37.2.6",
"jest": "^29.7.0",
"nodemon": "^3.1.9",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"rimraf": "^5.0.5",
"supertest": "^6.3.4"
"supertest": "^6.3.4",
"vite": "^5.0.12",
"vite-plugin-svgo": "^2.0.0",
"vite-plugin-svgr": "^4.5.0"
}
}

View File

@ -8,7 +8,7 @@ import { loadConfig } from "../config.js";
const config = loadConfig();
const logger = log4js.getLogger("Auth");
logger.level = config.server.logLevel;
logger.level = config.logLevel;
export class KeycloakAuth {
constructor(config) {
@ -42,7 +42,7 @@ export class KeycloakAuth {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
},
}
);
const introspection = response.data;
@ -53,13 +53,10 @@ export class KeycloakAuth {
}
// Verify required roles if configured
if (
this.config.requiredRoles &&
this.config.requiredRoles.length > 0
) {
if (this.config.requiredRoles && this.config.requiredRoles.length > 0) {
const hasRequiredRole = this.checkRoles(
introspection,
this.config.requiredRoles,
this.config.requiredRoles
);
if (!hasRequiredRole) {
logger.info("User doesn't have required roles");
@ -103,8 +100,8 @@ export class KeycloakAuth {
if (token.resource_access[client].roles) {
roles.push(
...token.resource_access[client].roles.map(
(role) => `${client}:${role}`,
),
(role) => `${client}:${role}`
)
);
}
}

View File

@ -2,14 +2,18 @@
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import log4js from "log4js";
// Configure paths relative to this file
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CONFIG_PATH = path.resolve(__dirname, "../config.json");
const logger = log4js.getLogger("Config");
logger.level = "info";
// Determine environment
const NODE_ENV = process.env.NODE_ENV || 'development';
const NODE_ENV = process.env.NODE_ENV || "development";
// Load config file
export function loadConfig() {
@ -22,7 +26,9 @@ export function loadConfig() {
const config = JSON.parse(configData);
if (!config[NODE_ENV]) {
throw new Error(`Configuration for environment '${NODE_ENV}' not found in config.json`);
throw new Error(
`Configuration for environment '${NODE_ENV}' not found in config.json`
);
}
return config[NODE_ENV];
@ -32,6 +38,28 @@ export function loadConfig() {
}
}
// Save config file
export function saveConfig(newConfig) {
try {
logger.info("Saving...");
let config = {};
if (fs.existsSync(CONFIG_PATH)) {
const configData = fs.readFileSync(CONFIG_PATH, "utf8");
config = JSON.parse(configData);
}
// Update current environment
config[NODE_ENV] = newConfig;
// Write back to file with 2-space indentation
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf8");
logger.info(`Configuration for '${NODE_ENV}' saved successfully.`);
} catch (err) {
logger.error("Error saving config:", err);
throw err;
}
}
// Get current environment
export function getEnvironment() {
return NODE_ENV;

View File

@ -1,26 +0,0 @@
import mongoose from "mongoose";
const { Schema } = mongoose;
const filamentSchema = new mongoose.Schema({
name: { required: true, type: String },
barcode: { required: false, type: String },
url: { required: false, type: String },
image: { required: false, type: Buffer },
color: { required: true, type: String },
vendor: { type: Schema.Types.ObjectId, ref: "Vendor", required: true },
type: { required: true, type: String },
cost: { required: true, type: Number },
diameter: { required: true, type: Number },
density: { required: true, type: Number },
createdAt: { required: true, type: Date },
updatedAt: { required: true, type: Date },
emptySpoolWeight: { required: true, type: Number },
});
filamentSchema.virtual("id").get(function () {
return this._id.toHexString();
});
filamentSchema.set("toJSON", { virtuals: true });
export const filamentModel = mongoose.model("Filament", filamentSchema);

View File

@ -1,33 +0,0 @@
import mongoose from "mongoose";
const { Schema } = mongoose;
// Define the main filamentStock schema
const filamentStockSchema = new Schema(
{
state: {
type: { type: String, required: true },
percent: { type: String, required: true },
},
startingGrossWeight: { type: Number, required: true },
startingNetWeight: { type: Number, required: true },
currentGrossWeight: { type: Number, required: true },
currentNetWeight: { type: Number, required: true },
filament: { type: mongoose.Schema.Types.ObjectId, ref: "Filament" },
stockEvents: [{ type: mongoose.Schema.Types.ObjectId, ref: "StockEvent" }]
},
{ timestamps: true },
);
// Add virtual id getter
filamentStockSchema.virtual("id").get(function () {
return this._id.toHexString();
});
// Configure JSON serialization to include virtuals
filamentStockSchema.set("toJSON", { virtuals: true });
// Create and export the model
export const filamentStockModel = mongoose.model(
"FilamentStock",
filamentStockSchema,
);

View File

@ -1,24 +0,0 @@
import mongoose from "mongoose";
const { Schema } = mongoose;
const gcodeFileSchema = new mongoose.Schema({
name: { required: true, type: String },
gcodeFileName: { required: false, type: String },
gcodeFileInfo: { required: true, type: Object },
size: { type: Number, required: false },
filament: { type: Schema.Types.ObjectId, ref: "Filament", required: true },
parts: [{ type: Schema.Types.ObjectId, ref: "Part", required: true }],
cost: { type: Number, required: false },
createdAt: { type: Date },
updatedAt: { type: Date },
});
gcodeFileSchema.index({ name: "text", brand: "text" });
gcodeFileSchema.virtual("id").get(function () {
return this._id.toHexString();
});
gcodeFileSchema.set("toJSON", { virtuals: true });
export const gcodeFileModel = mongoose.model("GCodeFile", gcodeFileSchema);

View File

@ -1,15 +0,0 @@
import mongoose from "mongoose";
import { loadConfig } from "../config.js";
import log4js from "log4js";
// Load configuration
const config = loadConfig();
const logger = log4js.getLogger("Mongo DB");
logger.level = config.server.logLevel;
function dbConnect() {
mongoose.connection.once("open", () => logger.info("Database connected."));
return mongoose.connect(config.database.url, {});
}
export { dbConnect };

View File

@ -1,61 +0,0 @@
import mongoose from "mongoose";
const { Schema } = mongoose;
// Define the moonraker connection schema
const moonrakerSchema = new Schema(
{
host: { type: String, required: true },
port: { type: Number, required: true },
protocol: { type: String, required: true },
apiKey: { type: String, default: null, required: false },
},
{ _id: false },
);
// Define the alert schema
const alertSchema = new Schema(
{
priority: { type: String, required: true }, // order to show
type: { type: String, required: true }, // selectFilament, error, info, message,
message: { type: String, required: false }
},
{ timestamps: true, _id: false }
);
// Define the main printer schema
const printerSchema = new Schema(
{
name: { type: String, required: true },
online: { type: Boolean, required: true, default: false },
state: {
type: { type: String, required: true, default: "Offline" },
progress: { required: false, type: Number, default: 0 },
},
connectedAt: { type: Date, default: null },
loadedFilament: {
type: Schema.Types.ObjectId,
ref: "Filament",
default: null,
},
moonraker: { type: moonrakerSchema, required: true },
tags: [{ type: String }],
firmware: { type: String },
currentJob: { type: Schema.Types.ObjectId, ref: "PrintJob" },
currentSubJob: { type: Schema.Types.ObjectId, ref: "PrintSubJob" },
currentFilamentStock: { type: Schema.Types.ObjectId, ref: "FilamentStock" },
subJobs: [{ type: Schema.Types.ObjectId, ref: "PrintSubJob" }],
alerts: [alertSchema],
},
{ timestamps: true },
);
// Add virtual id getter
printerSchema.virtual("id").get(function () {
return this._id.toHexString();
});
// Configure JSON serialization to include virtuals
printerSchema.set("toJSON", { virtuals: true });
// Create and export the model
export const printerModel = mongoose.model("Printer", printerSchema);

View File

@ -1,39 +0,0 @@
import mongoose from "mongoose";
const { Schema } = mongoose;
const printJobSchema = new mongoose.Schema({
state: {
type: { required: true, type: String },
progress: { required: false, type: Number, default: 0 },
},
subJobStats : {
required: false, type: Object
},
printers: [{ type: Schema.Types.ObjectId, ref: "Printer", required: false }],
createdAt: { required: true, type: Date },
updatedAt: { required: true, type: Date },
startedAt: { required: false, type: Date },
finishedAt: { required: false, type: Date },
gcodeFile: {
type: Schema.Types.ObjectId,
ref: "GCodeFile",
required: false,
},
quantity: {
type: Number,
required: true,
default: 1,
min: 1,
},
subJobs: [
{type: Schema.Types.ObjectId, ref: "PrintSubJob", required: false}
],
});
printJobSchema.virtual("id").get(function () {
return this._id.toHexString();
});
printJobSchema.set("toJSON", { virtuals: true });
export const printJobModel = mongoose.model("PrintJob", printJobSchema);

View File

@ -1,50 +0,0 @@
import mongoose from "mongoose";
const { Schema } = mongoose;
const printSubJobSchema = new mongoose.Schema({
printer: {
type: Schema.Types.ObjectId,
ref: "Printer",
required: true
},
printJob: {
type: Schema.Types.ObjectId,
ref: "PrintJob",
required: true
},
subJobId: {
type: String,
required: true
},
gcodeFile: {
type: Schema.Types.ObjectId,
ref: "GCodeFile",
required: true,
},
state: {
type: { required: true, type: String },
progress: { required: false, type: Number, default: 0 },
},
number: {
type: Number,
required: true
},
createdAt: {
type: Date,
default: Date.now
},
updatedAt: {
type: Date,
default: Date.now
},
startedAt: { required: false, type: Date },
finishedAt: { required: false, type: Date },
});
printSubJobSchema.virtual("id").get(function () {
return this._id.toHexString();
});
printSubJobSchema.set("toJSON", { virtuals: true });
export const printSubJobModel = mongoose.model("PrintSubJob", printSubJobSchema);

View File

@ -1,26 +0,0 @@
import mongoose from "mongoose";
const { Schema } = mongoose;
const stockEventSchema = new Schema(
{
type: { type: String, required: true },
value: { type: Number, required: true },
unit: { type: String, required: true},
subJob: { type: Schema.Types.ObjectId, ref: "PrintSubJob", required: false },
job: { type: Schema.Types.ObjectId, ref: "PrintJob", required: false },
filamentStock: { type: Schema.Types.ObjectId, ref: "FilamentStock", required: true },
timestamp: { type: Date, default: Date.now }
},
{ timestamps: true }
);
// Add virtual id getter
stockEventSchema.virtual("id").get(function () {
return this._id.toHexString();
});
// Configure JSON serialization to include virtuals
stockEventSchema.set("toJSON", { virtuals: true });
// Create and export the model
export const stockEventModel = mongoose.model("StockEvent", stockEventSchema);

View File

@ -0,0 +1,14 @@
import { loadConfig } from "../config.js";
import log4js from "log4js";
const config = loadConfig();
const logger = log4js.getLogger("Document Printer Manager");
logger.level = config.server.logLevel;
export class DocumentPrinterManager {
constructor(socketClient) {
this.socketClient = socketClient;
this.documentPrinterClients = new Map();
}
}

16
src/electron/App.css Normal file
View File

@ -0,0 +1,16 @@
#root {
margin: 0 auto;
width: 100vw;
height: 100vh;
}
/* Theme-aware styles */
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

241
src/electron/App.jsx Normal file
View File

@ -0,0 +1,241 @@
import "./App.css";
import {
Flex,
Button,
Typography,
Tag,
Menu,
ConfigProvider,
theme,
Layout,
Modal,
} from "antd";
import { MenuOutlined } from "@ant-design/icons";
import React, { useState, useEffect } from "react";
import merge from "lodash/merge";
import unionBy from "lodash/unionBy";
import Overview from "./pages/Overview";
import Printers from "./pages/Printers";
import Loading from "./pages/Loading";
import OTPInput from "./pages/OTPInput";
import CloudIcon from "./icons/CloudIcon";
import LockIcon from "./icons/LockIcon";
import SettingsIcon from "./icons/SettingsIcon";
import Disconnected from "./pages/Disconnected";
const App = () => {
const [host, setHost] = useState({});
const [printers, setPrinters] = useState([]);
const [documentPrinters, setDocumentPrinters] = useState([]);
const [connected, setConnected] = useState(false);
const [authenticated, setAuthenticated] = useState(false);
const [error, setError] = useState(null);
const [currentPageKey, setCurrentPageKey] = useState("overview");
const [loading, setLoading] = useState(true);
const [isDarkMode, setIsDarkMode] = useState(false);
// Listen for system theme changes
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = (e) => {
setIsDarkMode(e.matches);
console.log("CHANGE", e);
};
mediaQuery.addEventListener("change", handleChange);
setIsDarkMode(mediaQuery.matches);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);
useEffect(() => {
console.log("Setting up IPC listeners...");
// Set up IPC listeners when component mounts
window.electronAPI.onIPCData("setHost", (newHost) => {
console.log("Host data received:", newHost);
setHost((prev) => merge(prev, newHost));
});
window.electronAPI.onIPCData("setPrinters", (newPrinters) => {
console.log("Printers data:", newPrinters);
setPrinters(newPrinters);
});
window.electronAPI.onIPCData("setPrinter", (newPrinter) => {
console.log("Printer data:", newPrinter);
setPrinters((prev) => unionBy(prev, [newPrinter], "_id"));
});
window.electronAPI.onIPCData(
"setDocumentPrinters",
(newDocumentPrinters) => {
console.log("Document printers data:", newDocumentPrinters);
setDocumentPrinters((prev) =>
unionBy(prev, newDocumentPrinters, "_id")
);
}
);
window.electronAPI.onIPCData("setAuthenticated", (setAuthenticated) => {
console.log("Set authenticated:", setAuthenticated);
setLoading(setAuthenticated);
});
window.electronAPI.onIPCData("setConnected", (isConnected) => {
console.log("Set connected:", isConnected);
setConnected(isConnected);
});
window.electronAPI.onIPCData("setAuthenticated", (isAuthenticated) => {
console.log("Set authenticated:", isAuthenticated);
setAuthenticated(isAuthenticated);
});
window.electronAPI.onIPCData("setLoading", (isLoading) => {
console.log("Set loading:", isLoading);
setLoading(isLoading);
});
window.electronAPI.onIPCData("setPrinters", (newPrinters) => {
console.log("Printers data:", newPrinters);
setPrinters((prev) => unionBy(prev, newPrinters, "_id"));
});
console.log("Sending get data...");
// Request initial data
window.electronAPI.sendIPC("getData");
// Cleanup listeners when component unmounts
return () => {
window.electronAPI.removeAllListeners("setHost");
window.electronAPI.removeAllListeners("setPrinters");
window.electronAPI.removeAllListeners("setDocumentPrinters");
window.electronAPI.removeAllListeners("setAuthenticated");
window.electronAPI.removeAllListeners("setConnected");
window.electronAPI.removeAllListeners("setLoading");
};
}, []); // Empty dependency array means this runs once on mount
// Function to render the appropriate page based on currentPageKey and auth status
const renderCurrentPage = () => {
// If loading, show loading
if (loading) {
return <Loading />;
}
// If not authenticated but connected, show OTP input
if (connected === false && loading == false) {
return <Disconnected />;
}
// If not authenticated but connected, show OTP input
if (authenticated === false && connected === true) {
return <OTPInput />;
}
// If authenticated and connected, show the selected page
switch (currentPageKey) {
case "overview":
return <Overview printers={printers} host={host} loading={loading} />;
case "printers":
return <Printers printers={printers} />;
case "documentPrinters":
return <div>Document Printers Page (to be implemented)</div>;
default:
return <Overview />;
}
};
// Handle menu item clicks
const handleMenuClick = ({ key }) => {
setCurrentPageKey(key);
};
const mainMenuItems = [
{
key: "overview",
label: "Overview",
},
{
key: "printers",
label: "Printers",
},
{
key: "documentPrinters",
label: "Document Printers",
},
];
return (
<ConfigProvider
theme={{
token: {
colorPrimary: "#007AFF",
colorSuccess: "#32D74B",
colorWarning: "#FF9F0A",
colorInfo: "#0A84FF",
colorLink: "#5AC8F5",
borderRadius: "10px",
},
components: {
Layout: {
headerBg: isDarkMode ? "#141414" : "#ffffff",
},
},
algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}
>
<Layout>
<Flex style={{ width: "100vw", height: "100vh" }} vertical>
<Flex
className="ant-menu-horizontal ant-menu-light"
style={{ lineHeight: "40px", padding: "0 8px 0 75px" }}
>
{loading == false && authenticated == true && connected == true ? (
<Menu
mode="horizontal"
items={mainMenuItems}
selectedKeys={[currentPageKey]}
style={{
flexWrap: "wrap",
border: 0,
lineHeight: "40px",
}}
overflowedIndicator={
<Button type="text" icon={<MenuOutlined />} />
}
onClick={handleMenuClick}
/>
) : null}
<div className="electron-navigation" style={{ flexGrow: 1 }}></div>
<Flex align="center" gap={"small"}>
<Button
type="text"
icon={<SettingsIcon />}
style={{ marginTop: "1px" }}
/>
<div>
<Tag
color={authenticated ? "success" : "warning"}
style={{ margin: 0 }}
icon={<LockIcon />}
/>
</div>
<div>
<Tag
color={connected ? "success" : "error"}
style={{ margin: 0 }}
icon={<CloudIcon />}
/>
</div>
</Flex>
</Flex>
<div style={{ overflow: "auto", margin: "16px", height: "100%" }}>
{renderCurrentPage()}
</div>
</Flex>
</Layout>
</ConfigProvider>
);
};
export default App;

View File

@ -0,0 +1,9 @@
import React from "react";
import Icon from "@ant-design/icons";
import { ReactComponent as CustomIconSvg } from "./assets/farmcontrollogo.svg";
const FarmControlLogo = (props) => (
<Icon component={CustomIconSvg} {...props} />
);
export default FarmControlLogo;

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'>
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" version="1.1" viewBox="0 0 1280 113" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1.0156 0 0 1.0156 -2.2117e-16 0)">
<g transform="matrix(.67805 0 0 .67805 -10.706 -23.499)" fill-rule="nonzero">
<path d="m15.789 187.1v-142.1c0-1.997 0.817-3.766 2.45-5.309 1.634-1.542 3.449-2.314 5.445-2.314h77.857c1.997 0 3.812 0.772 5.445 2.314 1.633 1.543 2.45 3.312 2.45 5.309v18.239c0 1.996-0.817 3.766-2.45 5.309-1.633 1.542-3.448 2.314-5.445 2.314h-49.273v35.934h43.557c1.996 0 3.811 0.771 5.444 2.314 1.634 1.542 2.45 3.312 2.45 5.308v18.24c0 1.996-0.816 3.765-2.45 5.308-1.633 1.543-3.448 2.314-5.444 2.314h-43.557v46.823c0 1.997-0.771 3.812-2.314 5.445s-3.312 2.45-5.308 2.45h-21.234c-1.997 0-3.766-0.817-5.309-2.45-1.542-1.633-2.314-3.448-2.314-5.445z"/>
<path d="m163.34 37.379h30.49c3.63 0 6.352 1.725 8.167 5.173l44.646 144.28c0 2.178-0.772 4.084-2.314 5.717-1.543 1.633-3.403 2.45-5.581 2.45h-24.229c-3.629 0-6.079-1.815-7.35-5.445l-5.717-20.961h-45.734l-5.717 20.961c-1.27 3.63-3.72 5.445-7.35 5.445h-24.228c-2.178 0-4.038-0.817-5.581-2.45s-2.314-3.539-2.314-5.717l44.646-144.28c1.814-3.448 4.537-5.173 8.166-5.173zm15.245 46.552-13.883 51.179h27.767l-13.884-51.179z"/>
<path d="m315.51 117.69c6.715 0 12.16-1.906 16.334-5.717s6.261-9.8 6.261-17.967-2.087-14.201-6.261-18.103-9.619-5.853-16.334-5.853h-12.25v47.64h12.25zm29.129 74.591-22.051-42.468-6.261 0.544h-13.067v36.751c0 1.997-0.771 3.812-2.314 5.445-1.542 1.633-3.312 2.45-5.308 2.45h-21.234c-1.996 0-3.766-0.817-5.309-2.45-1.542-1.633-2.314-3.448-2.314-5.445v-142.1c0-1.997 0.817-3.766 2.451-5.309 1.633-1.542 3.448-2.314 5.444-2.314h41.651c0.908 0 2.087 0.046 3.539 0.137 1.452 0.09 4.31 0.499 8.575 1.225 4.265 0.725 8.303 1.724 12.115 2.994 3.811 1.27 8.076 3.358 12.794 6.261 4.719 2.904 8.757 6.262 12.114 10.073 3.358 3.811 6.216 8.847 8.576 15.109 2.359 6.261 3.539 13.203 3.539 20.825 0 19.056-6.715 33.484-20.145 43.284l25.861 50.091c0 2.177-0.68 3.992-2.041 5.444s-3.131 2.178-5.309 2.178h-25.317c-2.541 0-4.537-0.907-5.989-2.722z"/>
<path d="m480.48 174.04h-3.266c-1.997 0-3.358-0.726-4.084-2.177l-33.212-60.708v75.952c0 1.997-0.771 3.812-2.314 5.445-1.542 1.633-3.312 2.45-5.308 2.45h-21.234c-1.996 0-3.766-0.817-5.309-2.45-1.542-1.633-2.314-3.448-2.314-5.445v-141.83c0-1.996 0.772-3.811 2.314-5.445 1.543-1.633 3.313-2.45 5.309-2.45h24.5c2.904 0 4.901 1.089 5.99 3.267l37.023 66.424 37.023-66.424c1.089-2.178 3.085-3.267 5.989-3.267h24.5c1.997 0 3.766 0.817 5.309 2.45 1.543 1.634 2.314 3.449 2.314 5.445v141.83c0 1.997-0.771 3.812-2.314 5.445s-3.312 2.45-5.309 2.45h-21.233c-1.997 0-3.766-0.817-5.309-2.45s-2.314-3.448-2.314-5.445v-75.952l-33.212 60.708c-0.726 1.451-1.905 2.177-3.539 2.177z"/>
</g>
<g transform="matrix(.67805 0 0 .67805 15.294 -23.499)" fill="url(#a)" fill-rule="nonzero">
<path d="m656.07 198c-22.686 0-40.608-7.759-53.765-23.276-13.158-15.517-19.737-34.981-19.737-58.393s6.579-42.876 19.737-58.393c13.157-15.517 31.079-23.276 53.765-23.276 39.019 0 62.703 18.149 71.052 54.446-0.363 1.996-1.316 3.72-2.859 5.172-1.542 1.452-3.312 2.178-5.308 2.178h-23.412c-2.722 0-4.809-1.361-6.261-4.083-4.356-16.153-15.426-24.229-33.212-24.229-12.16 0-21.143 4.447-26.951 13.339-5.807 8.893-8.711 20.508-8.711 34.846 0 14.156 2.904 25.725 8.711 34.709 5.808 8.984 14.791 13.475 26.951 13.475 17.786 0 28.856-8.076 33.212-24.228 1.452-2.722 3.539-4.083 6.261-4.083h23.412c1.996 0 3.766 0.725 5.308 2.177 1.543 1.452 2.496 3.176 2.859 5.173-8.349 36.297-32.033 54.446-71.052 54.446z"/>
<path d="m767.14 57.797c12.704-15.427 30.399-23.14 53.085-23.14s40.335 7.713 52.949 23.14c12.613 15.426 18.92 34.936 18.92 58.529 0 23.412-6.352 42.876-19.056 58.393s-30.309 23.276-52.813 23.276-40.108-7.759-52.812-23.276-19.056-34.981-19.056-58.393c0-23.593 6.261-43.103 18.783-58.529zm19.056 58.529c0 32.123 11.343 48.184 34.029 48.184s34.029-16.061 34.029-48.184-11.343-48.185-34.029-48.185-34.029 16.062-34.029 48.185z"/>
<path d="m1005.3 191.73-47.91-78.129v73.501c0 1.997-0.772 3.812-2.314 5.445-1.543 1.633-3.313 2.45-5.309 2.45h-21.234c-1.996 0-3.766-0.817-5.308-2.45-1.543-1.633-2.314-3.448-2.314-5.445v-141.83c0-1.996 0.771-3.811 2.314-5.445 1.542-1.633 3.312-2.45 5.308-2.45h22.867c2.723 0 4.719 1.089 5.99 3.267l50.09 82.758v-78.13c0-1.996 0.77-3.811 2.31-5.445 1.55-1.633 3.32-2.45 5.31-2.45h21.24c1.99 0 3.76 0.817 5.3 2.45 1.55 1.634 2.32 3.449 2.32 5.445v141.83c0 1.997-0.77 3.812-2.32 5.445-1.54 1.633-3.31 2.45-5.3 2.45h-25.05c-2.72 0-4.72-1.089-5.99-3.267z"/>
<path d="m1076.7 37.379h99.9c2 0 3.81 0.772 5.45 2.314 1.63 1.543 2.45 3.312 2.45 5.309v18.239c0 1.996-0.82 3.766-2.45 5.309-1.64 1.542-3.45 2.314-5.45 2.314h-31.57v116.24c0 1.997-0.78 3.812-2.32 5.445s-3.31 2.45-5.31 2.45h-21.5c-2 0-3.77-0.817-5.31-2.45s-2.32-3.448-2.32-5.445v-116.24h-31.57c-2 0-3.81-0.772-5.45-2.314-1.63-1.543-2.45-3.313-2.45-5.309v-18.239c0-1.997 0.82-3.766 2.45-5.309 1.64-1.542 3.45-2.314 5.45-2.314z"/>
<path d="m1258 117.69c6.71 0 12.16-1.906 16.33-5.717 4.18-3.811 6.27-9.8 6.27-17.967s-2.09-14.201-6.27-18.103c-4.17-3.902-9.62-5.853-16.33-5.853h-12.25v47.64h12.25zm29.13 74.591-22.05-42.468-6.26 0.544h-13.07v36.751c0 1.997-0.77 3.812-2.31 5.445-1.55 1.633-3.32 2.45-5.31 2.45h-21.24c-1.99 0-3.76-0.817-5.3-2.45-1.55-1.633-2.32-3.448-2.32-5.445v-142.1c0-1.997 0.82-3.766 2.45-5.309 1.64-1.542 3.45-2.314 5.45-2.314h41.65c0.9 0 2.08 0.046 3.54 0.137 1.45 0.09 4.31 0.499 8.57 1.225 4.27 0.725 8.3 1.724 12.12 2.994 3.81 1.27 8.07 3.358 12.79 6.261 4.72 2.904 8.76 6.262 12.11 10.073 3.36 3.811 6.22 8.847 8.58 15.109 2.36 6.261 3.54 13.203 3.54 20.825 0 19.056-6.72 33.484-20.15 43.284l25.87 50.091c0 2.177-0.68 3.992-2.05 5.444-1.36 1.452-3.13 2.178-5.31 2.178h-25.31c-2.54 0-4.54-0.907-5.99-2.722z"/>
<path d="m1360.6 57.797c12.71-15.427 30.4-23.14 53.08-23.14 22.69 0 40.34 7.713 52.95 23.14 12.62 15.426 18.92 34.936 18.92 58.529 0 23.412-6.35 42.876-19.05 58.393-12.71 15.517-30.31 23.276-52.82 23.276-22.5 0-40.1-7.759-52.81-23.276-12.7-15.517-19.05-34.981-19.05-58.393 0-23.593 6.26-43.103 18.78-58.529zm19.06 58.529c0 32.123 11.34 48.184 34.02 48.184 22.69 0 34.03-16.061 34.03-48.184s-11.34-48.185-34.03-48.185c-22.68 0-34.02 16.062-34.02 48.185z"/>
<path d="m1514.4 187.38v-142.1c0-1.996 0.77-3.811 2.31-5.445 1.55-1.633 3.32-2.45 5.31-2.45h21.24c1.99 0 3.76 0.817 5.31 2.45 1.54 1.634 2.31 3.449 2.31 5.445v116.24h49.82c1.99 0 3.81 0.771 5.44 2.314 1.64 1.542 2.45 3.312 2.45 5.308v18.24c0 1.996-0.81 3.765-2.45 5.308-1.63 1.543-3.45 2.314-5.44 2.314h-78.4c-2 0-3.82-0.771-5.45-2.314s-2.45-3.312-2.45-5.308z"/>
</g>
<g transform="matrix(1.304 0 0 1.304 -18.701 -415.07)">
<path d="m947.98 337.61h-46.26c-1.056 0-2.016-0.408-2.88-1.224s-1.296-1.752-1.296-2.808v-11.232c0-1.056 0.432-1.992 1.296-2.808s1.824-1.224 2.88-1.224h66.099c3.382 0 6.386 1.364 9.01 4.091 2.727 2.624 4.091 5.628 4.091 9.01v66.099c0 1.056-0.408 2.016-1.224 2.88s-1.752 1.296-2.808 1.296h-11.232c-1.056 0-1.992-0.432-2.808-1.296s-1.224-1.824-1.224-2.88v-46.26l-49.262 49.263c-0.747 0.747-1.714 1.137-2.902 1.171s-2.156-0.323-2.902-1.069l-7.942-7.943c-0.747-0.746-1.104-1.714-1.07-2.902 0.034-1.187 0.425-2.155 1.171-2.902l49.263-49.262z" fill-rule="nonzero"/>
</g>
</g>
<defs>
<linearGradient id="a" x2="1" gradientTransform="matrix(9.8182e-15 -160.34 160.34 9.8182e-15 1115.9 195)" gradientUnits="userSpaceOnUse"><stop stop-color="#00a2ff" offset="0"/><stop stop-color="#008eff" offset="1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1.0502,0,0,1.0502,3,1.95893)">
<path d="M21.134,55.708C22.759,55.708 24.041,55.029 24.932,53.671L54.306,7.879C54.964,6.849 55.227,5.987 55.227,5.139C55.227,2.987 53.714,1.503 51.54,1.503C49.998,1.503 49.102,2.021 48.166,3.49L21.009,46.634L7.015,28.64C6.104,27.434 5.149,26.929 3.799,26.929C1.567,26.929 0,28.491 0,30.648C0,31.575 0.346,32.511 1.126,33.458L17.316,53.715C18.394,55.063 19.56,55.708 21.134,55.708Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 934 B

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.842938,0,0,0.842938,-1.20699,10.7166)">
<path d="M17.234,50.498L58.219,50.498C69.004,50.498 77.357,42.333 77.357,31.858C77.357,21.284 68.895,13.259 57.377,13.259C53.159,4.936 45.5,0 36.011,0C23.706,0 13.354,9.339 12.075,21.965C5.656,23.887 1.432,29.266 1.432,36.013C1.432,44.099 7.374,50.498 17.234,50.498ZM17.256,44.64C11.012,44.64 7.29,41.301 7.29,36.174C7.29,31.759 10.12,28.604 15.222,27.255C17,26.812 17.651,26.016 17.821,24.103C18.751,13.486 26.384,5.858 36.011,5.858C43.345,5.858 49.09,9.961 52.635,17.093C53.419,18.674 54.336,19.211 56.288,19.211C65.99,19.211 71.473,24.864 71.473,32.006C71.473,39.129 65.818,44.64 58.439,44.64L17.256,44.64Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.804399,0,0,0.804399,5.78037,0)">
<path d="M15.46,10.251C15.46,3.526 18.935,0 25.609,0L37.353,0C40.949,0 43.76,0.934 46.097,3.327L61.681,19.184C64.149,21.714 65.003,24.337 65.003,28.446L65.003,53.213C65.003,59.932 61.528,63.464 54.854,63.464L49.549,63.464L49.549,57.342L54.32,57.342C57.356,57.342 58.881,55.738 58.881,52.833L58.881,26.958L45.094,26.958C41.253,26.958 39.216,24.946 39.216,21.079L39.216,6.122L26.118,6.122C23.081,6.122 21.582,7.752 21.582,10.632L21.582,16.032C21.407,16.028 21.225,16.027 21.039,16.027L15.46,16.027L15.46,10.251ZM44.326,20.307C44.326,21.378 44.769,21.847 45.841,21.847L57.287,21.847L44.326,8.681L44.326,20.307Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.804399,0,0,0.804399,5.78037,0)">
<path d="M0,69.24C0,75.991 3.455,79.491 10.149,79.491L39.4,79.491C46.094,79.491 49.549,75.959 49.549,69.24L49.549,45.188C49.549,41.003 49.018,39.044 46.4,36.375L29.462,19.176C26.947,16.609 24.804,16.027 21.039,16.027L10.149,16.027C3.481,16.027 0,19.528 0,26.278L0,69.24ZM6.122,68.859L6.122,26.633C6.122,23.779 7.621,22.149 10.663,22.149L20.266,22.149L20.266,39.256C20.266,43.734 22.48,45.916 26.901,45.916L43.421,45.916L43.421,68.859C43.421,71.765 41.896,73.369 38.886,73.369L10.637,73.369C7.621,73.369 6.122,71.765 6.122,68.859ZM27.506,40.517C26.216,40.517 25.666,39.972 25.666,38.677L25.666,23.351L42.57,40.517L27.506,40.517Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.784149,0,0,0.784149,2.8024,2.29292)">
<g transform="matrix(1,0,0,1,1.52726,3.10699)">
<path d="M58.753,9.703L58.753,11.382L53.049,11.382L53.049,9.269C53.049,6.903 51.849,5.734 49.494,5.734L21.921,5.734C19.592,5.734 18.366,6.903 18.366,9.269L18.366,11.382L12.662,11.382L12.662,9.703C12.662,3.356 16.079,0.469 21.927,0.469L49.487,0.469C55.598,0.469 58.753,3.356 58.753,9.703Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,1.52726,3.10699)">
<path d="M71.415,21.061L71.415,49.816C71.415,56.191 67.983,59.496 61.633,59.496L58.388,59.496L58.388,53.961L61.639,53.961C64.204,53.961 65.557,52.614 65.557,50.044L65.557,20.839C65.557,18.269 64.204,16.911 61.639,16.911L9.801,16.911C7.211,16.911 5.884,18.269 5.884,20.839L5.884,50.044C5.884,52.614 7.211,53.961 9.801,53.961L13.027,53.961L13.027,59.496L9.807,59.496C3.432,59.496 0,56.191 0,49.816L0,21.061C0,14.712 3.694,11.382 9.807,11.382L61.633,11.382C67.983,11.382 71.415,14.712 71.415,21.061ZM58.554,24.233C58.554,26.468 56.711,28.285 54.527,28.285C52.292,28.285 50.474,26.468 50.474,24.233C50.474,22.049 52.292,20.206 54.527,20.206C56.711,20.206 58.554,22.049 58.554,24.233Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,1.52726,3.10699)">
<path d="M19.642,69.086L51.773,69.086C56.203,69.086 58.388,67.054 58.388,62.465L58.388,37.831C58.388,33.248 56.203,31.216 51.773,31.216L19.642,31.216C15.372,31.216 13.027,33.248 13.027,37.831L13.027,62.465C13.027,67.054 15.213,69.086 19.642,69.086ZM20.958,63.693C19.464,63.693 18.688,62.948 18.688,61.423L18.688,38.853C18.688,37.329 19.464,36.615 20.958,36.615L50.483,36.615C52.002,36.615 52.727,37.329 52.727,38.853L52.727,61.423C52.727,62.948 52.002,63.693 50.483,63.693L20.958,63.693ZM25.386,46.96L46.097,46.96C47.318,46.96 48.221,46.032 48.221,44.806C48.221,43.642 47.318,42.745 46.097,42.745L25.386,42.745C24.154,42.745 23.231,43.642 23.231,44.806C23.231,46.032 24.159,46.96 25.386,46.96ZM25.386,57.596L46.097,57.596C47.318,57.596 48.221,56.674 48.221,55.504C48.221,54.304 47.318,53.376 46.097,53.376L25.386,53.376C24.159,53.376 23.231,54.304 23.231,55.504C23.231,56.674 24.154,57.596 25.386,57.596Z" style="fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.804972,0,0,0.804972,2,13.2492)">
<path d="M10.251,46.567L64.286,46.567C70.946,46.567 74.537,43.028 74.537,36.419L74.537,25.612C74.537,21.168 73.402,17.984 70.78,15.337L60.737,5.006C57.346,1.558 54.946,0.02 50.458,0.02L24.084,0.02C19.622,0.02 17.197,1.558 13.826,5.006L3.757,15.337C1.121,18.035 0,21.089 0,25.612L0,36.419C0,43.028 3.591,46.567 10.251,46.567ZM10.528,40.574C7.661,40.574 6.044,39.008 6.044,36.039L6.044,29.648C6.044,27.744 6.941,26.821 8.819,26.821L65.718,26.821C67.596,26.821 68.493,27.744 68.493,29.648L68.493,36.039C68.493,39.008 66.876,40.574 64.009,40.574L10.528,40.574ZM8.292,21.549C7.221,21.549 6.919,20.566 7.559,19.921L19.028,7.799C20.779,5.964 22.053,5.291 24.419,5.291L50.124,5.291C52.484,5.291 53.758,5.964 55.515,7.799L67.004,19.921C67.618,20.566 67.342,21.549 66.245,21.549L8.292,21.549ZM12.847,36.651C14.503,36.651 15.898,35.276 15.898,33.599C15.898,31.943 14.503,30.574 12.847,30.574C11.171,30.574 9.801,31.943 9.801,33.599C9.801,35.276 11.171,36.651 12.847,36.651ZM21.209,37.622C22.246,37.622 23.134,36.81 23.134,35.794L23.134,31.61C23.134,30.579 22.246,29.767 21.209,29.767C20.193,29.767 19.381,30.579 19.381,31.61L19.381,35.794C19.381,36.81 20.193,37.622 21.209,37.622ZM27.271,37.622C28.308,37.622 29.196,36.81 29.196,35.794L29.196,31.61C29.196,30.579 28.308,29.767 27.271,29.767C26.261,29.767 25.449,30.579 25.449,31.61L25.449,35.794C25.449,36.81 26.261,37.622 27.271,37.622ZM33.806,35.517C34.849,35.517 35.698,34.667 35.698,33.599C35.698,32.551 34.849,31.708 33.806,31.708C32.758,31.708 31.909,32.551 31.909,33.599C31.909,34.667 32.758,35.517 33.806,35.517ZM40.361,37.622C41.366,37.622 42.178,36.81 42.178,35.794L42.178,31.61C42.178,30.579 41.366,29.767 40.361,29.767C39.319,29.767 38.431,30.579 38.431,31.61L38.431,35.794C38.431,36.81 39.319,37.622 40.361,37.622ZM46.423,37.622C47.459,37.622 48.246,36.81 48.246,35.794L48.246,31.61C48.246,30.579 47.459,29.767 46.423,29.767C45.387,29.767 44.518,30.579 44.518,31.61L44.518,35.794C44.518,36.81 45.387,37.622 46.423,37.622ZM53.651,35.581L63.098,35.581C64.146,35.581 64.946,34.704 64.946,33.656C64.946,32.629 64.146,31.834 63.098,31.834L53.651,31.834C52.624,31.834 51.823,32.629 51.823,33.656C51.823,34.704 52.624,35.581 53.651,35.581Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.990847,0,0,0.990847,0,0)">
<rect x="0" y="0" width="64.753" height="64.591" style="fill-opacity:0;"/>
<path d="M32.28,64.56C50.104,64.56 64.566,50.099 64.566,32.28C64.566,14.461 50.104,0 32.28,0C14.461,0 0,14.461 0,32.28C0,50.099 14.461,64.56 32.28,64.56ZM32.28,58.201C17.947,58.201 6.359,46.613 6.359,32.28C6.359,17.947 17.947,6.359 32.28,6.359C46.613,6.359 58.207,17.947 58.207,32.28C58.207,46.613 46.613,58.201 32.28,58.201Z" style="fill-rule:nonzero;"/>
<path d="M26.817,49.476L39.395,49.476C40.778,49.476 41.862,48.471 41.862,47.072C41.862,45.752 40.778,44.704 39.395,44.704L35.914,44.704L35.914,29.94C35.914,28.095 34.991,26.874 33.251,26.874L27.365,26.874C25.977,26.874 24.898,27.927 24.898,29.247C24.898,30.647 25.977,31.652 27.365,31.652L30.523,31.652L30.523,44.704L26.817,44.704C25.434,44.704 24.35,45.752 24.35,47.072C24.35,48.471 25.434,49.476 26.817,49.476ZM32.083,22.041C34.443,22.041 36.303,20.15 36.303,17.807C36.303,15.436 34.443,13.556 32.083,13.556C29.766,13.556 27.869,15.436 27.869,17.807C27.869,20.15 29.766,22.041 32.083,22.041Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.931914,0,0,0.931914,11.6234,3)">
<path d="M7.234,62.237L36.497,62.237C41.243,62.237 43.731,59.693 43.731,54.569L43.731,32.436C43.731,27.356 41.243,24.811 36.497,24.811L7.234,24.811C2.483,24.811 0,27.356 0,32.436L0,54.569C0,59.693 2.483,62.237 7.234,62.237ZM8.121,56.636C6.822,56.636 6.081,55.843 6.081,54.368L6.081,32.649C6.081,31.169 6.822,30.413 8.121,30.413L35.615,30.413C36.939,30.413 37.644,31.169 37.644,32.649L37.644,54.368C37.644,55.843 36.939,56.636 35.615,56.636L8.121,56.636ZM5.716,27.436L11.568,27.436L11.568,16.922C11.568,9.584 16.262,5.596 21.853,5.596C27.432,5.596 32.194,9.584 32.194,16.922L32.194,27.436L38.02,27.436L38.02,17.477C38.02,5.946 30.389,0 21.853,0C13.342,0 5.716,5.946 5.716,17.477L5.716,27.436Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'>
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" version="1.1" viewBox="0 0 72 70" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<path d="m17.549 64.222c-5.878-0.024-9.043-3.093-9.043-8.994v-41.37c0-5.9 3.433-8.994 9.113-8.994h36.389c5.899 0 9.088 3.094 9.088 8.994v41.37c0 5.924-3.189 8.994-9.088 8.994h-36.389-0.07zm7.075-5.012c-1.387 0-2.108-0.692-2.108-2.108v-1.937c0-1.416 0.721-2.08 2.108-2.08h22.378c1.411 0 2.085 0.664 2.085 2.08v1.937c0 1.416-0.674 2.108-2.085 2.108h-22.378zm-10.651-36.9v33.13c0 2.281 1.125 3.525 3.324 3.632-0.028-0.318-0.042-0.652-0.042-1.002v-3.855c0-4.258 2.179-6.146 6.147-6.146h24.798c4.116 0 6.147 1.888 6.147 6.146v3.855c0 0.35-0.014 0.685-0.041 1.003 2.193-0.1 3.347-1.345 3.347-3.633v-33.13h-3.306v0.74c0 4.861-2.148 7.801-6.179 8.699l-2.826 2.826c-0.818 0.818-2.145 0.818-2.963 0l-2.83-2.83c-4.033-0.902-6.175-3.841-6.175-8.695v-0.74h-19.401zm31.291-5.442h0.056c2.346 0.02 3.584 1.281 3.584 3.65v2.744c0 2.388-1.257 3.64-3.64 3.64h-2.783c-2.407 0-3.64-1.252-3.64-3.64v-2.744c0-2.388 1.233-3.65 3.64-3.65h2.783zm12.389 0.55v-3.766c0-2.388-1.257-3.651-3.64-3.651h-36.4c-2.407 0-3.64 1.263-3.64 3.651v3.766h19.834c1.087-3.743 4.178-5.687 8.68-5.687h2.771c4.676 0 7.65 1.944 8.682 5.687h3.713z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.913503,0,0,0.913503,1,1.02599)">
<path d="M30.765,67.814L37.106,67.814C39.686,67.814 41.73,66.21 42.301,63.74L43.571,58.211L44.403,57.924L49.226,60.88C51.404,62.214 53.95,61.894 55.787,60.057L60.179,55.702C62.038,53.848 62.325,51.271 60.991,49.13L57.983,44.343L58.291,43.569L63.808,42.261C66.253,41.691 67.871,39.621 67.871,37.066L67.871,30.901C67.871,28.352 66.278,26.308 63.808,25.706L58.342,24.373L58.009,23.548L61.016,18.761C62.351,16.62 62.089,14.068 60.204,12.189L55.813,7.808C54.001,5.997 51.455,5.639 49.278,6.985L44.454,9.941L43.571,9.602L42.301,4.074C41.73,1.598 39.686,0 37.106,0L30.765,0C28.184,0 26.141,1.609 25.57,4.074L24.274,9.602L23.391,9.941L18.593,6.985C16.416,5.651 13.844,5.997 12.033,7.808L7.666,12.189C5.781,14.068 5.494,16.62 6.854,18.761L9.836,23.548L9.529,24.373L4.062,25.706C1.581,26.314 0,28.352 0,30.901L0,37.066C0,39.621 1.618,41.691 4.062,42.261L9.58,43.569L9.862,44.343L6.88,49.13C5.52,51.271 5.832,53.848 7.692,55.702L12.058,60.057C13.895,61.894 16.467,62.214 18.644,60.88L23.443,57.924L24.274,58.211L25.57,63.74C26.141,66.21 28.184,67.814 30.765,67.814ZM31.541,62.151C31.015,62.151 30.745,61.927 30.657,61.444L28.804,53.744C26.825,53.298 24.926,52.498 23.413,51.53L16.646,55.684C16.276,55.959 15.856,55.934 15.518,55.539L12.184,52.211C11.841,51.868 11.826,51.498 12.065,51.083L16.236,44.367C15.376,42.874 14.541,40.998 14.064,39.018L6.364,37.197C5.881,37.109 5.657,36.839 5.657,36.313L5.657,31.629C5.657,31.078 5.856,30.839 6.364,30.745L14.038,28.898C14.527,26.822 15.448,24.877 16.184,23.524L12.039,16.808C11.775,16.367 11.784,15.998 12.128,15.628L15.492,12.352C15.856,11.988 16.199,11.963 16.646,12.207L23.356,16.284C24.773,15.438 26.837,14.589 28.824,14.064L30.657,6.37C30.745,5.887 31.015,5.662 31.541,5.662L36.33,5.662C36.856,5.662 37.12,5.887 37.188,6.37L39.072,14.115C41.099,14.609 42.916,15.43 44.463,16.309L51.183,12.218C51.66,11.974 51.972,11.994 52.368,12.363L55.701,15.639C56.07,16.009 56.059,16.378 55.794,16.819L51.661,23.524C52.417,24.871 53.318,26.822 53.801,28.887L61.506,30.745C61.989,30.839 62.214,31.078 62.214,31.629L62.214,36.313C62.214,36.839 61.958,37.109 61.506,37.197L53.776,39.029C53.298,40.992 52.489,42.891 51.609,44.367L55.763,51.072C56.008,51.487 56.019,51.856 55.649,52.2L52.342,55.528C51.972,55.923 51.578,55.943 51.183,55.673L44.443,51.53C42.903,52.498 41.113,53.276 39.072,53.744L37.188,61.444C37.12,61.927 36.856,62.151 36.33,62.151L31.541,62.151ZM33.927,45.757C40.487,45.757 45.777,40.467 45.777,33.907C45.777,27.347 40.487,22.057 33.927,22.057C27.372,22.057 22.077,27.347 22.077,33.907C22.077,40.467 27.372,45.757 33.927,45.757ZM33.927,40.396C30.341,40.396 27.438,37.498 27.438,33.907C27.438,30.316 30.341,27.418 33.927,27.418C37.518,27.418 40.416,30.316 40.416,33.907C40.416,37.498 37.518,40.396 33.927,40.396Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1.15242,0,0,1.15242,3,2.94855)">
<path d="M1.001,49.366C2.359,50.693 4.649,50.676 5.942,49.383L25.166,30.159L44.379,49.377C45.694,50.693 47.982,50.713 49.315,49.36C50.647,48.002 50.653,45.765 49.337,44.444L30.125,25.2L49.337,5.988C50.653,4.672 50.673,2.41 49.315,1.077C47.957,-0.281 45.694,-0.287 44.379,1.055L25.166,20.267L5.942,1.049C4.649,-0.264 2.333,-0.312 1.001,1.071C-0.326,2.43 -0.309,4.689 0.984,5.982L20.208,25.2L0.984,44.455C-0.309,45.743 -0.352,48.033 1.001,49.366Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1000 B

View File

@ -0,0 +1,40 @@
import PropTypes from "prop-types";
import { Space, Tag } from "antd";
import CheckIcon from "../icons/CheckIcon";
import XMarkIcon from "../icons/XMarkIcon";
const BoolDisplay = ({
value,
yesNo,
showIcon = true,
showText = true,
showColor = true,
}) => {
var falseText = "False";
var trueText = "True";
if (yesNo) {
falseText = "No";
trueText = "Yes";
}
return (
<Space>
<Tag
style={{ margin: 0 }}
color={showColor ? (value ? "success" : "error") : "default"}
icon={showIcon ? value ? <CheckIcon /> : <XMarkIcon /> : undefined}
>
{showText ? (value === true ? trueText : falseText) : null}
</Tag>
</Space>
);
};
BoolDisplay.propTypes = {
value: PropTypes.bool.isRequired,
yesNo: PropTypes.bool,
showIcon: PropTypes.bool,
showText: PropTypes.bool,
showColor: PropTypes.bool,
};
export default BoolDisplay;

View File

@ -0,0 +1,73 @@
import React from "react";
import PropTypes from "prop-types";
import { Button, Tooltip, message } from "antd";
import CopyIcon from "../icons/CopyIcon";
const CopyButton = ({
text,
tooltip = "Copy",
size = "small",
type = "text",
}) => {
const [messageApi, contextHolder] = message.useMessage();
const doCopy = (copyText) => {
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard
.writeText(copyText)
.then(() => {
messageApi.success("Copied to clipboard");
})
.catch(() => {
messageApi.error("Failed to copy");
});
} else if (
document.queryCommandSupported &&
document.queryCommandSupported("copy")
) {
// Legacy fallback
const textarea = document.createElement("textarea");
textarea.value = copyText;
textarea.setAttribute("readonly", "");
textarea.style.position = "absolute";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand("copy");
messageApi.success("Copied to clipboard");
} catch (err) {
messageApi.error("Failed to copy");
}
document.body.removeChild(textarea);
} else {
messageApi.error("Copy not supported in this browser");
}
};
return (
<>
{contextHolder}
<Tooltip title={tooltip} arrow={false}>
<Button
icon={<CopyIcon />}
style={{ minWidth: 25 }}
width={20}
size={size}
type={type}
onClick={() => doCopy(text)}
/>
</Tooltip>
</>
);
};
CopyButton.propTypes = {
text: PropTypes.string.isRequired,
style: PropTypes.object,
tooltip: PropTypes.string,
size: PropTypes.string,
type: PropTypes.string,
};
export default CopyButton;

View File

@ -0,0 +1,85 @@
import React from "react";
import PropTypes from "prop-types";
import { Typography, Descriptions, Flex } from "antd";
import CopyButton from "./CopyButton";
import BoolDislay from "./BoolDisplay";
import StateDislay from "./StateDisplay";
const { Text } = Typography;
const HostInformation = ({ host, bordered, size, column }) => {
return (
<Descriptions
bordere={bordered}
size={size}
column={column}
items={[
{
label: "ID",
children: (
<Flex style={{ minWidth: 0 }}>
<Text code ellipsis>
HST:{host?._id}
</Text>
<CopyButton />
</Flex>
),
},
{
label: "Name",
children: <Text>{host?.name}</Text>,
},
{
label: "Active",
children: <BoolDislay value={host?.active} yesNo={true} />,
},
{
label: "State",
children: <StateDislay state={host?.state} />,
},
{
label: "OS Platform",
children: <Text>{host?.deviceInfo?.os?.platform}</Text>,
},
{
label: "OS Type",
children: <Text>{host?.deviceInfo?.os?.type}</Text>,
},
{
label: "OS Release",
children: <Text>{host?.deviceInfo?.os?.release}</Text>,
},
{
label: "Arch",
children: <Text>{host?.deviceInfo?.os?.arch}</Text>,
},
{
label: "Hostname",
children: <Text>{host?.deviceInfo?.os?.hostname}</Text>,
},
{
label: "CPU Model",
children: <Text>{host?.deviceInfo?.cpu?.model}</Text>,
},
{
label: "CPU Cores",
children: <Text>{host?.deviceInfo?.cpu?.cores}</Text>,
},
{
label: "CPU Speed",
children: <Text>{host?.deviceInfo?.cpu?.speedMHz}MHz</Text>,
},
]}
/>
);
};
HostInformation.propTypes = {
text: PropTypes.string.isRequired,
style: PropTypes.object,
tooltip: PropTypes.string,
size: PropTypes.string,
type: PropTypes.string,
};
export default HostInformation;

View File

@ -0,0 +1,29 @@
import { Card, Flex, Typography } from "antd";
import InfoCircleIcon from "../icons/InfoCircleIcon";
import PropTypes from "prop-types";
const { Text } = Typography;
const MissingPlaceholder = ({ message }) => {
return (
<Card size="small">
<Flex
justify="center"
gap={"small"}
style={{ height: "100%" }}
align="center"
>
<Text type="secondary">
<InfoCircleIcon />
</Text>
<Text type="secondary">{message}</Text>
</Flex>
</Card>
);
};
MissingPlaceholder.propTypes = {
message: PropTypes.string.isRequired,
};
export default MissingPlaceholder;

View File

@ -0,0 +1,60 @@
import React from "react";
import PropTypes from "prop-types";
import { Typography, List, Button, Flex } from "antd";
import PrinterIcon from "../icons/PrinterIcon";
import InfoCircleIcon from "../icons/InfoCircleIcon";
import StateDisplay from "./StateDisplay";
import DocumentPrinterIcon from "../icons/DocumentPrinterIcon";
import MissingPlaceholder from "./MissingPlaceholder";
const { Text } = Typography;
const PrinterList = ({ printers, type = "printer" }) => {
if ((printers?.length || 0) <= 0) {
return (
<MissingPlaceholder
message={`No ${
type == "printer" ? "printers" : "document printers"
} added.`}
/>
);
}
return (
<List
dataSource={printers}
size="small"
bordered
renderItem={(printer) => (
<List.Item actions={[]}>
<List.Item.Meta
description={
<Flex gap={"middle"} justify="space-between" align="center">
<Flex gap={"small"}>
<Text>
{type == "printer" && <PrinterIcon />}
{type == "documentPrinter" && <DocumentPrinterIcon />}
</Text>
<Text>{printer.name || printer._id}</Text>
</Flex>
<Flex gap={"middle"} align="center">
<StateDisplay state={printer.state} />
<Button
key="info"
icon={<InfoCircleIcon />}
type="text"
size="small"
/>
</Flex>
</Flex>
}
/>
</List.Item>
)}
/>
);
};
PrinterList.propTypes = {
printers: PropTypes.array.isRequired,
};
export default PrinterList;

View File

@ -0,0 +1,36 @@
// PrinterSelect.js
import PropTypes from 'prop-types'
import { Progress, Flex, Space } from 'antd'
import StateTag from './StateTag'
const StateDisplay = ({ state, showProgress = true, showState = true }) => {
const currentState = state || {
type: 'unknown',
progress: 0
}
return (
<Flex gap='small' align={'center'}>
{showState && (
<Space>
<StateTag state={currentState.type} />
</Space>
)}
{showProgress && currentState?.progress && currentState?.progress > 0 ? (
<Progress
percent={Math.round(currentState.progress * 100)}
status='active'
style={{ width: '150px', marginBottom: '2px' }}
/>
) : null}
</Flex>
)
}
StateDisplay.propTypes = {
state: PropTypes.object,
showProgress: PropTypes.bool,
showState: PropTypes.bool
}
export default StateDisplay

View File

@ -0,0 +1,107 @@
import PropTypes from 'prop-types'
import { Badge, Flex, Tag } from 'antd'
import { useMemo } from 'react'
const StateTag = ({ state, showBadge = true, style = {} }) => {
const { badgeStatus, badgeText } = useMemo(() => {
let status = 'default'
let text = 'Unknown'
switch (state) {
case 'online':
status = 'success'
text = 'Online'
break
case 'standby':
status = 'success'
text = 'Standby'
break
case 'complete':
status = 'success'
text = 'Complete'
break
case 'offline':
status = 'default'
text = 'Offline'
break
case 'shutdown':
status = 'default'
text = 'Shutdown'
break
case 'initializing':
status = 'warning'
text = 'Initializing'
break
case 'printing':
status = 'processing'
text = 'Printing'
break
case 'paused':
status = 'warning'
text = 'Paused'
break
case 'cancelled':
status = 'error'
text = 'Cancelled'
break
case 'loading':
status = 'processing'
text = 'Uploading'
break
case 'processing':
status = 'processing'
text = 'Processing'
break
case 'ready':
status = 'success'
text = 'Ready'
break
case 'unconsumed':
status = 'success'
text = 'Unconsumed'
break
case 'error':
status = 'error'
text = 'Error'
break
case 'startup':
status = 'warning'
text = 'Startup'
break
case 'draft':
status = 'default'
text = 'Draft'
break
case 'failed':
status = 'error'
text = 'Failed'
break
case 'queued':
status = 'warning'
text = 'Queued'
break
default:
status = 'default'
text = state || 'Unknown'
}
return { badgeStatus: status, badgeText: text }
}, [state])
return (
<Tag color={badgeStatus} style={{ marginRight: 0, ...style }}>
<Flex gap={6}>
{showBadge && <Badge status={badgeStatus} />}
{badgeText}
</Flex>
</Tag>
)
}
StateTag.propTypes = {
state: PropTypes.string,
showBadge: PropTypes.bool,
style: PropTypes.object
}
export default StateTag

View File

@ -0,0 +1,6 @@
import Icon from "@ant-design/icons";
import CustomIconSvg from "../assets/icons/checkicon.svg?react";
const CheckIcon = (props) => <Icon component={CustomIconSvg} {...props} />;
export default CheckIcon;

View File

@ -0,0 +1,6 @@
import Icon from "@ant-design/icons";
import CustomIconSvg from "../assets/icons/cloudicon.svg?react";
const CloudIcon = (props) => <Icon component={CustomIconSvg} {...props} />;
export default CloudIcon;

View File

@ -0,0 +1,6 @@
import Icon from "@ant-design/icons";
import CustomIconSvg from "../assets/icons/copyicon.svg?react";
const CopyIcon = (props) => <Icon component={CustomIconSvg} {...props} />;
export default CopyIcon;

View File

@ -0,0 +1,8 @@
import Icon from "@ant-design/icons";
import CustomIconSvg from "../assets/icons/documentprintericon.svg?react";
const DocumentPrinterIcon = (props) => (
<Icon component={CustomIconSvg} {...props} />
);
export default DocumentPrinterIcon;

View File

@ -0,0 +1,6 @@
import Icon from "@ant-design/icons";
import CustomIconSvg from "../assets/icons/hosticon.svg?react";
const HostIcon = (props) => <Icon component={CustomIconSvg} {...props} />;
export default HostIcon;

View File

@ -0,0 +1,6 @@
import Icon from "@ant-design/icons";
import CustomIconSvg from "../assets/icons/infocircleicon.svg?react";
const InfoCircleIcon = (props) => <Icon component={CustomIconSvg} {...props} />;
export default InfoCircleIcon;

View File

@ -0,0 +1,6 @@
import Icon from "@ant-design/icons";
import CustomIconSvg from "../assets/icons/lockicon.svg?react";
const LockIcon = (props) => <Icon component={CustomIconSvg} {...props} />;
export default LockIcon;

View File

@ -0,0 +1,6 @@
import Icon from "@ant-design/icons";
import CustomIconSvg from "../assets/icons/printericon.svg?react";
const PrinterIcon = (props) => <Icon component={CustomIconSvg} {...props} />;
export default PrinterIcon;

View File

@ -0,0 +1,6 @@
import Icon from "@ant-design/icons";
import CustomIconSvg from "../assets/icons/settingsicon.svg?react";
const SettingsIcon = (props) => <Icon component={CustomIconSvg} {...props} />;
export default SettingsIcon;

View File

@ -0,0 +1,6 @@
import Icon from "@ant-design/icons";
import CustomIconSvg from "../assets/icons/xmarkicon.svg?react";
const XMarkIcon = (props) => <Icon component={CustomIconSvg} {...props} />;
export default XMarkIcon;

28
src/electron/index.css Normal file
View File

@ -0,0 +1,28 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
padding: 0;
min-width: 320px;
min-height: 100vh;
}
/* Remove default button styles to let Ant Design handle them */
button {
font-family: inherit;
}
/* Ensure proper theme support */
#root {
width: 100%;
height: 100%;
}

19
src/electron/index.html Normal file
View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="stylesheet" href="./fonts.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Farm Control Server</title>
<style>
.electron-navigation {
-webkit-app-region: drag;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.jsx"></script>
</body>
</html>

112
src/electron/ipc.js Normal file
View File

@ -0,0 +1,112 @@
import log4js from "log4js";
import { notPrompting } from "../utils.js";
const logger = log4js.getLogger("IPC");
let mainWindow = null;
export async function setupIPC() {
// Only import Electron if we're in an Electron environment
let ipcMain;
try {
const electron = await import("electron");
ipcMain = electron.ipcMain;
mainWindow = global.mainWindow;
} catch (error) {
logger.warn("Electron not available, skipping IPC setup");
return;
}
// Only proceed if we have ipcMain
if (!ipcMain) {
logger.warn("ipcMain not available, skipping IPC setup");
return;
}
// Generic IPC handler for custom messages
console.log("SETTING GET DATA HANDLER");
ipcMain.on("getData", (event) => {
logger.info("Getting data...");
try {
// Get the global socket client instance
const socketClient = global.socketClient;
if (!socketClient) {
sendIPC("setAuthenticated", false);
sendIPC("setConnected", false);
sendIPC("setLoading", false);
sendIPC("setHost", {});
sendIPC("setPrinters", []);
return;
}
// Send individual data pieces to match renderer expectations
sendIPC("setAuthenticated", socketClient.authenticated);
sendIPC("setConnected", socketClient.connected);
sendIPC("setLoading", socketClient.loading);
sendIPC("setHost", socketClient.host || {});
sendIPC("setPrinters", socketClient.printerManager.printers || []);
} catch (error) {
logger.error("Error getting printer data:", error);
sendIPC("setAuthenticated", false);
sendIPC("setConnected", false);
sendIPC("setLoading", false);
sendIPC("setHost", {});
sendIPC("setPrinters", []);
}
});
// Window management IPC handlers
ipcMain.on("window-minimize", (event) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.minimize();
}
});
ipcMain.on("window-maximize", (event) => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow.maximize();
}
}
});
ipcMain.on("window-close", (event) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.close();
}
});
// OTP authentication handler
ipcMain.on("authenticateOTP", async (event, otp) => {
logger.info("Authenticating with OTP...");
try {
const socketClient = global.socketClient;
if (socketClient) {
notPrompting();
await socketClient.authenticateWithOtp(otp);
} else {
logger.error("Socket client not available for OTP authentication");
}
} catch (error) {
logger.error("Error during OTP authentication:", error);
}
});
logger.info("IPC handlers setup complete");
}
export function sendIPC(channel, data) {
try {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(channel, data);
logger.info(`Message sent to main window on channel: ${channel}`, data);
} else {
logger.warn(
`No main window available, cannot send message on channel: ${channel}`
);
}
} catch (error) {
logger.error(`Error sending message on channel ${channel}:`, error);
}
}

11
src/electron/main.jsx Normal file
View File

@ -0,0 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.jsx";
import "antd/dist/reset.css";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>
);

View File

@ -0,0 +1,27 @@
import React from "react";
import { Flex, Result, Button } from "antd";
const Disconnected = ({}) => {
return (
<Flex
style={{ width: "100%", height: "100%" }}
justify="center"
align="center"
>
<Flex gap="large" align="center">
<Result
title="Disconnected From Server"
subTitle="The host cannot connect to the server."
>
<Flex justify="center">
<Button>Reconnect</Button>
</Flex>
</Result>
</Flex>
</Flex>
);
};
Disconnected.propTypes = {};
export default Disconnected;

View File

@ -0,0 +1,25 @@
import React from "react";
import { Typography, Spin, Flex } from "antd";
import { LoadingOutlined } from "@ant-design/icons";
const { Text } = Typography;
const Loading = ({}) => {
return (
<Flex
style={{ width: "100%", height: "100%" }}
justify="center"
align="center"
>
<Flex gap="large" align="center">
<Text style={{ fontSize: 32 }}>
<LoadingOutlined />
</Text>
</Flex>
</Flex>
);
};
Loading.propTypes = {};
export default Loading;

View File

@ -0,0 +1,46 @@
import React, { useState } from "react";
import { Flex, Result, Input } from "antd";
const OTPInput = ({}) => {
const [otp, setOtp] = useState("");
const handleOtpChange = (value) => {
setOtp(value);
// Check if all 6 digits have been entered
if (value.length === 6) {
// Send IPC command to authenticate with OTP
if (window.electronAPI) {
window.electronAPI.sendIPC("authenticateOTP", value);
}
}
};
return (
<Flex
style={{ width: "100%", height: "100%" }}
justify="center"
align="center"
>
<Flex gap="large" align="center">
<Result
title="Enter Passcode To Continue"
subTitle="Please enter a one time passcode in order to authenticate the host."
>
<Flex justify="center">
<Input.OTP
size="large"
value={otp}
onChange={handleOtpChange}
length={6}
/>
</Flex>
</Result>
</Flex>
</Flex>
);
};
OTPInput.propTypes = {};
export default OTPInput;

View File

@ -0,0 +1,67 @@
import React from "react";
import { Space, Card, Typography, Flex } from "antd";
import HostInformation from "../components/HostInformation.jsx";
import PrinterIcon from "../icons/PrinterIcon.jsx";
import HostIcon from "../icons/HostIcon.jsx";
import DocumentPrinterIcon from "../icons/DocumentPrinterIcon.jsx";
import PrinterList from "../components/PrinterList.jsx";
const { Text } = Typography;
const Overview = ({ loading, host, printers, documentPrinters }) => {
return (
<Flex
vertical
size="large"
style={{ width: "100%", height: "100%" }}
gap={"middle"}
>
<Card
title={
<Space>
<HostIcon />
Host Information
</Space>
}
size="small"
style={{ minWidth: "400px", flexShrink: 1 }}
>
<HostInformation
host={host}
size={"small"}
column={{ xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }}
/>
</Card>
<Flex gap={"middle"} wrap style={{ flexGrow: 1 }}>
<Card
title={
<Space>
<PrinterIcon />
Printers
</Space>
}
size="small"
style={{ minWidth: "400px", flexGrow: 1, flex: "1 1 0%" }}
>
<PrinterList printers={printers} type="printer" />
</Card>
<Card
title={
<Space>
<DocumentPrinterIcon />
Document Printers
</Space>
}
size="small"
style={{ minWidth: "400px", flexGrow: 1, flex: "1 1 0%" }}
>
<PrinterList printers={documentPrinters} type="documentPrinter" />
</Card>
</Flex>
</Flex>
);
};
Overview.propTypes = {};
export default Overview;

View File

@ -0,0 +1,25 @@
import React from "react";
import { Space, Card, Flex } from "antd";
import PrinterIcon from "../icons/PrinterIcon.jsx";
import PrinterList from "../components/PrinterList.jsx";
const Printers = ({ printers }) => {
return (
<Flex
vertical
size="large"
style={{ width: "100%", height: "100%" }}
gap={"middle"}
>
<Flex gap={"middle"} wrap style={{ flexGrow: 1 }}>
<Card size="small" style={{ minWidth: "400px", flexGrow: 1 }}>
<PrinterList printers={printers} type="printer" />
</Card>
</Flex>
</Flex>
);
};
Printers.propTypes = {};
export default Printers;

21
src/electron/preload.js Normal file
View File

@ -0,0 +1,21 @@
import { contextBridge, ipcRenderer } from "electron";
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld("electronAPI", {
onIPCData: (channel, callback) => {
ipcRenderer.on(channel, (event, data) => callback(data));
},
// Send messages to main process
sendIPC: (channel, data) => {
console.log("SEND IPC", channel);
ipcRenderer.send(channel, data);
},
// Window management
minimize: () => ipcRenderer.send("window-minimize"),
maximize: () => ipcRenderer.send("window-maximize"),
close: () => ipcRenderer.send("window-close"),
removeAllListeners: (channel) => {
ipcRenderer.removeAllListeners(channel);
},
});

View File

@ -0,0 +1,119 @@
@font-face {
font-family: 'DM Sans';
src:
url('./DMSans-Regular.woff2') format('woff2'),
url('./DMSans-Regular.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'DM Sans';
src:
url('./DMSans-Bold.woff2') format('woff2'),
url('./DMSans-Bold.woff') format('woff');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'DM Sans';
src:
url('./DMSans-Italic.woff2') format('woff2'),
url('./DMSans-Italic.woff') format('woff');
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'DM Sans';
src:
url('./DMSans-Medium.woff2') format('woff2'),
url('./DMSans-Medium.woff') format('woff');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'DM Sans';
src:
url('./DMSans-MediumItalic.woff2') format('woff2'),
url('./DMSans-MediumItalic.woff') format('woff');
font-weight: 500;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'DM Sans';
src:
url('./DMSans-BoldItalic.woff2') format('woff2'),
url('./DMSans-BoldItalic.woff') format('woff');
font-weight: 700;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'DM Mono';
src:
url('./DMMono-Regular.woff2') format('woff2'),
url('./DMMono-Regular.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'DM Mono';
src:
url('./DMMono-Medium.woff2') format('woff2'),
url('./DMMono-Medium.woff') format('woff');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'DM Mono';
src:
url('./DMMono-MediumItalic.woff2') format('woff2'),
url('./DMMono-MediumItalic.woff') format('woff');
font-weight: 500;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'DM Mono';
src:
url('./DMMono-Light.woff2') format('woff2'),
url('./DMMono-Light.woff') format('woff');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'DM Mono';
src:
url('./DMMono-LightItalic.woff2') format('woff2'),
url('./DMMono-LightItalic.woff') format('woff');
font-weight: 300;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'DM Mono';
src:
url('./DMMono-Italic.woff2') format('woff2'),
url('./DMMono-Italic.woff') format('woff');
font-weight: 400;
font-style: italic;
font-display: swap;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
import svgr from "vite-plugin-svgr";
import svgo from "vite-plugin-svgo";
export default defineConfig({
plugins: [react(), svgr(), svgo()],
root: path.resolve(__dirname),
build: {
outDir: path.resolve(__dirname, "../../build/electron"),
emptyOutDir: true,
rollupOptions: {
input: {
main: path.resolve(__dirname, "index.html"),
},
},
},
server: {
port: 5173,
},
});

91
src/electron/window.js Normal file
View File

@ -0,0 +1,91 @@
/* eslint-disable no-undef */
// Remove CommonJS requires and use ES module imports
import path from "path";
import { loadConfig } from "../config.js";
import log4js from "log4js";
import { fileURLToPath } from "url";
// Load configuration
const config = loadConfig();
const logger = log4js.getLogger("Electron");
logger.level = config.logLevel;
export async function createElectronWindow() {
// Only import Electron if we're in an Electron environment
let app, BrowserWindow;
try {
const electron = (await import("electron")).default;
app = electron.app;
BrowserWindow = electron.BrowserWindow;
logger.trace("Imported electron");
} catch (error) {
logger.warn(
"Electron not available, skipping window creation. Error:",
error
);
return;
}
// Only proceed if we have app
if (!app) {
logger.warn("Electron app not available, skipping window creation");
return;
}
// __dirname workaround for ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
return new Promise((resolve) => {
function createWindow() {
logger.debug("Creating browser window...");
const win = new BrowserWindow({
width: 900,
height: 600,
resizable: true,
frame: false,
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 14, y: 12 },
webPreferences: {
nodeIntegration: true,
contextIsolation: true,
preload:
process.env.NODE_ENV === "development"
? path.join(__dirname, "preload.js")
: path.join(__dirname, "..", "build", "electron", "preload.js"),
},
});
// Make the window globally accessible for IPC
global.mainWindow = win;
logger.info("Preload Script", path.join(__dirname, "preload.js"));
if (process.env.NODE_ENV === "development") {
logger.info("Loading development url...");
win.loadURL("http://localhost:5173"); // Vite dev server
} else {
// In production, the built files will be in the build/electron directory
win.loadFile(
path.join(__dirname, "..", "build", "electron", "index.html")
);
}
// Resolve the promise when the window is ready
win.webContents.on("did-finish-load", () => {
resolve(win);
});
}
app.whenReady().then(() => {
createWindow();
app.on("activate", function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on("window-all-closed", function () {
if (process.platform !== "darwin") app.quit();
});
});
}

5
src/host/hostmanager.js Normal file
View File

@ -0,0 +1,5 @@
export class HostManager {
constructor(socketClient) {
this.socketClient = socketClient;
}
}

View File

@ -1,38 +1,36 @@
import { loadConfig } from "./config.js";
import { dbConnect } from "./database/mongo.js";
import { PrinterManager } from "./printer/printermanager.js";
import { SocketManager } from "./socket/socketmanager.js";
import { KeycloakAuth } from "./auth/auth.js";
import express from "express";
import log4js from "log4js";
import { createElectronWindow } from "./electron/window.js";
import { setupIPC } from "./electron/ipc.js";
import { SocketClient } from "./socket/socketclient.js";
// Load configuration
const config = loadConfig();
const logger = log4js.getLogger("FarmControl Server");
logger.level = config.server.logLevel;
const logger = log4js.getLogger("App");
logger.level = config.logLevel;
// Create Express app
const app = express();
// Connect to database
dbConnect();
// Setup Keycloak Integration
const keycloakAuth = new KeycloakAuth(config);
// Create printer manager
const printerManager = new PrinterManager(config);
const socketManager = new SocketManager(config, printerManager, keycloakAuth);
printerManager.setSocketManager(socketManager);
// Start Express server
app.listen(config.server.port, () => {
logger.info(`Server listening on port ${config.server.port}`);
export async function init() {
// Create Electron window first
logger.info("Creating electron window...");
await createElectronWindow().catch((err) => {
logger.warn("Failed to create Electron window:", err);
});
// Setup IPC communication after window is created
setupIPC().catch((err) => {
logger.warn("Failed to setup IPC:", err);
});
const socketClient = new SocketClient();
// Make socket client globally accessible for IPC handlers
global.socketClient = socketClient;
socketClient.connect();
process.on("SIGINT", () => {
logger.info("Shutting down...");
printerManager.closeAllConnections();
socketClient.disconnect();
process.exit(0);
});
}
init();

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@ import log4js from "log4js";
const config = loadConfig();
const logger = log4js.getLogger("JSON RPC");
logger.level = config.server.logLevel;
logger.level = config.logLevel;
export class JsonRPC {
constructor() {
@ -27,7 +27,6 @@ export class JsonRPC {
// Process incoming messages
processMessage(message) {
if (message.method && this.methods[message.method]) {
// Handle method call or notification
this.methods[message.method](message.params);

View File

@ -2,104 +2,98 @@
import { JsonRPC } from "./jsonrpc.js";
import { WebSocket } from "ws";
import { loadConfig } from "../config.js";
import { printerModel } from "../database/printer.schema.js";
import { printSubJobModel } from "../database/printsubjob.schema.js";
import { PrinterDatabase } from "./database.js";
import log4js from "log4js";
import axios from "axios";
import FormData from "form-data";
import _ from "lodash";
// Load configuration
const config = loadConfig();
const logger = log4js.getLogger("Printer Client");
logger.level = config.server.logLevel;
logger.level = config.logLevel;
export class PrinterClient {
constructor(printer, printerManager, socketManager) {
this.id = printer.id;
this.name = printer.name;
constructor(printer, printerManager) {
this.id = printer._id;
this.printer = printer;
this.printerManager = printerManager;
this.socketManager = socketManager;
this.state = { type: 'offline '};
this.klippyState = { type: 'offline'};
this.socketClient = printerManager.socketClient;
this.database = new PrinterDatabase(this.socketClient, this.printer);
this.state = { type: "offline " };
this.klippyState = { type: "offline" };
this.config = printer.moonraker;
this.version = printer.version;
this.jsonRpc = new JsonRPC();
this.socket = null;
this.connectionId = null;
this.currentJobId = null;
this.temperatureObject = {};
this.currentJobState = { type: "unknown", progress: 0 };
this.currentSubJobId = null;
this.currentSubJobState = null;
this.currentFilamentStockId = printer.currentFilamentStock?._id.toString() || null;
this.currentFilamentStockDensity = printer.currentFilamentStock?.filament?.density || null
this.currentFilamentStockId =
printer.currentFilamentStock?._id.toString() || null;
this.currentFilamentStockDensity =
printer.currentFilamentStock?.filament?.density || null;
this.registerEventHandlers();
this.subscribeToActions();
this.baseSubscription = {
print_stats: null,
display_status: null,
'filament_switch_sensor fsensor': null,
output_pin: null
"filament_switch_sensor fsensor": null,
output_pin: null,
extruder: null,
heater_bed: null,
};
this.subscriptions = new Map();
this.queuedJobIds = [];
this.isOnline = printer.online;
this.subJobIsCancelling = false;
this.subJobCancelId = null;
this.database = new PrinterDatabase(socketManager);
this.filamentDetected = false;
}
registerEventHandlers() {
// Register event handlers for Moonraker notifications
this.jsonRpc.registerMethod(
"notify_gcode_response",
"notify_gcode_response"
//this.handleGcodeResponse.bind(this),
);
this.jsonRpc.registerMethod(
"notify_status_update",
this.handleStatusUpdate.bind(this),
this.handleStatusUpdate.bind(this)
);
this.jsonRpc.registerMethod(
"notify_klippy_disconnected",
this.handleKlippyDisconnected.bind(this),
this.handleKlippyDisconnected.bind(this)
);
this.jsonRpc.registerMethod(
"notify_klippy_ready",
this.handleKlippyReady.bind(this),
this.handleKlippyReady.bind(this)
);
this.jsonRpc.registerMethod(
"notify_filelist_changed",
this.handleFileListChanged.bind(this),
this.handleFileListChanged.bind(this)
);
this.jsonRpc.registerMethod(
"notify_metadata_update",
this.handleMetadataUpdate.bind(this),
this.handleMetadataUpdate.bind(this)
);
this.jsonRpc.registerMethod(
"notify_power_changed",
this.handlePowerChanged.bind(this),
this.handlePowerChanged.bind(this)
);
}
async getPrinterConnectionConfig() {
try {
const config = await this.database.getPrinterConfig(this.id);
if (config) {
this.config = config;
logger.info(`Reloaded connection config! (${this.name})`);
logger.debug(this.config);
}
} catch (error) {
logger.error(
`Failed to get printer connection config! (${this.name}):`,
error,
);
}
subscribeToActions() {
this.socketClient.subscribeToObjectActions({
objectType: "printer",
_id: this.id,
});
}
async connect() {
await this.getPrinterConnectionConfig();
const { protocol, host, port } = this.config;
const wsUrl = `${protocol}://${host}:${port}/websocket`;
@ -109,8 +103,10 @@ export class PrinterClient {
this.jsonRpc.setSocket(this.socket);
await this.database.clearAlerts();
this.socket.on("open", () => {
logger.info(`Connected to Moonraker (${this.name})`);
logger.info(`Connected to Moonraker (${this.printer.name})`);
this.isOnline = true;
this.identifyConnection();
});
@ -120,7 +116,7 @@ export class PrinterClient {
});
this.socket.on("close", () => {
logger.info(`Disconnected from Moonraker (${this.name})`);
logger.info(`Disconnected from Moonraker (${this.printer.name})`);
this.isOnline = false;
this.state = { type: "offline" };
this.updatePrinterState();
@ -130,7 +126,7 @@ export class PrinterClient {
});
this.socket.on("error", (error) => {
logger.error(`Moonraker connection error (${this.name}):`, error);
logger.error(`Moonraker connection error (${this.printer.name}):`, error);
});
}
@ -146,19 +142,22 @@ export class PrinterClient {
args.api_key = this.config.apiKey;
}
logger.debug(`Identifying connection... (${this.name})`);
logger.debug(`Identifying connection... (${this.printer.name})`);
this.jsonRpc
.callMethodWithKwargs("server.connection.identify", args)
.then(async (result) => {
this.connectionId = result.connection_id;
logger.info(
`Connection identified with ID: ${this.connectionId} (${this.name})`,
`Connection identified with ID: ${this.connectionId} (${this.printer.name})`
);
await this.initialize();
})
.catch((error) => {
logger.error(`Error identifying connection (${this.name}):`, error);
logger.error(
`Error identifying connection (${this.printer.name}):`,
error
);
});
}
@ -179,54 +178,62 @@ export class PrinterClient {
this.klippyState = { type: serverResult.klippy_state };
logger.info(
"Server:",
`Moonraker ${serverResult.moonraker_version} (${this.name})`,
`State: ${this.klippyState.type}`,
`Moonraker ${serverResult.moonraker_version} (${this.printer.name})`,
`State: ${this.klippyState.type}`
);
try {
const klippyResult = await this.jsonRpc.callMethod("printer.info");
logger.info(
`Klippy info for ${this.name}: ${klippyResult.hostname}, ${klippyResult.software_version}`,
`Klippy info for ${this.printer.name}: ${klippyResult.hostname}, ${klippyResult.software_version}`
);
// Update firmware version in database
await this.database.updatePrinterFirmware(this.id, klippyResult.software_version);
await this.database.updatePrinterFirmware(
klippyResult.software_version
);
logger.info(
`Updated firmware version for ${this.name} to ${klippyResult.software_version}`,
`Updated firmware version for ${this.printer.name} to ${klippyResult.software_version}`
);
if (klippyResult.state === "error" && klippyResult.state_message) {
logger.error(
`Klippy error for ${this.name}: ${klippyResult.state_message}`,
this.database.addAlert(this.id, {
type: "klippyError",
`Klippy error for ${this.printer.name}: ${klippyResult.state_message}`,
this.database.addAlert({
type: "error",
message: klippyResult.state_message,
priority: 9,
timestamp: new Date()
timestamp: new Date(),
})
);
}
if (klippyResult.state === "startup" && klippyResult.state_message) {
logger.warn(
`Klippy startup message for ${this.name}: ${klippyResult.state_message}`,
this.database.addAlert(this.id, {
type: "klippyStartup",
`Klippy startup message for ${this.printer.name}: ${klippyResult.state_message}`,
this.database.addAlert({
type: "info",
message: klippyResult.state_message,
priority: 8,
timestamp: new Date()
timestamp: new Date(),
})
);
}
} catch (error) {
logger.error(`Error getting Klippy info (${this.name}):`, error);
logger.error(
`Error getting Klippy info for ${this.printer.name}:`,
error
);
}
} catch (error) {
logger.error(`Error getting server info (${this.name}):`, error);
logger.error(
`Error getting server info for ${this.printer.name}:`,
error
);
}
}
async getQueuedJobsInfo() {
logger.info(`Getting queued jobs info for (${this.name})`);
logger.info(`Getting queued jobs info for (${this.printer.name})`);
const result = await this.sendPrinterCommand({
method: "server.job_queue.status",
});
@ -236,23 +243,23 @@ export class PrinterClient {
}
async getPrinterState() {
logger.info(`Getting state of (${this.name})`);
logger.info(`Getting state of (${this.printer.name})`);
if (!this.isOnline) {
logger.error(
`Cannot send command: Not connected to Moonraker (${this.name})`,
`Cannot send command: Not connected to Moonraker. (${this.printer.name})`
);
return false;
}
if (this.klippyState.type === "error") {
logger.error(`Klippy is reporting error for ${this.name}`);
logger.error(`Klippy is reporting error for ${this.printer.name}`);
this.state = this.klippyState;
this.updatePrinterState();
return;
}
if (this.klippyState.type === "shutdown") {
logger.error(`Klippy is reporting shutdown for ${this.name}`);
logger.error(`Klippy is reporting shutdown for ${this.printer.name}.`);
this.state = this.klippyState;
this.updatePrinterState();
return;
@ -261,21 +268,21 @@ export class PrinterClient {
try {
const result = await this.jsonRpc.callMethodWithKwargs(
"printer.objects.query",
{ objects: this.baseSubscription },
{ objects: this.baseSubscription }
);
logger.debug(`Command sent to (${this.name})`);
logger.debug(`Command sent to ${this.printer.name}`);
if (result.status != undefined) {
this.handleStatusUpdate([result.status]);
}
return result;
} catch (error) {
logger.error(`Error sending command to (${this.name}):`, error);
logger.error(`Error sending command to ${this.printer.name}:`, error);
return false;
}
}
async updateSubscriptions() {
logger.info(`Updating subscriptions for (${this.name})`);
logger.info(`Updating subscriptions for (${this.printer.name})`);
// Start with base subscription content
const allSubscriptions = { ...this.baseSubscription };
@ -285,11 +292,14 @@ export class PrinterClient {
Object.assign(allSubscriptions, value);
}
logger.debug("Combined subscriptions:", Object.keys(allSubscriptions).join(", "));
logger.debug(
"Combined subscriptions:",
Object.keys(allSubscriptions).join(", ")
);
if (!this.isOnline) {
logger.error(
`Cannot send command: Not connected to Moonraker (${this.name})`,
`Cannot send command: Not connected to Moonraker (${this.printer.name})`
);
return false;
}
@ -298,22 +308,22 @@ export class PrinterClient {
await this.jsonRpc.callMethodWithKwargs("printer.objects.subscribe", {
objects: allSubscriptions,
});
logger.debug(`Command sent to (${this.name})`);
logger.debug(`Command sent to ${this.printer.name}`);
logger.debug({
objects: allSubscriptions,
})
});
return true;
} catch (error) {
logger.error(`Error sending command to (${this.name}):`, error);
logger.error(`Error sending command to (${this.printer.name}):`, error);
return false;
}
}
async sendPrinterCommand(command) {
logger.info(`Sending ${command.method} command to (${this.name})`);
logger.info(`Sending ${command.method} command to (${this.printer.name})`);
if (!this.isOnline) {
logger.error(
`Cannot send command: Not connected to Moonraker (${this.name})`,
`Cannot send command: Not connected to Moonraker (${this.printer.name})`
);
return false;
}
@ -321,9 +331,9 @@ export class PrinterClient {
try {
const result = await this.jsonRpc.callMethodWithKwargs(
command.method,
command.params,
command.params
);
logger.debug(`Command sent to (${this.name})`);
logger.debug(`Command sent to ${this.printer.name}`);
if (result.status != undefined) {
if (command.method == "printer.objects.query") {
this.handleStatusUpdate([result.status]);
@ -331,7 +341,7 @@ export class PrinterClient {
}
return result;
} catch (error) {
logger.error(`Error sending command to (${this.name}):`, error);
logger.error(`Error sending command to (${this.printer.name}):`, error);
return false;
}
}
@ -350,7 +360,9 @@ export class PrinterClient {
if (status.print_stats?.state) {
const newState = status.print_stats.state;
if (newState !== this.state.type) {
logger.info(`Printer ${this.name} state changed from ${this.state.type} to ${newState}`);
logger.info(
`Printer ${this.printer.name} state changed from ${this.state.type} to ${newState}`
);
this.state.type = newState;
stateChanged = true;
}
@ -361,86 +373,152 @@ export class PrinterClient {
const filamentLengthCm = status.print_stats.filament_used / 10;
const filamentDiameterCm = 0.175; // 1.75mm in cm
const filamentRadiusCm = filamentDiameterCm / 2;
const filamentVolumeCm3 = Math.PI * Math.pow(filamentRadiusCm, 2) * filamentLengthCm;
const filamentVolumeCm3 =
Math.PI * Math.pow(filamentRadiusCm, 2) * filamentLengthCm;
// Calculate weight in grams
const filamentWeightG = filamentVolumeCm3 * this.currentFilamentStockDensity;
const filamentWeightG =
filamentVolumeCm3 * this.currentFilamentStockDensity;
if (this.currentSubJobId != null && this.currentJobId != null && this.currentFilamentStockId != null) {
this.database.updateFilamentStockWeight(this.currentFilamentStockId, (-1 * filamentWeightG), this.currentSubJobId, this.currentJobId);
if (
this.currentSubJobId != null &&
this.currentJobId != null &&
this.currentFilamentStockId != null
) {
this.database.updateFilamentStockWeight(
this.currentFilamentStockId,
-1 * filamentWeightG,
this.currentSubJobId,
this.currentJobId
);
}
}
if (status.display_status?.progress !== undefined) {
const newProgress = status.display_status.progress;
if (newProgress !== this.state.progress) {
logger.info(`Printer ${this.name} progress changed from ${this.state.progress} to ${newProgress}`);
logger.info(
`Printer ${this.printer.name} progress changed from ${this.state.progress} to ${newProgress}`
);
this.state.progress = newProgress;
progressChanged = true;
}
if (status.display_status.message) {
await this.database.updateDisplayStatus(this.id, status.display_status.message);
await this.database.updateDisplayStatus(status.display_status.message);
}
}
const temperatureChanged =
status.extruder?.temperature !== undefined ||
status.hot_end?.temperature !== undefined;
if (status.extruder?.temperature !== undefined) {
_.merge(this.temperatureObject, {
extruder: { current: status.extruder.temperature },
});
}
if (status.extruder?.target !== undefined) {
_.merge(this.temperatureObject, {
extruder: { target: status.extruder.target },
});
}
if (status.heater_bed?.temperature !== undefined) {
_.merge(this.temperatureObject, {
bed: { current: status.heater_bed.temperature },
});
}
if (status.heater_bed?.target !== undefined) {
_.merge(this.temperatureObject, {
bed: { target: status.heater_bed.target },
});
}
if (temperatureChanged) {
this.socketClient.objectEvent({
objectType: "printer",
_id: this.id,
eventType: "temperature",
eventData: this.temperatureObject,
});
}
// Handle filament switch sensor
if (status['filament_switch_sensor fsensor']?.filament_detected !== undefined) {
const newFilamentDetected = status['filament_switch_sensor fsensor'].filament_detected;
if (
status["filament_switch_sensor fsensor"]?.filament_detected !== undefined
) {
const newFilamentDetected =
status["filament_switch_sensor fsensor"].filament_detected;
if (newFilamentDetected !== this.filamentDetected) {
logger.info(`Printer ${this.name} filament detection changed from ${this.filamentDetected} to ${newFilamentDetected} with no currentFilamentId`);
logger.info(
`Printer ${this.printer.name} filament detection changed from ${this.filamentDetected} to ${newFilamentDetected} with no currentFilamentId`
);
this.filamentDetected = newFilamentDetected;
if (newFilamentDetected == true && this.currentFilamentStockId == null) {
await this.database.addAlert(this.id, {
type: "loadFilamentStock",
if (
newFilamentDetected == true &&
this.currentFilamentStockId == null
) {
await this.database.addAlert({
type: "info",
message:
"No filament loaded. Please load filament to continue printing.",
priority: 1,
timestamp: new Date()
timestamp: new Date(),
});
} else if (newFilamentDetected == false && this.currentFilamentStockId != null) {
this.currentFilamentStockId = null
await this.database.setCurrentFilamentStock(this.id, null)
} else if (
newFilamentDetected == false &&
this.currentFilamentStockId != null
) {
this.currentFilamentStockId = null;
await this.database.setCurrentFilamentStock(null);
// Remove filament select alert if it exists
await this.database.removeAlerts(this.id, {type:"loadFilamentStock"});
await this.database.removeAlerts({
type: "loadFilamentStock",
});
}
}
}
if (stateChanged || progressChanged) {
// Update printer state first
await this.updatePrinterState()
await this.updatePrinterState();
// Set current job to null when not printing or paused
if (!["printing", "paused"].includes(this.state.type)) {
this.currentJobId = null;
this.currentSubJobId = null;
await this.database.clearCurrentJob(this.id, `Printer is in ${this.state.type} state`);
await this.database.clearCurrentJob(
this.id,
`Printer is in ${this.state.type} state`
);
await this.getQueuedJobsInfo();
} else {
// If we have a current subjob, update its state
if (this.currentSubJobId) {
logger.debug(`Updating current subjob ${this.currentSubJobId} state:`, this.state);
await this.database.updateSubJobState(this.currentSubJobId, this.state);
logger.debug(
`Updating current subjob ${this.currentSubJobId} state:`,
this.state
);
await this.database.updateSubJobState(
this.currentSubJobId,
this.state
);
} else {
// If no current subjob but we have queued jobs, check if we need to update printer subjobs
await this.getQueuedJobsInfo();
}
}
}
this.socketManager.broadcastToSubscribers(this.id, {
method: "notify_status_update",
params: status,
});
}
async updatePrinterState() {
try {
const state = this.klippyState.type !== 'ready' ? this.klippyState : this.state;
await this.database.updatePrinterState(this.id, state, this.isOnline);
const state =
this.klippyState.type !== "ready" ? this.klippyState : this.state;
this.database.updatePrinterState(state, this.isOnline);
} catch (error) {
logger.error(`Failed to update printer state:`, error);
}
@ -448,7 +526,7 @@ this.database.updateFilamentStockWeight(this.currentFilamentStockId, (-1 * filam
async removePrinterSubJob(subJobId) {
try {
await this.database.removePrinterSubJob(this.id, subJobId);
await this.database.removePrinterSubJob(subJobId);
} catch (error) {
logger.error(`Failed to remove subjob:`, error);
}
@ -460,18 +538,20 @@ this.database.updateFilamentStockWeight(this.currentFilamentStockId, (-1 * filam
queuedJobIds: this.queuedJobIds,
currentSubJobId: this.currentSubJobId,
currentJobId: this.currentJobId,
printerState: this.state.type
printerState: this.state.type,
});
const printer = await printerModel.findById(this.id).populate('subJobs');
const printer = await this.database.getPrinter();
const subJobs = printer.subJobs
const subJobs = printer.subJobs;
// If printer is not printing or paused, clear current job/subjob
if (!["printing", "paused"].includes(this.state.type)) {
if (this.currentSubJobId || this.currentJobId) {
logger.info(`Clearing current job/subjob for printer ${this.name} as state is ${this.state.type}`);
await this.database.clearCurrentJob(this.id);
logger.info(
`Clearing current job/subjob for printer ${this.printer.name} as state is ${this.state.type}`
);
await this.database.clearCurrentJob();
this.currentSubJobId = null;
this.currentJobId = null;
}
@ -481,32 +561,45 @@ this.database.updateFilamentStockWeight(this.currentFilamentStockId, (-1 * filam
const sortedSubJobs = subJobs.sort((a, b) => a.number - b.number);
// Find subjobs that are in queued state
const queuedSubJobs = sortedSubJobs.filter(subJob =>
const queuedSubJobs = sortedSubJobs.filter(
(subJob) =>
subJob.state.type === "queued" &&
this.queuedJobIds.includes(subJob.subJobId)
);
// Find subjobs that are not in queued state but should be
const missingQueuedSubJobs = sortedSubJobs.filter(subJob =>
const missingQueuedSubJobs = sortedSubJobs.filter(
(subJob) =>
subJob.state.type === "queued" &&
!this.queuedJobIds.includes(subJob.subJobId)
);
// If we have missing queued jobs and printer is in standby, mark them as failed
if (missingQueuedSubJobs.length > 0 && this.state.type === "standby") {
logger.warn(`Found ${missingQueuedSubJobs.length} missing queued jobs for printer ${this.name} in standby state`);
logger.warn(
`Found ${missingQueuedSubJobs.length} missing queued jobs for printer ${this.printer.name} in standby state`
);
for (const subJob of missingQueuedSubJobs) {
logger.info(`Marking missing queued subjob ${subJob.id} as failed`);
await this.database.updateSubJobState(subJob.id, { type: "failed" });
await this.database.updateSubJobState(subJob.id, {
type: "failed",
});
}
}
// If we have a current subjob, verify it's still valid
if (this.currentSubJobId) {
const currentSubJob = sortedSubJobs.find(sj => sj.id === this.currentSubJobId);
if (!currentSubJob || !this.queuedJobIds.includes(currentSubJob.subJobId)) {
logger.info(`Current subjob ${this.currentSubJobId} is no longer valid, clearing it`);
await this.database.clearCurrentJob(this.id);
const currentSubJob = sortedSubJobs.find(
(sj) => sj.id === this.currentSubJobId
);
if (
!currentSubJob ||
!this.queuedJobIds.includes(currentSubJob.subJobId)
) {
logger.info(
`Current subjob ${this.currentSubJobId} is no longer valid, clearing it`
);
await this.database.clearCurrentJob();
this.currentSubJobId = null;
this.currentJobId = null;
}
@ -514,12 +607,21 @@ this.database.updateFilamentStockWeight(this.currentFilamentStockId, (-1 * filam
// If we don't have a current subjob but have queued jobs, find the first one
if (!this.currentSubJobId) {
const result = await this.database.setCurrentJobForPrinting(this.id, this.queuedJobIds);
const result = await this.database.setCurrentJobForPrinting(
this.id,
this.queuedJobIds
);
if (result) {
logger.info(`Setting first queued subjob as current for printer ${this.name}: `, result.currentSubJob._id);
logger.info(
`Setting first queued subjob as current for printer ${this.printer.name}: `,
result.currentSubJob._id
);
this.currentSubJobId = result.currentSubJob._id;
this.currentJobId = result.currentJob._id;
await this.database.updateSubJobState(this.currentSubJobId, this.state);
await this.database.updateSubJobState(
this.currentSubJobId,
this.state
);
}
}
}
@ -529,17 +631,25 @@ this.database.updateFilamentStockWeight(this.currentFilamentStockId, (-1 * filam
if (!this.queuedJobIds.includes(subJob.subJobId)) {
if (subJob.subJobId === this.subJobCancelId) {
logger.info(`Cancelling subjob ${subJob.id}`);
await this.database.updateSubJobState(subJob.id, { type: "cancelled" });
await this.database.removePrinterSubJob(this.id, subJob.id);
} else if (!["failed", "complete", "draft", "cancelled"].includes(subJob.state.type)) {
await this.database.updateSubJobState(subJob.id, {
type: "cancelled",
});
await this.database.removePrinterSubJob(subJob.id);
} else if (
!["failed", "complete", "draft", "cancelled"].includes(
subJob.state.type
)
) {
// Update the subjob state to match printer state
await this.database.updateSubJobState(subJob.id, this.state);
}
}
if (["failed", "complete", "cancelled"].includes(subJob.state.type)) {
logger.info(`Removing completed/failed/cancelled subjob ${subJob.id} from printer ${this.name}`);
await this.database.removePrinterSubJob(this.id, subJob.id);
logger.info(
`Removing completed/failed/cancelled subjob ${subJob.id} from printer ${this.printer.name}`
);
await this.database.removePrinterSubJob(subJob.id);
}
}
} catch (error) {
@ -548,47 +658,52 @@ this.database.updateFilamentStockWeight(this.currentFilamentStockId, (-1 * filam
}
async handleKlippyDisconnected() {
logger.info(`Klippy disconnected (${this.name})`);
logger.info(`Klippy disconnected (${this.printer.name})`);
this.state = { type: "offline" };
this.klippyState = { type: 'offline'};
this.klippyState = { type: "offline" };
this.isOnline = false;
this.isPrinting = false;
this.isError = false;
this.isReady = false;
await this.database.clearAlerts(this.id);
await this.database.clearAlerts();
await this.updatePrinterState();
await this.updatePrinterSubJobs();
}
async handleKlippyReady() {
logger.info(`Klippy ready (${this.name})`);
logger.info(`Klippy ready (${this.printer.name})`);
await this.initialize();
}
handleFileListChanged(fileInfo) {
logger.debug(`File list changed for ${this.name}:`, fileInfo);
logger.debug(`File list changed for ${this.printer.name}:`, fileInfo);
}
handleMetadataUpdate(metadata) {
logger.info(`Metadata updated for ${this.name}:`, metadata.filename);
logger.info(
`Metadata updated for ${this.printer.name}:`,
metadata.filename
);
}
handlePowerChanged(powerStatus) {
logger.info(`Power status changed for ${this.name}:`, powerStatus);
logger.info(`Power status changed for ${this.printer.name}:`, powerStatus);
}
async uploadGcodeFile(fileBlob, fileName) {
logger.info(`Uploading G-code file ${fileName} to ${this.name}`);
logger.info(`Uploading G-code file ${fileName} to ${this.printer.name}`);
if (!this.isOnline) {
logger.error(
`Cannot upload file: Not connected to Moonraker (${this.name})`,
`Cannot upload file: Not connected to Moonraker (${this.printer.name})`
);
return false;
}
try {
const { protocol, host, port } = this.config;
const httpUrl = `${protocol === "ws" ? "http" : "https"}://${host}:${port}/server/files/upload`;
const httpUrl = `${
protocol === "ws" ? "http" : "https"
}://${host}:${port}/server/files/upload`;
// Convert Blob to Buffer
const arrayBuffer = await fileBlob.arrayBuffer();
@ -612,14 +727,14 @@ this.database.updateFilamentStockWeight(this.currentFilamentStockId, (-1 * filam
headers,
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total,
(progressEvent.loaded * 100) / progressEvent.total
);
logger.debug(
`Uploading file to ${this.name}: ` +
`Uploading file to ${this.printer.name}: ` +
fileName +
" " +
percentCompleted +
"%",
"%"
);
this.socketManager.broadcast("notify_printer_update", {
printerId: this.id,
@ -636,17 +751,19 @@ this.database.updateFilamentStockWeight(this.currentFilamentStockId, (-1 * filam
throw new Error("Failed to upload G-code file to printer");
}
logger.info(`Successfully uploaded file ${fileName} to ${this.name}`);
logger.info(
`Successfully uploaded file ${fileName} to ${this.printer.name}`
);
return true;
} catch (error) {
logger.error(`Error uploading file to ${this.name}:`, error);
logger.error(`Error uploading file to ${this.printer.name}:`, error);
return false;
}
}
async deploySubJobs(jobId) {
try {
const printSubJobs = await printSubJobModel
const printSubJobs = await subJobModel
.find({ printJob: jobId })
.sort({ number: 1 });
@ -658,7 +775,7 @@ this.database.updateFilamentStockWeight(this.currentFilamentStockId, (-1 * filam
"to printer:",
this.id,
"with files:",
`${jobId.id}.gcode`,
`${jobId.id}.gcode`
);
const result = await this.sendPrinterCommand({
@ -674,7 +791,7 @@ this.database.updateFilamentStockWeight(this.currentFilamentStockId, (-1 * filam
}
// Update the PrintSubJob model
const updatedSubJob = await printSubJobModel.findByIdAndUpdate(
const updatedSubJob = await subJobModel.findByIdAndUpdate(
subJob.id,
{
subJobId: result.queued_jobs[result.queued_jobs.length - 1].job_id,
@ -689,7 +806,7 @@ this.database.updateFilamentStockWeight(this.currentFilamentStockId, (-1 * filam
}
// Update the printer's subJobs array
await this.database.addSubJobToPrinter(this.id, updatedSubJob._id);
await this.database.addSubJobToPrinter(updatedSubJob._id);
await this.database.updateSubJobState(subJob.id, { type: "queued" });
logger.info("Sub job deployed to printer:", this.id);
@ -724,8 +841,74 @@ this.database.updateFilamentStockWeight(this.currentFilamentStockId, (-1 * filam
async loadFilamentStock(filamentStockId) {
this.currentFilamentStockId = filamentStockId;
const result = await this.database.setCurrentFilamentStock(this.id, this.currentFilamentStockId);
this.currentFilamentStockDensity = result.filament.density
await this.database.removeAlerts(this.id, {type: 'loadFilamentStock'})
const result = await this.database.setCurrentFilamentStock(
this.id,
this.currentFilamentStockId
);
this.currentFilamentStockDensity = result.filament.density;
await this.database.removeAlerts({ type: "loadFilamentStock" });
}
async setTemperature(temperature) {
logger.info(`Setting temperature for ${this.printer.name}:`, temperature);
if (!this.isOnline) {
logger.error(
`Cannot set temperature: Not connected to Moonraker (${this.printer.name})`
);
return false;
}
try {
let gcodeCommands = [];
// Handle extruder temperature
if (temperature.extruder?.target !== undefined) {
gcodeCommands.push(
`SET_HEATER_TEMPERATURE HEATER=extruder TARGET=${temperature.extruder.target}`
);
}
// Handle bed temperature
if (temperature.bed?.target !== undefined) {
gcodeCommands.push(
`SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=${temperature.bed.target}`
);
}
if (gcodeCommands.length === 0) {
logger.warn(
`No valid temperature targets provided for ${this.printer.name}`
);
return false;
}
// Send each temperature command
for (const gcodeCommand of gcodeCommands) {
const result = await this.sendPrinterCommand({
method: "printer.gcode.script",
params: {
script: gcodeCommand,
},
});
if (!result) {
logger.error(
`Failed to set temperature with command: ${gcodeCommand}`
);
return false;
}
}
_.merge(this.temperatureObject, temperature);
logger.info(`Successfully set temperature for ${this.printer.name}`);
return true;
} catch (error) {
logger.error(
`Error setting temperature for ${this.printer.name}:`,
error
);
return false;
}
}
}

View File

@ -1,107 +1,90 @@
// printer-manager.js - Manages multiple printer connections through MongoDB
import { PrinterClient } from "./printerclient.js";
import { printerModel } from "../database/printer.schema.js"; // Import your printer model
import { printSubJobModel } from "../database/printsubjob.schema.js"; // Import your subjob model
import { loadConfig } from "../config.js";
import log4js from "log4js";
import { printJobModel } from "../database/printjob.schema.js";
import { sendIPC } from "../electron/ipc.js";
// Load configuration
const config = loadConfig();
const logger = log4js.getLogger("Printer Manager");
logger.level = config.server.logLevel;
logger.level = config.logLevel;
export class PrinterManager {
constructor(config) {
this.config = config;
this.printerClientConnections = new Map();
this.statusCheckInterval = null;
this.initializePrinterConnections();
constructor(socketClient) {
this.socketClient = socketClient;
this.printerClients = new Map();
this.printers = [];
}
async initializePrinterConnections() {
async reloadPrinters() {
try {
// Get all printers from the database
const printers = await printerModel.find({}).populate({ path: "currentFilamentStock",
populate: {
path: "filament",
},});
this.printers = await this.socketClient.listObjects({
objectType: "printer",
filter: { host: this.socketClient.id },
});
for (const printer of printers) {
await this.connectToPrinter(printer);
sendIPC("setPrinters", this.printers);
// Remove printer clients that are no longer in the printers list
const printerIds = this.printers.map((printer) => printer._id);
for (const [printerId, printerClient] of this.printerClients.entries()) {
if (!printerIds.includes(printerId)) {
// Close the connection before removing
if (printerClient.socket) {
printerClient.socket.close();
}
this.printerClients.delete(printerId);
logger.info(`Removed printer client for printer ID: ${printerId}`);
}
}
logger.info(`Initialized connections to ${printers.length} printers`);
// Add new printer clients for printers not in the printerClients map
for (const printer of this.printers) {
const printerId = printer._id;
if (!this.printerClients.has(printerId)) {
const printerClient = new PrinterClient(printer, this);
await printerClient.connect();
this.printerClients.set(printerId, printerClient);
logger.info(`Added printer client for printer ID: ${printerId}`);
}
}
} catch (error) {
logger.error(`Error initializing printer connections: ${error.message}`);
logger.error("Failed to update printers:", error);
this.printers = [];
}
}
async connectToPrinter(printer) {
// Create and store the connection
const printerClientConnection = new PrinterClient(
printer,
this,
this.socketManager,
);
this.printerClientConnections.set(printer.id, printerClientConnection);
// Connect to the printer
await printerClientConnection.connect();
logger.info(`Connected to printer: ${printer.name} (${printer.id})`);
return true;
}
async setupPrintersListener() {}
getPrinterClient(printerId) {
return this.printerClientConnections.get(printerId);
return this.printerClients.get(printerId);
}
getAllPrinterClients() {
return this.printerClientConnections.values();
}
// Process command for a specific printer
async processPrinterCommand(command) {
const printerId = command.params.printerId;
const printerClientConnection =
this.printerClientConnections.get(printerId);
if (!printerClientConnection) {
return {
success: false,
error: `Printer with ID ${printerId} not found`,
};
}
return await printerClientConnection.sendPrinterCommand(command);
return this.printerClients.values();
}
async updateSubscription(printerId, socketId, mergedSubscription) {
const printerClientConnection =
this.printerClientConnections.get(printerId);
if (!printerClientConnection) {
const printerClient = this.printerClients.get(printerId);
if (!printerClient) {
return {
success: false,
error: `Printer with ID ${printerId} not found`,
};
}
printerClientConnection.subscriptions.set(socketId, mergedSubscription);
return await printerClientConnection.updateSubscriptions();
printerClient.subscriptions.set(socketId, mergedSubscription);
return await printerClient.updateSubscriptions();
}
// Close all printer connections
closeAllConnections() {
for (const printerClientConnection of this.printerClientConnections.values()) {
if (printerClientConnection.socket) {
printerClientConnection.socket.close();
for (const printerClient of this.printerClients.values()) {
if (printerClient.socket) {
printerClient.socket.close();
}
}
}
setSocketManager(socketManager) {
this.socketManager = socketManager;
}
async downloadGCODE(gcodeFileId) {
logger.info(`Downloading G-code file ${gcodeFileId}`);
try {
@ -109,12 +92,15 @@ export class PrinterManager {
const url = `http://localhost:8080/gcodefiles/${gcodeFileId}/content/`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${this.socketManager.socketClientConnections.values().next().value.socket.handshake.auth.token}`,
Authorization: `Bearer ${
this.socketManager.socketClientConnections.values().next().value
.socket.handshake.auth.token
}`,
},
});
if (!response.ok) {
throw new Error(
`Failed to download G-code file: ${response.statusText}`,
`Failed to download G-code file: ${response.statusText}`
);
}
const gcodeContent = await response.blob();
@ -133,7 +119,7 @@ export class PrinterManager {
async deployPrintJob(printJobId) {
logger.info(`Deploying print job ${printJobId}`);
const printJob = await printJobModel
const printJob = await jobModel
.findById(printJobId)
.populate("printers")
.populate("subJobs");
@ -174,7 +160,7 @@ export class PrinterManager {
async cancelSubJob(subJobId) {
logger.info(`Canceling sub job ${subJobId}`);
const subJob = await printSubJobModel.findById(subJobId);
const subJob = await subJobModel.findById(subJobId);
if (!subJob) {
throw new Error("Sub job not found");
}

Some files were not shown because too many files have changed in this diff Show More