d03cc3c811
- Add GET /api/projects/{id}/stages/{stage}/instances/{iid}/logs endpoint
- Supports JSON mode (returns array of lines) and SSE mode (streams in real-time)
- Docker log stream header (8-byte prefix) stripped automatically
- ContainerLogs component with:
- Tail line selector (50/200/500/1000)
- Follow button for real-time streaming via SSE
- Auto-scroll to bottom
- Dark terminal-style display
- Close button
- Logs button (events icon) on each instance card
- i18n keys in EN and RU
602 lines
20 KiB
TypeScript
602 lines
20 KiB
TypeScript
import type {
|
|
ApiEnvelope,
|
|
ContainerStats,
|
|
Deploy,
|
|
DeployLog,
|
|
DockerHealth,
|
|
ProxyHealth,
|
|
EventLogEntry,
|
|
EventLogStats,
|
|
InspectResult,
|
|
Instance,
|
|
LocalImage,
|
|
NpmCertificate,
|
|
NpmAccessList,
|
|
ProxyRoute,
|
|
Project,
|
|
ProjectDetail,
|
|
Registry,
|
|
RegistryImage,
|
|
Settings,
|
|
StaleContainer,
|
|
Stage,
|
|
StageEnv,
|
|
Volume,
|
|
VolumeScopeInfo,
|
|
BrowseResult,
|
|
DnsZone,
|
|
DnsRecordView,
|
|
BackupInfo
|
|
} from './types';
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────
|
|
|
|
class ApiError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly status: number
|
|
) {
|
|
super(message);
|
|
this.name = 'ApiError';
|
|
}
|
|
}
|
|
|
|
import { getAuthToken, clearAuth } from './auth';
|
|
|
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const token = getAuthToken();
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
...(init?.headers as Record<string, string>)
|
|
};
|
|
if (token) {
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
|
|
const res = await fetch(path, {
|
|
...init,
|
|
headers
|
|
});
|
|
|
|
// Redirect to login on 401 (expired/missing token).
|
|
if (res.status === 401 && typeof window !== 'undefined' && !path.includes('/auth/')) {
|
|
clearAuth();
|
|
window.location.href = '/login';
|
|
throw new ApiError('Authentication required', 401);
|
|
}
|
|
|
|
let envelope: ApiEnvelope<T>;
|
|
try {
|
|
envelope = await res.json();
|
|
} catch {
|
|
throw new ApiError(
|
|
`Server returned non-JSON response (HTTP ${res.status})`,
|
|
res.status
|
|
);
|
|
}
|
|
|
|
if (!envelope.success) {
|
|
throw new ApiError(envelope.error ?? 'Unknown API error', res.status);
|
|
}
|
|
|
|
return envelope.data as T;
|
|
}
|
|
|
|
function get<T>(path: string): Promise<T> {
|
|
return request<T>(path);
|
|
}
|
|
|
|
function post<T>(path: string, body?: unknown): Promise<T> {
|
|
return request<T>(path, {
|
|
method: 'POST',
|
|
body: body !== undefined ? JSON.stringify(body) : undefined
|
|
});
|
|
}
|
|
|
|
function put<T>(path: string, body: unknown): Promise<T> {
|
|
return request<T>(path, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(body)
|
|
});
|
|
}
|
|
|
|
function del<T>(path: string): Promise<T> {
|
|
return request<T>(path, { method: 'DELETE' });
|
|
}
|
|
|
|
// ── Projects ────────────────────────────────────────────────────────
|
|
|
|
export function listProjects(): Promise<Project[]> {
|
|
return get<Project[]>('/api/projects');
|
|
}
|
|
|
|
export function getProject(id: string): Promise<ProjectDetail> {
|
|
return get<ProjectDetail>(`/api/projects/${id}`);
|
|
}
|
|
|
|
export function createProject(data: Partial<Project>): Promise<Project> {
|
|
return post<Project>('/api/projects', data);
|
|
}
|
|
|
|
export function updateProject(id: string, data: Partial<Project>): Promise<Project> {
|
|
return put<Project>(`/api/projects/${id}`, data);
|
|
}
|
|
|
|
export function deleteProject(id: string): Promise<{ deleted: string }> {
|
|
return del<{ deleted: string }>(`/api/projects/${id}`);
|
|
}
|
|
|
|
// ── Stages ─────────────────────────────────────────────────────────
|
|
|
|
export function createStage(projectId: string, data: Partial<Stage>): Promise<Stage> {
|
|
return post<Stage>(`/api/projects/${projectId}/stages`, data);
|
|
}
|
|
|
|
export function updateStage(projectId: string, stageId: string, data: Partial<Stage>): Promise<Stage> {
|
|
return put<Stage>(`/api/projects/${projectId}/stages/${stageId}`, data);
|
|
}
|
|
|
|
export function deleteStage(projectId: string, stageId: string): Promise<void> {
|
|
return del<void>(`/api/projects/${projectId}/stages/${stageId}`);
|
|
}
|
|
|
|
// ── Instances ───────────────────────────────────────────────────────
|
|
|
|
export function listInstances(projectId: string, stageId: string): Promise<Instance[]> {
|
|
return get<Instance[]>(`/api/projects/${projectId}/stages/${stageId}/instances`);
|
|
}
|
|
|
|
export function deployInstance(
|
|
projectId: string,
|
|
stageId: string,
|
|
imageTag: string
|
|
): Promise<{ status: string }> {
|
|
return post<{ status: string }>(`/api/projects/${projectId}/stages/${stageId}/instances`, {
|
|
image_tag: imageTag
|
|
});
|
|
}
|
|
|
|
export function removeInstance(
|
|
projectId: string,
|
|
stageId: string,
|
|
instanceId: string
|
|
): Promise<{ deleted: string }> {
|
|
return del<{ deleted: string }>(
|
|
`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}`
|
|
);
|
|
}
|
|
|
|
export function stopInstance(
|
|
projectId: string,
|
|
stageId: string,
|
|
instanceId: string
|
|
): Promise<{ status: string }> {
|
|
return post<{ status: string }>(
|
|
`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/stop`
|
|
);
|
|
}
|
|
|
|
export function startInstance(
|
|
projectId: string,
|
|
stageId: string,
|
|
instanceId: string
|
|
): Promise<{ status: string }> {
|
|
return post<{ status: string }>(
|
|
`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/start`
|
|
);
|
|
}
|
|
|
|
export function restartInstance(
|
|
projectId: string,
|
|
stageId: string,
|
|
instanceId: string
|
|
): Promise<{ status: string }> {
|
|
return post<{ status: string }>(
|
|
`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/restart`
|
|
);
|
|
}
|
|
|
|
// ── Deploys ─────────────────────────────────────────────────────────
|
|
|
|
export function listDeploys(limit = 50): Promise<Deploy[]> {
|
|
return get<Deploy[]>(`/api/deploys?limit=${limit}`);
|
|
}
|
|
|
|
export function getDeployLogs(deployId: string): Promise<DeployLog[]> {
|
|
return get<DeployLog[]>(`/api/deploys/${deployId}/logs`);
|
|
}
|
|
|
|
export function inspectImage(image: string): Promise<InspectResult> {
|
|
return post<InspectResult>('/api/deploy/inspect', { image });
|
|
}
|
|
|
|
export function quickDeploy(data: {
|
|
name?: string;
|
|
image: string;
|
|
tag?: string;
|
|
registry?: string;
|
|
port?: number;
|
|
force?: boolean;
|
|
enable_proxy?: boolean;
|
|
auto_deploy?: boolean;
|
|
}): Promise<{ project: Project; status: string }> {
|
|
return post<{ project: Project; status: string }>('/api/deploy/quick', data);
|
|
}
|
|
|
|
// ── Registries ──────────────────────────────────────────────────────
|
|
|
|
export function listRegistries(): Promise<Registry[]> {
|
|
return get<Registry[]>('/api/registries');
|
|
}
|
|
|
|
export function createRegistry(data: Partial<Registry>): Promise<Registry> {
|
|
return post<Registry>('/api/registries', data);
|
|
}
|
|
|
|
export function updateRegistry(id: string, data: Partial<Registry>): Promise<Registry> {
|
|
return put<Registry>(`/api/registries/${id}`, data);
|
|
}
|
|
|
|
export function deleteRegistry(id: string): Promise<{ deleted: string }> {
|
|
return del<{ deleted: string }>(`/api/registries/${id}`);
|
|
}
|
|
|
|
export function testRegistry(id: string): Promise<{ status: string }> {
|
|
return post<{ status: string }>(`/api/registries/${id}/test`);
|
|
}
|
|
|
|
export function listRegistryTags(registryId: string, image: string): Promise<string[]> {
|
|
return get<string[]>(`/api/registries/${registryId}/tags/${encodeURIComponent(image)}`);
|
|
}
|
|
|
|
export function listRegistryImages(registryId: string): Promise<RegistryImage[]> {
|
|
return get<RegistryImage[]>(`/api/registries/${registryId}/images`);
|
|
}
|
|
|
|
// ── Settings ────────────────────────────────────────────────────────
|
|
|
|
export function getSettings(): Promise<Settings> {
|
|
return get<Settings>('/api/settings');
|
|
}
|
|
|
|
export function updateSettings(data: Partial<Settings>): Promise<Settings> {
|
|
return put<Settings>('/api/settings', data);
|
|
}
|
|
|
|
export function getWebhookUrl(): Promise<{ webhook_url: string }> {
|
|
return get<{ webhook_url: string }>('/api/settings/webhook-url');
|
|
}
|
|
|
|
export function regenerateWebhookUrl(): Promise<{ webhook_url: string }> {
|
|
return post<{ webhook_url: string }>('/api/settings/webhook-url/regenerate');
|
|
}
|
|
|
|
// ── Proxy Routes ───────────────────────────────────────────────────
|
|
|
|
export function listProxyRoutes(): Promise<ProxyRoute[]> {
|
|
return get<ProxyRoute[]>('/api/proxies');
|
|
}
|
|
|
|
// ── Docker Management ──────────────────────────────────────────────
|
|
|
|
export function fetchContainerLogs(
|
|
projectId: string, stageId: string, instanceId: string, tail = 200
|
|
): Promise<string[]> {
|
|
return get<string[]>(`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/logs?tail=${tail}`);
|
|
}
|
|
|
|
export function listProjectImages(projectId: string): Promise<LocalImage[]> {
|
|
return get<LocalImage[]>(`/api/projects/${projectId}/images`);
|
|
}
|
|
|
|
export function pruneImages(): Promise<{ images_removed: number; space_reclaimed_mb: number }> {
|
|
return post<{ images_removed: number; space_reclaimed_mb: number }>('/api/docker/prune-images');
|
|
}
|
|
|
|
export function testNpmConnection(data: { npm_url?: string; npm_email?: string; npm_password?: string }): Promise<{ status: string }> {
|
|
return post<{ status: string }>('/api/settings/npm/test', data);
|
|
}
|
|
|
|
export function listNpmCertificates(): Promise<NpmCertificate[]> {
|
|
return get<NpmCertificate[]>('/api/settings/npm-certificates');
|
|
}
|
|
|
|
export function listNpmAccessLists(): Promise<NpmAccessList[]> {
|
|
return get<NpmAccessList[]>('/api/settings/npm-access-lists');
|
|
}
|
|
|
|
// ── DNS ────────────────────────────────────────────────────────────
|
|
|
|
export function testDnsConnection(provider: string, token: string, zoneId: string): Promise<{ success: boolean; error?: string }> {
|
|
return post<{ success: boolean; error?: string }>('/api/settings/dns/test', { provider, token, zone_id: zoneId });
|
|
}
|
|
|
|
export function listDnsZones(token?: string): Promise<DnsZone[]> {
|
|
return post<DnsZone[]>('/api/settings/dns/zones', { token: token ?? '' });
|
|
}
|
|
|
|
export function getDnsRecords(): Promise<DnsRecordView[]> {
|
|
return get<DnsRecordView[]>('/api/dns/records');
|
|
}
|
|
|
|
export function syncDnsRecords(): Promise<{ created: number; deleted: number; already_synced: number }> {
|
|
return post<{ created: number; deleted: number; already_synced: number }>('/api/dns/sync');
|
|
}
|
|
|
|
export function deleteDnsRecord(fqdn: string): Promise<void> {
|
|
return del<void>(`/api/dns/records/${encodeURIComponent(fqdn)}`);
|
|
}
|
|
|
|
// ── Backups ────────────────────────────────────────────────────────
|
|
|
|
export function listBackups(): Promise<BackupInfo[]> {
|
|
return get<BackupInfo[]>('/api/backups');
|
|
}
|
|
|
|
export function triggerBackup(): Promise<BackupInfo> {
|
|
return post<BackupInfo>('/api/backups');
|
|
}
|
|
|
|
export function deleteBackup(id: string): Promise<void> {
|
|
return del<void>(`/api/backups/${id}`);
|
|
}
|
|
|
|
export function restoreBackup(id: string): Promise<{ status: string; message: string }> {
|
|
return post<{ status: string; message: string }>(`/api/backups/${id}/restore`);
|
|
}
|
|
|
|
export function backupDownloadUrl(id: string): string {
|
|
return `/api/backups/${id}/download`;
|
|
}
|
|
|
|
// ── Health ──────────────────────────────────────────────────────────
|
|
|
|
export function getHealth(): Promise<{ docker: DockerHealth; proxy?: ProxyHealth }> {
|
|
return get<{ docker: DockerHealth; proxy?: ProxyHealth }>('/api/health');
|
|
}
|
|
|
|
// ── Auth ─────────────────────────────────────────────────────────────
|
|
|
|
export function login(username: string, password: string): Promise<{ token: string; expires_at: string }> {
|
|
return post<{ token: string; expires_at: string }>('/api/auth/login', { username, password });
|
|
}
|
|
|
|
export function getCurrentUser(): Promise<{ id: string; username: string; email: string; role: string }> {
|
|
return get<{ id: string; username: string; email: string; role: string }>('/api/auth/me');
|
|
}
|
|
|
|
// Auth settings
|
|
export async function getAuthSettings(): Promise<any> {
|
|
return request<any>('/api/auth/settings');
|
|
}
|
|
|
|
export async function updateAuthSettings(settings: any): Promise<any> {
|
|
return request<any>('/api/auth/settings', {
|
|
method: 'PUT',
|
|
body: JSON.stringify(settings)
|
|
});
|
|
}
|
|
|
|
export async function listUsers(): Promise<any[]> {
|
|
return request<any[]>('/api/auth/users');
|
|
}
|
|
|
|
export async function createUser(data: { username: string; password: string; email?: string; role?: string }): Promise<any> {
|
|
return request<any>('/api/auth/users', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
});
|
|
}
|
|
|
|
export async function updateUser(uid: string, data: { email?: string; role?: string }): Promise<any> {
|
|
return request<any>(`/api/auth/users/${uid}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data)
|
|
});
|
|
}
|
|
|
|
export async function changeUserPassword(uid: string, password: string): Promise<any> {
|
|
return request<any>(`/api/auth/users/${uid}/password`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ password })
|
|
});
|
|
}
|
|
|
|
export async function deleteUser(uid: string): Promise<any> {
|
|
return request<any>(`/api/auth/users/${uid}`, { method: 'DELETE' });
|
|
}
|
|
|
|
export async function logout(): Promise<void> {
|
|
await request<any>('/api/auth/logout', { method: 'POST' });
|
|
}
|
|
|
|
// ── Config Export ────────────────────────────────────────────────────
|
|
|
|
export function exportConfigUrl(): string {
|
|
return '/api/config/export';
|
|
}
|
|
|
|
// ── Stage Env Overrides ──────────────────────────────────────────────
|
|
|
|
export function listStageEnv(projectId: string, stageId: string): Promise<StageEnv[]> {
|
|
return get<StageEnv[]>(`/api/projects/${projectId}/stages/${stageId}/env`);
|
|
}
|
|
|
|
export function createStageEnv(
|
|
projectId: string,
|
|
stageId: string,
|
|
data: { key: string; value: string; encrypted?: boolean }
|
|
): Promise<StageEnv> {
|
|
return post<StageEnv>(`/api/projects/${projectId}/stages/${stageId}/env`, data);
|
|
}
|
|
|
|
export function updateStageEnv(
|
|
projectId: string,
|
|
stageId: string,
|
|
envId: string,
|
|
data: { key?: string; value?: string; encrypted?: boolean }
|
|
): Promise<StageEnv> {
|
|
return put<StageEnv>(`/api/projects/${projectId}/stages/${stageId}/env/${envId}`, data);
|
|
}
|
|
|
|
export function deleteStageEnv(
|
|
projectId: string,
|
|
stageId: string,
|
|
envId: string
|
|
): Promise<{ deleted: string }> {
|
|
return del<{ deleted: string }>(`/api/projects/${projectId}/stages/${stageId}/env/${envId}`);
|
|
}
|
|
|
|
// ── Volumes ──────────────────────────────────────────────────────────
|
|
|
|
export function listVolumes(projectId: string): Promise<Volume[]> {
|
|
return get<Volume[]>(`/api/projects/${projectId}/volumes`);
|
|
}
|
|
|
|
export function createVolume(
|
|
projectId: string,
|
|
data: { source: string; target: string; scope: string; name?: string; mode?: string }
|
|
): Promise<Volume> {
|
|
return post<Volume>(`/api/projects/${projectId}/volumes`, data);
|
|
}
|
|
|
|
export function updateVolume(
|
|
projectId: string,
|
|
volId: string,
|
|
data: { source?: string; target?: string; scope?: string; name?: string; mode?: string }
|
|
): Promise<Volume> {
|
|
return put<Volume>(`/api/projects/${projectId}/volumes/${volId}`, data);
|
|
}
|
|
|
|
export function listVolumeScopes(): Promise<VolumeScopeInfo[]> {
|
|
return get<VolumeScopeInfo[]>('/api/volumes/scopes');
|
|
}
|
|
|
|
export function deleteVolume(
|
|
projectId: string,
|
|
volId: string
|
|
): Promise<{ deleted: string }> {
|
|
return del<{ deleted: string }>(`/api/projects/${projectId}/volumes/${volId}`);
|
|
}
|
|
|
|
export function browseVolume(
|
|
projectId: string,
|
|
volId: string,
|
|
params?: { path?: string; stage?: string; tag?: string }
|
|
): Promise<BrowseResult> {
|
|
const query = new URLSearchParams();
|
|
if (params?.path) query.set('path', params.path);
|
|
if (params?.stage) query.set('stage', params.stage);
|
|
if (params?.tag) query.set('tag', params.tag);
|
|
const qs = query.toString();
|
|
return get<BrowseResult>(`/api/projects/${projectId}/volumes/${volId}/browse${qs ? `?${qs}` : ''}`);
|
|
}
|
|
|
|
export function volumeDownloadUrl(
|
|
projectId: string,
|
|
volId: string,
|
|
params?: { path?: string; stage?: string; tag?: string }
|
|
): string {
|
|
const query = new URLSearchParams();
|
|
if (params?.path) query.set('path', params.path);
|
|
if (params?.stage) query.set('stage', params.stage);
|
|
if (params?.tag) query.set('tag', params.tag);
|
|
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('auth_token') : null;
|
|
if (token) query.set('token', token);
|
|
const qs = query.toString();
|
|
return `/api/projects/${projectId}/volumes/${volId}/download${qs ? `?${qs}` : ''}`;
|
|
}
|
|
|
|
export async function uploadToVolume(
|
|
projectId: string,
|
|
volId: string,
|
|
files: FileList,
|
|
params?: { path?: string; stage?: string; tag?: string }
|
|
): Promise<{ uploaded: string[]; count: number }> {
|
|
const query = new URLSearchParams();
|
|
if (params?.path) query.set('path', params.path);
|
|
if (params?.stage) query.set('stage', params.stage);
|
|
if (params?.tag) query.set('tag', params.tag);
|
|
const qs = query.toString();
|
|
|
|
const formData = new FormData();
|
|
for (let i = 0; i < files.length; i++) {
|
|
formData.append('files', files[i]);
|
|
}
|
|
|
|
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('auth_token') : null;
|
|
const headers: Record<string, string> = {};
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
|
|
const res = await fetch(`/api/projects/${projectId}/volumes/${volId}/upload${qs ? `?${qs}` : ''}`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData,
|
|
});
|
|
|
|
const envelope = await res.json();
|
|
if (!envelope.success) throw new Error(envelope.error ?? 'Upload failed');
|
|
return envelope.data;
|
|
}
|
|
|
|
// ── Event Log ───────────────────────────────────────────────────────
|
|
|
|
export function fetchEventLog(params?: {
|
|
severity?: string;
|
|
source?: string;
|
|
since?: string;
|
|
until?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
}): Promise<EventLogEntry[]> {
|
|
const query = new URLSearchParams();
|
|
if (params?.severity) query.set('severity', params.severity);
|
|
if (params?.source) query.set('source', params.source);
|
|
if (params?.since) query.set('since', params.since);
|
|
if (params?.until) query.set('until', params.until);
|
|
if (params?.limit) query.set('limit', String(params.limit));
|
|
if (params?.offset) query.set('offset', String(params.offset));
|
|
const qs = query.toString();
|
|
return get<EventLogEntry[]>(`/api/events/log${qs ? `?${qs}` : ''}`);
|
|
}
|
|
|
|
export function fetchEventLogStats(): Promise<EventLogStats> {
|
|
return get<EventLogStats>('/api/events/log/stats');
|
|
}
|
|
|
|
export function deleteEvent(id: number): Promise<{ status: string }> {
|
|
return del<{ status: string }>(`/api/events/log/${id}`);
|
|
}
|
|
|
|
export function clearAllEvents(): Promise<{ status: string; count: number }> {
|
|
return del<{ status: string; count: number }>('/api/events/log');
|
|
}
|
|
|
|
// ── Stale Containers ────────────────────────────────────────────────
|
|
|
|
export function fetchStaleContainers(): Promise<StaleContainer[]> {
|
|
return get<StaleContainer[]>('/api/containers/stale');
|
|
}
|
|
|
|
export function cleanupStaleContainer(id: string): Promise<{ deleted: string }> {
|
|
return post<{ deleted: string }>(`/api/containers/stale/${id}/cleanup`);
|
|
}
|
|
|
|
export function bulkCleanupStaleContainers(): Promise<{ deleted: number }> {
|
|
return post<{ deleted: number }>('/api/containers/stale/cleanup');
|
|
}
|
|
|
|
// ── Container Stats ────────────────────────────────────────────────
|
|
|
|
export function fetchContainerStats(
|
|
projectId: string,
|
|
stageId: string,
|
|
instanceId: string
|
|
): Promise<ContainerStats> {
|
|
return get<ContainerStats>(
|
|
`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/stats`
|
|
);
|
|
}
|
|
|
|
export { ApiError };
|