small fix, don't worry about it

This commit is contained in:
Tomas Dvorak committed 2026-04-10 12:02:36 +02:00
1 parent 08bd0c6e5c
commit 08cb5754f3
638 files changed
+57330 -34704

No files matched your search

+960
View File
@@ -0,0 +1,960 @@
import type { components, paths } from '@/generated/api-types';
export type ServiceStatus = 'running' | 'stopped' | 'building' | 'failed' | 'unknown';
export type ProjectStats = {
service_count: number;
deployment_count: number;
running_services: number;
last_deployment?: string;
};
export type ProjectEntity = {
id: string;
name: string;
description: string;
createdAt?: string;
updatedAt?: string;
stats: ProjectStats;
};
export type UserProfile = {
id: string;
email: string;
name: string;
avatarUrl?: string;
createdAt?: string;
updatedAt?: string;
};
export type ServiceEntity = {
id: string;
projectId: string;
name: string;
type: string;
status: ServiceStatus;
createdAt?: string;
updatedAt?: string;
image?: string;
command?: string;
environment?: string;
gitRepo?: string;
gitBranch?: string;
buildPath?: string;
cpu?: string;
memory?: string;
};
export type ServiceVariable = {
id: string;
serviceId: string;
key: string;
value: string;
isSecret: boolean;
createdAt?: string;
updatedAt?: string;
};
export type BuildStatus = 'pending' | 'running' | 'success' | 'failed' | 'cancelled';
export type BuildEntity = {
id: string;
projectId?: string;
serviceId?: string;
status: BuildStatus;
progress: number;
startedAt?: string;
completedAt?: string;
imageName?: string;
imageTag?: string;
size: number;
error?: string;
log?: string;
metadata: Record<string, string>;
};
export type ListBuildsInput = {
projectId?: string;
serviceId?: string;
status?: BuildStatus;
page?: number;
limit?: number;
};
export type ListBuildsResult = {
builds: BuildEntity[];
total: number;
page: number;
limit: number;
};
export type TemplateEntity = {
id: string;
name: string;
description: string;
category: string;
logo: string;
configRaw: string;
variablesRaw: string;
isOfficial: boolean;
createdAt?: string;
updatedAt?: string;
};
export type TemplateConfigEntity = {
type: string;
runtime: string;
buildCommand: string;
startCommand: string;
port: number;
healthCheck: string;
environment: Record<string, string>;
dockerfile?: string;
nixpacksConfig: Record<string, string>;
};
export type TemplateVariableEntity = {
key: string;
label: string;
defaultValue: string;
required: boolean;
secret: boolean;
description: string;
};
export type TemplateDetailEntity = {
template: TemplateEntity;
config: TemplateConfigEntity;
variables: TemplateVariableEntity[];
};
export type ListTemplatesInput = {
category?: string;
};
export type DeployTemplateInput = {
projectId: string;
name: string;
variables?: Record<string, string>;
};
export type DeployTemplateResult = {
serviceId: string;
message: string;
};
export type UpdateUserProfileInput = {
name?: string;
avatarUrl?: string;
};
export type AuditLogEntity = {
id: string;
userId: string;
userEmail: string;
resource: string;
resourceId: string;
action: string;
details: string;
ipAddress: string;
userAgent: string;
createdAt?: string;
};
export type ListAuditLogsInput = {
resource?: string;
action?: string;
page?: number;
limit?: number;
};
export type DeploymentEntity = {
id: string;
serviceId: string;
commitHash?: string;
status: string;
imageName?: string;
imageTag?: string;
buildLog?: string;
runtimeLog?: string;
error?: string;
startedAt?: string;
completedAt?: string;
createdAt?: string;
updatedAt?: string;
};
export type CreateDeploymentInput = {
commitHash?: string;
branch?: string;
trigger?: string;
envVars?: Record<string, string>;
};
export type RollbackDeploymentResult = {
deployment?: DeploymentEntity;
message: string;
};
export type ServiceLogEntity = {
timestamp?: string;
message: string;
stream: string;
};
export type ListServiceLogsInput = {
tail?: string;
follow?: boolean;
};
export type GetDeploymentLogsInput = {
type?: 'all' | 'build' | 'runtime';
};
type RawProject = components['schemas']['Project'] & {
stats?: Partial<ProjectStats>;
};
type RawService = components['schemas']['Service'] & {
image?: string;
command?: string;
environment?: string;
git_repo?: string;
git_branch?: string;
build_path?: string;
cpu?: string;
memory?: string;
};
type RawUserProfile = components['schemas']['User'] & {
avatar_url?: string;
};
type RawServiceVariable = {
id?: string;
service_id?: string;
key?: string;
value?: string;
is_secret?: boolean;
created_at?: string;
updated_at?: string;
};
type RawBuildStatus = components['schemas']['BuildStatus'];
type RawBuildListResponse = components['schemas']['BuildListResponse'];
type RawServiceTemplate = components['schemas']['ServiceTemplate'];
type RawTemplateConfig = components['schemas']['TemplateConfig'];
type RawTemplateVariable = components['schemas']['TemplateVariable'];
type RawTemplateDetailResponse = components['schemas']['TemplateDetailResponse'];
type RawDeployTemplateResponse = components['schemas']['DeployTemplateResponse'];
type RawAuditLog = components['schemas']['AuditLog'];
type RawAuditLogListResponse = components['schemas']['AuditLogListResponse'];
type RawDeployment = components['schemas']['Deployment'];
type RawDeploymentListResponse = components['schemas']['DeploymentListResponse'];
type RawCreateDeploymentRequest = components['schemas']['CreateDeploymentRequest'];
type RawServiceLog = components['schemas']['ServiceLogEntry'];
type RawServiceLogsResponse = components['schemas']['ServiceLogsResponse'];
type RawDeploymentLogsResponse = components['schemas']['DeploymentLogsResponse'];
type RawRollbackDeploymentResponse = components['schemas']['RollbackDeploymentResponse'];
export type CreateProjectInput = components['schemas']['CreateProjectRequest'];
export type CreateServiceInput = components['schemas']['CreateServiceRequest'] & {
environment?: 'production' | 'preview' | 'development';
project_id?: string;
image?: string;
command?: string;
git_repo?: string;
git_branch?: string;
build_path?: string;
cpu?: string;
memory?: string;
};
export class ApiError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
type ProjectsResponse200 = paths['/projects']['get']['responses'][200]['content']['application/json'];
type JsonLike = Record<string, unknown>;
const rawBase = (import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8082';
const normalizedBase = rawBase.replace(/\/$/, '');
const API_BASE = /\/api\/v1$/.test(normalizedBase) ? normalizedBase : `${normalizedBase}/api/v1`;
export function getApiBaseUrl(): string {
return API_BASE;
}
function authHeaders(): HeadersInit {
return { 'Content-Type': 'application/json' };
}
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
...init,
credentials: 'include',
headers: {
...authHeaders(),
...(init?.headers ?? {}),
},
});
if (response.status === 204) {
return undefined as T;
}
const payload = (await response.json().catch(() => null)) as JsonLike | null;
if (!response.ok) {
const message =
(payload?.error as string | undefined) ??
(payload?.message as string | undefined) ??
`Request failed with status ${response.status}`;
throw new ApiError(message, response.status);
}
return payload as T;
}
async function requestText(path: string, init?: RequestInit): Promise<string> {
const response = await fetch(`${API_BASE}${path}`, {
...init,
credentials: 'include',
headers: {
...authHeaders(),
...(init?.headers ?? {}),
},
});
const text = await response.text();
if (!response.ok) {
throw new ApiError(text || `Request failed with status ${response.status}`, response.status);
}
return text;
}
function normalizeProject(project: RawProject): ProjectEntity | null {
if (!project.id || !project.name) {
return null;
}
return {
id: project.id,
name: project.name,
description: project.description ?? '',
createdAt: project.created_at,
updatedAt: project.updated_at,
stats: {
service_count: project.stats?.service_count ?? project.services_count ?? 0,
deployment_count: project.stats?.deployment_count ?? 0,
running_services: project.stats?.running_services ?? 0,
last_deployment: project.stats?.last_deployment ?? undefined,
},
};
}
function normalizeUserProfile(profile: RawUserProfile): UserProfile | null {
if (!profile.id || !profile.email || !profile.name) {
return null;
}
return {
id: profile.id,
email: profile.email,
name: profile.name,
avatarUrl: profile.avatar_url ?? undefined,
createdAt: profile.created_at ?? undefined,
updatedAt: profile.updated_at ?? undefined,
};
}
function normalizeService(service: RawService): ServiceEntity | null {
if (!service.id || !service.name || !service.project_id) {
return null;
}
const status = (service.status ?? 'unknown') as ServiceStatus;
return {
id: service.id,
projectId: service.project_id,
name: service.name,
type: service.type ?? 'web',
status,
createdAt: service.created_at,
updatedAt: service.updated_at,
image: service.image,
command: service.command,
environment: service.environment,
gitRepo: service.git_repo,
gitBranch: service.git_branch,
buildPath: service.build_path,
cpu: service.cpu,
memory: service.memory,
};
}
function normalizeProjectArray(raw: unknown): ProjectEntity[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => normalizeProject(entry as RawProject))
.filter((entry): entry is ProjectEntity => entry !== null);
}
function normalizeServiceArray(raw: unknown): ServiceEntity[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => normalizeService(entry as RawService))
.filter((entry): entry is ServiceEntity => entry !== null);
}
function normalizeBuild(build: RawBuildStatus): BuildEntity | null {
if (!build.id || !build.status) {
return null;
}
return {
id: build.id,
projectId: build.project_id,
serviceId: build.service_id,
status: build.status as BuildStatus,
progress: build.progress ?? 0,
startedAt: build.started_at,
completedAt: build.completed_at,
imageName: build.image_name,
imageTag: build.image_tag,
size: build.size ?? 0,
error: build.error,
log: build.log,
metadata: build.metadata ?? {},
};
}
function normalizeBuildArray(raw: unknown): BuildEntity[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => normalizeBuild(entry as RawBuildStatus))
.filter((entry): entry is BuildEntity => entry !== null);
}
function normalizeTemplate(template: RawServiceTemplate): TemplateEntity | null {
if (!template.id || !template.name) {
return null;
}
return {
id: template.id,
name: template.name,
description: template.description ?? '',
category: template.category ?? '',
logo: template.logo ?? '',
configRaw: template.config ?? '',
variablesRaw: template.variables ?? '',
isOfficial: Boolean(template.is_official),
createdAt: template.created_at,
updatedAt: template.updated_at,
};
}
function normalizeTemplateArray(raw: unknown): TemplateEntity[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => normalizeTemplate(entry as RawServiceTemplate))
.filter((entry): entry is TemplateEntity => entry !== null);
}
function normalizeStringRecord(raw: unknown): Record<string, string> {
if (!raw || typeof raw !== 'object') {
return {};
}
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (typeof value === 'string') {
result[key] = value;
}
}
return result;
}
function normalizeTemplateConfig(config?: RawTemplateConfig): TemplateConfigEntity {
return {
type: config?.type ?? '',
runtime: config?.runtime ?? '',
buildCommand: config?.build_command ?? '',
startCommand: config?.start_command ?? '',
port: config?.port ?? 0,
healthCheck: config?.health_check ?? '',
environment: normalizeStringRecord(config?.environment),
dockerfile: config?.dockerfile ?? undefined,
nixpacksConfig: normalizeStringRecord(config?.nixpacks_config),
};
}
function normalizeTemplateVariable(variable: RawTemplateVariable): TemplateVariableEntity | null {
if (!variable.key) {
return null;
}
return {
key: variable.key,
label: variable.label ?? variable.key,
defaultValue: variable.default ?? '',
required: Boolean(variable.required),
secret: Boolean(variable.secret),
description: variable.description ?? '',
};
}
function normalizeTemplateVariableArray(raw: unknown): TemplateVariableEntity[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => normalizeTemplateVariable(entry as RawTemplateVariable))
.filter((entry): entry is TemplateVariableEntity => entry !== null);
}
function normalizeAuditLog(log: RawAuditLog): AuditLogEntity | null {
if (!log.id || !log.user_id || !log.resource || !log.action) {
return null;
}
return {
id: log.id,
userId: log.user_id,
userEmail: log.user_email ?? '',
resource: log.resource,
resourceId: log.resource_id ?? '',
action: log.action,
details: log.details ?? '',
ipAddress: log.ip_address ?? '',
userAgent: log.user_agent ?? '',
createdAt: log.created_at,
};
}
function normalizeAuditLogArray(raw: unknown): AuditLogEntity[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => normalizeAuditLog(entry as RawAuditLog))
.filter((entry): entry is AuditLogEntity => entry !== null);
}
function normalizeDeployment(deployment: RawDeployment): DeploymentEntity | null {
if (!deployment.id || !deployment.service_id || !deployment.status) {
return null;
}
return {
id: deployment.id,
serviceId: deployment.service_id,
commitHash: deployment.commit_hash ?? undefined,
status: deployment.status,
imageName: deployment.image_name ?? undefined,
imageTag: deployment.image_tag ?? undefined,
buildLog: deployment.build_log ?? undefined,
runtimeLog: deployment.runtime_log ?? undefined,
error: deployment.error ?? undefined,
startedAt: deployment.started_at ?? undefined,
completedAt: deployment.completed_at ?? undefined,
createdAt: deployment.created_at ?? undefined,
updatedAt: deployment.updated_at ?? undefined,
};
}
function normalizeDeploymentArray(raw: unknown): DeploymentEntity[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => normalizeDeployment(entry as RawDeployment))
.filter((entry): entry is DeploymentEntity => entry !== null);
}
function normalizeServiceLog(log: RawServiceLog): ServiceLogEntity | null {
if (!log.message || !log.stream) {
return null;
}
return {
timestamp: log.timestamp ?? undefined,
message: log.message,
stream: log.stream,
};
}
function normalizeServiceLogArray(raw: unknown): ServiceLogEntity[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => normalizeServiceLog(entry as RawServiceLog))
.filter((entry): entry is ServiceLogEntity => entry !== null);
}
export async function listProjects(): Promise<ProjectEntity[]> {
const payload = await requestJson<ProjectsResponse200 | { projects?: RawProject[] }>(`/projects`);
return normalizeProjectArray((payload as { projects?: RawProject[] }).projects);
}
export async function getProjectById(projectId: string): Promise<ProjectEntity> {
const payload = await requestJson<RawProject | { project?: RawProject }>(`/projects/${projectId}`);
const rawProject = (payload as { project?: RawProject }).project ?? (payload as RawProject);
const parsed = normalizeProject(rawProject);
if (!parsed) {
throw new ApiError('Project payload is invalid', 500);
}
return parsed;
}
export async function getCurrentUserProfile(): Promise<UserProfile> {
const payload = await requestJson<RawUserProfile | { user?: RawUserProfile }>(`/user/profile`);
const rawProfile = (payload as { user?: RawUserProfile }).user ?? (payload as RawUserProfile);
const profile = normalizeUserProfile(rawProfile);
if (!profile) {
throw new ApiError('User profile payload is invalid', 500);
}
return profile;
}
export async function updateCurrentUserProfile(input: UpdateUserProfileInput): Promise<UserProfile> {
const payload = await requestJson<RawUserProfile | { user?: RawUserProfile }>(`/user/profile`, {
method: 'PUT',
body: JSON.stringify({
name: input.name,
avatar_url: input.avatarUrl,
}),
});
const rawProfile = (payload as { user?: RawUserProfile }).user ?? (payload as RawUserProfile);
const profile = normalizeUserProfile(rawProfile);
if (!profile) {
throw new ApiError('Updated user profile payload is invalid', 500);
}
return profile;
}
export async function createProject(input: CreateProjectInput): Promise<ProjectEntity> {
const payload = await requestJson<RawProject | { project?: RawProject }>(`/projects`, {
method: 'POST',
body: JSON.stringify(input),
});
const rawProject = (payload as { project?: RawProject }).project ?? (payload as RawProject);
const parsed = normalizeProject(rawProject);
if (!parsed) {
throw new ApiError('Create project response is invalid', 500);
}
return parsed;
}
export async function listServicesByProject(projectId: string): Promise<ServiceEntity[]> {
const payload = await requestJson<RawService[] | { services?: RawService[] }>(`/projects/${projectId}/services`);
const rows = Array.isArray(payload) ? payload : payload.services ?? [];
return normalizeServiceArray(rows);
}
export async function getServiceById(serviceId: string): Promise<ServiceEntity> {
const payload = await requestJson<RawService | { service?: RawService }>(`/services/${serviceId}`);
const row = (payload as { service?: RawService }).service ?? (payload as RawService);
const parsed = normalizeService(row);
if (!parsed) {
throw new ApiError('Service payload is invalid', 500);
}
return parsed;
}
export async function createService(projectId: string, input: CreateServiceInput): Promise<ServiceEntity> {
const payload = await requestJson<RawService | { service?: RawService }>(`/projects/${projectId}/services`, {
method: 'POST',
body: JSON.stringify({ ...input, project_id: projectId }),
});
const row = (payload as { service?: RawService }).service ?? (payload as RawService);
const parsed = normalizeService(row);
if (!parsed) {
throw new ApiError('Create service response is invalid', 500);
}
return parsed;
}
export async function deleteService(serviceId: string): Promise<void> {
await requestJson<{ message?: string }>(`/services/${serviceId}`, {
method: 'DELETE',
});
}
export async function listServiceVariables(serviceId: string): Promise<ServiceVariable[]> {
const payload = await requestJson<{ variables?: RawServiceVariable[] }>(`/services/${serviceId}/variables`);
const rows = payload.variables ?? [];
const result: ServiceVariable[] = [];
for (const row of rows) {
if (!row.id || !row.service_id || !row.key) {
continue;
}
result.push({
id: row.id,
serviceId: row.service_id,
key: row.key,
value: row.value ?? '',
isSecret: Boolean(row.is_secret),
createdAt: row.created_at,
updatedAt: row.updated_at,
});
}
return result;
}
export async function listAuditLogs(input: ListAuditLogsInput = {}): Promise<AuditLogEntity[]> {
const searchParams = new URLSearchParams();
if (input.resource) {
searchParams.set('resource', input.resource);
}
if (input.action) {
searchParams.set('action', input.action);
}
if (input.page && input.page > 0) {
searchParams.set('page', String(input.page));
}
if (input.limit && input.limit > 0) {
searchParams.set('limit', String(input.limit));
}
const query = searchParams.toString();
const payload = await requestJson<RawAuditLogListResponse>(query ? `/audit-logs?${query}` : '/audit-logs');
return normalizeAuditLogArray(payload.audit_logs);
}
export async function listDeployments(serviceId: string): Promise<DeploymentEntity[]> {
const payload = await requestJson<RawDeploymentListResponse>(`/services/${serviceId}/deployments`);
return normalizeDeploymentArray(payload.deployments);
}
export async function createDeployment(
serviceId: string,
input: CreateDeploymentInput = {},
): Promise<DeploymentEntity> {
const requestBody: RawCreateDeploymentRequest = {
commit_hash: input.commitHash,
branch: input.branch,
trigger: input.trigger,
env_vars: input.envVars,
};
const payload = await requestJson<RawDeployment>(`/services/${serviceId}/deployments`, {
method: 'POST',
body: JSON.stringify(requestBody),
});
const deployment = normalizeDeployment(payload);
if (!deployment) {
throw new ApiError('Create deployment response is invalid', 500);
}
return deployment;
}
export async function listServiceLogs(
serviceId: string,
input: ListServiceLogsInput = {},
): Promise<ServiceLogEntity[]> {
const searchParams = new URLSearchParams();
if (input.tail) {
searchParams.set('tail', input.tail);
}
if (typeof input.follow === 'boolean') {
searchParams.set('follow', String(input.follow));
}
const query = searchParams.toString();
const payload = await requestJson<RawServiceLogsResponse>(
query ? `/services/${serviceId}/logs?${query}` : `/services/${serviceId}/logs`,
);
return normalizeServiceLogArray(payload.logs);
}
export async function getDeploymentLogs(
deploymentId: string,
input: GetDeploymentLogsInput = {},
): Promise<{
logs: ServiceLogEntity[];
buildLog: string;
runtimeLog: string;
}> {
const searchParams = new URLSearchParams();
if (input.type) {
searchParams.set('type', input.type);
}
const query = searchParams.toString();
const payload = await requestJson<RawDeploymentLogsResponse>(
query ? `/deployments/${deploymentId}/logs?${query}` : `/deployments/${deploymentId}/logs`,
);
return {
logs: normalizeServiceLogArray(payload.logs),
buildLog: payload.build_log ?? '',
runtimeLog: payload.runtime_log ?? '',
};
}
export async function rollbackDeployment(deploymentId: string): Promise<RollbackDeploymentResult> {
const payload = await requestJson<RawRollbackDeploymentResponse>(`/deployments/${deploymentId}/rollback`, {
method: 'POST',
});
return {
deployment: payload.deployment ? normalizeDeployment(payload.deployment) ?? undefined : undefined,
message: payload.message ?? 'Rollback initiated',
};
}
export async function listBuilds(input: ListBuildsInput = {}): Promise<ListBuildsResult> {
const searchParams = new URLSearchParams();
if (input.projectId) {
searchParams.set('project_id', input.projectId);
}
if (input.serviceId) {
searchParams.set('service_id', input.serviceId);
}
if (input.status) {
searchParams.set('status', input.status);
}
if (input.page && input.page > 0) {
searchParams.set('page', String(input.page));
}
if (input.limit && input.limit > 0) {
searchParams.set('limit', String(input.limit));
}
const query = searchParams.toString();
const payload = await requestJson<RawBuildListResponse>(query ? `/builds?${query}` : '/builds');
return {
builds: normalizeBuildArray(payload.builds),
total: payload.total ?? 0,
page: payload.page ?? input.page ?? 1,
limit: payload.limit ?? input.limit ?? 20,
};
}
export async function cancelBuild(buildId: string): Promise<string> {
const payload = await requestJson<{ message?: string }>(`/builds/${buildId}/cancel`, {
method: 'POST',
});
return payload.message ?? 'Build cancelled';
}
export async function getBuildLogs(buildId: string, follow = false): Promise<string> {
const query = follow ? '?follow=true' : '';
return requestText(`/builds/${buildId}/logs${query}`);
}
export async function listTemplates(input: ListTemplatesInput = {}): Promise<TemplateEntity[]> {
const searchParams = new URLSearchParams();
if (input.category) {
searchParams.set('category', input.category);
}
const query = searchParams.toString();
const payload = await requestJson<{ templates?: RawServiceTemplate[] }>(
query ? `/templates?${query}` : '/templates',
);
return normalizeTemplateArray(payload.templates);
}
export async function getTemplateById(templateId: string): Promise<TemplateDetailEntity> {
const payload = await requestJson<RawTemplateDetailResponse>(`/templates/${templateId}`);
const template = payload.template ? normalizeTemplate(payload.template) : null;
if (!template) {
throw new ApiError('Template payload is invalid', 500);
}
return {
template,
config: normalizeTemplateConfig(payload.config),
variables: normalizeTemplateVariableArray(payload.variables),
};
}
export async function deployTemplate(
templateId: string,
input: DeployTemplateInput,
): Promise<DeployTemplateResult> {
const payload = await requestJson<RawDeployTemplateResponse>(`/templates/${templateId}/deploy`, {
method: 'POST',
body: JSON.stringify({
project_id: input.projectId,
name: input.name,
variables: input.variables ?? {},
}),
});
if (!payload.service_id) {
throw new ApiError('Template deployment response is invalid', 500);
}
return {
serviceId: payload.service_id,
message: payload.message ?? 'Service created from template',
};
}
export function serviceStatusClass(status: ServiceStatus): string {
switch (status) {
case 'running':
return 'status-running';
case 'building':
return 'status-building';
case 'failed':
return 'status-failed';
case 'stopped':
return 'status-stopped';
default:
return 'status-stopped';
}
}
+225
View File
@@ -0,0 +1,225 @@
export type AuthUser = {
id: string;
email: string;
name: string;
image?: string | null;
};
export type AuthSession = {
id: string;
userId: string;
expiresAt: string;
};
export type AuthSessionPayload = {
user: AuthUser;
session: AuthSession;
};
export class AuthError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'AuthError';
this.status = status;
}
}
const rawAuthBase = (import.meta.env.VITE_AUTH_URL as string | undefined) ?? 'http://localhost:8082/api/auth';
const AUTH_BASE = rawAuthBase.replace(/\/$/, '');
export function getAuthBaseUrl(): string {
return AUTH_BASE;
}
function normalizeSessionPayload(payload: unknown): AuthSessionPayload | null {
if (!payload || typeof payload !== 'object') {
return null;
}
const objectPayload = payload as Record<string, unknown>;
const source = (objectPayload.data as Record<string, unknown> | undefined) ?? objectPayload;
const user = source.user as Record<string, unknown> | undefined;
const session = source.session as Record<string, unknown> | undefined;
if (!user || !session || typeof user.id !== 'string' || typeof user.email !== 'string' || typeof user.name !== 'string' || typeof session.id !== 'string' || typeof session.userId !== 'string' || typeof session.expiresAt !== 'string') {
return null;
}
return {
user: {
id: user.id,
email: user.email,
name: user.name,
image: typeof user.image === 'string' ? user.image : null,
},
session: {
id: session.id,
userId: session.userId,
expiresAt: session.expiresAt,
},
};
}
async function authRequest(path: string, init?: RequestInit): Promise<unknown> {
const response = await fetch(`${AUTH_BASE}${path}`, {
...init,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
});
if (response.status === 204) {
return null;
}
const payload = await response.json().catch(() => null);
if (!response.ok) {
const objectPayload = payload as Record<string, unknown> | null;
const message =
(objectPayload?.message as string | undefined) ??
(objectPayload?.error as string | undefined) ??
`Auth request failed with status ${response.status}`;
throw new AuthError(message, response.status);
}
return payload;
}
function parseOAuthRedirectPayload(payload: unknown): { url: string; redirect: boolean } {
if (!payload || typeof payload !== 'object') {
throw new AuthError('Invalid OAuth response payload', 500);
}
const objectPayload = payload as Record<string, unknown>;
const source = (objectPayload.data as Record<string, unknown> | undefined) ?? objectPayload;
const url = source.url;
const redirect = source.redirect;
if (typeof url !== 'string' || !url.trim()) {
throw new AuthError('OAuth redirect URL missing in response', 500);
}
return {
url,
redirect: redirect !== false,
};
}
export async function getAuthSession(): Promise<AuthSessionPayload | null> {
const response = await fetch(`${AUTH_BASE}/get-session`, {
method: 'GET',
credentials: 'include',
});
if (response.status === 401) {
return null;
}
const payload = await response.json().catch(() => null);
if (!response.ok) {
const objectPayload = payload as Record<string, unknown> | null;
const message =
(objectPayload?.message as string | undefined) ??
(objectPayload?.error as string | undefined) ??
`Auth session request failed with status ${response.status}`;
throw new AuthError(message, response.status);
}
return normalizeSessionPayload(payload);
}
export async function signInWithEmail(email: string, password: string): Promise<AuthSessionPayload | null> {
const payload = await authRequest('/sign-in/email', {
method: 'POST',
body: JSON.stringify({
email,
password,
}),
});
return normalizeSessionPayload(payload);
}
export async function signUpWithEmail(name: string, email: string, password: string): Promise<AuthSessionPayload | null> {
const payload = await authRequest('/sign-up/email', {
method: 'POST',
body: JSON.stringify({
name,
email,
password,
}),
});
return normalizeSessionPayload(payload);
}
export async function requestMagicLinkInvite(email: string, callbackURL: string): Promise<void> {
await authRequest('/sign-in/magic-link', {
method: 'POST',
body: JSON.stringify({
email,
callbackURL,
metadata: {
invite: true,
},
}),
});
}
export async function startGitHubSignIn(callbackURL: string): Promise<void> {
const payload = await authRequest('/sign-in/social', {
method: 'POST',
body: JSON.stringify({
provider: 'github',
callbackURL,
disableRedirect: true,
}),
});
const oauth = parseOAuthRedirectPayload(payload);
if (oauth.redirect) {
window.location.assign(oauth.url);
}
}
async function startOAuth2ProviderSignIn(providerId: string, callbackURL: string): Promise<void> {
const payload = await authRequest('/sign-in/oauth2', {
method: 'POST',
body: JSON.stringify({
providerId,
callbackURL,
disableRedirect: true,
}),
});
const oauth = parseOAuthRedirectPayload(payload);
if (oauth.redirect) {
window.location.assign(oauth.url);
}
}
export async function startGitLabSignIn(callbackURL: string): Promise<void> {
await startOAuth2ProviderSignIn('gitlab', callbackURL);
}
export async function startBitbucketSignIn(callbackURL: string): Promise<void> {
await startOAuth2ProviderSignIn('bitbucket', callbackURL);
}
export async function startGiteaSignIn(callbackURL: string): Promise<void> {
await startOAuth2ProviderSignIn('gitea', callbackURL);
}
export async function signOutAuthSession(): Promise<void> {
await authRequest('/sign-out', {
method: 'POST',
body: JSON.stringify({}),
});
}
+233
View File
@@ -0,0 +1,233 @@
import type { ProjectEntity, ServiceEntity } from '@/lib/api-client';
import type { ServiceVariable } from '@/features/workspace/auto-connections';
export const demoProjects: ProjectEntity[] = [
{
id: 'demo-project-core',
name: 'Core Platform',
description: 'Primary production workload with web, API, queue, and data services.',
createdAt: '2026-03-12T09:30:00Z',
updatedAt: '2026-03-31T09:10:00Z',
stats: {
service_count: 4,
deployment_count: 27,
running_services: 3,
last_deployment: '2026-03-31T08:42:00Z',
},
},
{
id: 'demo-project-growth',
name: 'Growth Surface',
description: 'Landing pages and campaign services for growth experiments.',
createdAt: '2026-03-01T14:05:00Z',
updatedAt: '2026-03-30T16:44:00Z',
stats: {
service_count: 3,
deployment_count: 14,
running_services: 3,
last_deployment: '2026-03-30T15:01:00Z',
},
},
{
id: 'demo-project-ml',
name: 'Inference Lab',
description: 'Internal inference jobs and model-serving edge services.',
createdAt: '2026-02-18T07:12:00Z',
updatedAt: '2026-03-29T19:22:00Z',
stats: {
service_count: 2,
deployment_count: 9,
running_services: 1,
last_deployment: '2026-03-29T18:06:00Z',
},
},
];
export const demoServicesByProject: Record<string, ServiceEntity[]> = {
'demo-project-core': [
{
id: 'demo-svc-web',
projectId: 'demo-project-core',
name: 'Web Frontend',
type: 'web',
status: 'running',
environment: 'production',
image: 'ghcr.io/containr/web:2026.03.31',
command: 'npm run serve',
gitBranch: 'main',
createdAt: '2026-03-12T10:00:00Z',
updatedAt: '2026-03-31T08:42:00Z',
},
{
id: 'demo-svc-api',
projectId: 'demo-project-core',
name: 'API Gateway',
type: 'web',
status: 'running',
environment: 'production',
image: 'ghcr.io/containr/api:2026.03.31',
command: './server',
gitBranch: 'main',
createdAt: '2026-03-12T10:02:00Z',
updatedAt: '2026-03-31T08:43:00Z',
},
{
id: 'demo-svc-worker',
projectId: 'demo-project-core',
name: 'Queue Worker',
type: 'worker',
status: 'building',
environment: 'production',
image: 'ghcr.io/containr/worker:2026.03.31',
command: './worker',
gitBranch: 'main',
createdAt: '2026-03-12T10:05:00Z',
updatedAt: '2026-03-31T08:44:00Z',
},
{
id: 'demo-svc-postgres',
projectId: 'demo-project-core',
name: 'Postgres',
type: 'database',
status: 'running',
environment: 'production',
image: 'postgres:15-alpine',
command: 'postgres',
gitBranch: 'main',
createdAt: '2026-03-12T10:08:00Z',
updatedAt: '2026-03-31T08:40:00Z',
},
],
'demo-project-growth': [
{
id: 'demo-growth-site',
projectId: 'demo-project-growth',
name: 'Marketing Site',
type: 'web',
status: 'running',
environment: 'production',
image: 'ghcr.io/containr/marketing:latest',
command: 'npm run start',
gitBranch: 'main',
createdAt: '2026-03-03T09:00:00Z',
updatedAt: '2026-03-30T12:10:00Z',
},
{
id: 'demo-growth-api',
projectId: 'demo-project-growth',
name: 'Campaign API',
type: 'web',
status: 'running',
environment: 'production',
image: 'ghcr.io/containr/campaign-api:latest',
command: './api',
gitBranch: 'main',
createdAt: '2026-03-03T09:04:00Z',
updatedAt: '2026-03-30T12:08:00Z',
},
{
id: 'demo-growth-cache',
projectId: 'demo-project-growth',
name: 'Redis Cache',
type: 'database',
status: 'running',
environment: 'production',
image: 'redis:7-alpine',
command: 'redis-server',
gitBranch: 'main',
createdAt: '2026-03-03T09:07:00Z',
updatedAt: '2026-03-30T12:07:00Z',
},
],
'demo-project-ml': [
{
id: 'demo-ml-api',
projectId: 'demo-project-ml',
name: 'Inference API',
type: 'web',
status: 'running',
environment: 'production',
image: 'ghcr.io/containr/inference-api:latest',
command: './serve',
gitBranch: 'main',
createdAt: '2026-02-18T08:00:00Z',
updatedAt: '2026-03-29T18:00:00Z',
},
{
id: 'demo-ml-worker',
projectId: 'demo-project-ml',
name: 'Batch Evaluator',
type: 'worker',
status: 'failed',
environment: 'production',
image: 'ghcr.io/containr/evaluator:latest',
command: './run-jobs',
gitBranch: 'main',
createdAt: '2026-02-18T08:03:00Z',
updatedAt: '2026-03-29T19:22:00Z',
},
],
};
const allServices = Object.values(demoServicesByProject).flat();
export const demoVariablesByProject: Record<string, Record<string, ServiceVariable[]>> = {
'demo-project-core': {
'demo-svc-web': (
[
{ key: 'API_BASE_URL', value: '{{api_gateway_url}}', isSecret: false },
{ key: 'CACHE_URL', value: '{{redis_url}}', isSecret: false },
] satisfies ServiceVariable[]
),
'demo-svc-api': (
[
{ key: 'DATABASE_URL', value: '{{db_url}}', isSecret: true },
{ key: 'REDIS_URL', value: '{{redis_url}}', isSecret: true },
] satisfies ServiceVariable[]
),
'demo-svc-worker': (
[
{ key: 'DATABASE_URL', value: '{{postgres_url}}', isSecret: true },
{ key: 'QUEUE_BACKEND', value: '{{api_gateway_host}}', isSecret: false },
] satisfies ServiceVariable[]
),
'demo-svc-postgres': [],
},
'demo-project-growth': {
'demo-growth-site': (
[
{ key: 'CAMPAIGN_API_URL', value: '{{campaign_api_url}}', isSecret: false },
] satisfies ServiceVariable[]
),
'demo-growth-api': (
[
{ key: 'REDIS_URL', value: '{{redis_cache_url}}', isSecret: true },
] satisfies ServiceVariable[]
),
'demo-growth-cache': [],
},
'demo-project-ml': {
'demo-ml-api': [],
'demo-ml-worker': (
[
{ key: 'MODEL_API_URL', value: '{{inference_api_url}}', isSecret: false },
] satisfies ServiceVariable[]
),
},
};
export function getDemoProjectById(projectId: string): ProjectEntity | undefined {
return demoProjects.find((project) => project.id === projectId);
}
export function getDemoServicesByProject(projectId: string): ServiceEntity[] {
return demoServicesByProject[projectId] ?? [];
}
export function getDemoServiceById(serviceId: string): ServiceEntity | undefined {
return allServices.find((service) => service.id === serviceId);
}
export function getDemoVariablesByProject(projectId: string): Record<string, ServiceVariable[]> {
return demoVariablesByProject[projectId] ?? {};
}
+47
View File
@@ -0,0 +1,47 @@
export function formatRelative(iso?: string): string {
if (!iso) {
return 'n/a';
}
const timestamp = Date.parse(iso);
if (Number.isNaN(timestamp)) {
return 'n/a';
}
const diffMs = Date.now() - timestamp;
const minute = 60_000;
const hour = 60 * minute;
const day = 24 * hour;
if (diffMs < minute) {
return 'just now';
}
if (diffMs < hour) {
return `${Math.floor(diffMs / minute)}m ago`;
}
if (diffMs < day) {
return `${Math.floor(diffMs / hour)}h ago`;
}
return `${Math.floor(diffMs / day)}d ago`;
}
export function formatDate(iso?: string): string {
if (!iso) {
return 'n/a';
}
const dt = new Date(iso);
if (Number.isNaN(dt.getTime())) {
return 'n/a';
}
return dt.toLocaleString();
}
export function seededMetric(seed: string, min: number, max: number): number {
let hash = 0;
for (let idx = 0; idx < seed.length; idx += 1) {
hash = (hash * 31 + seed.charCodeAt(idx)) & 0xffffffff;
}
const ratio = Math.abs(hash) % 10_000 / 10_000;
return Math.round(min + (max - min) * ratio);
}
+19
View File
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { getAuthSession } from '@/lib/auth-client';
type UseAuthSessionOptions = {
enabled?: boolean;
};
export function useAuthSession(options: UseAuthSessionOptions = {}) {
const { enabled = true } = options;
return useQuery({
queryKey: ['auth-session'],
queryFn: getAuthSession,
enabled,
staleTime: 30_000,
refetchOnWindowFocus: true,
retry: false,
});
}
+131
View File
@@ -0,0 +1,131 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { getApiBaseUrl } from '@/lib/api-client';
type BuildUpdatePayload = {
channel: string;
data: unknown;
};
type BuildUpdateMessage = {
type?: string;
channel?: string;
data?: unknown;
};
function toWebSocketUrl(apiBase: string): string {
const wsBase = apiBase.startsWith('https://') ? apiBase.replace('https://', 'wss://') : apiBase.replace('http://', 'ws://');
return `${wsBase}/ws`;
}
export function useBuildUpdates(
buildIds: string[],
onBuildUpdate: (payload: BuildUpdatePayload) => void,
): boolean {
const [connected, setConnected] = useState(false);
const callbackRef = useRef(onBuildUpdate);
useEffect(() => {
callbackRef.current = onBuildUpdate;
}, [onBuildUpdate]);
const normalizedBuildIds = useMemo(
() => Array.from(new Set(buildIds.filter(Boolean))).sort(),
[buildIds],
);
const buildIdsKey = normalizedBuildIds.join('|');
const subscriptionIds = useMemo(
() => (buildIdsKey ? buildIdsKey.split('|') : []),
[buildIdsKey],
);
useEffect(() => {
if (subscriptionIds.length === 0) {
return;
}
const wsUrl = toWebSocketUrl(getApiBaseUrl());
let socket: WebSocket | null = null;
let reconnectTimer: number | null = null;
let closedByEffect = false;
const subscribeAll = () => {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
for (const buildId of subscriptionIds) {
socket.send(
JSON.stringify({
action: 'subscribe',
channel: `build:${buildId}`,
}),
);
}
};
const connect = () => {
socket = new WebSocket(wsUrl);
socket.onopen = () => {
setConnected(true);
subscribeAll();
};
socket.onmessage = (event) => {
const lines = String(event.data ?? '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
try {
const parsed = JSON.parse(line) as BuildUpdateMessage;
if (parsed.type !== 'build_update' || !parsed.channel) {
continue;
}
callbackRef.current({
channel: parsed.channel,
data: parsed.data,
});
} catch {
// Ignore malformed messages.
}
}
};
socket.onclose = () => {
setConnected(false);
if (closedByEffect) {
return;
}
reconnectTimer = window.setTimeout(connect, 2000);
};
socket.onerror = () => {
setConnected(false);
};
};
connect();
return () => {
closedByEffect = true;
setConnected(false);
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
}
if (socket && socket.readyState === WebSocket.OPEN) {
for (const buildId of subscriptionIds) {
socket.send(
JSON.stringify({
action: 'unsubscribe',
channel: `build:${buildId}`,
}),
);
}
}
socket?.close();
};
}, [buildIdsKey, subscriptionIds]);
return subscriptionIds.length > 0 && connected;
}