feat: host color and icon columns that sync

This commit is contained in:
LukeGus committed 2026-10-25 11:25:50 -05:00
1 parent 90f5aebafb
commit ea0e614183
21 files changed
+13970

No files matched your search

+1
View File
@@ -7,6 +7,7 @@
- The mobile app can sign in through your browser with any login method, including passkeys and security keys
- Plugins can say how they show in the mobile app
- The plugin list tells the mobile app which plugin API each plugin needs, so the app can say when it or a plugin needs an update
- Hosts can have a color and an icon, which sync to the desktop and mobile apps
## 26.10.5
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `ssh_data` ADD `color` text;--> statement-breakpoint
ALTER TABLE `ssh_data` ADD `icon` text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -533,6 +533,13 @@
"when": 1792430772041,
"tag": "0075_session_policy",
"breakpoints": true
},
{
"idx": 76,
"version": "5",
"when": 1792945361809,
"tag": "0076_host_color_icon",
"breakpoints": true
}
]
}
@@ -0,0 +1,2 @@
ALTER TABLE "ssh_data" ADD COLUMN "color" text;--> statement-breakpoint
ALTER TABLE "ssh_data" ADD COLUMN "icon" text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -533,6 +533,13 @@
"when": 1792430770990,
"tag": "0075_session_policy",
"breakpoints": true
},
{
"idx": 76,
"version": "7",
"when": 1792945360507,
"tag": "0076_host_color_icon",
"breakpoints": true
}
]
}
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `ssh_data` ADD `color` text;--> statement-breakpoint
ALTER TABLE `ssh_data` ADD `icon` text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -498,6 +498,13 @@
"when": 1792430769899,
"tag": "0070_session_policy",
"breakpoints": true
},
{
"idx": 71,
"version": "6",
"when": 1792945434651,
"tag": "0071_host_color_icon",
"breakpoints": true
}
]
}
+2
View File
@@ -1316,6 +1316,8 @@ const migrateSchema = () => {
{ column: "ssh_port", sql: "ALTER TABLE ssh_data ADD COLUMN ssh_port INTEGER DEFAULT 22" },
{ column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" },
{ column: "parent_host_id", sql: "ALTER TABLE ssh_data ADD COLUMN parent_host_id INTEGER REFERENCES ssh_data(id) ON DELETE SET NULL" },
{ column: "color", sql: "ALTER TABLE ssh_data ADD COLUMN color TEXT" },
{ column: "icon", sql: "ALTER TABLE ssh_data ADD COLUMN icon TEXT" },
];
for (const migration of sshDataMigrations) {
+3
View File
@@ -193,6 +193,9 @@ export const hosts = mysqlTable(
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
notes: text("notes"),
// Hex color and Lucide icon name a layout may show next to the host.
color: text("color"),
icon: text("icon"),
enableSsh: boolean("enable_ssh").notNull().default(true),
sshPort: int("ssh_port").default(22),
+3
View File
@@ -193,6 +193,9 @@ export const hosts = pgTable(
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
notes: text("notes"),
// Hex color and Lucide icon name a layout may show next to the host.
color: text("color"),
icon: text("icon"),
enableSsh: boolean("enable_ssh").notNull().default(true),
sshPort: integer("ssh_port").default(22),
+3
View File
@@ -183,6 +183,9 @@ export const hosts = sqliteTable(
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
notes: text("notes"),
// Hex color and Lucide icon name a layout may show next to the host.
color: text("color"),
icon: text("icon"),
enableSsh: integer("enable_ssh", { mode: "boolean" }).notNull().default(true),
sshPort: integer("ssh_port").default(22),
@@ -30,6 +30,33 @@ export function applyHostKeyTypeUpdate(
}
}
const HOST_COLOR = /^#[0-9a-fA-F]{6}$/;
const HOST_ICON = /^[a-z0-9-]{1,64}$/;
/**
* Color and icon from a host write. A field left out stays as stored, so
* editors that don't know about them never clear them. Bad values store null.
*/
export function normalizeHostLookFields(values: Record<string, unknown>): {
color?: string | null;
icon?: string | null;
} {
const result: { color?: string | null; icon?: string | null } = {};
if (values.color !== undefined) {
result.color =
typeof values.color === "string" && HOST_COLOR.test(values.color)
? values.color.toLowerCase()
: null;
}
if (values.icon !== undefined) {
result.icon =
typeof values.icon === "string" && HOST_ICON.test(values.icon)
? values.icon
: null;
}
return result;
}
const PROTOCOL_ENABLE_FIELDS = ["enableSsh"] as const;
export function normalizeProtocolEnableFields(
+3
View File
@@ -33,6 +33,7 @@ import {
isOptionalAuthType,
isOptionalBoolean,
isValidPort,
normalizeHostLookFields,
normalizeProtocolEnableFields,
OWNER_PRIVATE_AUTH_FIELDS,
OWNER_PRIVATE_SSH_OPTION_FIELDS,
@@ -335,6 +336,7 @@ router.post(
? JSON.stringify(portKnockSequence)
: null,
...normalizeProtocolEnableFields(hostData),
...normalizeHostLookFields(hostData),
sshPort: sshPort || port || 22,
};
@@ -912,6 +914,7 @@ router.put(
? JSON.stringify(portKnockSequence)
: null,
...normalizeProtocolEnableFields(hostData),
...normalizeHostLookFields(hostData),
sshPort: sshPort || port || 22,
};
+2
View File
@@ -73,6 +73,8 @@ async function exportRow(
: resolvedHost.tags || [],
pin: !!resolvedHost.pin,
notes: resolvedHost.notes || null,
color: resolvedHost.color || null,
icon: resolvedHost.icon || null,
// Every plugin's host settings, secrets redacted, for import to hand back.
pluginSettings: pluginSettings.get(host.id as number) ?? {},
// Each plugin protocol's login, secrets included like the SSH ones.
+2
View File
@@ -28,6 +28,7 @@ import { applyPluginHostImportSettings } from "../../database/routes/host-plugin
import {
isNonEmptyString,
isValidPort,
normalizeHostLookFields,
normalizeImportedHost,
} from "../../database/routes/host-normalizers.js";
import { hasHostLocks, hostLock, manualCreateBlockedBy } from "../locks.js";
@@ -571,6 +572,7 @@ export async function runBulkImport(
? "true"
: "false",
notes: hostData.notes || null,
...normalizeHostLookFields(hostData as Record<string, unknown>),
useSocks5: hostData.useSocks5 ? 1 : 0,
socks5Host: hostData.socks5Host || null,
socks5Port: hostData.socks5Port || null,
@@ -214,3 +214,43 @@ describe("host.created", () => {
}
});
});
describe("host color and icon", () => {
async function stored(id: number) {
const [row] = await ctx.drizzle
.select({ color: hosts.color, icon: hosts.icon })
.from(hosts)
.where(eq(hosts.id, id));
return row;
}
it("saves them on create and keeps them when an update leaves them out", async () => {
const created = await request(app)
.post("/host/db/host")
.send({
...base,
authType: "password",
password: "pw",
color: "#22C55E",
icon: "database",
});
expect(created.status).toBe(200);
expect(await stored(created.body.id)).toEqual({
color: "#22c55e",
icon: "database",
});
await request(app)
.put(`/host/db/host/${created.body.id}`)
.send({ ...base, name: "renamed" });
expect(await stored(created.body.id)).toEqual({
color: "#22c55e",
icon: "database",
});
await request(app)
.put(`/host/db/host/${created.body.id}`)
.send({ ...base, color: null, icon: null });
expect(await stored(created.body.id)).toEqual({ color: null, icon: null });
});
});
@@ -8,6 +8,7 @@ import {
keepsStoredSshAuth,
isOptionalBoolean,
isValidPort,
normalizeHostLookFields,
normalizeImportedHost,
normalizeProtocolEnableFields,
renameFolderPath,
@@ -529,3 +530,25 @@ describe("impliedSshAuthType", () => {
expect(impliedSshAuthType({})).toBeUndefined();
});
});
describe("normalizeHostLookFields", () => {
it("leaves both fields out when the write does not send them", () => {
expect(normalizeHostLookFields({ name: "web" })).toEqual({});
});
it("keeps a hex color and a Lucide name", () => {
expect(
normalizeHostLookFields({ color: "#3B82F6", icon: "server-cog" }),
).toEqual({ color: "#3b82f6", icon: "server-cog" });
});
it("stores null for cleared or bad values", () => {
expect(normalizeHostLookFields({ color: "red", icon: "<script>" })).toEqual(
{ color: null, icon: null },
);
expect(normalizeHostLookFields({ color: null, icon: "" })).toEqual({
color: null,
icon: null,
});
});
});
+8
View File
@@ -72,6 +72,10 @@ export type Host = {
terminalConfig?: HostTerminalConfig;
sshOptions?: HostSshOptions;
notes?: string;
/** Hex color, like #3b82f6. */
color?: string | null;
/** Lucide icon name. */
icon?: string | null;
useSocks5?: boolean;
socks5Host?: string;
@@ -182,6 +186,10 @@ export interface HostData {
terminalConfig?: HostTerminalConfig;
sshOptions?: HostSshOptions;
notes?: string;
/** Hex color, like #3b82f6. */
color?: string | null;
/** Lucide icon name. */
icon?: string | null;
useSocks5?: boolean;
socks5Host?: string;