Files
doktainer-upstream/src/lib/api.ts
T

2851 lines
76 KiB
TypeScript

/**
* API Client — DOKTAINER
* Centralized HTTP client with auth token injection
*/
import { emitAuthStateChanged } from "@/lib/auth-events";
import {
getSensitiveStorageItem,
removeSensitiveStorageItem,
sensitiveStorageKeys,
setSensitiveStorageItem,
} from "@/lib/browser-storage";
import {
buildTerminalWebSocketUrl,
resolveWebSocketBaseUrl,
} from "@/lib/websocket-security";
import {
clearStoredOrganizationId,
getStoredOrganizationId,
setStoredOrganizationId,
} from "@/lib/organization-state";
const DEFAULT_API_PORT = process.env.NEXT_PUBLIC_API_PORT || "4000";
function getBrowserHttpOrigin() {
if (typeof window === "undefined") {
return null;
}
return window.location.origin;
}
export function getApiBaseUrl() {
return (
process.env.NEXT_PUBLIC_API_URL ||
`${getBrowserHttpOrigin() || `http://localhost:${DEFAULT_API_PORT}`}/api/v1`
);
}
function getWsBaseUrl(): string | undefined {
return process.env.NEXT_PUBLIC_WS_URL || undefined;
}
export function getHealthUrl() {
return new URL("/health", getApiBaseUrl()).toString();
}
export const API_BASE_URL = getApiBaseUrl();
const BASE_URL = API_BASE_URL;
export const HEALTH_URL = getHealthUrl();
const PUBLIC_AUTH_PATHS = [
"/auth/login",
"/auth/register",
"/auth/invitations",
"/auth/registration-status",
] as const;
const READ_ONLY_ALLOWED_MUTATION_PATHS = new Set(["/auth/logout"]);
function getBrowserOrigin(): string | undefined {
if (typeof window === "undefined") {
return undefined;
}
return window.location.origin;
}
function getWebSocketBaseUrl(): string {
return resolveWebSocketBaseUrl(getWsBaseUrl(), getBrowserOrigin());
}
function isPublicAuthPath(path: string): boolean {
return PUBLIC_AUTH_PATHS.some((publicPath) => path.startsWith(publicPath));
}
function methodRequiresWriteAccess(method: string | undefined): boolean {
const normalizedMethod = method?.toUpperCase() ?? "GET";
return !["GET", "HEAD", "OPTIONS"].includes(normalizedMethod);
}
function enforceViewerReadOnlyClient(path: string, method: string | undefined) {
if (!methodRequiresWriteAccess(method)) {
return;
}
if (READ_ONLY_ALLOWED_MUTATION_PATHS.has(path)) {
return;
}
const currentUser = getUser();
if (currentUser?.role !== "VIEWER") {
return;
}
throw new Error("Viewer role is read-only");
}
export function redirectToLogin(reason?: string): void {
if (typeof window === "undefined") {
return;
}
const target = reason
? `/login?reason=${encodeURIComponent(reason)}`
: "/login";
if (window.location.pathname === "/login") {
if (
window.location.search !==
`?reason=${encodeURIComponent(reason ?? "")}` &&
reason
) {
window.location.replace(target);
}
return;
}
window.location.replace(target);
}
function handleUnauthorizedSession(
path: string,
hadToken: boolean,
): never | void {
if (!hadToken || isPublicAuthPath(path)) {
return;
}
clearToken();
redirectToLogin("session-expired");
throw new Error("Unauthorized - session expired");
}
// ─── Token helpers ────────────────────────────────────────────────────────────
export function getToken(): string | null {
return getSensitiveStorageItem(sensitiveStorageKeys.token);
}
export function setToken(token: string): void {
setSensitiveStorageItem(sensitiveStorageKeys.token, token);
emitAuthStateChanged();
}
export function clearToken(): void {
removeSensitiveStorageItem(sensitiveStorageKeys.token);
removeSensitiveStorageItem(sensitiveStorageKeys.user);
clearStoredOrganizationId();
emitAuthStateChanged();
}
export function getUser(): UserInfo | null {
try {
const raw = getSensitiveStorageItem(sensitiveStorageKeys.user);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
export function setUser(user: UserInfo): void {
setSensitiveStorageItem(sensitiveStorageKeys.user, JSON.stringify(user));
if (user.activeOrganizationId) {
setStoredOrganizationId(user.activeOrganizationId);
}
emitAuthStateChanged();
}
// ─── Types ────────────────────────────────────────────────────────────────────
export interface UserInfo {
id: string;
name: string;
email: string;
role: string;
activeOrganizationId?: string | null;
}
export interface OrganizationRecord {
id: string;
name: string;
slug: string;
logoUrl: string | null;
isDefault: boolean;
isActive: boolean;
memberCount: number;
serverCount: number;
createdAt: string;
updatedAt: string;
}
export interface CreateOrganizationBody {
name: string;
logoUrl?: string;
}
export type ProjectEnvironmentKind =
| "PRODUCTION"
| "STAGING"
| "DEVELOPMENT"
| "PREVIEW"
| "CUSTOM";
export interface ProjectEnvironmentRecord {
id: string;
projectId: string;
serverId: string;
name: string;
slug: string;
kind: ProjectEnvironmentKind;
description: string | null;
containersCount: number;
createdAt: string;
updatedAt: string;
server: {
id: string;
name: string;
ip: string;
status: "ONLINE" | "OFFLINE" | "WARNING" | "UNKNOWN";
};
}
export interface ProjectRecord {
id: string;
organizationId: string;
name: string;
slug: string;
description: string | null;
environmentCount: number;
containersCount: number;
createdAt: string;
updatedAt: string;
environments: ProjectEnvironmentRecord[];
}
export interface ProjectEnvironmentCreateBody {
name: string;
kind: ProjectEnvironmentKind;
serverId: string;
description?: string;
}
export type ProjectEnvironmentUpdateBody = ProjectEnvironmentCreateBody;
export interface ProjectEnvironmentContainersBody {
containerIds: string[];
}
export interface ProjectCreateBody {
name: string;
description?: string;
environments?: ProjectEnvironmentCreateBody[];
}
export type ProjectUpdateBody = Pick<ProjectCreateBody, "name" | "description">;
export interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: string | Record<string, unknown>;
}
export interface VerificationResponse<T = unknown> {
success: boolean;
data?: T;
message?: string;
}
export interface RegistrationStatusResponse {
success: boolean;
data: {
registrationOpen: boolean;
hasOwnerAccount: boolean;
};
}
export type ThemePreference = "dark" | "light" | "system";
export type NotificationProviderType =
| "slack"
| "telegram"
| "discord"
| "lark"
| "teams"
| "email"
| "resend"
| "gotify"
| "ntfy"
| "mattermost"
| "pushover"
| "custom";
export interface NotificationProviderRecord {
id: string;
type: NotificationProviderType;
name: string;
enabled: boolean;
actions: string[];
channel: string;
webhookUrl: string;
webhookConfigured: boolean;
smtpHost: string;
smtpPort: number;
smtpSecure: boolean;
smtpUsername: string;
smtpPassword: string;
smtpPasswordConfigured: boolean;
smtpFromEmail: string;
smtpFromName: string;
telegramChatId: string;
telegramBotToken: string;
telegramBotTokenConfigured: boolean;
serverUrl: string;
topic: string;
userKey: string;
apiKey: string;
apiKeyConfigured: boolean;
}
export interface SettingsRecord {
general: {
panelName: string;
panelUrl: string;
timezone: string;
theme: ThemePreference;
sessionTimeoutMinutes: number;
};
notifications: {
providers: NotificationProviderRecord[];
};
security: {
twoFactorEnabled: boolean;
twoFactorPendingSetup?: boolean;
ipWhitelistEnabled: boolean;
ipWhitelist: string[];
};
integrations: {
cloudflare: {
connected: boolean;
zoneId: string;
hasApiToken: boolean;
};
awsS3: {
connected: boolean;
accessKeyId: string;
region: string;
bucket: string;
hasSecretAccessKey: boolean;
};
github: {
connected: boolean;
hasToken: boolean;
};
gitlab: {
connected: boolean;
hasToken: boolean;
};
};
}
export interface UpdateSettingsBody {
general: SettingsRecord["general"];
notifications: SettingsRecord["notifications"];
security: SettingsRecord["security"];
integrations: {
cloudflare: {
connected: boolean;
zoneId: string;
apiToken?: string;
};
awsS3: {
connected: boolean;
accessKeyId: string;
secretAccessKey?: string;
region: string;
bucket: string;
};
github: {
connected: boolean;
token?: string;
};
gitlab: {
connected: boolean;
token?: string;
};
};
}
export type PanelAccessProxy = "NGINX" | "CADDY" | "TRAEFIK";
export interface PanelProxyCapability {
type: PanelAccessProxy;
label: string;
installed: boolean;
active: boolean;
available: boolean;
supportsProvisioning: boolean;
reason: string | null;
}
export interface PanelAccessCapabilities {
proxies: PanelProxyCapability[];
autoSsl: {
installed: boolean;
available: boolean;
reason: string | null;
};
defaultProxy: PanelAccessProxy | null;
upstream: string;
target: {
type: "local" | "docker-bridge";
label: string;
serverId: null;
diagnostic: string | null;
};
}
export interface PanelAccessProvisionResult {
domain: string;
panelUrl: string;
proxy: PanelAccessProxy;
configPath: string;
enabledPath: string;
reloadTarget: string;
sslEnabled: boolean;
message: string;
}
export type S3StorageProvider =
| "awsS3"
| "alibabaOss"
| "arvanAos"
| "ceph"
| "chinaMobileEos"
| "cloudflareR2"
| "digitalOceanSpaces"
| "dreamObjects"
| "googleCloudStorage"
| "huaweiObs"
| "ibmCos"
| "custom";
export interface S3StorageTargetServer {
id: string;
name: string;
ip: string;
}
export interface S3StorageDestinationRecord {
id: string;
name: string;
provider: S3StorageProvider;
enabled: boolean;
accessKeyId: string;
secretAccessKey: string;
hasSecretAccessKey: boolean;
region: string;
bucket: string;
endpoint: string;
additionalFlags: string[];
retentionDays: number | null;
maxBackupCount: number | null;
objectPrefix: string;
serverId: string | null;
targetServer: S3StorageTargetServer | null;
createdAt: string;
updatedAt: string;
}
export interface S3StorageDestinationInput {
name: string;
provider: S3StorageProvider;
enabled: boolean;
accessKeyId: string;
secretAccessKey?: string;
region: string;
bucket: string;
endpoint?: string;
additionalFlags: string[];
retentionDays?: number | null;
maxBackupCount?: number | null;
objectPrefix?: string;
serverId?: string | null;
}
export interface S3StorageDestinationVerifyInput extends S3StorageDestinationInput {
id?: string;
}
export type GitProviderType = "github" | "gitlab" | "bitbucket" | "gitea";
export interface GitProviderRecord {
id: string;
provider: GitProviderType;
name: string;
enabled: boolean;
appName: string;
appId: string;
clientId: string;
clientSecret: string;
hasClientSecret: boolean;
webhookSecret: string;
hasWebhookSecret: boolean;
appUrl: string;
installationUrl: string;
providerUrl: string;
internalUrl: string;
accountUsername: string;
accountEmail: string;
namespace: string;
organizationScoped: boolean;
organizationName: string;
createdAt: string;
updatedAt: string;
}
export interface GitProviderRepository {
id: string;
name: string;
fullName: string;
cloneUrl: string;
htmlUrl: string;
defaultBranch: string;
visibility: "PUBLIC" | "PRIVATE";
}
export interface GitProviderBranch {
name: string;
isDefault: boolean;
commitSha: string;
}
export interface GitProviderInput {
provider: GitProviderType;
name: string;
enabled: boolean;
appName: string;
appId?: string;
clientId?: string;
clientSecret?: string;
webhookSecret?: string;
appUrl?: string;
installationUrl?: string;
providerUrl?: string;
internalUrl?: string;
accountUsername?: string;
accountEmail?: string;
namespace?: string;
organizationScoped: boolean;
organizationName?: string;
}
export interface GitProviderVerifyInput extends GitProviderInput {
id?: string;
}
type ApiErrorPayload = {
error?: unknown;
message?: unknown;
[key: string]: unknown;
};
function stringifyApiErrorValue(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (value === null || value === undefined) {
return "";
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function buildResponseFallbackMessage(response: Response, fallback: string) {
if (response.status >= 500) {
return "Server returned an internal error. Please try again or check the backend logs.";
}
if (response.status === 404) {
return "Requested resource was not found.";
}
return fallback;
}
async function readJsonResponse<T>(
response: Response,
fallbackMessage = "Request failed",
): Promise<T> {
const text = await response.text();
const trimmedText = text.trim();
if (!trimmedText) {
if (response.ok) {
return {} as T;
}
throw new Error(buildResponseFallbackMessage(response, fallbackMessage));
}
try {
return JSON.parse(trimmedText) as T;
} catch {
throw new Error(buildResponseFallbackMessage(response, fallbackMessage));
}
}
async function readApiErrorMessage(
response: Response,
fallbackMessage = "Request failed",
): Promise<string> {
try {
const payload = await readJsonResponse<ApiErrorPayload>(
response,
fallbackMessage,
);
const message =
stringifyApiErrorValue(payload.error) ||
stringifyApiErrorValue(payload.message);
return message || buildResponseFallbackMessage(response, fallbackMessage);
} catch (error) {
return error instanceof Error ? error.message : fallbackMessage;
}
}
// ─── Core fetch wrapper ───────────────────────────────────────────────────────
async function request<T>(
path: string,
options: RequestInit = {},
config?: { timeoutMs?: number },
): Promise<T> {
const res = await requestRaw(path, options, config);
const json = await readJsonResponse<ApiErrorPayload & T>(res);
if (!res.ok) {
throw new Error(
stringifyApiErrorValue(json.error) ||
stringifyApiErrorValue(json.message) ||
buildResponseFallbackMessage(res, "Request failed"),
);
}
return json as T;
}
async function requestRaw(
path: string,
options: RequestInit = {},
config?: { timeoutMs?: number },
): Promise<Response> {
enforceViewerReadOnlyClient(path, options.method);
const token = getToken();
const hadToken = Boolean(token);
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
};
const controller = new AbortController();
const timeoutMs = config?.timeoutMs;
const timeoutId =
timeoutMs && timeoutMs > 0
? setTimeout(() => controller.abort(), timeoutMs)
: null;
if (options.body !== undefined && !("Content-Type" in headers)) {
headers["Content-Type"] = "application/json";
}
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const organizationId = getStoredOrganizationId();
if (organizationId) {
headers["x-organization-id"] = organizationId;
}
let res: Response;
try {
res = await fetch(`${BASE_URL}${path}`, {
...options,
headers,
signal: options.signal ?? controller.signal,
});
} catch (error) {
if (error instanceof Error && error.name === "AbortError" && timeoutMs) {
throw new Error(
`Request timed out after ${Math.ceil(timeoutMs / 1000)}s`,
);
}
throw error;
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
if (res.status === 401) {
handleUnauthorizedSession(path, hadToken);
}
return res;
}
function get<T>(path: string, config?: { timeoutMs?: number }) {
return request<T>(path, { method: "GET" }, config);
}
function post<T>(path: string, body: unknown, config?: { timeoutMs?: number }) {
return request<T>(
path,
{ method: "POST", body: JSON.stringify(body) },
config,
);
}
function put<T>(path: string, body: unknown, config?: { timeoutMs?: number }) {
return request<T>(
path,
{ method: "PUT", body: JSON.stringify(body) },
config,
);
}
function patch<T>(
path: string,
body: unknown,
config?: { timeoutMs?: number },
) {
return request<T>(
path,
{ method: "PATCH", body: JSON.stringify(body) },
config,
);
}
function del<T>(path: string, body?: unknown, config?: { timeoutMs?: number }) {
return request<T>(
path,
{
method: "DELETE",
body: body === undefined ? undefined : JSON.stringify(body),
},
config,
);
}
// ─── WebSocket helper ─────────────────────────────────────────────────────────
export function createWs(path: string): WebSocket {
const token = getToken();
const sep = path.includes("?") ? "&" : "?";
return new WebSocket(
`${getWebSocketBaseUrl()}${path}${token ? `${sep}token=${encodeURIComponent(token)}` : ""}`,
);
}
// ─── Auth ─────────────────────────────────────────────────────────────────────
export const auth = {
registrationStatus: () =>
get<RegistrationStatusResponse>("/auth/registration-status"),
login: (email: string, password: string, totpCode?: string) =>
post<
| {
success: boolean;
token: string;
user: UserInfo;
requiresTwoFactor?: false;
}
| { success: boolean; requiresTwoFactor: true; message?: string }
>("/auth/login", {
email,
password,
totpCode,
}),
register: (name: string, email: string, password: string) =>
post<{ success: boolean; token: string; user: UserInfo }>(
"/auth/register",
{ name, email, password },
),
me: () => get<{ success: boolean; data: UserInfo }>("/auth/me"),
logout: () =>
request<{ success: boolean; message: string }>("/auth/logout", {
method: "POST",
}),
changePassword: (currentPassword: string, newPassword: string) =>
patch<{ success: boolean; message: string }>("/auth/password", {
currentPassword,
newPassword,
}),
twoFactorStatus: () =>
get<{
success: boolean;
data: {
enabled: boolean;
pendingSetup: boolean;
manualEntryKey: string | null;
};
}>("/auth/2fa/status"),
setupTwoFactor: () =>
post<{
success: boolean;
data: {
qrCodeDataUrl: string;
otpauthUrl: string;
manualEntryKey: string;
};
message?: string;
}>("/auth/2fa/setup", {}),
enableTwoFactor: (code: string) =>
post<{ success: boolean; message: string }>("/auth/2fa/enable", { code }),
disableTwoFactor: (currentPassword: string, code: string) =>
del<{ success: boolean; message: string }>("/auth/2fa", {
currentPassword,
code,
}),
getInvitation: (token: string) =>
get<{
success: boolean;
data: {
id: string;
email: string;
name: string;
role: UserRole;
allServersAccess: boolean;
serverIds: string[];
expiresAt: string;
};
}>(`/auth/invitations/${token}`),
acceptInvitation: (
token: string,
body: { name?: string; password: string },
) =>
post<{ success: boolean; token: string; user: UserInfo }>(
`/auth/invitations/${token}/accept`,
body,
),
};
// ─── Users ───────────────────────────────────────────────────────────────────
export type UserRole = "SUPER_ADMIN" | "OPERATOR" | "DEVELOPER" | "VIEWER";
export interface UserServerAssignment {
serverId: string;
server: {
id: string;
name: string;
};
}
export interface UserRecord {
id: string;
name: string;
email: string;
role: UserRole;
isActive: boolean;
allServersAccess: boolean;
lastLogin: string | null;
createdAt: string;
serverAssignments: UserServerAssignment[];
}
export interface CreateUserBody {
name: string;
email: string;
password: string;
role: Exclude<UserRole, "SUPER_ADMIN">;
}
export interface UpdateUserServerAccessBody {
allServersAccess: boolean;
serverIds: string[];
}
export interface UserInvitationRecord {
id: string;
name: string;
email: string;
role: Exclude<UserRole, "SUPER_ADMIN">;
allServersAccess: boolean;
serverIds: string[];
expiresAt: string;
createdAt: string;
servers: Array<{ id: string; name: string }>;
}
export interface CreateUserInvitationBody extends UpdateUserServerAccessBody {
name: string;
email: string;
role: Exclude<UserRole, "SUPER_ADMIN">;
expiresInDays?: number;
}
export const users = {
list: () => get<{ success: boolean; data: UserRecord[] }>("/users"),
listInvitations: () =>
get<{ success: boolean; data: UserInvitationRecord[] }>(
"/users/invitations",
),
invite: (body: CreateUserInvitationBody) =>
post<{
success: boolean;
data: UserInvitationRecord & { inviteUrl: string };
}>("/users/invitations", body),
regenerateInvitation: (id: string) =>
post<{ success: boolean; data: { inviteUrl: string; expiresAt: string } }>(
`/users/invitations/${id}/regenerate`,
{},
),
revokeInvitation: (id: string) =>
del<{ success: boolean; message?: string }>(`/users/invitations/${id}`),
create: (body: CreateUserBody) =>
post<{ success: boolean; data: UserRecord }>("/users", body),
updateRole: (id: string, role: Exclude<UserRole, "SUPER_ADMIN">) =>
patch<{ success: boolean; data: Pick<UserRecord, "id" | "name" | "role"> }>(
`/users/${id}/role`,
{ role },
),
updateServerAccess: (id: string, body: UpdateUserServerAccessBody) =>
patch<{
success: boolean;
data: Pick<UserRecord, "id" | "allServersAccess" | "serverAssignments">;
}>(`/users/${id}/server-access`, body),
remove: (id: string) =>
del<{ success: boolean; message?: string }>(`/users/${id}`),
};
export const organizationsApi = {
list: () =>
get<{ success: boolean; data: OrganizationRecord[] }>("/organizations"),
create: (body: CreateOrganizationBody) =>
post<{ success: boolean; data: OrganizationRecord }>(
"/organizations",
body,
),
update: (id: string, body: Partial<CreateOrganizationBody>) =>
patch<{ success: boolean; data: OrganizationRecord }>(
`/organizations/${id}`,
body,
),
setDefault: (id: string) =>
post<{ success: boolean; data: { id: string; name: string } }>(
`/organizations/${id}/default`,
{},
),
activate: (id: string) =>
post<{ success: boolean; data: { id: string; name: string } }>(
`/organizations/${id}/activate`,
{},
),
remove: (id: string, body: { confirmation: "DELETE" }) =>
del<{ success: boolean; message?: string }>(`/organizations/${id}`, body),
};
export const projectsApi = {
list: () =>
request<{ success: boolean; data: ProjectRecord[] }>(
"/projects",
{
method: "GET",
cache: "no-store",
headers: {
"Cache-Control": "no-cache",
},
},
{ timeoutMs: 10000 },
),
detail: (id: string) =>
get<{ success: boolean; data: ProjectRecord }>(`/projects/${id}`),
create: (body: ProjectCreateBody) =>
post<{ success: boolean; data: ProjectRecord }>("/projects", body),
update: (id: string, body: ProjectUpdateBody) =>
patch<{ success: boolean; data: ProjectRecord }>(`/projects/${id}`, body),
addEnvironment: (projectId: string, body: ProjectEnvironmentCreateBody) =>
post<{ success: boolean; data: ProjectEnvironmentRecord }>(
`/projects/${projectId}/environments`,
body,
),
updateEnvironment: (
environmentId: string,
body: ProjectEnvironmentUpdateBody,
) =>
patch<{ success: boolean; data: ProjectEnvironmentRecord }>(
`/projects/environments/${environmentId}`,
body,
),
updateEnvironmentContainers: (
environmentId: string,
body: ProjectEnvironmentContainersBody,
) =>
put<{ success: boolean; data: ProjectEnvironmentRecord }>(
`/projects/environments/${environmentId}/containers`,
body,
),
remove: (id: string, body: { confirmation: "DELETE" }) =>
del<{ success: boolean; message?: string }>(`/projects/${id}`, body),
removeEnvironment: (
environmentId: string,
body: { confirmation: "DELETE" },
) =>
del<{ success: boolean; message?: string }>(
`/projects/environments/${environmentId}`,
body,
),
};
export type ApiKeyExpiryOption = "never" | "30d" | "90d" | "1y";
export interface ApiKeyRecord {
id: string;
name: string;
keyPrefix: string;
permissions: string[];
lastUsed: string | null;
expiresAt: string | null;
isActive: boolean;
requestCount: number;
createdAt: string;
}
export interface CreateApiKeyBody {
name: string;
permissions: string[];
expiresIn: ApiKeyExpiryOption;
}
export interface CreatedApiKeyRecord extends ApiKeyRecord {
rawKey: string;
}
export interface CommitHistoryNotificationRecord {
id: string;
sha: string;
shortSha: string;
title: string;
message: string;
authorName: string;
authorUsername: string;
authorAvatarUrl: string | null;
url: string;
committedAt: string;
}
export const topbarNotificationsApi = {
listCommits: () =>
get<{
success: boolean;
data: CommitHistoryNotificationRecord[];
meta: {
repository: string;
branch: string;
path: string;
};
}>("/notifications/commits", { timeoutMs: 15000 }),
};
export const apiKeys = {
list: () => get<{ success: boolean; data: ApiKeyRecord[] }>("/api-keys"),
create: (body: CreateApiKeyBody) =>
post<{
success: boolean;
data: CreatedApiKeyRecord;
message?: string;
}>("/api-keys", body),
revoke: (id: string) =>
del<{ success: boolean; message?: string }>(`/api-keys/${id}`),
};
export const settingsApi = {
get: () => get<{ success: boolean; data: SettingsRecord }>("/settings"),
getPanelAccessCapabilities: () =>
get<{ success: boolean; data: PanelAccessCapabilities }>(
"/settings/panel-access/capabilities",
{ timeoutMs: 45000 },
),
provisionPanelDomain: (body: {
domain: string;
proxy: PanelAccessProxy;
autoSsl: boolean;
}) =>
post<{
success: boolean;
data: {
settings: SettingsRecord;
provision: PanelAccessProvisionResult;
};
message?: string;
}>("/settings/panel-access/provision", body, { timeoutMs: 30000 }),
update: (body: UpdateSettingsBody) =>
patch<{ success: boolean; data: SettingsRecord; message?: string }>(
"/settings",
body,
),
reset: () =>
post<{ success: boolean; data: SettingsRecord; message?: string }>(
"/settings/reset",
{},
),
verifyIntegration: (
provider: "cloudflare" | "awsS3" | "github" | "gitlab",
body: Record<string, unknown>,
) =>
post<VerificationResponse<Record<string, unknown>>>(
`/settings/integrations/${provider}/verify`,
body,
),
testNotification: (
provider: "email" | "telegram" | "discord" | "custom",
body: Record<string, unknown>,
) =>
post<VerificationResponse>(
`/settings/notifications/${provider}/test`,
body,
),
};
export const storageDestinationsApi = {
list: () =>
get<{ success: boolean; data: S3StorageDestinationRecord[] }>(
"/storage-destinations",
),
create: (body: S3StorageDestinationInput) =>
post<{
success: boolean;
data: S3StorageDestinationRecord;
message?: string;
}>("/storage-destinations", body),
update: (id: string, body: S3StorageDestinationInput) =>
put<{
success: boolean;
data: S3StorageDestinationRecord;
message?: string;
}>(`/storage-destinations/${id}`, body),
remove: (id: string) =>
del<{ success: boolean; message?: string }>(`/storage-destinations/${id}`),
verify: (body: S3StorageDestinationVerifyInput) =>
post<VerificationResponse<Record<string, unknown>>>(
"/storage-destinations/verify",
body,
),
};
export const gitProvidersApi = {
list: () =>
get<{ success: boolean; data: GitProviderRecord[] }>("/git-providers"),
listRepositories: (id: string) =>
get<{
success: boolean;
data: GitProviderRepository[];
meta?: { owner?: string; provider?: GitProviderType };
}>(`/git-providers/${id}/repositories`),
listBranches: (id: string, repoFullName: string, defaultBranch?: string) =>
get<{
success: boolean;
data: GitProviderBranch[];
meta?: { repositoryFullName?: string; provider?: GitProviderType };
}>(
`/git-providers/${id}/branches?repoFullName=${encodeURIComponent(repoFullName)}${
defaultBranch
? `&defaultBranch=${encodeURIComponent(defaultBranch)}`
: ""
}`,
),
create: (body: GitProviderInput) =>
post<{
success: boolean;
data: GitProviderRecord;
message?: string;
}>("/git-providers", body),
update: (id: string, body: GitProviderInput) =>
put<{
success: boolean;
data: GitProviderRecord;
message?: string;
}>(`/git-providers/${id}`, body),
remove: (id: string) =>
del<{ success: boolean; message?: string }>(`/git-providers/${id}`),
verify: (body: GitProviderVerifyInput) =>
post<VerificationResponse<Record<string, unknown>>>(
"/git-providers/verify",
body,
{ timeoutMs: 15000 },
),
};
// ─── Servers ──────────────────────────────────────────────────────────────────
export interface Server {
id: string;
name: string;
ip: string;
sshPort: number;
username: string;
authType: "PASSWORD" | "SSH_KEY";
status: "ONLINE" | "OFFLINE" | "WARNING" | "UNKNOWN";
os: string | null;
location: string | null;
tags: string[];
containers: number;
lastHealth: string | null;
createdAt: string;
metrics?: ServerMetric | null;
}
export interface ServerMetric {
id: string;
serverId: string;
cpuPct: number;
ramPct: number;
diskPct: number;
ramUsed: string;
ramTotal: string;
diskUsed: string;
diskTotal: string;
networkRxBps?: string | null;
networkTxBps?: string | null;
networkMbps?: number | null;
uptimeSec: string;
os: string | null;
recordedAt: string;
}
export interface ServerDetails extends Omit<Server, "metrics"> {
metrics: ServerMetric[];
}
export interface DockerRuntimeStatus {
installed: boolean;
daemonRunning: boolean;
available: boolean;
version: string | null;
reason: string | null;
canInstall: boolean;
probeFailed?: boolean;
platform: {
distro: string | null;
packageManager: "apt-get" | "dnf" | "yum" | "zypper" | "apk" | null;
sudoNonInteractive: boolean;
supportedForFail2banInstall: boolean;
};
}
export interface ServerSystemUser {
username: string;
uid: number | null;
gid: number | null;
home: string | null;
shell: string | null;
groups: string[];
isRoot: boolean;
isSshUser: boolean;
}
export interface ServerServiceStatus {
name: string;
active: string;
enabled: string;
description: string | null;
}
export interface ServerDiskMount {
filesystem: string;
type: string;
size: string;
used: string;
available: string;
usedPercent: string;
mountPoint: string;
}
export interface DockerPruneOptions {
images?: boolean;
containers?: boolean;
networks?: boolean;
volumes?: boolean;
buildCache?: boolean;
}
export type WebStackComponentKey =
| "nginx"
| "apache"
| "caddy"
| "php"
| "nodejs"
| "pm2"
| "mysql"
| "redis"
| "postgresql"
| "composer"
| "certbot";
export type WebStackAction = "install" | "upgrade" | "reinstall" | "remove";
export interface WebStackComponentStatus {
key: WebStackComponentKey;
label: string;
category:
| "web-server"
| "runtime"
| "tooling"
| "process-manager"
| "database"
| "cache";
description: string;
installed: boolean;
version: string | null;
serviceName: string | null;
active: string | null;
enabled: string | null;
availableActions: WebStackAction[];
recommendedFor: string[];
notes: string[];
}
export interface ServerWebCapability {
ready: boolean;
summary: string;
notes: string[];
packageManager: DockerRuntimeStatus["platform"]["packageManager"];
canManage: boolean;
primaryWebServer: string | null;
support: {
staticSites: boolean;
phpApps: boolean;
javascriptApps: boolean;
sslAutomation: boolean;
processManager: boolean;
relationalDatabase: boolean;
cache: boolean;
};
components: WebStackComponentStatus[];
}
export interface ServerConfigSnapshot {
hostname: string | null;
os: string | null;
kernel: string | null;
currentUser: string | null;
serverUser: string;
users: ServerSystemUser[];
rootUser: ServerSystemUser | null;
nonRootUsers: ServerSystemUser[];
hasRootUser: boolean;
sudoNonInteractive: boolean;
docker: DockerRuntimeStatus;
services: ServerServiceStatus[];
webServer: ServerWebCapability;
diskMounts: ServerDiskMount[];
lastBoot: string | null;
fetchedAt: string;
}
export interface ServerCreateBody {
name: string;
ip: string;
sshPort?: number;
username?: string;
authType: "PASSWORD" | "SSH_KEY";
sshKey?: string;
password?: string;
location?: string;
tags?: string[];
}
export interface ServerDeleteBody {
confirmation: "DELETE";
}
export const servers = {
list: () =>
get<{ success: boolean; data: Server[] }>("/servers", { timeoutMs: 10000 }),
get: (id: string) =>
get<{ success: boolean; data: ServerDetails }>(`/servers/${id}`),
create: (body: ServerCreateBody) =>
post<{ success: boolean; data: Server }>("/servers", body),
update: (id: string, body: Partial<ServerCreateBody>) =>
put<{ success: boolean; data: Server }>(`/servers/${id}`, body),
delete: (id: string, body: ServerDeleteBody) =>
del<{ success: boolean; message?: string }>(`/servers/${id}`, body),
testConnection: (id: string) =>
post<{
success: boolean;
data: { connected: boolean; status: string; error?: string };
connected?: boolean;
status?: string;
error?: string;
}>(`/servers/${id}/test`, {}, { timeoutMs: 12000 }),
dockerStatus: (id: string) =>
get<{ success: boolean; data: DockerRuntimeStatus }>(
`/servers/${id}/docker`,
{ timeoutMs: 12000 },
),
getConfig: (id: string) =>
get<{ success: boolean; data: ServerConfigSnapshot }>(
`/servers/${id}/config`,
{ timeoutMs: 45000 },
),
reset: (id: string, confirmation: string) =>
post<{ success: boolean; message: string }>(
`/servers/${id}/reset`,
{ confirmation },
{ timeoutMs: 15000 },
),
reboot: (id: string) =>
post<{ success: boolean; message: string }>(
`/servers/${id}/reboot`,
{},
{ timeoutMs: 15000 },
),
restartNginx: (id: string) =>
post<{ success: boolean; message: string }>(
`/servers/${id}/nginx/restart`,
{},
{ timeoutMs: 60000 },
),
restartService: (id: string, service: string) =>
post<{ success: boolean; message: string }>(
`/servers/${id}/services/${encodeURIComponent(service)}/restart`,
{},
{ timeoutMs: 300000 },
),
manageWebStack: (
id: string,
component: WebStackComponentKey,
action: WebStackAction,
) =>
post<{
success: boolean;
data: ServerWebCapability;
message?: string;
details?: string[];
meta?: {
component: WebStackComponentKey;
componentLabel: string;
action: WebStackAction;
};
}>(
`/servers/${id}/web-stack/${encodeURIComponent(component)}/${encodeURIComponent(action)}`,
{},
{ timeoutMs: 300000 },
),
installDocker: (id: string) =>
post<{ success: boolean; data: DockerRuntimeStatus }>(
`/servers/${id}/docker/install`,
{},
{ timeoutMs: 45000 },
),
pruneDocker: (id: string, options?: DockerPruneOptions) =>
post<{
success: boolean;
data: DockerRuntimeStatus;
message?: string;
details?: string[];
rawOutput?: string;
}>(`/servers/${id}/docker/prune`, { options }, { timeoutMs: 60000 }),
uninstallDocker: (id: string) =>
post<{ success: boolean; data: DockerRuntimeStatus }>(
`/servers/${id}/docker/uninstall`,
{},
{ timeoutMs: 45000 },
),
reinstallDocker: (id: string) =>
post<{ success: boolean; data: DockerRuntimeStatus }>(
`/servers/${id}/docker/reinstall`,
{},
{ timeoutMs: 60000 },
),
refreshMetrics: (id: string) =>
post<{ success: boolean; data: ServerMetric }>(
`/servers/${id}/health`,
{},
{
timeoutMs: 12000,
},
),
};
// ─── Containers ───────────────────────────────────────────────────────────────
export interface Container {
id: string;
name: string;
image: string;
serverId: string;
environmentId?: string | null;
sourceType?: "APP_INSTALLER" | "MANUAL" | "GIT_CLONE" | "GIT_PROVIDER";
deployMode?: "IMAGE" | "DOCKERFILE" | "COMPOSE" | null;
status: "RUNNING" | "STOPPED" | "STARTING" | "STOPPING" | "PAUSED" | "ERROR";
dockerId: string | null;
ports: string[];
envVars: string[];
restartPolicy: string;
cpuUsage?: string | null;
ramUsage?: string | null;
createdAt: string;
server?: { name: string; ip: string };
}
export interface ContainerProcess {
pid: string;
ppid: string;
user: string;
cpu: string;
memory: string;
elapsed: string;
command: string;
}
export interface ContainerRuntimeIo {
raw: string;
read?: string;
write?: string;
totalBytes?: number | null;
readBytes?: number | null;
writeBytes?: number | null;
}
export interface ContainerRuntimeMemory {
raw: string;
used?: string;
limit?: string;
usedBytes?: number | null;
limitBytes?: number | null;
}
export interface ContainerRuntimeStats {
cpuPercent: number;
memoryPercent: number;
pids: number;
memory: ContainerRuntimeMemory;
network: ContainerRuntimeIo;
io: ContainerRuntimeIo;
}
export interface ContainerDetails {
container: {
id: string;
name: string;
image: string;
status: string;
dockerId: string | null;
serverId: string;
};
server: {
id: string;
name: string;
ip: string;
};
logs: string;
inspect: Record<string, unknown>;
stats: ContainerRuntimeStats;
processes: ContainerProcess[];
}
export interface ContainerMetrics {
container: {
id: string;
name: string;
status: string;
dockerId: string | null;
serverId: string;
};
stats: ContainerRuntimeStats;
sampledAt: string;
}
export interface ContainerFileEntry {
name: string;
path: string;
type: "directory" | "file" | "symlink" | "special";
size: number | null;
modified: string | null;
}
export interface ContainerDirectoryListing {
path: string;
parentPath: string | null;
entries: ContainerFileEntry[];
}
export interface ContainerFileContent {
path: string;
name: string;
size: number;
modified: string | null;
isBinary: boolean;
tooLarge: boolean;
mimeType: string | null;
previewBase64: string | null;
content: string;
}
export interface ContainerFileDownload {
path: string;
name: string;
size: number;
contentBase64: string;
}
export interface ContainerProjectEnvFile {
found: boolean;
path: string | null;
content: string;
checkedPaths: string[];
source: string;
message: string;
}
export interface ContainerProjectEnvWriteResult {
path: string;
size: number;
source: "container" | "project";
restartRequired: boolean;
}
export type ContainerSourceType =
| "APP_INSTALLER"
| "MANUAL"
| "GIT_CLONE"
| "GIT_PROVIDER";
export type ContainerDeployMode = "IMAGE" | "DOCKERFILE" | "COMPOSE";
export type GitBuildType =
| "NIXPACKS"
| "HEROKU_BUILDPACKS"
| "PAKETO_BUILDPACKS"
| "STATIC"
| "DOCKERFILE"
| "COMPOSE";
export type RepositoryVisibility = "PUBLIC" | "PRIVATE";
export interface ComposeEnvFileOverrideBody {
path: string;
content: string;
}
export interface ContainerDeployBody {
name: string;
serverId: string;
environmentId?: string;
networkId?: string;
image?: string;
ports?: string;
env?: string;
restartPolicy?: string;
volumes?: string;
sourceType?: ContainerSourceType;
deployMode?: ContainerDeployMode;
buildType?: GitBuildType;
repoUrl?: string;
repoBranch?: string;
repoVisibility?: RepositoryVisibility;
accessToken?: string;
gitProviderId?: string;
buildPath?: string;
startCommand?: string;
portOverride?: string;
publishDirectory?: string;
composeContent?: string;
composeFilePath?: string;
composeEnvFiles?: ComposeEnvFileOverrideBody[];
dockerfileContent?: string;
dockerfilePath?: string;
dockerContextPath?: string;
imageTag?: string;
}
export interface ProcessJobLogEntry {
id: number;
at: string;
message: string;
}
export interface ProcessJobRecord {
id: string;
type: string;
status:
| "queued"
| "running"
| "cancelling"
| "cancelled"
| "success"
| "error";
createdAt: string;
updatedAt: string;
logs: ProcessJobLogEntry[];
result?: unknown;
error?: string;
cancelReason?: string;
}
function parseServerSentEventBlock(block: string) {
let event = "message";
const dataLines: string[] = [];
for (const line of block.split("\n")) {
if (line.startsWith("event:")) {
event = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
}
if (dataLines.length === 0) {
return null;
}
return {
event,
data: JSON.parse(dataLines.join("\n")) as unknown,
};
}
export const containers = {
list: (params?: { serverId?: string; status?: string }) => {
const qs = new URLSearchParams();
if (params?.serverId) qs.set("serverId", params.serverId);
if (params?.status) qs.set("status", params.status);
const q = qs.toString();
return get<{ success: boolean; data: Container[] }>(
`/containers${q ? `?${q}` : ""}`,
);
},
get: (id: string) =>
get<{ success: boolean; data: Container }>(`/containers/${id}`),
deploy: (body: ContainerDeployBody) =>
post<{
success: boolean;
data: Container | null;
message?: string;
meta?: {
matchedContainers?: Container[];
matchedCount?: number;
};
}>("/containers", body),
createDeployJob: (body: ContainerDeployBody) =>
post<{ success: boolean; data: ProcessJobRecord }>(
"/containers/jobs/deploy",
body,
),
getJob: (jobId: string) =>
get<{ success: boolean; data: ProcessJobRecord }>(
`/containers/jobs/${jobId}`,
),
cancelJob: (jobId: string) =>
post<{ success: boolean; data: ProcessJobRecord; message?: string }>(
`/containers/jobs/${jobId}/cancel`,
{},
{ timeoutMs: 15000 },
),
streamJob: async (
jobId: string,
handlers: {
signal?: AbortSignal;
onLog?: (entry: ProcessJobLogEntry) => void;
onStatus?: (job: ProcessJobRecord) => void;
},
) => {
const response = await requestRaw(`/containers/jobs/${jobId}/stream`, {
method: "GET",
signal: handlers.signal,
});
if (!response.ok) {
throw new Error(
await readApiErrorMessage(
response,
"Failed to open process log stream",
),
);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("Streaming response is not available");
}
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split(/\n\n/);
buffer = blocks.pop() ?? "";
for (const block of blocks) {
const parsed = parseServerSentEventBlock(block.trim());
if (!parsed) continue;
if (parsed.event === "log") {
handlers.onLog?.(parsed.data as ProcessJobLogEntry);
} else if (parsed.event === "status") {
handlers.onStatus?.(parsed.data as ProcessJobRecord);
}
}
}
},
sync: (body?: { serverId?: string }) =>
post<{
success: boolean;
data: Container[];
meta?: {
summary: Array<{
serverId: string;
serverName: string;
synced: number;
}>;
};
}>("/containers/sync", body ?? {}, { timeoutMs: 30000 }),
action: (id: string, action: "start" | "stop" | "restart" | "rm") =>
post<{ success: boolean; data?: Container }>(
`/containers/${id}/action`,
{ action },
{ timeoutMs: 65000 },
),
rebuild: (id: string) =>
post<{ success: boolean; data?: Container; message?: string }>(
`/containers/${id}/rebuild`,
{},
),
createRebuildJob: (id: string) =>
post<{ success: boolean; data: ProcessJobRecord }>(
`/containers/${id}/rebuild-job`,
{},
),
logs: (id: string, tail?: number) =>
get<{ success: boolean; data: { logs: string; containerName: string } }>(
`/containers/${id}/logs${tail ? `?lines=${tail}` : ""}`,
{ timeoutMs: 25000 },
),
inspect: (id: string) =>
get<{ success: boolean; data: Record<string, unknown> }>(
`/containers/${id}/inspect`,
{ timeoutMs: 20000 },
),
processes: (id: string) =>
get<{ success: boolean; data: ContainerProcess[] }>(
`/containers/${id}/processes`,
{ timeoutMs: 20000 },
),
details: (id: string, lines?: number) =>
get<{ success: boolean; data: ContainerDetails }>(
`/containers/${id}/details${lines ? `?lines=${lines}` : ""}`,
{ timeoutMs: 30000 },
),
metrics: (id: string) =>
get<{ success: boolean; data: ContainerMetrics }>(
`/containers/${id}/metrics`,
{ timeoutMs: 15000 },
),
listFiles: (id: string, path?: string) => {
const qs = new URLSearchParams();
if (path) qs.set("path", path);
const query = qs.toString();
return get<{ success: boolean; data: ContainerDirectoryListing }>(
`/containers/${id}/files${query ? `?${query}` : ""}`,
{ timeoutMs: 12000 },
);
},
readFile: (id: string, path: string) => {
const qs = new URLSearchParams({ path });
return get<{ success: boolean; data: ContainerFileContent }>(
`/containers/${id}/file?${qs.toString()}`,
{ timeoutMs: 18000 },
);
},
projectEnv: (id: string) =>
get<{ success: boolean; data: ContainerProjectEnvFile }>(
`/containers/${id}/project-env`,
{ timeoutMs: 25000 },
),
updateProjectEnv: (
id: string,
body: { path: string; content: string; source: "container" | "project" },
) =>
put<{ success: boolean; data: ContainerProjectEnvWriteResult }>(
`/containers/${id}/project-env`,
body,
{ timeoutMs: 25000 },
),
exec: (id: string, command: string) =>
post<{
success: boolean;
data: { stdout: string; stderr: string; exitCode: number };
}>(`/containers/${id}/exec`, { command }, { timeoutMs: 25000 }),
writeFile: (id: string, body: { path: string; content: string }) =>
put<{ success: boolean; data: { path: string; size: number } }>(
`/containers/${id}/file`,
body,
{ timeoutMs: 25000 },
),
createFile: (id: string, body: { path: string; content?: string }) =>
post<{ success: boolean; data: { path: string; size: number } }>(
`/containers/${id}/files/file`,
body,
{ timeoutMs: 25000 },
),
createFolder: (id: string, body: { path: string }) =>
post<{ success: boolean; data: { path: string } }>(
`/containers/${id}/files/folder`,
body,
{ timeoutMs: 25000 },
),
renamePath: (id: string, body: { path: string; newPath: string }) =>
patch<{ success: boolean; data: { path: string } }>(
`/containers/${id}/files/rename`,
body,
{ timeoutMs: 25000 },
),
deletePath: (id: string, path: string) => {
const qs = new URLSearchParams({ path });
return del<{ success: boolean; data: { path: string } }>(
`/containers/${id}/files?${qs.toString()}`,
undefined,
{ timeoutMs: 25000 },
);
},
downloadFile: (id: string, path: string) => {
const qs = new URLSearchParams({ path });
return get<{ success: boolean; data: ContainerFileDownload }>(
`/containers/${id}/file/download?${qs.toString()}`,
{ timeoutMs: 35000 },
);
},
uploadFile: (
id: string,
body: {
directoryPath: string;
fileName: string;
contentBase64: string;
},
) =>
post<{ success: boolean; data: { path: string; size: number } }>(
`/containers/${id}/file/upload`,
body,
{ timeoutMs: 35000 },
),
};
// ─── Metrics ──────────────────────────────────────────────────────────────────
export interface DashboardOverview {
servers: { total: number; online: number };
containers: { total: number; running: number };
domains: { total: number };
ssl: { valid: number };
recentLogs: AuditLog[];
}
export interface DashboardResourcePoint {
time: string;
cpu: number;
ram: number;
disk: number;
recordedAt: string;
}
export interface DashboardContainerServerPoint {
name: string;
running: number;
stopped: number;
other: number;
total: number;
}
export interface DashboardLiveMetrics {
resourceUsage: DashboardResourcePoint[];
containerStatusByServer: DashboardContainerServerPoint[];
updatedAt: string;
}
export interface AuditLog {
id: string;
action: string;
category: string;
level: string;
message: string;
createdAt: string;
user?: { name: string };
server?: { name: string };
}
export const metrics = {
overview: () =>
get<{ success: boolean; data: DashboardOverview }>("/metrics/overview"),
live: (points?: number) =>
get<{ success: boolean; data: DashboardLiveMetrics }>(
`/metrics/live${points ? `?points=${points}` : ""}`,
),
serverHistory: (serverId: string, limit?: number) =>
get<{ success: boolean; data: ServerMetric[] }>(
`/metrics/server/${serverId}/history${limit ? `?limit=${limit}` : ""}`,
),
};
// ─── Domains ──────────────────────────────────────────────────────────────────
export interface Domain {
id: string;
name: string;
domainNameType: "EXACT" | "WILDCARD";
configMode: "SHARED" | "ISOLATED";
reviewStatus: "CONFIRMED" | "NEEDS_REVIEW";
isPrimary: boolean;
type: "A" | "AAAA" | "CNAME" | "MX" | "TXT";
value: string;
serverId: string | null;
targetContainerId?: string | null;
targetPort?: number | null;
proxy: "TRAEFIK" | "NGINX" | "CADDY" | "NONE";
discoverySource:
| "MANUAL"
| "NGINX"
| "TRAEFIK"
| "CADDY"
| "CADDY_ADMIN"
| "CERTBOT";
sslEnabled: boolean;
autoRenew: boolean;
isActive: boolean;
createdAt: string;
updatedAt: string;
server?: { name: string } | null;
targetContainer?: {
id: string;
name: string;
image: string;
} | null;
sslCert?: SslCert | null;
}
export type DomainProxy = Domain["proxy"];
export type DomainDiscoverySource = Domain["discoverySource"];
export type DomainNameType = Domain["domainNameType"];
export type DomainConfigMode = Domain["configMode"];
export type DomainReviewStatus = Domain["reviewStatus"];
export interface SslCert {
id: string;
domainId: string;
issuer: string;
issuedAt: string | null;
expiresAt: string | null;
status: "VALID" | "EXPIRING" | "EXPIRED" | "PENDING" | "FAILED";
autoRenew: boolean;
createdAt?: string;
updatedAt?: string;
domain?: {
id: string;
name: string;
serverId?: string | null;
server?: { id: string; name: string; ip: string } | null;
};
}
export interface SslRenewOperation {
operationId: string;
certId: string;
domainName: string;
status: "RUNNING" | "COMPLETED" | "FAILED";
stage: string;
message: string | null;
error: string | null;
timings: Partial<{
resolveCertificateMs: number;
certbotRenewMs: number;
readCertificateMs: number;
fallbackIssueMs: number;
certbotLockWaitMs: number;
certbotAttempts: number;
updateDatabaseMs: number;
reloadProxyMs: number;
totalMs: number;
}>;
createdAt: string;
startedAt: string;
finishedAt: string | null;
}
export const domains = {
list: () => get<{ success: boolean; data: Domain[] }>("/domains"),
sync: (body?: { serverId?: string }) =>
post<{
success: boolean;
data: Domain[];
meta?: {
summary: Array<{
serverId: string;
serverName: string;
synced: number;
}>;
};
}>("/domains/sync", body ?? {}, { timeoutMs: 90000 }),
get: (id: string) =>
get<{ success: boolean; data: Domain }>(`/domains/${id}`),
create: (body: {
name: string;
domainNameType?: DomainNameType;
configMode?: DomainConfigMode;
isPrimary?: boolean;
type?: string;
value: string;
serverId?: string;
targetContainerId?: string;
targetPort?: number;
proxy?: DomainProxy;
sslEnabled?: boolean;
autoRenew?: boolean;
}) => post<{ success: boolean; data: Domain }>("/domains", body),
update: (
id: string,
body: Partial<{
name: string;
domainNameType: DomainNameType;
configMode: DomainConfigMode;
isPrimary: boolean;
type: string;
value: string;
serverId: string;
targetContainerId: string;
targetPort: number;
proxy: DomainProxy;
sslEnabled: boolean;
autoRenew: boolean;
}>,
) => put<{ success: boolean; data: Domain }>(`/domains/${id}`, body),
delete: (id: string) => del<{ success: boolean }>(`/domains/${id}`),
};
export const sslCerts = {
list: () => get<{ success: boolean; data: SslCert[] }>("/ssl"),
sync: (body?: { serverId?: string }) =>
post<{
success: boolean;
data: SslCert[];
meta?: {
summary: Array<{
serverId: string;
serverName: string;
synced: number;
}>;
};
}>("/ssl/sync", body ?? {}, { timeoutMs: 90000 }),
listExpiring: () =>
get<{ success: boolean; data: SslCert[] }>("/ssl/expiring"),
getRenewOperation: (operationId: string) =>
get<{ success: boolean; data: SslRenewOperation }>(
`/ssl/renew-operations/${operationId}`,
),
issue: (body: { domainId: string; issuer?: string; autoRenew?: boolean }) =>
post<{ success: boolean; data: SslCert }>("/ssl", body),
renew: (id: string) =>
post<{ success: boolean; data: SslRenewOperation }>(`/ssl/${id}/renew`, {}),
toggleAutoRenew: (id: string, autoRenew: boolean) =>
patch<{ success: boolean; data: SslCert }>(`/ssl/${id}/auto-renew`, {
autoRenew,
}),
delete: (id: string) => del<{ success: boolean }>(`/ssl/${id}`),
};
export interface NetworkRecord {
id: string;
name: string;
driver: string;
scope: string;
subnet: string | null;
gateway: string | null;
containers: number;
serverId: string;
createdAt: string;
updatedAt: string;
server?: { id: string; name: string; ip: string };
}
export interface NetworkDetailContainer {
id: string;
name: string;
endpointId: string | null;
macAddress: string | null;
ipv4Address: string | null;
ipv6Address: string | null;
}
export interface NetworkDetails {
network: NetworkRecord;
server: {
id: string;
name: string;
ip: string;
};
detail: {
name: string;
id: string | null;
created: string | null;
scope: string;
driver: string;
enableIPv4: boolean | null;
enableIPv6: boolean | null;
internal: boolean;
attachable: boolean;
ingress: boolean;
labels: Record<string, string>;
options: Record<string, string>;
subnet: string | null;
gateway: string | null;
containers: NetworkDetailContainer[];
raw: Record<string, unknown>;
};
}
export const networks = {
list: (serverId?: string) => {
const q = serverId ? `?serverId=${serverId}` : "";
return get<{ success: boolean; data: NetworkRecord[] }>(`/networks${q}`);
},
details: (id: string) =>
get<{ success: boolean; data: NetworkDetails }>(`/networks/${id}`),
sync: (body?: { serverId?: string }) =>
post<{
success: boolean;
data: NetworkRecord[];
meta?: {
summary: Array<{
serverId: string;
serverName: string;
synced: number;
}>;
};
}>("/networks/sync", body ?? {}, { timeoutMs: 30000 }),
create: (body: {
name: string;
driver: string;
scope: string;
subnet?: string;
gateway?: string;
containers?: number;
serverId: string;
}) => post<{ success: boolean; data: NetworkRecord }>("/networks", body),
delete: (id: string) => del<{ success: boolean }>(`/networks/${id}`),
};
export interface SecurityFirewallRule {
id: string | null;
number: number | null;
rule: string;
action: string;
direction: string;
from: string;
description: string;
enabled: boolean;
}
export interface FirewallStatus {
enabled: boolean;
rules: SecurityFirewallRule[];
}
export interface BannedIp {
ip: string;
jail: string;
bannedAt: string;
}
export interface Fail2banStatus {
enabled: boolean;
installed: boolean;
bannedIPs: BannedIp[];
}
export interface SecurityPlatformInfo {
distro: string | null;
packageManager: "apt-get" | "dnf" | "yum" | "zypper" | "apk" | null;
sudoNonInteractive: boolean;
supportedForFail2banInstall: boolean;
}
export interface SecuritySnapshot {
server: Pick<Server, "id" | "name" | "ip" | "status">;
firewall: FirewallStatus;
fail2ban: Fail2banStatus;
platform: SecurityPlatformInfo;
}
export interface SecurityOverviewItem {
server: Pick<Server, "id" | "name" | "ip" | "status">;
firewall: {
enabled: boolean;
rulesCount: number;
};
fail2ban: {
enabled: boolean;
installed: boolean;
bannedCount: number;
};
platform: SecurityPlatformInfo;
error: string | null;
}
export const security = {
overview: () =>
get<{ success: boolean; data: SecurityOverviewItem[] }>(
"/security/overview",
{ timeoutMs: 15000 },
),
get: (serverId: string) =>
get<{ success: boolean; data: SecuritySnapshot }>(`/security/${serverId}`, {
timeoutMs: 15000,
}),
getFirewall: (serverId: string) =>
get<{ success: boolean; data: FirewallStatus }>(
`/security/${serverId}/firewall`,
{ timeoutMs: 15000 },
),
enableFirewall: (serverId: string) =>
post<{ success: boolean; message: string }>(
`/security/${serverId}/firewall/enable`,
{},
),
disableFirewall: (serverId: string) =>
post<{ success: boolean; message: string }>(
`/security/${serverId}/firewall/disable`,
{},
),
addFirewallRule: (
serverId: string,
body: { rule: string; action: "allow" | "deny"; from?: string },
) =>
post<{ success: boolean; message: string }>(
`/security/${serverId}/firewall/rules`,
body,
),
deleteFirewallRule: (serverId: string, ruleNum: number) =>
del<{ success: boolean; message: string }>(
`/security/${serverId}/firewall/rules/${ruleNum}`,
),
disableFirewallRule: (
serverId: string,
ruleNum: number,
body: {
rule: string;
action: "allow" | "deny";
direction?: string;
from?: string;
description?: string;
},
) =>
post<{ success: boolean; message: string }>(
`/security/${serverId}/firewall/rules/${ruleNum}/disable`,
body,
),
enableSavedFirewallRule: (serverId: string, ruleId: string) =>
post<{ success: boolean; message: string }>(
`/security/${serverId}/firewall/rules/saved/${ruleId}/enable`,
{},
),
deleteSavedFirewallRule: (serverId: string, ruleId: string) =>
del<{ success: boolean; message: string }>(
`/security/${serverId}/firewall/rules/saved/${ruleId}`,
),
getFail2ban: (serverId: string) =>
get<{ success: boolean; data: Fail2banStatus }>(
`/security/${serverId}/fail2ban`,
{ timeoutMs: 15000 },
),
enableFail2ban: (serverId: string) =>
post<{ success: boolean; message: string }>(
`/security/${serverId}/fail2ban/enable`,
{},
),
disableFail2ban: (serverId: string) =>
post<{ success: boolean; message: string }>(
`/security/${serverId}/fail2ban/disable`,
{},
),
installFail2ban: (serverId: string) =>
post<{ success: boolean; message: string }>(
`/security/${serverId}/fail2ban/install`,
{},
),
unbanIp: (serverId: string, body: { ip: string; jail?: string }) =>
post<{ success: boolean; message: string }>(
`/security/${serverId}/fail2ban/unban`,
body,
),
};
// ─── Apps ────────────────────────────────────────────────────────────────────
export interface AppTemplate {
id: string;
name: string;
desc: string;
category: string;
icon?: string;
presentation?: {
icon?: string;
color?: string;
installs?: number;
};
image: string;
defaultPort: string;
defaultEnv: string;
defaultVolumes?: string;
defaultCommand?: string;
defaultNetwork?: string;
restartPolicy: string;
popular: boolean;
tags: string[];
presets?: Array<{
id: string;
name: string;
desc: string;
defaultPort?: string;
defaultEnv?: string;
defaultVolumes?: string;
defaultCommand?: string;
defaultNetwork?: string;
restartPolicy?: string;
}>;
}
export type AppCatalogSourceMode =
| "local"
| "auto-detect"
| "manifest-url"
| "github-archive";
export interface AppCatalogSourceOption {
id: AppCatalogSourceMode;
label: string;
description: string;
requiresUrl: boolean;
placeholder?: string;
exampleUrl?: string;
}
export interface AppCatalogMeta {
source: AppCatalogSourceMode;
label: string;
url?: string;
fetchedAt: string;
isRemote: boolean;
cached?: boolean;
expiresAt?: string;
format?: string;
}
export interface AppInstall {
id: string;
appId: string;
appName: string;
serverId: string;
containerName: string | null;
port: string | null;
status:
| "PENDING"
| "INSTALLING"
| "RUNNING"
| "STARTING"
| "STOPPING"
| "STOPPED"
| "PAUSED"
| "FAILED"
| "REMOVED";
error: string | null;
installedAt: string;
server?: { name: string; ip: string };
environment?: {
containerId: string;
id: string;
projectId: string;
name: string;
project?: {
id: string;
name: string;
slug: string;
};
} | null;
}
export interface AppInstallRuntimeDetails {
container: {
id: string;
name: string;
image: string;
status: string;
dockerId: string | null;
serverId: string;
};
server: {
id: string;
name: string;
ip: string;
};
logs: string;
inspect: Record<string, unknown>;
stats: ContainerRuntimeStats;
processes: ContainerProcess[];
diagnostics: {
primary:
| "OK"
| "CONTAINER_NOT_FOUND"
| "INSPECT_FAILED"
| "RUNTIME_UNAVAILABLE";
runtimeMessage: string | null;
inspect: { available: boolean; error: string | null };
logs: { available: boolean; error: string | null };
stats: { available: boolean; error: string | null };
processes: { available: boolean; error: string | null };
};
}
export const apps = {
templates: () =>
get<{ success: boolean; data: AppTemplate[] }>("/apps/templates"),
catalogSources: () =>
get<{ success: boolean; data: AppCatalogSourceOption[] }>(
"/apps/catalog/sources",
),
syncCatalog: (body: { source: AppCatalogSourceMode; url?: string }) =>
post<{ success: boolean; data: AppTemplate[]; meta: AppCatalogMeta }>(
"/apps/catalog/sync",
body,
),
installs: (serverId?: string, options?: { includeRuntime?: boolean }) => {
const params = new URLSearchParams();
if (serverId) {
params.set("serverId", serverId);
}
if (options?.includeRuntime === false) {
params.set("includeRuntime", "false");
}
const q = params.toString();
return get<{ success: boolean; data: AppInstall[] }>(
`/apps/installs${q ? `?${q}` : ""}`,
);
},
runtimeDetails: (id: string, lines?: number) =>
get<{ success: boolean; data: AppInstallRuntimeDetails }>(
`/apps/installs/${id}/runtime-details${lines ? `?lines=${lines}` : ""}`,
{ timeoutMs: 35000 },
),
install: (body: {
mode?: "template" | "custom";
appId?: string;
presetId?: string;
templateSnapshot?: AppTemplate;
serverId: string;
environmentId?: string;
networkId?: string;
appName?: string;
image?: string;
tag?: string;
containerName?: string;
port?: string;
ports?: string;
env?: string;
volumes?: string;
network?: string;
restartPolicy?: string;
command?: string;
}) =>
post<{ success: boolean; data: AppInstall }>("/apps/install", body, {
timeoutMs: 45000,
}),
rebuild: (id: string) =>
post<{ success: boolean; data: AppInstall; message?: string }>(
`/apps/installs/${id}/rebuild`,
{},
{ timeoutMs: 60000 },
),
action: (id: string, action: "start" | "stop" | "restart") =>
post<{ success: boolean; message?: string }>(
`/apps/installs/${id}/action`,
{
action,
},
{ timeoutMs: 65000 },
),
remove: (id: string) =>
del<{ success: boolean }>(`/apps/installs/${id}`, undefined, {
timeoutMs: 65000,
}),
};
// ─── Backups ──────────────────────────────────────────────────────────────────
export interface Backup {
id: string;
name: string;
type: "DATABASE" | "VOLUME" | "FULL";
databaseEngine:
| "POSTGRESQL"
| "MYSQL"
| "MARIADB"
| "MONGODB"
| "REDIS"
| null;
serverId: string;
target: string;
sizeMb: number | null;
filePath: string | null;
status: "PENDING" | "RUNNING" | "COMPLETED" | "FAILED";
error: string | null;
createdAt: string;
server?: { name: string; ip: string };
}
export interface BackupSelectOption {
value: string;
label: string;
disabled?: boolean;
status?: string;
path?: string;
}
export interface BackupStorageDestinationOption {
id: string;
name: string;
enabled: boolean;
serverId: string | null;
bucket: string;
endpoint: string;
provider: string;
disabled: boolean;
}
export interface BackupOptionsPayload {
databaseTargets: BackupSelectOption[];
volumeTargets: BackupSelectOption[];
storageDestinations: BackupStorageDestinationOption[];
}
export type BackupDownloadFormat = "zip" | "sql-zip" | "tar-gz";
export const backups = {
list: (serverId?: string) => {
const q = serverId ? `?serverId=${serverId}` : "";
return get<{ success: boolean; data: Backup[] }>(`/backups${q}`);
},
options: (serverId: string) =>
get<{ success: boolean; data: BackupOptionsPayload }>(
`/backups/options?serverId=${encodeURIComponent(serverId)}`,
),
stats: () =>
get<{
success: boolean;
data: {
total: number;
completed: number;
running: number;
failed: number;
};
}>("/backups/stats"),
create: (body: {
name: string;
type: "DATABASE" | "VOLUME" | "FULL";
serverId: string;
target?: "Local" | "S3";
storageDestinationId?: string;
dbContainer?: string;
volumePath?: string;
}) => post<{ success: boolean; data: Backup }>("/backups", body),
download: async (id: string, format: BackupDownloadFormat) => {
const res = await requestRaw(
`/backups/${id}/download?format=${encodeURIComponent(format)}`,
{ method: "GET" },
);
if (!res.ok) {
throw new Error(await readApiErrorMessage(res, "Download failed"));
}
const disposition = res.headers.get("content-disposition") || "";
const fileNameMatch = disposition.match(/filename="?([^";]+)"?/i);
return {
blob: await res.blob(),
fileName: fileNameMatch?.[1] || `backup-${id}.tar.gz`,
};
},
restore: (id: string) =>
post<{ success: boolean }>(`/backups/${id}/restore`, {}),
delete: (id: string) => del<{ success: boolean }>(`/backups/${id}`),
};
// ─── Logs ─────────────────────────────────────────────────────────────────────
export const logs = {
list: (params?: {
serverId?: string;
category?: string;
level?: string;
search?: string;
limit?: number;
offset?: number;
}) => {
const qs = new URLSearchParams();
if (params?.serverId) qs.set("serverId", params.serverId);
if (params?.category) qs.set("category", params.category);
if (params?.level) qs.set("level", params.level);
if (params?.search) qs.set("search", params.search);
if (params?.limit) qs.set("limit", String(params.limit));
if (params?.offset) qs.set("offset", String(params.offset));
const q = qs.toString();
return get<{ success: boolean; data: AuditLog[]; total: number }>(
`/logs${q ? `?${q}` : ""}`,
);
},
};
// ─── Terminal ─────────────────────────────────────────────────────────────────
export const terminal = {
sessions: () =>
get<{
success: boolean;
data: Array<{
id: string;
serverId: string;
serverName: string;
serverIp: string;
createdAt: string;
lastActiveAt: string;
cols: number;
rows: number;
}>;
}>("/terminal/sessions"),
closeSession: (id: string) =>
del<{ success: boolean; message: string }>(`/terminal/sessions/${id}`),
exec: (serverId: string, command: string) =>
post<{
success: boolean;
stdout: string;
stderr: string;
exitCode: number;
}>("/terminal/exec", { serverId, command }),
wsTicket: (
serverId: string,
sessionId?: string,
target?: { type: "shell" } | { type: "command"; command: string },
) =>
post<{
success: boolean;
data: {
ticket: string;
expiresAt: string;
};
}>("/terminal/ws-ticket", { serverId, sessionId, target }),
/** Returns WebSocket URL for interactive shell */
wsUrl: (
serverId: string,
cols = 220,
rows = 50,
options?: { sessionId?: string; ticket?: string | null },
): string => {
return buildTerminalWebSocketUrl({
configuredUrl: getWsBaseUrl(),
browserOrigin: getBrowserOrigin(),
serverId,
cols,
rows,
sessionId: options?.sessionId,
ticket: options?.ticket,
});
},
};
export const apiClient = {
auth,
apiKeys,
servers,
containers,
metrics,
domains,
sslCerts,
apps,
backups,
logs,
security,
terminal,
};
export default apiClient;