Notes and Expiry fields add (#453)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* Notes and Expiry add

* fix: cleanup files

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
This commit was merged in pull request #453.
This commit is contained in:
Denis
2025-12-20 10:11:28 +07:00
committed by GitHub
parent ab1c63a4f6
commit ef8d3a9d9c
9 changed files with 140 additions and 33 deletions

View File

@@ -210,6 +210,8 @@ async function initializeCompleteDatabase(): Promise<void> {
stats_config TEXT, stats_config TEXT,
docker_config TEXT, docker_config TEXT,
terminal_config TEXT, terminal_config TEXT,
notes TEXT,
expiration_date TEXT,
use_socks5 INTEGER, use_socks5 INTEGER,
socks5_host TEXT, socks5_host TEXT,
socks5_port INTEGER, socks5_port INTEGER,
@@ -575,6 +577,9 @@ const migrateSchema = () => {
); );
addColumnIfNotExists("ssh_data", "docker_config", "TEXT"); addColumnIfNotExists("ssh_data", "docker_config", "TEXT");
addColumnIfNotExists("ssh_data", "notes", "TEXT");
addColumnIfNotExists("ssh_data", "expiration_date", "TEXT");
// SOCKS5 Proxy columns // SOCKS5 Proxy columns
addColumnIfNotExists("ssh_data", "use_socks5", "INTEGER"); addColumnIfNotExists("ssh_data", "use_socks5", "INTEGER");
addColumnIfNotExists("ssh_data", "socks5_host", "TEXT"); addColumnIfNotExists("ssh_data", "socks5_host", "TEXT");

View File

@@ -93,6 +93,8 @@ export const sshData = sqliteTable("ssh_data", {
statsConfig: text("stats_config"), statsConfig: text("stats_config"),
terminalConfig: text("terminal_config"), terminalConfig: text("terminal_config"),
quickActions: text("quick_actions"), quickActions: text("quick_actions"),
notes: text("notes"),
expirationDate: text("expiration_date"),
useSocks5: integer("use_socks5", { mode: "boolean" }), useSocks5: integer("use_socks5", { mode: "boolean" }),
socks5Host: text("socks5_host"), socks5Host: text("socks5_host"),

View File

@@ -14,7 +14,17 @@ import {
hostAccess, hostAccess,
userRoles, userRoles,
} from "../db/schema.js"; } from "../db/schema.js";
import { eq, and, desc, isNotNull, or, isNull, gte, sql, inArray } from "drizzle-orm"; import {
eq,
and,
desc,
isNotNull,
or,
isNull,
gte,
sql,
inArray,
} from "drizzle-orm";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import multer from "multer"; import multer from "multer";
import { sshLogger } from "../../utils/logger.js"; import { sshLogger } from "../../utils/logger.js";
@@ -245,12 +255,15 @@ router.post(
statsConfig, statsConfig,
terminalConfig, terminalConfig,
forceKeyboardInteractive, forceKeyboardInteractive,
notes,
expirationDate,
useSocks5, useSocks5,
socks5Host, socks5Host,
socks5Port, socks5Port,
socks5Username, socks5Username,
socks5Password, socks5Password,
socks5ProxyChain, socks5ProxyChain,
overrideCredentialUsername,
} = hostData; } = hostData;
console.log("POST /db/ssh - Received SOCKS5 data:", { console.log("POST /db/ssh - Received SOCKS5 data:", {
@@ -285,6 +298,7 @@ router.post(
username, username,
authType: effectiveAuthType, authType: effectiveAuthType,
credentialId: credentialId || null, credentialId: credentialId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0, pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0, enableTerminal: enableTerminal ? 1 : 0,
enableTunnel: enableTunnel ? 1 : 0, enableTunnel: enableTunnel ? 1 : 0,
@@ -301,12 +315,16 @@ router.post(
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
notes: notes || null,
expirationDate: expirationDate || null,
useSocks5: useSocks5 ? 1 : 0, useSocks5: useSocks5 ? 1 : 0,
socks5Host: socks5Host || null, socks5Host: socks5Host || null,
socks5Port: socks5Port || null, socks5Port: socks5Port || null,
socks5Username: socks5Username || null, socks5Username: socks5Username || null,
socks5Password: socks5Password || null, socks5Password: socks5Password || null,
socks5ProxyChain: socks5ProxyChain ? JSON.stringify(socks5ProxyChain) : null, socks5ProxyChain: socks5ProxyChain
? JSON.stringify(socks5ProxyChain)
: null,
}; };
if (effectiveAuthType === "password") { if (effectiveAuthType === "password") {
@@ -489,13 +507,20 @@ router.put(
statsConfig, statsConfig,
terminalConfig, terminalConfig,
forceKeyboardInteractive, forceKeyboardInteractive,
notes,
expirationDate,
useSocks5, useSocks5,
socks5Host, socks5Host,
socks5Port, socks5Port,
socks5Username, socks5Username,
socks5Password, socks5Password,
socks5ProxyChain, socks5ProxyChain,
overrideCredentialUsername,
} = hostData; } = hostData;
// Temporary logging to debug notes and expirationDate
console.log("DEBUG - Update host data:", { notes, expirationDate });
if ( if (
!isNonEmptyString(userId) || !isNonEmptyString(userId) ||
!isNonEmptyString(ip) || !isNonEmptyString(ip) ||
@@ -523,6 +548,7 @@ router.put(
username, username,
authType: effectiveAuthType, authType: effectiveAuthType,
credentialId: credentialId || null, credentialId: credentialId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0, pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0, enableTerminal: enableTerminal ? 1 : 0,
enableTunnel: enableTunnel ? 1 : 0, enableTunnel: enableTunnel ? 1 : 0,
@@ -539,12 +565,16 @@ router.put(
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
notes: notes || null,
expirationDate: expirationDate || null,
useSocks5: useSocks5 ? 1 : 0, useSocks5: useSocks5 ? 1 : 0,
socks5Host: socks5Host || null, socks5Host: socks5Host || null,
socks5Port: socks5Port || null, socks5Port: socks5Port || null,
socks5Username: socks5Username || null, socks5Username: socks5Username || null,
socks5Password: socks5Password || null, socks5Password: socks5Password || null,
socks5ProxyChain: socks5ProxyChain ? JSON.stringify(socks5ProxyChain) : null, socks5ProxyChain: socks5ProxyChain
? JSON.stringify(socks5ProxyChain)
: null,
}; };
if (effectiveAuthType === "password") { if (effectiveAuthType === "password") {
@@ -742,6 +772,8 @@ router.get(
credentialId: sshData.credentialId, credentialId: sshData.credentialId,
overrideCredentialUsername: sshData.overrideCredentialUsername, overrideCredentialUsername: sshData.overrideCredentialUsername,
quickActions: sshData.quickActions, quickActions: sshData.quickActions,
notes: sshData.notes,
expirationDate: sshData.expirationDate,
// Shared access info // Shared access info
isShared: sql<boolean>`${hostAccess.id} IS NOT NULL`, isShared: sql<boolean>`${hostAccess.id} IS NOT NULL`,
@@ -755,12 +787,11 @@ router.get(
eq(hostAccess.hostId, sshData.id), eq(hostAccess.hostId, sshData.id),
or( or(
eq(hostAccess.userId, userId), eq(hostAccess.userId, userId),
roleIds.length > 0 ? inArray(hostAccess.roleId, roleIds) : sql`false`, roleIds.length > 0
), ? inArray(hostAccess.roleId, roleIds)
or( : sql`false`,
isNull(hostAccess.expiresAt),
gte(hostAccess.expiresAt, now),
), ),
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
), ),
) )
.where( .where(
@@ -769,10 +800,7 @@ router.get(
and( and(
// Shared to user directly (not expired) // Shared to user directly (not expired)
eq(hostAccess.userId, userId), eq(hostAccess.userId, userId),
or( or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
isNull(hostAccess.expiresAt),
gte(hostAccess.expiresAt, now),
),
), ),
roleIds.length > 0 roleIds.length > 0
? and( ? and(

View File

@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return ( return (
<textarea <textarea
className={cn( className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "flex min-h-[80px] w-full rounded-md border border-input bg-transparent dark:bg-input/30 px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] duration-200 outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 disabled:pointer-events-none",
className, className,
)} )}
ref={ref} ref={ref}

View File

@@ -670,6 +670,8 @@
"folder": "Folder", "folder": "Folder",
"tags": "Tags", "tags": "Tags",
"pin": "Pin", "pin": "Pin",
"notes": "Notes",
"expirationDate": "Expiration Date",
"passwordRequired": "Password is required when using password authentication", "passwordRequired": "Password is required when using password authentication",
"sshKeyRequired": "SSH Private Key is required when using key authentication", "sshKeyRequired": "SSH Private Key is required when using key authentication",
"keyTypeRequired": "Key Type is required when using key authentication", "keyTypeRequired": "Key Type is required when using key authentication",
@@ -1615,6 +1617,8 @@
"folder": "folder", "folder": "folder",
"password": "password", "password": "password",
"keyPassword": "key password", "keyPassword": "key password",
"notes": "Add notes about this host...",
"expirationDate": "Select expiration date",
"pastePrivateKey": "Paste your private key here...", "pastePrivateKey": "Paste your private key here...",
"pastePublicKey": "Paste your public key here...", "pastePublicKey": "Paste your public key here...",
"credentialName": "My SSH Server", "credentialName": "My SSH Server",

View File

@@ -665,6 +665,8 @@
"folder": "Папка", "folder": "Папка",
"tags": "Теги", "tags": "Теги",
"pin": "Закрепить", "pin": "Закрепить",
"notes": "Заметки",
"expirationDate": "Дата истечения",
"passwordRequired": "Пароль требуется при использовании аутентификации по паролю", "passwordRequired": "Пароль требуется при использовании аутентификации по паролю",
"sshKeyRequired": "Приватный SSH-ключ требуется при использовании аутентификации по ключу", "sshKeyRequired": "Приватный SSH-ключ требуется при использовании аутентификации по ключу",
"keyTypeRequired": "Тип ключа требуется при использовании аутентификации по ключу", "keyTypeRequired": "Тип ключа требуется при использовании аутентификации по ключу",
@@ -915,7 +917,7 @@
"noProxyNodes": "Узлы прокси не настроены. Нажмите 'Добавить узел прокси' чтобы добавить.", "noProxyNodes": "Узлы прокси не настроены. Нажмите 'Добавить узел прокси' чтобы добавить.",
"proxyNode": "Узел прокси", "proxyNode": "Узел прокси",
"proxyType": "Тип прокси", "proxyType": "Тип прокси",
"advancedAuthSettings": "Расширенные настройки аутентификации" "advancedAuthSettings": "Расширенные настройки аутентификации",
"advancedAuthSettings": "Расширенные настройки аутентификации", "advancedAuthSettings": "Расширенные настройки аутентификации",
"addQuickAction": "Добавить Quick Action", "addQuickAction": "Добавить Quick Action",
"allHostsInFolderDeleted": "{{count}} хостов успешно удалены из папки \"{{folder}}\"", "allHostsInFolderDeleted": "{{count}} хостов успешно удалены из папки \"{{folder}}\"",
@@ -1609,6 +1611,8 @@
"folder": "папка", "folder": "папка",
"password": "пароль", "password": "пароль",
"keyPassword": "пароль ключа", "keyPassword": "пароль ключа",
"notes": "Добавьте заметки об этом хосте...",
"expirationDate": "Выберите дату истечения",
"pastePrivateKey": "Вставьте ваш приватный ключ здесь...", "pastePrivateKey": "Вставьте ваш приватный ключ здесь...",
"pastePublicKey": "Вставьте ваш публичный ключ здесь...", "pastePublicKey": "Вставьте ваш публичный ключ здесь...",
"credentialName": "Мой SSH-сервер", "credentialName": "Мой SSH-сервер",
@@ -1833,4 +1837,4 @@
"close": "Закрыть", "close": "Закрыть",
"hostManager": "Менеджер хостов" "hostManager": "Менеджер хостов"
} }
} }

View File

@@ -47,6 +47,8 @@ export interface SSHHost {
quickActions?: QuickAction[]; quickActions?: QuickAction[];
statsConfig?: string | Record<string, unknown>; statsConfig?: string | Record<string, unknown>;
terminalConfig?: TerminalConfig; terminalConfig?: TerminalConfig;
notes?: string;
expirationDate?: string;
useSocks5?: boolean; useSocks5?: boolean;
socks5Host?: string; socks5Host?: string;
@@ -107,6 +109,8 @@ export interface SSHHostData {
quickActions?: QuickActionData[]; quickActions?: QuickActionData[];
statsConfig?: string | Record<string, unknown>; statsConfig?: string | Record<string, unknown>;
terminalConfig?: TerminalConfig; terminalConfig?: TerminalConfig;
notes?: string;
expirationDate?: string;
// SOCKS5 Proxy configuration // SOCKS5 Proxy configuration
useSocks5?: boolean; useSocks5?: boolean;

View File

@@ -14,6 +14,7 @@ import {
} from "@/components/ui/form.tsx"; } from "@/components/ui/form.tsx";
import { Input } from "@/components/ui/input.tsx"; import { Input } from "@/components/ui/input.tsx";
import { PasswordInput } from "@/components/ui/password-input.tsx"; import { PasswordInput } from "@/components/ui/password-input.tsx";
import { Textarea } from "@/components/ui/textarea.tsx";
import { ScrollArea } from "@/components/ui/scroll-area.tsx"; import { ScrollArea } from "@/components/ui/scroll-area.tsx";
import { Separator } from "@/components/ui/separator.tsx"; import { Separator } from "@/components/ui/separator.tsx";
import { import {
@@ -534,6 +535,8 @@ export function HostManagerEditor({
}), }),
) )
.default([]), .default([]),
notes: z.string().optional(),
expirationDate: z.string().optional(),
useSocks5: z.boolean().optional(), useSocks5: z.boolean().optional(),
socks5Host: z.string().optional(), socks5Host: z.string().optional(),
socks5Port: z.coerce.number().min(1).max(65535).optional(), socks5Port: z.coerce.number().min(1).max(65535).optional(),
@@ -639,6 +642,8 @@ export function HostManagerEditor({
statsConfig: DEFAULT_STATS_CONFIG, statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG, terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false, forceKeyboardInteractive: false,
notes: "",
expirationDate: "",
useSocks5: false, useSocks5: false,
socks5Host: "", socks5Host: "",
socks5Port: 1080, socks5Port: 1080,
@@ -741,6 +746,8 @@ export function HostManagerEditor({
: [], : [],
}, },
forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive), forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive),
notes: cleanedHost.notes || "",
expirationDate: cleanedHost.expirationDate || "",
useSocks5: Boolean(cleanedHost.useSocks5), useSocks5: Boolean(cleanedHost.useSocks5),
socks5Host: cleanedHost.socks5Host || "", socks5Host: cleanedHost.socks5Host || "",
socks5Port: cleanedHost.socks5Port || 1080, socks5Port: cleanedHost.socks5Port || 1080,
@@ -862,15 +869,6 @@ export function HostManagerEditor({
const submitData: Partial<SSHHost> = { const submitData: Partial<SSHHost> = {
...data, ...data,
}; };
if (proxyMode === "single") {
submitData.socks5ProxyChain = [];
} else if (proxyMode === "chain") {
submitData.socks5Host = "";
submitData.socks5Port = 1080;
submitData.socks5Username = "";
submitData.socks5Password = "";
}
if (data.authType !== "credential") { if (data.authType !== "credential") {
submitData.credentialId = undefined; submitData.credentialId = undefined;
@@ -1390,6 +1388,47 @@ export function HostManagerEditor({
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="expirationDate"
render={({ field }) => (
<FormItem className="col-span-10">
<FormLabel>{t("hosts.expirationDate")}</FormLabel>
<FormControl>
<Input
type="date"
placeholder={t("placeholders.expirationDate")}
value={field.value || ""}
onChange={field.onChange}
onBlur={field.onBlur}
name={field.name}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem className="col-span-26">
<FormLabel>{t("hosts.notes")}</FormLabel>
<FormControl>
<Textarea
placeholder={t("placeholders.notes")}
className="resize-none"
rows={3}
value={field.value || ""}
onChange={field.onChange}
onBlur={field.onBlur}
name={field.name}
/>
</FormControl>
</FormItem>
)}
/>
</div> </div>
<FormLabel className="mb-3 mt-3 font-bold"> <FormLabel className="mb-3 mt-3 font-bold">
{t("hosts.authentication")} {t("hosts.authentication")}

View File

@@ -643,7 +643,7 @@ function initializeApiInstances() {
// RBAC API (port 30001) // RBAC API (port 30001)
rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC"); rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC");
// Docker Management API (port 30007) // Docker Management API (port 30007)
dockerApi = createApiInstance(getApiUrl("/docker", 30007), "DOCKER"); dockerApi = createApiInstance(getApiUrl("/docker", 30007), "DOCKER");
} }
@@ -931,6 +931,8 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
: null, : null,
terminalConfig: hostData.terminalConfig || null, terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
notes: hostData.notes || "",
expirationDate: hostData.expirationDate || "",
useSocks5: Boolean(hostData.useSocks5), useSocks5: Boolean(hostData.useSocks5),
socks5Host: hostData.socks5Host || null, socks5Host: hostData.socks5Host || null,
socks5Port: hostData.socks5Port || null, socks5Port: hostData.socks5Port || null,
@@ -1009,6 +1011,8 @@ export async function updateSSHHost(
: null, : null,
terminalConfig: hostData.terminalConfig || null, terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
notes: hostData.notes || "",
expirationDate: hostData.expirationDate || "",
useSocks5: Boolean(hostData.useSocks5), useSocks5: Boolean(hostData.useSocks5),
socks5Host: hostData.socks5Host || null, socks5Host: hostData.socks5Host || null,
socks5Port: hostData.socks5Port || null, socks5Port: hostData.socks5Port || null,
@@ -3240,7 +3244,9 @@ export async function updateRole(
} }
} }
export async function deleteRole(roleId: number): Promise<{ success: boolean }> { export async function deleteRole(
roleId: number,
): Promise<{ success: boolean }> {
try { try {
const response = await rbacApi.delete(`/rbac/roles/${roleId}`); const response = await rbacApi.delete(`/rbac/roles/${roleId}`);
return response.data; return response.data;
@@ -3250,7 +3256,9 @@ export async function deleteRole(roleId: number): Promise<{ success: boolean }>
} }
// User-Role Management // User-Role Management
export async function getUserRoles(userId: string): Promise<{ roles: UserRole[] }> { export async function getUserRoles(
userId: string,
): Promise<{ roles: UserRole[] }> {
try { try {
const response = await rbacApi.get(`/rbac/users/${userId}/roles`); const response = await rbacApi.get(`/rbac/users/${userId}/roles`);
return response.data; return response.data;
@@ -3264,7 +3272,9 @@ export async function assignRoleToUser(
roleId: number, roleId: number,
): Promise<{ success: boolean }> { ): Promise<{ success: boolean }> {
try { try {
const response = await rbacApi.post(`/rbac/users/${userId}/roles`, { roleId }); const response = await rbacApi.post(`/rbac/users/${userId}/roles`, {
roleId,
});
return response.data; return response.data;
} catch (error) { } catch (error) {
throw handleApiError(error, "assign role to user"); throw handleApiError(error, "assign role to user");
@@ -3276,7 +3286,9 @@ export async function removeRoleFromUser(
roleId: number, roleId: number,
): Promise<{ success: boolean }> { ): Promise<{ success: boolean }> {
try { try {
const response = await rbacApi.delete(`/rbac/users/${userId}/roles/${roleId}`); const response = await rbacApi.delete(
`/rbac/users/${userId}/roles/${roleId}`,
);
return response.data; return response.data;
} catch (error) { } catch (error) {
throw handleApiError(error, "remove role from user"); throw handleApiError(error, "remove role from user");
@@ -3295,14 +3307,19 @@ export async function shareHost(
}, },
): Promise<{ success: boolean }> { ): Promise<{ success: boolean }> {
try { try {
const response = await rbacApi.post(`/rbac/host/${hostId}/share`, shareData); const response = await rbacApi.post(
`/rbac/host/${hostId}/share`,
shareData,
);
return response.data; return response.data;
} catch (error) { } catch (error) {
throw handleApiError(error, "share host"); throw handleApiError(error, "share host");
} }
} }
export async function getHostAccess(hostId: number): Promise<{ accessList: AccessRecord[] }> { export async function getHostAccess(
hostId: number,
): Promise<{ accessList: AccessRecord[] }> {
try { try {
const response = await rbacApi.get(`/rbac/host/${hostId}/access`); const response = await rbacApi.get(`/rbac/host/${hostId}/access`);
return response.data; return response.data;
@@ -3316,11 +3333,15 @@ export async function revokeHostAccess(
accessId: number, accessId: number,
): Promise<{ success: boolean }> { ): Promise<{ success: boolean }> {
try { try {
const response = await rbacApi.delete(`/rbac/host/${hostId}/access/${accessId}`); const response = await rbacApi.delete(
`/rbac/host/${hostId}/access/${accessId}`,
);
return response.data; return response.data;
} catch (error) { } catch (error) {
throw handleApiError(error, "revoke host access"); throw handleApiError(error, "revoke host access");
}
}
// ============================================================================ // ============================================================================
// DOCKER MANAGEMENT API // DOCKER MANAGEMENT API
// ============================================================================ // ============================================================================