Overhaul: real metrics, Git integration UI, open-source hygiene, root cleanup

Backend:
- GET /services/:id/metrics with real Docker container stats
- DELETE /git/providers/:id and GET repo branches endpoint

Frontend:
- Service detail shows real telemetry or honest empty states
- Live status distinguishes idle/polling/reconnecting
- Git providers section in Settings; repo+branch pickers in Add Service
- Real logo, compact Projects header, canvas fitView fix
- index.css deduplicated (1725 -> 1384 lines)
- Fixed smoke tests, lint errors, ToastProvider/context unification

Repo hygiene:
- Added LICENSE (MIT), CONTRIBUTING.md, SECURITY.md; rewrote README
- Untracked .env.prod and data/letsencrypt secrets; fixed .gitignore
- Removed legacy root src/internal/cmd and stale configs/docs/artifacts
- Moved guides and design references under docs/

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-15 18:59:40 +02:00
1 parent 355a97bab4
commit 9f3b196032
499 files changed
+4471 -94235

No files matched your search

+440
View File
@@ -148,6 +148,105 @@ export type UpdateUserProfileInput = {
avatarUrl?: string;
};
export type CreateUserInput = {
name: string;
email: string;
password: string;
};
export type UpgradeStatus = {
imageRef: string;
registry: string;
dockerAvailable: boolean;
installed: boolean;
digest?: string;
size?: number;
authConfigured: boolean;
message: string;
};
export type SystemLoad = {
load1m: number;
load5m: number;
load15m: number;
};
export type HostMonitoring = {
hostname: string;
os: string;
architecture: string;
cpu: {
cores: number;
};
memory: {
total: number;
used: number;
available: number;
usagePercent: number;
};
storage: {
path: string;
total: number;
used: number;
available: number;
usagePercent: number;
};
load: SystemLoad;
uptimeSeconds: number;
dockerAvailable: boolean;
docker?: {
containers?: number;
images?: number;
driver?: string;
server?: string;
};
collectedAt: string;
};
export type AgentCapabilities = {
containerRuntimes: string[];
supportedArchitectures: string[];
maxContainers: number;
storageDriver: string;
networkPlugins: string[];
features: string[];
};
export type NodeResources = {
cpu: {
cores: number;
allocation: number;
usage: number;
};
memory: {
total: number;
allocated: number;
used: number;
available: number;
};
storage: {
total: number;
allocated: number;
used: number;
available: number;
};
};
export type NodeAgentEntity = {
id: string;
name: string;
hostname: string;
ipAddress: string;
port: number;
status: string;
version: string;
capabilities: AgentCapabilities;
resources: NodeResources;
lastHeartbeat?: string;
createdAt?: string;
updatedAt?: string;
};
export type AuditLogEntity = {
id: string;
userId: string;
@@ -256,6 +355,91 @@ type RawServiceLog = components['schemas']['ServiceLogEntry'];
type RawServiceLogsResponse = components['schemas']['ServiceLogsResponse'];
type RawDeploymentLogsResponse = components['schemas']['DeploymentLogsResponse'];
type RawRollbackDeploymentResponse = components['schemas']['RollbackDeploymentResponse'];
type RawUpgradeStatus = {
image_ref?: string;
registry?: string;
docker_available?: boolean;
installed?: boolean;
digest?: string;
size?: number;
auth_configured?: boolean;
message?: string;
};
type RawSystemLoad = {
load_1m?: number;
load_5m?: number;
load_15m?: number;
};
type RawHostMonitoring = {
hostname?: string;
os?: string;
architecture?: string;
cpu?: {
cores?: number;
};
memory?: {
total?: number;
used?: number;
available?: number;
usage_percent?: number;
};
storage?: {
path?: string;
total?: number;
used?: number;
available?: number;
usage_percent?: number;
};
load?: RawSystemLoad;
uptime_seconds?: number;
docker_available?: boolean;
docker?: {
containers?: number;
images?: number;
driver?: string;
server?: string;
};
collected_at?: string;
};
type RawNodeAgent = {
id?: string;
name?: string;
hostname?: string;
ip_address?: string;
port?: number;
status?: string;
version?: string;
capabilities?: {
container_runtimes?: string[];
supported_architectures?: string[];
max_containers?: number;
storage_driver?: string;
network_plugins?: string[];
features?: string[];
};
resources?: {
cpu?: {
cores?: number;
allocation?: number;
usage?: number;
};
memory?: {
total?: number;
allocated?: number;
used?: number;
available?: number;
};
storage?: {
total?: number;
allocated?: number;
used?: number;
available?: number;
};
};
last_heartbeat?: string;
created_at?: string;
updated_at?: string;
};
export type CreateProjectInput = components['schemas']['CreateProjectRequest'];
export type CreateServiceInput = components['schemas']['CreateServiceRequest'] & {
@@ -286,12 +470,17 @@ 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_ROOT = normalizedBase.replace(/\/api\/v1$/, '').replace(/\/api$/, '');
const API_BASE = /\/api\/v1$/.test(normalizedBase) ? normalizedBase : `${normalizedBase}/api/v1`;
export function getApiBaseUrl(): string {
return API_BASE;
}
export function getAgentPublicBaseUrl(): string {
return `${API_ROOT}/api/agents`;
}
function authHeaders(): HeadersInit {
return { 'Content-Type': 'application/json' };
}
@@ -669,6 +858,151 @@ export async function updateCurrentUserProfile(input: UpdateUserProfileInput): P
return profile;
}
export async function createUser(input: CreateUserInput): Promise<UserProfile> {
const payload = await requestJson<RawUserProfile | { user?: RawUserProfile }>(`/users`, {
method: 'POST',
body: JSON.stringify(input),
});
const rawProfile = (payload as { user?: RawUserProfile }).user ?? (payload as RawUserProfile);
const profile = normalizeUserProfile(rawProfile);
if (!profile) {
throw new ApiError('Create user response is invalid', 500);
}
return profile;
}
function normalizeUpgradeStatus(payload: RawUpgradeStatus): UpgradeStatus {
return {
imageRef: payload.image_ref ?? '',
registry: payload.registry ?? '',
dockerAvailable: Boolean(payload.docker_available),
installed: Boolean(payload.installed),
digest: payload.digest,
size: payload.size,
authConfigured: Boolean(payload.auth_configured),
message: payload.message ?? '',
};
}
function normalizeSystemLoad(payload?: RawSystemLoad): SystemLoad {
return {
load1m: payload?.load_1m ?? 0,
load5m: payload?.load_5m ?? 0,
load15m: payload?.load_15m ?? 0,
};
}
function normalizeHostMonitoring(payload: RawHostMonitoring): HostMonitoring {
return {
hostname: payload.hostname ?? '',
os: payload.os ?? '',
architecture: payload.architecture ?? '',
cpu: {
cores: payload.cpu?.cores ?? 0,
},
memory: {
total: payload.memory?.total ?? 0,
used: payload.memory?.used ?? 0,
available: payload.memory?.available ?? 0,
usagePercent: payload.memory?.usage_percent ?? 0,
},
storage: {
path: payload.storage?.path ?? '/',
total: payload.storage?.total ?? 0,
used: payload.storage?.used ?? 0,
available: payload.storage?.available ?? 0,
usagePercent: payload.storage?.usage_percent ?? 0,
},
load: normalizeSystemLoad(payload.load),
uptimeSeconds: payload.uptime_seconds ?? 0,
dockerAvailable: Boolean(payload.docker_available),
docker: payload.docker,
collectedAt: payload.collected_at ?? '',
};
}
function normalizeAgent(agent: RawNodeAgent): NodeAgentEntity | null {
if (!agent.id || !agent.name) {
return null;
}
return {
id: agent.id,
name: agent.name,
hostname: agent.hostname ?? '',
ipAddress: agent.ip_address ?? '',
port: agent.port ?? 0,
status: agent.status ?? 'unknown',
version: agent.version ?? '',
capabilities: {
containerRuntimes: agent.capabilities?.container_runtimes ?? [],
supportedArchitectures: agent.capabilities?.supported_architectures ?? [],
maxContainers: agent.capabilities?.max_containers ?? 0,
storageDriver: agent.capabilities?.storage_driver ?? '',
networkPlugins: agent.capabilities?.network_plugins ?? [],
features: agent.capabilities?.features ?? [],
},
resources: {
cpu: {
cores: agent.resources?.cpu?.cores ?? 0,
allocation: agent.resources?.cpu?.allocation ?? 0,
usage: agent.resources?.cpu?.usage ?? 0,
},
memory: {
total: agent.resources?.memory?.total ?? 0,
allocated: agent.resources?.memory?.allocated ?? 0,
used: agent.resources?.memory?.used ?? 0,
available: agent.resources?.memory?.available ?? 0,
},
storage: {
total: agent.resources?.storage?.total ?? 0,
allocated: agent.resources?.storage?.allocated ?? 0,
used: agent.resources?.storage?.used ?? 0,
available: agent.resources?.storage?.available ?? 0,
},
},
lastHeartbeat: agent.last_heartbeat,
createdAt: agent.created_at,
updatedAt: agent.updated_at,
};
}
function normalizeAgentArray(raw: unknown): NodeAgentEntity[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => normalizeAgent(entry as RawNodeAgent))
.filter((entry): entry is NodeAgentEntity => entry !== null);
}
export async function getUpgradeStatus(): Promise<UpgradeStatus> {
const payload = await requestJson<RawUpgradeStatus>(`/system/upgrade/status`);
return normalizeUpgradeStatus(payload);
}
export async function pullUpgradeImage(): Promise<UpgradeStatus> {
const payload = await requestJson<RawUpgradeStatus>(`/system/upgrade/pull`, {
method: 'POST',
body: JSON.stringify({}),
});
return normalizeUpgradeStatus(payload);
}
export async function getHostMonitoring(): Promise<HostMonitoring> {
const payload = await requestJson<RawHostMonitoring>(`/system/host`);
return normalizeHostMonitoring(payload);
}
export async function listAgents(): Promise<NodeAgentEntity[]> {
const payload = await requestJson<{ agents?: RawNodeAgent[] }>(`/agents`);
return normalizeAgentArray(payload.agents);
}
export async function createProject(input: CreateProjectInput): Promise<ProjectEntity> {
const payload = await requestJson<RawProject | { project?: RawProject }>(`/projects`, {
method: 'POST',
@@ -958,3 +1292,109 @@ export function serviceStatusClass(status: ServiceStatus): string {
return 'status-stopped';
}
}
export type ServiceInstanceMetrics = {
container_id: string;
name: string;
state: string;
cpu_percent: number;
memory_usage_bytes: number;
memory_limit_bytes: number;
network_rx_bytes: number;
network_tx_bytes: number;
started_at?: string;
};
export type ServiceMetrics = {
service_id: string;
status: 'ok' | 'no_containers' | 'docker_unavailable';
instances: ServiceInstanceMetrics[];
cpu_percent: number;
memory_usage_bytes: number;
memory_limit_bytes: number;
network_rx_bytes: number;
network_tx_bytes: number;
collected_at: string;
};
export async function getServiceMetrics(serviceId: string): Promise<ServiceMetrics> {
const payload = await requestJson<{ metrics?: ServiceMetrics }>(`/services/${serviceId}/metrics`);
if (!payload.metrics) {
throw new ApiError('Service metrics payload is invalid', 500);
}
return payload.metrics;
}
export type GitProviderEntity = {
id: string;
name: string;
display_name: string;
api_url: string;
created_at?: string;
};
export type GitRepositoryEntity = {
id: string;
provider_id: string;
name: string;
full_name: string;
description: string;
clone_url: string;
default_branch: string;
is_private: boolean;
};
export type GitBranchEntity = {
name: string;
protected: boolean;
};
export async function listGitProviders(): Promise<GitProviderEntity[]> {
const payload = await requestJson<{ providers?: GitProviderEntity[] }>('/git/providers');
return payload.providers ?? [];
}
export async function createGitProvider(input: {
name: 'github' | 'gitlab' | 'bitbucket' | 'gitea';
display_name: string;
access_token: string;
api_url?: string;
}): Promise<GitProviderEntity> {
const payload = await requestJson<GitProviderEntity>('/git/providers', {
method: 'POST',
body: JSON.stringify(input),
});
if (!payload.id) {
throw new ApiError('Provider response is invalid', 500);
}
return payload;
}
export async function deleteGitProvider(providerId: string): Promise<void> {
await requestJson(`/git/providers/${providerId}`, { method: 'DELETE' });
}
export async function listGitRepositories(
providerId: string,
search?: string,
): Promise<GitRepositoryEntity[]> {
const params = new URLSearchParams({ limit: '100' });
if (search?.trim()) {
params.set('search', search.trim());
}
const payload = await requestJson<{ repositories?: GitRepositoryEntity[] }>(
`/git/providers/${providerId}/repositories?${params.toString()}`,
);
return payload.repositories ?? [];
}
export async function listGitBranches(
providerId: string,
owner: string,
repo: string,
): Promise<GitBranchEntity[]> {
const payload = await requestJson<{ branches?: GitBranchEntity[] }>(
`/git/providers/${providerId}/repositories/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`,
);
return payload.branches ?? [];
}
+33 -28
View File
@@ -16,6 +16,12 @@ export type AuthSessionPayload = {
session: AuthSession;
};
export type AuthBootstrap = {
hasUsers: boolean;
userCount: number;
mode: 'register' | 'login';
};
export class AuthError extends Error {
readonly status: number;
@@ -33,6 +39,30 @@ export function getAuthBaseUrl(): string {
return AUTH_BASE;
}
export async function getAuthBootstrap(): Promise<AuthBootstrap> {
const apiBase = ((import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8082').replace(/\/$/, '');
const normalizedApiBase = /\/api\/v1$/.test(apiBase) ? apiBase : `${apiBase}/api/v1`;
const response = await fetch(`${normalizedApiBase}/auth/bootstrap`, {
method: 'GET',
credentials: 'include',
});
const payload = (await response.json().catch(() => null)) as Record<string, unknown> | null;
if (!response.ok) {
const message =
(payload?.message as string | undefined) ??
(payload?.error as string | undefined) ??
`Auth bootstrap request failed with status ${response.status}`;
throw new AuthError(message, response.status);
}
return {
hasUsers: Boolean(payload?.has_users),
userCount: typeof payload?.user_count === 'number' ? payload.user_count : 0,
mode: payload?.mode === 'register' ? 'register' : 'login',
};
}
function normalizeSessionPayload(payload: unknown): AuthSessionPayload | null {
if (!payload || typeof payload !== 'object') {
return null;
@@ -160,19 +190,6 @@ export async function signUpWithEmail(name: string, email: string, password: str
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',
@@ -189,11 +206,11 @@ export async function startGitHubSignIn(callbackURL: string): Promise<void> {
}
}
async function startOAuth2ProviderSignIn(providerId: string, callbackURL: string): Promise<void> {
const payload = await authRequest('/sign-in/oauth2', {
export async function startGoogleSignIn(callbackURL: string): Promise<void> {
const payload = await authRequest('/sign-in/social', {
method: 'POST',
body: JSON.stringify({
providerId,
provider: 'google',
callbackURL,
disableRedirect: true,
}),
@@ -205,18 +222,6 @@ async function startOAuth2ProviderSignIn(providerId: string, callbackURL: string
}
}
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',
+10
View File
@@ -36,6 +36,16 @@ export function formatDate(iso?: string): string {
return dt.toLocaleString();
}
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0 B';
}
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
const value = bytes / Math.pow(1024, index);
return `${value >= 100 ? Math.round(value) : value.toFixed(1)} ${units[index]}`;
}
export function seededMetric(seed: string, min: number, max: number): number {
let hash = 0;
for (let idx = 0; idx < seed.length; idx += 1) {
+7 -2
View File
@@ -17,10 +17,12 @@ function toWebSocketUrl(apiBase: string): string {
return `${wsBase}/ws`;
}
export type LiveSyncStatus = 'idle' | 'offline' | 'live';
export function useBuildUpdates(
buildIds: string[],
onBuildUpdate: (payload: BuildUpdatePayload) => void,
): boolean {
): LiveSyncStatus {
const [connected, setConnected] = useState(false);
const callbackRef = useRef(onBuildUpdate);
useEffect(() => {
@@ -127,5 +129,8 @@ export function useBuildUpdates(
};
}, [buildIdsKey, subscriptionIds]);
return subscriptionIds.length > 0 && connected;
if (subscriptionIds.length === 0) {
return 'idle';
}
return connected ? 'live' : 'offline';
}