feat: convert wake-on-lan into a plugin (B20)

This commit is contained in:
LukeGus committed 2026-09-24 15:49:55 -05:00
1 parent d4ec444558
commit 2ac7779eda
50 files changed
+17844 -199

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
-- mac_address and wol_broadcast_address move to the wake-on-lan plugin's
-- host settings at boot, after this migration runs. Dropping them here would
-- delete the data before the copy has a chance to run.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -344,6 +344,13 @@
"when": 1790278287918,
"tag": "0048_hard_demogoblin",
"breakpoints": true
},
{
"idx": 49,
"version": "5",
"when": 1790282598229,
"tag": "0049_quiet_blacklash",
"breakpoints": true
}
]
}
@@ -0,0 +1,4 @@
-- mac_address and wol_broadcast_address move to the wake-on-lan plugin's
-- host settings at boot, after this migration runs. Dropping them here would
-- delete the data before the copy has a chance to run.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -351,6 +351,13 @@
"when": 1790278287300,
"tag": "0049_reflective_adam_destine",
"breakpoints": true
},
{
"idx": 50,
"version": "7",
"when": 1790282597585,
"tag": "0050_acoustic_lady_bullseye",
"breakpoints": true
}
]
}
+4
View File
@@ -0,0 +1,4 @@
-- mac_address and wol_broadcast_address move to the wake-on-lan plugin's
-- host settings at boot, after this migration runs. Dropping them here would
-- delete the data before the copy has a chance to run.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -323,6 +323,13 @@
"when": 1790278286648,
"tag": "0045_public_menace",
"breakpoints": true
},
{
"idx": 46,
"version": "6",
"when": 1790282596918,
"tag": "0046_misty_midnight",
"breakpoints": true
}
]
}
+33
View File
@@ -6996,6 +6996,10 @@
"resolved": "plugins/fleets",
"link": true
},
"node_modules/@termix-plugin/homepage": {
"resolved": "plugins/homepage",
"link": true
},
"node_modules/@termix-plugin/host-metrics": {
"resolved": "plugins/host-metrics",
"link": true
@@ -7016,6 +7020,10 @@
"resolved": "plugins/serial",
"link": true
},
"node_modules/@termix-plugin/session-recording": {
"resolved": "plugins/session-recording",
"link": true
},
"node_modules/@termix-plugin/session-sharing": {
"resolved": "plugins/session-sharing",
"link": true
@@ -7040,6 +7048,10 @@
"resolved": "plugins/tunnels",
"link": true
},
"node_modules/@termix-plugin/wake-on-lan": {
"resolved": "plugins/wake-on-lan",
"link": true
},
"node_modules/@termix-plugin/web-endpoint": {
"resolved": "plugins/web-endpoint",
"link": true
@@ -18572,6 +18584,13 @@
"@termix/plugin-sdk": "*"
}
},
"plugins/homepage": {
"name": "@termix-plugin/homepage",
"version": "1.0.0",
"devDependencies": {
"@termix/plugin-sdk": "*"
}
},
"plugins/host-metrics": {
"name": "@termix-plugin/host-metrics",
"version": "1.0.0",
@@ -18614,6 +18633,13 @@
"@termix/plugin-sdk": "*"
}
},
"plugins/session-recording": {
"name": "@termix-plugin/session-recording",
"version": "1.0.0",
"devDependencies": {
"@termix/plugin-sdk": "*"
}
},
"plugins/session-sharing": {
"name": "@termix-plugin/session-sharing",
"version": "1.0.0",
@@ -18662,6 +18688,13 @@
"@termix/plugin-sdk": "*"
}
},
"plugins/wake-on-lan": {
"name": "@termix-plugin/wake-on-lan",
"version": "1.0.0",
"devDependencies": {
"@termix/plugin-sdk": "*"
}
},
"plugins/web-endpoint": {
"name": "@termix-plugin/web-endpoint",
"version": "1.0.0",
+1
View File
@@ -51,6 +51,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("credentials:use", "medium"),
entry("network:outbound", "medium"),
entry("network:serve", "medium"),
entry("network:broadcast", "medium"),
entry("users:read", "medium"),
entry("events:core", "medium"),
entry("notify:send", "medium"),
+27
View File
@@ -0,0 +1,27 @@
{
"hostAction": {
"title": "Wake",
"sent": "Wake-on-LAN packet sent",
"failed": "Failed to send Wake-on-LAN packet"
},
"permissions": {
"send": {
"title": "Send Wake-on-LAN packets",
"description": "Wake a host by sending a magic packet to its MAC address."
}
},
"settings": {
"host": {
"macAddress": {
"label": "MAC address",
"description": "The host's network MAC address, used to send Wake-on-LAN packets.",
"placeholder": "AA:BB:CC:DD:EE:FF"
},
"broadcastAddress": {
"label": "Broadcast address",
"description": "The subnet broadcast address to send the packet to. Leave blank for 255.255.255.255.",
"placeholder": "192.168.1.255"
}
}
}
}
+62
View File
@@ -0,0 +1,62 @@
{
"id": "wake-on-lan",
"name": "Wake-on-LAN",
"version": "1.0.0",
"description": "Wake a sleeping host by sending a Wake-on-LAN magic packet to its MAC address.",
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Networking",
"icon": "Zap",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": [
"hosts:read",
"network:broadcast",
"network:serve",
"ui:surface"
],
"provides": [
{
"service": "wake-on-lan.send",
"version": "1.0.0",
"permission": "wake-on-lan.send"
}
],
"contributes": {
"permissions": [
{
"name": "send",
"titleKey": "permissions.send.title",
"descriptionKey": "permissions.send.description",
"defaultRoles": ["admin", "user"]
}
],
"settings": {
"host": {
"fields": [
{
"key": "macAddress",
"type": "string",
"labelKey": "settings.host.macAddress.label",
"descriptionKey": "settings.host.macAddress.description",
"placeholderKey": "settings.host.macAddress.placeholder"
},
{
"key": "broadcastAddress",
"type": "string",
"labelKey": "settings.host.broadcastAddress.label",
"descriptionKey": "settings.host.broadcastAddress.description",
"placeholderKey": "settings.host.broadcastAddress.placeholder"
}
]
}
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@termix-plugin/wake-on-lan",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { createWakeOnLanService } from "./service.js";
import { registerWakeOnLanRoutes } from "./routes.js";
export async function activate(ctx: PluginContext) {
const service = createWakeOnLanService(ctx);
ctx.services.provide("wake-on-lan.send", service);
registerWakeOnLanRoutes(ctx.http.router<Router>(), ctx, service);
ctx.log.info("Wake-on-LAN routes mounted at /plugin-api/wake-on-lan");
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
@@ -20,7 +20,7 @@ export function isValidMac(mac: string): boolean {
return MAC_REGEX.test(mac);
}
export function sendWakeOnLan(
export function sendMagicPacket(
mac: string,
broadcastAddress = "255.255.255.255",
): Promise<void> {
+47
View File
@@ -0,0 +1,47 @@
import type { Request, Response, Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { WakeOnLanV1 } from "./service.js";
export function registerWakeOnLanRoutes(
router: Router,
ctx: PluginContext,
service: WakeOnLanV1,
): void {
router.use(ctx.rbac.require("send") as never);
/**
* @openapi
* /plugin-api/wake-on-lan/host/{id}/wake:
* post:
* summary: Send a Wake-on-LAN magic packet to a host
* tags:
* - Wake-on-LAN
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Packet sent
* 400:
* description: No valid MAC address configured
* 404:
* description: Host not found
*/
router.post("/host/:id/wake", async (req: Request, res: Response) => {
const hostId = Number.parseInt(String(req.params.id), 10);
if (!Number.isInteger(hostId)) {
return res.status(400).json({ error: "Invalid host id" });
}
try {
await service.wake(hostId);
res.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const status = message === "Host not found" ? 404 : 400;
res.status(status).json({ error: message });
}
});
}
@@ -0,0 +1,33 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { isValidMac, sendMagicPacket } from "./magic-packet.js";
export interface WakeOnLanV1 {
wake: (hostId: number) => Promise<void>;
}
export function createWakeOnLanService(ctx: PluginContext): WakeOnLanV1 {
return {
async wake(hostId: number): Promise<void> {
const host = await ctx.hosts.get(hostId);
if (!host) {
throw new Error("Host not found");
}
const macAddress = await ctx.settings.getHost<string>(
hostId,
"macAddress",
);
if (!macAddress || !isValidMac(macAddress)) {
throw new Error("No valid MAC address configured");
}
const broadcastAddress = await ctx.settings.getHost<string>(
hostId,
"broadcastAddress",
);
await ctx.capabilities.require("network:broadcast");
await sendMagicPacket(macAddress, broadcastAddress || undefined);
ctx.log.info(`Wake-on-LAN packet sent to host ${hostId}`);
},
};
}
@@ -0,0 +1,28 @@
import { toast } from "sonner";
import type { PluginApiClient, TermixApp } from "@termix/plugin-sdk/frontend";
export function hasMacAddress(host: Record<string, unknown>): boolean {
const settings = host.pluginSettings as
Record<string, Record<string, unknown>> | undefined;
const macAddress = settings?.["wake-on-lan"]?.macAddress;
return typeof macAddress === "string" && macAddress.trim().length > 0;
}
function errorMessage(error: unknown, fallback: string): string {
const message = (error as { response?: { data?: { error?: string } } })
?.response?.data?.error;
return message ?? fallback;
}
export function wakeHost(
api: PluginApiClient,
t: TermixApp["t"],
hostId: string,
): void {
void api
.post(`/host/${hostId}/wake`)
.then(() => toast.success(t("hostAction.sent")))
.catch((error: unknown) => {
toast.error(errorMessage(error, t("hostAction.failed")));
});
}
@@ -0,0 +1,15 @@
import { Zap } from "lucide-react";
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import { hasMacAddress, wakeHost } from "./host-action.js";
export function activate(app: TermixApp): void {
app.registerHostAction({
id: "wake-on-lan",
titleKey: "hostAction.title",
icon: Zap,
kind: "open",
order: 50,
when: (host) => hasMacAddress(host as Record<string, unknown>),
run: (host) => wakeHost(app.api, app.t, String(host.id)),
});
}
@@ -0,0 +1,77 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import express, { type Router } from "express";
import {
createMockCtx,
type MockPluginContext,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import type { PluginHostSummary } from "@termix/plugin-sdk/backend";
import manifestJson from "../../manifest.json";
export const manifest = manifestJson as unknown as PluginManifest;
export interface TestServer {
mock: MockPluginContext;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
close: () => Promise<void>;
}
export async function startServer(
options: {
permissions?: string[];
hosts?: PluginHostSummary[];
capabilities?: string[];
} = {},
): Promise<TestServer> {
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: options.capabilities ?? manifest.capabilities,
router: () => (router = express.Router()),
permissions: options.permissions ?? ["wake-on-lan.send"],
hosts: options.hosts ?? [],
actor: "user-1",
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
mock,
async request(method, path, { user = "user-1", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
},
};
}
@@ -15,8 +15,8 @@ import dgram from "dgram";
import {
isValidMac,
buildMagicPacket,
sendWakeOnLan,
} from "../../utils/wake-on-lan.js";
sendMagicPacket,
} from "../../src/backend/magic-packet.js";
describe("isValidMac", () => {
it("accepts colon-separated MAC addresses", () => {
@@ -67,7 +67,7 @@ describe("buildMagicPacket", () => {
});
});
describe("sendWakeOnLan", () => {
describe("sendMagicPacket", () => {
let mockSocket: ReturnType<typeof dgram.createSocket>;
beforeEach(() => {
@@ -89,13 +89,13 @@ describe("sendWakeOnLan", () => {
});
it("rejects on invalid MAC address", async () => {
await expect(sendWakeOnLan("not-a-mac")).rejects.toThrow(
await expect(sendMagicPacket("not-a-mac")).rejects.toThrow(
"Invalid MAC address",
);
});
it("sends to 255.255.255.255 by default", async () => {
await sendWakeOnLan("aa:bb:cc:dd:ee:ff");
await sendMagicPacket("aa:bb:cc:dd:ee:ff");
expect(mockSocket.send).toHaveBeenCalledWith(
expect.any(Buffer),
0,
@@ -107,7 +107,7 @@ describe("sendWakeOnLan", () => {
});
it("sends to a custom broadcast address when provided", async () => {
await sendWakeOnLan("aa:bb:cc:dd:ee:ff", "192.168.1.255");
await sendMagicPacket("aa:bb:cc:dd:ee:ff", "192.168.1.255");
expect(mockSocket.send).toHaveBeenCalledWith(
expect.any(Buffer),
0,
@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { PluginHostSummary } from "@termix/plugin-sdk/backend";
import { startServer, type TestServer } from "./helpers.js";
vi.mock("../../src/backend/magic-packet.js", async () => {
const actual = await vi.importActual<
typeof import("../../src/backend/magic-packet.js")
>("../../src/backend/magic-packet.js");
return { ...actual, sendMagicPacket: vi.fn().mockResolvedValue(undefined) };
});
const HOST: PluginHostSummary = {
id: 1,
userId: "user-1",
name: "server",
ip: "10.0.0.5",
port: 22,
username: "root",
tags: null,
folder: null,
authType: "password",
};
describe("POST /host/:id/wake", () => {
let server: TestServer;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(async () => {
await server?.close();
});
it("sends the packet for a configured host", async () => {
server = await startServer({ hosts: [HOST] });
await server.mock.ctx.settings.setHost(
1,
"macAddress",
"aa:bb:cc:dd:ee:ff",
);
const response = await server.request("POST", "/host/1/wake");
expect(response.status).toBe(200);
expect(response.body).toEqual({ success: true });
});
it("404s for an unknown host", async () => {
server = await startServer({ hosts: [] });
const response = await server.request("POST", "/host/999/wake");
expect(response.status).toBe(404);
});
it("400s when no MAC address is configured", async () => {
server = await startServer({ hosts: [HOST] });
const response = await server.request("POST", "/host/1/wake");
expect(response.status).toBe(400);
});
it("denies a caller without wake-on-lan.send", async () => {
server = await startServer({ hosts: [HOST], permissions: [] });
const response = await server.request("POST", "/host/1/wake");
expect(response.status).toBe(403);
});
});
@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createMockCtx } from "@termix/plugin-sdk/testing";
import { createWakeOnLanService } from "../../src/backend/service.js";
vi.mock("../../src/backend/magic-packet.js", async () => {
const actual = await vi.importActual<
typeof import("../../src/backend/magic-packet.js")
>("../../src/backend/magic-packet.js");
return { ...actual, sendMagicPacket: vi.fn().mockResolvedValue(undefined) };
});
import { sendMagicPacket } from "../../src/backend/magic-packet.js";
const HOST = {
id: 1,
userId: "user-1",
name: "server",
ip: "10.0.0.5",
port: 22,
authType: "password",
};
describe("wake-on-lan service", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("sends a magic packet for a host with a valid MAC", async () => {
const mock = createMockCtx({
capabilities: ["hosts:read", "network:broadcast"],
hosts: [HOST],
actor: "user-1",
});
await mock.ctx.settings.setHost(1, "macAddress", "aa:bb:cc:dd:ee:ff");
const service = createWakeOnLanService(mock.ctx);
await service.wake(1);
expect(sendMagicPacket).toHaveBeenCalledWith(
"aa:bb:cc:dd:ee:ff",
undefined,
);
});
it("passes the configured broadcast address", async () => {
const mock = createMockCtx({
capabilities: ["hosts:read", "network:broadcast"],
hosts: [HOST],
actor: "user-1",
});
await mock.ctx.settings.setHost(1, "macAddress", "aa:bb:cc:dd:ee:ff");
await mock.ctx.settings.setHost(1, "broadcastAddress", "192.168.1.255");
const service = createWakeOnLanService(mock.ctx);
await service.wake(1);
expect(sendMagicPacket).toHaveBeenCalledWith(
"aa:bb:cc:dd:ee:ff",
"192.168.1.255",
);
});
it("rejects a host with no MAC address configured", async () => {
const mock = createMockCtx({
capabilities: ["hosts:read", "network:broadcast"],
hosts: [HOST],
actor: "user-1",
});
const service = createWakeOnLanService(mock.ctx);
await expect(service.wake(1)).rejects.toThrow(
"No valid MAC address configured",
);
expect(sendMagicPacket).not.toHaveBeenCalled();
});
it("rejects an unknown host", async () => {
const mock = createMockCtx({
capabilities: ["hosts:read", "network:broadcast"],
hosts: [],
actor: "user-1",
});
const service = createWakeOnLanService(mock.ctx);
await expect(service.wake(999)).rejects.toThrow("Host not found");
});
it("fails closed without network:broadcast", async () => {
const mock = createMockCtx({
capabilities: ["hosts:read"],
hosts: [HOST],
actor: "user-1",
});
await mock.ctx.settings.setHost(1, "macAddress", "aa:bb:cc:dd:ee:ff");
const service = createWakeOnLanService(mock.ctx);
await expect(service.wake(1)).rejects.toThrow();
expect(sendMagicPacket).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it } from "vitest";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
describe(`${manifest.id} activate`, () => {
it("registers the Wake host action", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.hostActions()).toEqual([
expect.objectContaining({ id: "wake-on-lan" }),
]);
});
it("removes the host action on deactivate", async () => {
const app = await renderWithApp(plugin, { manifest, locales });
await app.deactivate();
expect(app.registered.hostActions()).toEqual([]);
});
});
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { toast } from "sonner";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import { hasMacAddress, wakeHost } from "../../src/frontend/host-action.js";
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
afterEach(() => {
vi.clearAllMocks();
});
function stubApi(overrides: Partial<PluginApiClient> = {}): PluginApiClient {
return {
get: vi.fn(async () => ({ data: {} })),
post: vi.fn(async () => ({ data: {} })),
put: vi.fn(async () => ({ data: {} })),
patch: vi.fn(async () => ({ data: {} })),
delete: vi.fn(async () => ({ data: {} })),
...overrides,
} as PluginApiClient;
}
const t = (key: string) => key;
describe("hasMacAddress", () => {
it("is true when the host's wake-on-lan settings carry a MAC address", () => {
expect(
hasMacAddress({
pluginSettings: { "wake-on-lan": { macAddress: "aa:bb:cc:dd:ee:ff" } },
}),
).toBe(true);
});
it("is false with no plugin settings", () => {
expect(hasMacAddress({})).toBe(false);
});
it("is false with an empty MAC address", () => {
expect(
hasMacAddress({ pluginSettings: { "wake-on-lan": { macAddress: "" } } }),
).toBe(false);
});
});
describe("wakeHost", () => {
it("posts to the host's wake route and toasts success", async () => {
const api = stubApi();
wakeHost(api, t, "1");
await vi.waitFor(() =>
expect(api.post).toHaveBeenCalledWith("/host/1/wake"),
);
await vi.waitFor(() => expect(toast.success).toHaveBeenCalled());
});
it("toasts the server's error message on failure", async () => {
const api = stubApi({
post: vi
.fn()
.mockRejectedValue({ response: { data: { error: "no mac" } } }),
});
wakeHost(api, t, "1");
await vi.waitFor(() => expect(toast.error).toHaveBeenCalledWith("no mac"));
});
it("falls back to a generic message with no server error", async () => {
const api = stubApi({ post: vi.fn().mockRejectedValue(new Error("x")) });
wakeHost(api, t, "1");
await vi.waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("hostAction.failed"),
);
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);
+2 -4
View File
@@ -819,7 +819,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
socks5_password TEXT,
socks5_proxy_chain TEXT,
domain TEXT,
mac_address TEXT,
port_knock_sequence TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
@@ -928,8 +927,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
const sshHosts =
await createCurrentHostRepository().listDecryptedByUserId(userId);
const insertHost = exportDb.prepare(`
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, enable_terminal, enable_tunnel, tunnel_connections, jump_hosts, enable_file_manager, enable_web_ui, show_terminal_in_sidebar, show_file_manager_in_sidebar, show_tunnel_in_sidebar, show_docker_in_sidebar, show_server_stats_in_sidebar, default_path, status_check_enabled, status_check_interval, web_ui_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, domain, mac_address, port_knock_sequence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, enable_terminal, enable_tunnel, tunnel_connections, jump_hosts, enable_file_manager, enable_web_ui, show_terminal_in_sidebar, show_file_manager_in_sidebar, show_tunnel_in_sidebar, show_docker_in_sidebar, show_server_stats_in_sidebar, default_path, status_check_enabled, status_check_interval, web_ui_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, domain, port_knock_sequence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const decrypted of sshHosts) {
@@ -981,7 +980,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
decrypted.socks5Password || null,
decrypted.socks5ProxyChain || null,
decrypted.domain || null,
decrypted.macAddress || null,
decrypted.portKnockSequence || null,
decrypted.createdAt,
decrypted.updatedAt,
-2
View File
@@ -1473,7 +1473,6 @@ const migrateSchema = () => {
{ column: "host_key_first_seen", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_first_seen TEXT" },
{ column: "host_key_last_verified", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_last_verified TEXT" },
{ column: "host_key_changed_count", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_changed_count INTEGER NOT NULL DEFAULT 0" },
{ column: "mac_address", sql: "ALTER TABLE ssh_data ADD COLUMN mac_address TEXT" },
{ column: "port_knock_sequence", sql: "ALTER TABLE ssh_data ADD COLUMN port_knock_sequence TEXT" },
{ column: "enable_ssh", sql: "ALTER TABLE ssh_data ADD COLUMN enable_ssh INTEGER NOT NULL DEFAULT 1" },
{ column: "ssh_port", sql: "ALTER TABLE ssh_data ADD COLUMN ssh_port INTEGER DEFAULT 22" },
@@ -1486,7 +1485,6 @@ const migrateSchema = () => {
{ column: "telnet_password", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_password TEXT" },
{ column: "rdp_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" },
{ column: "vnc_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" },
{ column: "wol_broadcast_address", sql: "ALTER TABLE ssh_data ADD COLUMN wol_broadcast_address TEXT" },
{ column: "use_warpgate", sql: "ALTER TABLE ssh_data ADD COLUMN use_warpgate INTEGER NOT NULL DEFAULT 0" },
{ column: "telnet_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" },
{ column: "rdp_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_auth_type TEXT" },
-2
View File
@@ -332,8 +332,6 @@ export const hosts = mysqlTable(
// Ignored for rdp/vnc/telnet, which always require the remote server.
connectionOrigin: text("connection_origin"),
macAddress: text("mac_address"),
wolBroadcastAddress: text("wol_broadcast_address"),
portKnockSequence: text("port_knock_sequence"),
hostKeyFingerprint: text("host_key_fingerprint"),
-2
View File
@@ -333,8 +333,6 @@ export const hosts = pgTable(
// Ignored for rdp/vnc/telnet, which always require the remote server.
connectionOrigin: text("connection_origin"),
macAddress: text("mac_address"),
wolBroadcastAddress: text("wol_broadcast_address"),
portKnockSequence: text("port_knock_sequence"),
hostKeyFingerprint: text("host_key_fingerprint"),
-2
View File
@@ -325,8 +325,6 @@ export const hosts = sqliteTable(
// Ignored for rdp/vnc/telnet, which always require the remote server.
connectionOrigin: text("connection_origin"),
macAddress: text("mac_address"),
wolBroadcastAddress: text("wol_broadcast_address"),
portKnockSequence: text("port_knock_sequence"),
hostKeyFingerprint: text("host_key_fingerprint"),
@@ -1,9 +1,7 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { sendWakeOnLan, isValidMac } from "../../utils/wake-on-lan.js";
import type { RequestHandler, Response, Router } from "express";
import { sshLogger } from "../../utils/logger.js";
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
interface HostNetworkRoutesDeps {
authenticateJWT: RequestHandler;
@@ -95,54 +93,4 @@ export function registerHostNetworkRoutes(
}
},
);
router.post(
"/db/host/:id/wake",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const hostId = Number.parseInt(String(req.params.id), 10);
const userId = (req as AuthenticatedRequest).userId;
try {
const host = await createCurrentHostResolutionRepository().findHostById(
hostId,
userId,
);
if (!host || host.userId !== userId) {
return res.status(404).json({ error: "Host not found" });
}
if (!host.macAddress || !isValidMac(host.macAddress)) {
return res
.status(400)
.json({ error: "No valid MAC address configured" });
}
await sendWakeOnLan(
host.macAddress,
host.wolBroadcastAddress ?? undefined,
);
sshLogger.info("Wake-on-LAN packet sent", {
operation: "wake_on_lan",
userId,
hostId,
});
res.json({ success: true });
} catch (error) {
sshLogger.error("Wake-on-LAN failed", error, {
operation: "wake_on_lan",
userId,
hostId,
});
res.status(500).json({
error: getErrorMessage(error, "Failed to send WoL packet"),
});
}
},
);
}
-8
View File
@@ -227,8 +227,6 @@ router.post(
connectionOrigin,
portKnockSequence,
overrideCredentialUsername,
macAddress,
wolBroadcastAddress,
enableSsh,
sshPort,
rdpAuthType,
@@ -358,8 +356,6 @@ router.post(
connectionOrigin === "local" || connectionOrigin === "remote"
? connectionOrigin
: null,
macAddress: macAddress || null,
wolBroadcastAddress: wolBroadcastAddress || null,
portKnockSequence: portKnockSequence
? JSON.stringify(portKnockSequence)
: null,
@@ -908,8 +904,6 @@ router.put(
connectionOrigin,
portKnockSequence,
overrideCredentialUsername,
macAddress,
wolBroadcastAddress,
enableSsh,
sshPort,
rdpAuthType,
@@ -1040,8 +1034,6 @@ router.put(
connectionOrigin === "local" || connectionOrigin === "remote"
? connectionOrigin
: null,
macAddress: macAddress || null,
wolBroadcastAddress: wolBroadcastAddress || null,
portKnockSequence: portKnockSequence
? JSON.stringify(portKnockSequence)
: null,
+4
View File
@@ -401,6 +401,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { runAiSettingsMigration } =
await import("./utils/crypto-migration/ai-settings-migration.js");
await runAiSettingsMigration();
const { runWakeOnLanSettingsMigration } =
await import("./utils/crypto-migration/wake-on-lan-settings-migration.js");
await runWakeOnLanSettingsMigration();
} catch (error) {
systemLogger.warn("Plugin runtime failed to initialize", {
operation: "plugin_init",
@@ -0,0 +1,190 @@
/**
* The Wake-on-LAN host settings migration.
*
* An upgrade must be lossless: every host with a MAC address or a broadcast
* address must come through into the wake-on-lan plugin's host settings.
* Running it twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
pluginRows: [] as Array<{
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}>,
hostRows: [] as Array<Record<string, unknown>>,
pluginInstalled: true,
columnsGone: false,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "wake-on-lan" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
state.pluginRows.filter(
(row) => row.scope === scope && row.scopeId === scopeId,
),
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
);
if (existing) {
existing.value = value;
return;
}
state.pluginRows.push({ pluginId, scope, scopeId, key, value });
},
}),
}));
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async () => {
if (state.columnsGone) throw new Error("no such column: mac_address");
return state.hostRows;
},
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import {
wakeOnLanSettingsFromRow,
runWakeOnLanSettingsMigration,
} from "../../utils/crypto-migration/wake-on-lan-settings-migration.js";
const hostValues = (hostId: number) =>
Object.fromEntries(
state.pluginRows
.filter((row) => row.scope === "host" && row.scopeId === String(hostId))
.map((row) => [row.key, JSON.parse(row.value ?? "null")]),
);
beforeEach(() => {
state.pluginRows = [];
state.hostRows = [];
state.pluginInstalled = true;
state.columnsGone = false;
});
describe("wakeOnLanSettingsFromRow", () => {
it("carries over a MAC address", () => {
expect(
wakeOnLanSettingsFromRow({
id: 1,
mac_address: "aa:bb:cc:dd:ee:ff",
wol_broadcast_address: null,
}),
).toEqual({ macAddress: "aa:bb:cc:dd:ee:ff" });
});
it("carries over a broadcast address alongside the MAC address", () => {
expect(
wakeOnLanSettingsFromRow({
id: 1,
mac_address: "aa:bb:cc:dd:ee:ff",
wol_broadcast_address: "192.168.1.255",
}),
).toEqual({
macAddress: "aa:bb:cc:dd:ee:ff",
broadcastAddress: "192.168.1.255",
});
});
it("produces nothing for a host with neither set", () => {
expect(
wakeOnLanSettingsFromRow({
id: 1,
mac_address: null,
wol_broadcast_address: null,
}),
).toEqual({});
});
});
describe("runWakeOnLanSettingsMigration", () => {
it("moves every host's wake-on-lan settings and skips hosts with nothing set", async () => {
state.hostRows = [
{ id: 1, mac_address: "aa:bb:cc:dd:ee:ff", wol_broadcast_address: null },
{
id: 2,
mac_address: "11:22:33:44:55:66",
wol_broadcast_address: "192.168.1.255",
},
{ id: 3, mac_address: null, wol_broadcast_address: null },
];
const result = await runWakeOnLanSettingsMigration();
expect(result).toEqual({ hostsMoved: 2, hostsSkipped: 0 });
expect(hostValues(1)).toEqual({ macAddress: "aa:bb:cc:dd:ee:ff" });
expect(hostValues(2)).toEqual({
macAddress: "11:22:33:44:55:66",
broadcastAddress: "192.168.1.255",
});
expect(hostValues(3)).toEqual({});
});
it("changes nothing when run twice", async () => {
state.hostRows = [
{ id: 1, mac_address: "aa:bb:cc:dd:ee:ff", wol_broadcast_address: null },
];
await runWakeOnLanSettingsMigration();
const before = JSON.stringify(state.pluginRows);
const second = await runWakeOnLanSettingsMigration();
expect(second).toEqual({ hostsMoved: 0, hostsSkipped: 1 });
expect(JSON.stringify(state.pluginRows)).toBe(before);
});
it("keeps a value already saved in the plugin", async () => {
state.pluginRows.push({
pluginId: "wake-on-lan",
scope: "host",
scopeId: "1",
key: "macAddress",
value: JSON.stringify("11:11:11:11:11:11"),
});
state.hostRows = [
{ id: 1, mac_address: "aa:bb:cc:dd:ee:ff", wol_broadcast_address: null },
];
await runWakeOnLanSettingsMigration();
expect(hostValues(1)).toEqual({ macAddress: "11:11:11:11:11:11" });
});
it("does nothing before the plugin row exists or once the columns are gone", async () => {
state.hostRows = [
{ id: 1, mac_address: "aa:bb:cc:dd:ee:ff", wol_broadcast_address: null },
];
state.pluginInstalled = false;
expect(await runWakeOnLanSettingsMigration()).toEqual({
hostsMoved: 0,
hostsSkipped: 0,
});
state.pluginInstalled = true;
state.columnsGone = true;
expect(await runWakeOnLanSettingsMigration()).toEqual({
hostsMoved: 0,
hostsSkipped: 0,
});
expect(state.pluginRows).toEqual([]);
});
});
@@ -0,0 +1,113 @@
/**
* Moves each host's Wake-on-LAN settings out of ssh_data and into the
* wake-on-lan plugin's host settings: mac_address becomes macAddress and
* wol_broadcast_address becomes broadcastAddress.
*
* The columns stay in the database, unused: core's drizzle migrations run
* before this does, so dropping them there would lose the data first.
*
* Idempotent: a host that already has any wake-on-lan row is skipped, so
* this is safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../../database/repositories/factory.js";
import { selectRows } from "./raw-rows.js";
const PLUGIN_ID = "wake-on-lan";
interface LegacyHostRow {
id: number;
mac_address: string | null;
wol_broadcast_address: string | null;
}
/** The plugin's host settings for one legacy row, defaults left out. */
export function wakeOnLanSettingsFromRow(
row: LegacyHostRow,
): Record<string, unknown> {
const values: Record<string, unknown> = {};
if (row.mac_address) values.macAddress = row.mac_address;
if (row.wol_broadcast_address) {
values.broadcastAddress = row.wol_broadcast_address;
}
return values;
}
export interface WakeOnLanSettingsMigrationResult {
hostsMoved: number;
hostsSkipped: number;
}
export async function runWakeOnLanSettingsMigration(): Promise<WakeOnLanSettingsMigrationResult> {
const result: WakeOnLanSettingsMigrationResult = {
hostsMoved: 0,
hostsSkipped: 0,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
let rows: LegacyHostRow[];
try {
// Raw SQL: schema.ts no longer declares these columns.
rows = await selectRows<LegacyHostRow>(
sql`SELECT id, mac_address, wol_broadcast_address FROM ssh_data`,
);
} catch {
// A fresh install never had the columns.
return result;
}
for (const row of rows) {
const values = wakeOnLanSettingsFromRow(row);
if (Object.keys(values).length === 0) continue;
const scopeId = String(row.id);
try {
const existing = await pluginSettings.getAll(PLUGIN_ID, "host", scopeId);
if (existing.length > 0) {
result.hostsSkipped++;
continue;
}
for (const [key, value] of Object.entries(values)) {
await pluginSettings.set(
PLUGIN_ID,
"host",
scopeId,
key,
JSON.stringify(value),
);
}
result.hostsMoved++;
} catch (error) {
databaseLogger.warn(
"Wake-on-LAN host settings migration failed for a host",
{
operation: "wake_on_lan_settings_migration",
hostId: row.id,
error: error instanceof Error ? error.message : String(error),
},
);
}
}
if (result.hostsMoved > 0) {
databaseLogger.info(
`Moved Wake-on-LAN settings for ${result.hostsMoved} host(s) into plugin settings`,
{ operation: "wake_on_lan_settings_migration" },
);
}
return result;
}
-4
View File
@@ -238,8 +238,6 @@ export type Host = {
socks5Password?: string;
socks5ProxyChain?: ProxyNode[];
macAddress?: string;
wolBroadcastAddress?: string;
portKnockSequence?: Array<{
port: number;
protocol?: "tcp" | "udp";
@@ -392,8 +390,6 @@ export interface HostData {
socks5Password?: string;
socks5ProxyChain?: ProxyNode[];
macAddress?: string;
wolBroadcastAddress?: string;
portKnockSequence?: Array<{
port: number;
protocol?: "tcp" | "udp";
-2
View File
@@ -48,8 +48,6 @@ export type Host = {
keyPassword?: string;
keyType?: string;
notes?: string;
macAddress?: string;
wolBroadcastAddress?: string;
pin?: boolean;
sortOrder?: number | null;
-9
View File
@@ -126,15 +126,6 @@ export async function updateSSHHost(
}
}
export async function wakeOnLan(hostId: number): Promise<{ success: boolean }> {
try {
const response = await sshHostApi.post(`/db/host/${hostId}/wake`);
return response.data;
} catch (error) {
throw handleApiError(error, "wake on LAN");
}
}
export async function bulkImportSSHHosts(
hosts: SSHHostData[],
overwrite = false,
+4 -6
View File
@@ -570,9 +570,6 @@
"secureShell": "Secure Shell",
"addressIp": "Address / IP",
"friendlyName": "Friendly Name",
"macAddress": "MAC Address",
"wolBroadcastAddress": "WoL Broadcast Address",
"wolBroadcastAddressDesc": "Optional directed broadcast for Docker/routed networks (e.g. 192.168.1.255). Leave empty to use 255.255.255.255.",
"folderAndAdvanced": "Folder & Advanced",
"showAdvancedSettings": "Show advanced settings",
"hideAdvancedSettings": "Hide advanced settings",
@@ -971,9 +968,6 @@
"remoteDesktopUrlCopied": "Remote Desktop URL copied",
"expandActions": "Expand actions",
"collapseActions": "Collapse actions",
"wakeOnLanAction": "Wake on LAN",
"wakeOnLanSuccess": "Magic packet sent to {{name}}",
"wakeOnLanError": "Failed to send magic packet",
"cloneHostAction": "Clone Host",
"copyAddress": "Copy Address",
"copyLink": "Copy Link",
@@ -2796,6 +2790,10 @@
"title": "Accept connections",
"consequence": "It opens a port on the Termix server."
},
"network:broadcast": {
"title": "Send network broadcasts",
"consequence": "It can send packets to devices on the local network, such as a Wake-on-LAN signal."
},
"users:read": {
"title": "See usernames and roles",
"consequence": "Never password hashes, never two factor state."
-1
View File
@@ -1340,7 +1340,6 @@ export {
getSSHHosts,
createSSHHost,
updateSSHHost,
wakeOnLan,
bulkImportSSHHosts,
importSSHConfigHosts,
discoverProxmoxGuests,
-4
View File
@@ -143,8 +143,6 @@ export function createHostEditorForm(
tagInput: "",
notes: host?.notes ?? "",
pin: host?.pin ?? false,
macAddress: host?.macAddress ?? "",
wolBroadcastAddress: host?.wolBroadcastAddress ?? "",
useSocks5: host?.useSocks5 ?? d?.useSocks5 ?? false,
socks5Host: host?.socks5Host ?? d?.socks5Host ?? "",
socks5Port: host?.socks5Port ?? d?.socks5Port ?? 1080,
@@ -418,8 +416,6 @@ export function buildHostEditorPayload(
usesVault && form.vaultProfileId ? Number(form.vaultProfileId) : null,
overrideCredentialUsername: form.overrideCredentialUsername,
notes: form.notes,
macAddress: form.macAddress || null,
wolBroadcastAddress: form.wolBroadcastAddress || null,
enableTerminal: form.enableTerminal,
enableSessionLogging: form.enableSessionLogging,
enableCommandHistory: form.enableCommandHistory,
-39
View File
@@ -190,45 +190,6 @@ export function HostEditorGeneralTab({
onChange={(e) => setField("name", e.target.value)}
/>
</div>
{protocols.enableSsh && !simpleMode && (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.macAddress")}
</label>
<a
href="https://docs.termix.site/features/networking/wake-on-lan"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</div>
<Input
placeholder="AA:BB:CC:DD:EE:FF"
value={form.macAddress}
onChange={(e) => setField("macAddress", e.target.value)}
/>
</div>
)}
{protocols.enableSsh && !simpleMode && form.macAddress && (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.wolBroadcastAddress")}
</label>
<Input
placeholder="192.168.1.255"
value={form.wolBroadcastAddress}
onChange={(e) =>
setField("wolBroadcastAddress", e.target.value)
}
/>
<p className="text-[10px] text-muted-foreground/60">
{t("hosts.wolBroadcastAddressDesc")}
</p>
</div>
)}
</div>
</div>
</SectionCard>
-2
View File
@@ -85,8 +85,6 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
notes: h.notes,
pin: h.pin ?? false,
sortOrder: h.sortOrder ?? null,
macAddress: h.macAddress,
wolBroadcastAddress: h.wolBroadcastAddress,
connectionOrigin: h.connectionOrigin ?? null,
enableSsh: h.enableSsh != null ? h.enableSsh : isSshHost,
enableTerminal:
+1 -27
View File
@@ -22,7 +22,6 @@ import {
Terminal,
Trash2,
Users,
Zap,
} from "lucide-react";
import {
DropdownMenu,
@@ -35,7 +34,7 @@ import {
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import { toast } from "sonner";
import { getHostPassword, wakeOnLan } from "@/main-axios";
import { getHostPassword } from "@/main-axios";
import type { Host, TabType } from "@/types/ui-types";
import type {
HostDensity,
@@ -366,16 +365,6 @@ export function HostItem({
}
}
async function handleWakeOnLan(e: MouseEvent) {
e.stopPropagation();
try {
await wakeOnLan(Number(host.id));
toast.success(t("hosts.wakeOnLanSuccess", { name: host.name }));
} catch {
toast.error(t("hosts.wakeOnLanError"));
}
}
if (query && !hostMatchesQuery(host, query)) return null;
const depthStyle =
@@ -506,15 +495,6 @@ export function HostItem({
);
return separator ? [separator, button] : button;
})}
{host.macAddress && (
<button
title={t("hosts.wakeOnLanAction")}
onClick={handleWakeOnLan}
className={trayButtonClass}
>
<Zap className="size-3.5" />
</button>
)}
</>
);
@@ -704,12 +684,6 @@ export function HostItem({
</DropdownMenuItem>
);
})}
{host.macAddress && (
<DropdownMenuItem onClick={handleWakeOnLan}>
<Zap className="size-3.5 mr-2" />
{t("hosts.wakeOnLanAction")}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
-1
View File
@@ -596,7 +596,6 @@ export function SidebarTree({
tags: host.tags ?? [],
pin: host.pin ?? false,
notes: host.notes,
macAddress: host.macAddress,
// Key material is never sent to the frontend, so a cloned key-auth
// host would have authType "key" with no key — unusable. Reset to
// password so the clone is in a connectable (editable) state.
@@ -3,19 +3,6 @@ import type { SSHHostWithStatus } from "@/main-axios";
import { sshHostToHost } from "@/sidebar/HostManagerData";
describe("sshHostToHost", () => {
it("preserves the Wake-on-LAN broadcast address for editing", () => {
const host = sshHostToHost({
id: 1,
name: "server",
ip: "192.168.0.10",
port: 22,
username: "root",
wolBroadcastAddress: "192.168.0.255",
} as SSHHostWithStatus);
expect(host.wolBroadcastAddress).toBe("192.168.0.255");
});
it.each(["local", "remote"] as const)(
"preserves the %s connection origin for editing",
(connectionOrigin) => {
@@ -36,7 +36,6 @@ vi.mock("@/hooks/use-status-color-scheme", () => ({
vi.mock("@/main-axios", () => ({
getHostPassword: vi.fn(),
wakeOnLan: vi.fn(),
}));
import { HostItem } from "../../../../sidebar/tree/HostItem/HostItem";
@@ -64,7 +63,6 @@ const baseHost: Host = {
enableRdp: true,
enableVnc: true,
enableTelnet: true,
macAddress: "aa:bb:cc:dd:ee:ff",
quickActions: [],
} as unknown as Host;
@@ -140,7 +138,6 @@ describe("HostItem density parity", () => {
0,
);
expect(screen.getAllByText("hosts.shareHost").length).toBeGreaterThan(0);
expect(screen.getByText("hosts.wakeOnLanAction")).toBeTruthy();
});
it.each(["comfortable", "compact"] as const)(
@@ -177,14 +174,6 @@ describe("HostItem density parity", () => {
},
);
it.each(["comfortable", "compact"] as const)(
"exposes the wake-on-LAN button when a MAC address is set, in %s density",
(density) => {
renderHostItem(density);
expect(screen.getByTitle("hosts.wakeOnLanAction")).toBeTruthy();
},
);
it.each(["comfortable", "compact"] as const)(
"exposes the copy-password action in %s density",
(density) => {