fix: small qol fixes and began readme update

This commit is contained in:
LukeGus
2025-12-29 23:07:38 -06:00
parent 260c31f46e
commit a34a1ecdc2
35 changed files with 282 additions and 59696 deletions

View File

@@ -425,6 +425,7 @@ jobs:
cp translations-temp/translations-ro/ro.json src/locales/ 2>/dev/null || true
cp translations-temp/translations-el/el.json src/locales/ 2>/dev/null || true
cp translations-temp/translations-nb/nb.json src/locales/ 2>/dev/null || true
rm -rf translations-temp
- name: Create Pull Request
uses: peter-evans/create-pull-request@v6

View File

@@ -45,7 +45,7 @@ If you would like, you can support the project here!\
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a multi-platform
solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal
access, SSH tunneling capabilities, and remote file management, with many more tools to come. Termix is the perfect
access, SSH tunneling capabilities, remote file management, and many other tools. Termix is the perfect
free and self-hosted alternative to Termius available for all platforms.
# Features
@@ -53,20 +53,22 @@ free and self-hosted alternative to Termius available for all platforms.
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) with a browser-like tab system. Includes support for customizing the terminal including common terminal themes, fonts, and other components
- **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring
- **Remote File Manager** - Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly
- **Docker Management** - Start, stop, pause, remove containers. View container stats. Control container using docker exec terminal. It was not made to replace Portainer or Dockge but rather to simply manage your containers compared to creating them.
- **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders, and easily save reusable login info while being able to automate the deployment of SSH keys
- **Server Stats** - View CPU, memory, and disk usage along with network, uptime, and system information on any SSH server
- **Dashboard** - View server information at a glance on your dashboard
- **RBAC** - Create roles and share hosts across users/roles
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together.
- **Database Encryption** - Backend stored as encrypted SQLite database files. View [docs](https://docs.termix.site/security) for more.
- **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data
- **Automatic SSL Setup** - Built-in SSL certificate generation and management with HTTPS redirects
- **Modern UI** - Clean desktop/mobile-friendly interface built with React, Tailwind CSS, and Shadcn
- **Languages** - Built-in support for English, Chinese, German, and Portuguese
- **Modern UI** - Clean desktop/mobile-friendly interface built with React, Tailwind CSS, and Shadcn. Choose between dark or light mode based UI.
- **Languages** - Built-in support ~30 languages (bulk translated via Google Translate, results may vary ofc)
- **Platform Support** - Available as a web app, desktop application (Windows, Linux, and macOS), and dedicated mobile/tablet app for iOS and Android.
- **SSH Tools** - Create reusable command snippets that execute with a single click. Run one command simultaneously across multiple open terminals.
- **Command History** - Auto-complete and view previously ran SSH commands
- **Command Palette** - Double tap left shift to quickly access SSH connections with your keyboard
- **SSH Feature Rich** - Supports jump hosts, warpgate, TOTP based connections, etc.
- **SSH Feature Rich** - Supports jump hosts, warpgate, TOTP based connections, SOCKS5, password autofill, etc.
# Planned Features

View File

@@ -2111,10 +2111,12 @@ router.post(
continue;
}
if (!["password", "key", "credential"].includes(hostData.authType)) {
if (
!["password", "key", "credential", "none"].includes(hostData.authType)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', or 'credential'`,
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', or 'none'`,
);
continue;
}

View File

@@ -137,10 +137,12 @@ async function createJumpHostChain(
const clients: SSHClient[] = [];
try {
for (let i = 0; i < jumpHosts.length; i++) {
const jumpHostConfig = await resolveJumpHost(jumpHosts[i].hostId, userId);
const jumpHostConfigs = await Promise.all(
jumpHosts.map((jh) => resolveJumpHost(jh.hostId, userId)),
);
if (!jumpHostConfig) {
for (let i = 0; i < jumpHostConfigs.length; i++) {
if (!jumpHostConfigs[i]) {
dockerLogger.error(`Jump host ${i + 1} not found`, undefined, {
operation: "jump_host_chain",
hostId: jumpHosts[i].hostId,
@@ -148,6 +150,10 @@ async function createJumpHostChain(
clients.forEach((c) => c.end());
return null;
}
}
for (let i = 0; i < jumpHostConfigs.length; i++) {
const jumpHostConfig = jumpHostConfigs[i];
const jumpClient = new SSHClient();
clients.push(jumpClient);

View File

@@ -38,13 +38,54 @@ export function useConfirmation() {
const confirmWithToast = (
opts: ConfirmationOptions | string,
callback?: () => void,
variantOrConfirmLabel: "default" | "destructive" | string = "Confirm",
cancelLabel: string = "Cancel",
): Promise<boolean> => {
if (typeof opts === "string" && callback) {
callback();
return Promise.resolve(true);
}
return new Promise((resolve) => {
const isVariant =
variantOrConfirmLabel === "default" ||
variantOrConfirmLabel === "destructive";
const confirmLabel = isVariant ? "Confirm" : variantOrConfirmLabel;
return Promise.resolve(true);
if (typeof opts === "string" && callback) {
toast(opts, {
action: {
label: confirmLabel,
onClick: () => {
callback();
resolve(true);
},
},
cancel: {
label: cancelLabel,
onClick: () => {
resolve(false);
},
},
} as any);
} else if (typeof opts === "object" && callback) {
const actualConfirmLabel = opts.confirmText || confirmLabel;
const actualCancelLabel = opts.cancelText || cancelLabel;
toast(opts.description, {
action: {
label: actualConfirmLabel,
onClick: () => {
callback();
resolve(true);
},
},
cancel: {
label: actualCancelLabel,
onClick: () => {
resolve(false);
},
},
} as any);
} else {
resolve(false);
}
});
};
return {

View File

@@ -2367,6 +2367,7 @@
"confirmRemoveContainer": "Are you sure you want to remove the container '{{name}}'? This action cannot be undone.",
"runningContainerWarning": "Warning: This container is currently running. Removing it will stop the container first.",
"removing": "Removing...",
"loadingContainers": "Loading containers...",
"noContainersFound": "No containers found",
"noContainersFoundHint": "No Docker containers are available on this host",
"searchPlaceholder": "Search containers...",

View File

@@ -52,6 +52,7 @@ export function DockerManager({
React.useState<DockerValidation | null>(null);
const [isValidating, setIsValidating] = React.useState(false);
const [viewMode, setViewMode] = React.useState<"list" | "detail">("list");
const [isLoadingContainers, setIsLoadingContainers] = React.useState(false);
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
@@ -179,12 +180,17 @@ export function DockerManager({
const pollContainers = async () => {
try {
setIsLoadingContainers(true);
const data = await listDockerContainers(sessionId, true);
if (!cancelled) {
setContainers(data);
}
} catch (error) {
// Silently handle polling errors
} finally {
if (!cancelled) {
setIsLoadingContainers(false);
}
}
};
@@ -334,16 +340,27 @@ export function DockerManager({
{viewMode === "list" ? (
<div className="h-full px-4 py-4">
{sessionId ? (
<ContainerList
containers={containers}
sessionId={sessionId}
onSelectContainer={(id) => {
setSelectedContainer(id);
setViewMode("detail");
}}
selectedContainerId={selectedContainer}
onRefresh={refreshContainers}
/>
isLoadingContainers && containers.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<SimpleLoader size="lg" />
<p className="text-muted-foreground mt-4">
{t("docker.loadingContainers")}
</p>
</div>
</div>
) : (
<ContainerList
containers={containers}
sessionId={sessionId}
onSelectContainer={(id) => {
setSelectedContainer(id);
setViewMode("detail");
}}
selectedContainerId={selectedContainer}
onRefresh={refreshContainers}
/>
)
) : (
<div className="text-center py-8">
<p className="text-muted-foreground">No session available</p>

View File

@@ -123,7 +123,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [isFitted, setIsFitted] = useState(true);
const [isFitted, setIsFitted] = useState(false);
const [, setConnectionError] = useState<string | null>(null);
const [, setIsAuthenticated] = useState(false);
const [totpRequired, setTotpRequired] = useState(false);
@@ -714,6 +714,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
sudoPromptShownRef.current = false;
}, 3000);
},
t("common.confirm"),
t("common.cancel"),
);
setTimeout(() => {
sudoPromptShownRef.current = false;
@@ -1133,7 +1135,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
if (terminal.cols < 10 || terminal.rows < 3) {
requestAnimationFrame(() => {
fitAddonRef.current?.fit();
setIsFitted(true);
});
} else {
setIsFitted(true);
}
const element = xtermRef.current;
@@ -1479,6 +1484,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
className="h-full w-full"
style={{
pointerEvents: isVisible ? "auto" : "none",
visibility: isConnecting || !isFitted ? "hidden" : "visible",
}}
onClick={() => {
if (terminal && !splitScreen) {

View File

@@ -532,6 +532,184 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
}
};
const getSampleData = () => ({
hosts: [
{
name: t("interface.webServerProduction"),
ip: "192.168.1.100",
port: 22,
username: "admin",
authType: "password",
password: "your_secure_password_here",
folder: t("interface.productionFolder"),
tags: ["web", "production", "nginx"],
pin: true,
notes: "Main production web server running Nginx",
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
enableDocker: false,
defaultPath: "/var/www",
},
{
name: t("interface.databaseServer"),
ip: "192.168.1.101",
port: 22,
username: "dbadmin",
authType: "key",
key: "-----BEGIN OPENSSH PRIVATE KEY-----\\nYour SSH private key content here\\n-----END OPENSSH PRIVATE KEY-----",
keyPassword: "optional_key_passphrase",
keyType: "ssh-ed25519",
folder: t("interface.productionFolder"),
tags: ["database", "production", "postgresql"],
pin: false,
notes: "PostgreSQL production database",
enableTerminal: true,
enableTunnel: true,
enableFileManager: false,
enableDocker: false,
tunnelConnections: [
{
sourcePort: 5432,
endpointPort: 5432,
endpointHost: t("interface.webServerProduction"),
maxRetries: 3,
retryInterval: 10,
autoStart: true,
},
],
statsConfig: {
enabledWidgets: ["cpu", "memory", "disk", "network", "uptime"],
statusCheckEnabled: true,
statusCheckInterval: 30,
metricsEnabled: true,
metricsInterval: 30,
},
},
{
name: t("interface.developmentServer"),
ip: "192.168.1.102",
port: 2222,
username: "developer",
authType: "credential",
credentialId: 1,
overrideCredentialUsername: false,
folder: t("interface.developmentFolder"),
tags: ["dev", "testing"],
pin: false,
notes: "Development environment for testing",
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
enableDocker: true,
defaultPath: "/home/developer",
},
{
name: "Jump Host Server",
ip: "10.0.0.50",
port: 22,
username: "sysadmin",
authType: "password",
password: "secure_password",
folder: "Infrastructure",
tags: ["bastion", "jump-host"],
notes: "Jump host for accessing internal network",
enableTerminal: true,
enableTunnel: true,
enableFileManager: true,
enableDocker: false,
jumpHosts: [
{
hostId: 1,
},
],
quickActions: [
{
name: "System Update",
snippetId: 5,
},
],
},
{
name: "Server with SOCKS5 Proxy",
ip: "10.10.10.100",
port: 22,
username: "proxyuser",
authType: "password",
password: "secure_password",
folder: "Proxied Hosts",
tags: ["proxy", "socks5"],
notes: "Accessible through SOCKS5 proxy",
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
enableDocker: false,
useSocks5: true,
socks5Host: "proxy.example.com",
socks5Port: 1080,
socks5Username: "proxyauth",
socks5Password: "proxypass",
},
{
name: "Customized Terminal Server",
ip: "192.168.1.150",
port: 22,
username: "devops",
authType: "password",
password: "terminal_password",
folder: t("interface.developmentFolder"),
tags: ["custom", "terminal"],
notes: "Server with custom terminal configuration",
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
enableDocker: false,
defaultPath: "/opt/apps",
terminalConfig: {
cursorBlink: true,
cursorStyle: "bar",
fontSize: 16,
fontFamily: "jetbrainsMono",
letterSpacing: 0.5,
lineHeight: 1.2,
theme: "monokai",
scrollback: 50000,
bellStyle: "visual",
rightClickSelectsWord: true,
fastScrollModifier: "ctrl",
fastScrollSensitivity: 7,
minimumContrastRatio: 4,
backspaceMode: "normal",
agentForwarding: true,
environmentVariables: [
{
key: "NODE_ENV",
value: "development",
},
],
autoMosh: false,
sudoPasswordAutoFill: true,
sudoPassword: "sudo_password_here",
},
},
],
});
const handleDownloadSample = () => {
const sampleData = getSampleData();
const blob = new Blob([JSON.stringify(sampleData, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "sample-ssh-hosts.json";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleJsonImport = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
@@ -706,84 +884,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
</Tooltip>
</TooltipProvider>
<Button
variant="outline"
size="sm"
onClick={() => {
const sampleData = {
hosts: [
{
name: t("interface.webServerProduction"),
ip: "192.168.1.100",
port: 22,
username: "admin",
authType: "password",
password: "your_secure_password_here",
folder: t("interface.productionFolder"),
tags: ["web", "production", "nginx"],
pin: true,
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
defaultPath: "/var/www",
},
{
name: t("interface.databaseServer"),
ip: "192.168.1.101",
port: 22,
username: "dbadmin",
authType: "key",
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
keyPassword: "optional_key_passphrase",
keyType: "ssh-ed25519",
folder: t("interface.productionFolder"),
tags: ["database", "production", "postgresql"],
pin: false,
enableTerminal: true,
enableTunnel: true,
enableFileManager: false,
tunnelConnections: [
{
sourcePort: 5432,
endpointPort: 5432,
endpointHost: t("interface.webServerProduction"),
maxRetries: 3,
retryInterval: 10,
autoStart: true,
},
],
},
{
name: t("interface.developmentServer"),
ip: "192.168.1.102",
port: 2222,
username: "developer",
authType: "credential",
credentialId: 1,
folder: t("interface.developmentFolder"),
tags: ["dev", "testing"],
pin: false,
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
defaultPath: "/home/developer",
},
],
};
const blob = new Blob([JSON.stringify(sampleData, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "sample-ssh-hosts.json";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}}
>
<Button variant="outline" size="sm" onClick={handleDownloadSample}>
{t("hosts.downloadSample")}
</Button>
@@ -867,84 +968,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
</Tooltip>
</TooltipProvider>
<Button
variant="outline"
size="sm"
onClick={() => {
const sampleData = {
hosts: [
{
name: t("interface.webServerProduction"),
ip: "192.168.1.100",
port: 22,
username: "admin",
authType: "password",
password: "your_secure_password_here",
folder: t("interface.productionFolder"),
tags: ["web", "production", "nginx"],
pin: true,
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
defaultPath: "/var/www",
},
{
name: t("interface.databaseServer"),
ip: "192.168.1.101",
port: 22,
username: "dbadmin",
authType: "key",
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
keyPassword: "optional_key_passphrase",
keyType: "ssh-ed25519",
folder: t("interface.productionFolder"),
tags: ["database", "production", "postgresql"],
pin: false,
enableTerminal: true,
enableTunnel: true,
enableFileManager: false,
tunnelConnections: [
{
sourcePort: 5432,
endpointPort: 5432,
endpointHost: t("interface.webServerProduction"),
maxRetries: 3,
retryInterval: 10,
autoStart: true,
},
],
},
{
name: t("interface.developmentServer"),
ip: "192.168.1.102",
port: 2222,
username: "developer",
authType: "credential",
credentialId: 1,
folder: t("interface.developmentFolder"),
tags: ["dev", "testing"],
pin: false,
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
defaultPath: "/home/developer",
},
],
};
const blob = new Blob([JSON.stringify(sampleData, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "sample-ssh-hosts.json";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}}
>
<Button variant="outline" size="sm" onClick={handleDownloadSample}>
{t("hosts.downloadSample")}
</Button>

View File

@@ -639,7 +639,7 @@ export function HostTerminalTab({ form, snippets, t }: HostTerminalTabProps) {
control={form.control}
name="terminalConfig.sudoPasswordAutoFill"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.sudoPasswordAutoFill")}</FormLabel>
<FormDescription>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff