mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-07 13:22:54 +00:00
Initial commit
This commit is contained in:
129
routes/api/environments/+server.ts
Normal file
129
routes/api/environments/+server.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getEnvironments, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, type Environment } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
|
||||
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('environments', 'view')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
let environments = await getEnvironments();
|
||||
|
||||
// In enterprise mode, filter environments by user's accessible environments
|
||||
if (auth.authEnabled && auth.isEnterprise && auth.isAuthenticated && !auth.isAdmin) {
|
||||
const accessibleIds = await auth.getAccessibleEnvironmentIds();
|
||||
// accessibleIds is null if user has access to all environments
|
||||
if (accessibleIds !== null) {
|
||||
environments = environments.filter(env => accessibleIds.includes(env.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Get public IPs for all environments
|
||||
const publicIps = await getEnvironmentPublicIps();
|
||||
|
||||
// Get update check settings for all environments
|
||||
const updateCheckSettingsMap = new Map<number, { enabled: boolean; autoUpdate: boolean }>();
|
||||
for (const env of environments) {
|
||||
const settings = await getEnvUpdateCheckSettings(env.id);
|
||||
if (settings && settings.enabled) {
|
||||
updateCheckSettingsMap.set(env.id, { enabled: true, autoUpdate: settings.autoUpdate });
|
||||
}
|
||||
}
|
||||
|
||||
// Parse labels from JSON string to array, add public IPs, update check settings, and timezone
|
||||
const envWithParsedLabels = await Promise.all(environments.map(async env => {
|
||||
const updateSettings = updateCheckSettingsMap.get(env.id);
|
||||
const timezone = await getEnvironmentTimezone(env.id);
|
||||
return {
|
||||
...env,
|
||||
labels: parseLabels(env.labels as string | null),
|
||||
publicIp: publicIps[env.id.toString()] || null,
|
||||
updateCheckEnabled: updateSettings?.enabled || false,
|
||||
updateCheckAutoUpdate: updateSettings?.autoUpdate || false,
|
||||
timezone
|
||||
};
|
||||
}));
|
||||
|
||||
return json(envWithParsedLabels);
|
||||
} catch (error) {
|
||||
console.error('Failed to get environments:', error);
|
||||
return json({ error: 'Failed to get environments' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('environments', 'create')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await request.json();
|
||||
|
||||
if (!data.name) {
|
||||
return json({ error: 'Name is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Host is required for direct and hawser-standard connections
|
||||
const connectionType = data.connectionType || 'socket';
|
||||
if ((connectionType === 'direct' || connectionType === 'hawser-standard') && !data.host) {
|
||||
return json({ error: 'Host is required for this connection type' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate labels
|
||||
const labels = Array.isArray(data.labels) ? data.labels.slice(0, MAX_LABELS) : [];
|
||||
|
||||
const env = await createEnvironment({
|
||||
name: data.name,
|
||||
host: data.host,
|
||||
port: data.port || 2375,
|
||||
protocol: data.protocol || 'http',
|
||||
tlsCa: data.tlsCa,
|
||||
tlsCert: data.tlsCert,
|
||||
tlsKey: data.tlsKey,
|
||||
icon: data.icon || 'globe',
|
||||
socketPath: data.socketPath || '/var/run/docker.sock',
|
||||
collectActivity: data.collectActivity !== false,
|
||||
collectMetrics: data.collectMetrics !== false,
|
||||
highlightChanges: data.highlightChanges !== false,
|
||||
labels: serializeLabels(labels),
|
||||
connectionType: connectionType,
|
||||
hawserToken: data.hawserToken
|
||||
});
|
||||
|
||||
// Save public IP if provided
|
||||
if (data.publicIp) {
|
||||
await setEnvironmentPublicIp(env.id, data.publicIp);
|
||||
}
|
||||
|
||||
// Notify subprocesses to pick up the new environment
|
||||
refreshSubprocessEnvironments();
|
||||
|
||||
// Auto-assign Admin role to creator (Enterprise only)
|
||||
if (auth.isEnterprise && auth.authEnabled && auth.isAuthenticated && !auth.isAdmin) {
|
||||
const user = auth.user;
|
||||
if (user) {
|
||||
try {
|
||||
const adminRole = await getRoleByName('Admin');
|
||||
if (adminRole) {
|
||||
await assignUserRole(user.id, adminRole.id, env.id);
|
||||
}
|
||||
} catch (roleError) {
|
||||
// Log but don't fail - environment was created successfully
|
||||
console.error(`Failed to auto-assign Admin role to user ${user.id} for environment ${env.id}:`, roleError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return json(env);
|
||||
} catch (error) {
|
||||
console.error('Failed to create environment:', error);
|
||||
const message = error instanceof Error ? error.message : 'Failed to create environment';
|
||||
return json({ error: message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
158
routes/api/environments/[id]/+server.ts
Normal file
158
routes/api/environments/[id]/+server.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, getGitStacksForEnvironmentOnly, deleteGitStack } from '$lib/server/db';
|
||||
import { clearDockerClientCache } from '$lib/server/docker';
|
||||
import { deleteGitStackFiles } from '$lib/server/git';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
|
||||
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
|
||||
import { unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { closeEdgeConnection } from '$lib/server/hawser';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('environments', 'view')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
const env = await getEnvironment(id);
|
||||
|
||||
if (!env) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get public IP for this environment
|
||||
const publicIps = await getEnvironmentPublicIps();
|
||||
const publicIp = publicIps[id.toString()] || null;
|
||||
|
||||
// Parse labels from JSON string to array
|
||||
return json({
|
||||
...env,
|
||||
labels: parseLabels(env.labels as string | null),
|
||||
publicIp
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get environment:', error);
|
||||
return json({ error: 'Failed to get environment' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
const data = await request.json();
|
||||
|
||||
// Clear cached Docker client before updating
|
||||
clearDockerClientCache(id);
|
||||
|
||||
// Handle labels - only update if provided in the request
|
||||
const labels = data.labels !== undefined
|
||||
? serializeLabels(Array.isArray(data.labels) ? data.labels.slice(0, MAX_LABELS) : [])
|
||||
: undefined;
|
||||
|
||||
const env = await updateEnvironment(id, {
|
||||
name: data.name,
|
||||
host: data.host,
|
||||
port: data.port,
|
||||
protocol: data.protocol,
|
||||
tlsCa: data.tlsCa,
|
||||
tlsCert: data.tlsCert,
|
||||
tlsKey: data.tlsKey,
|
||||
icon: data.icon,
|
||||
socketPath: data.socketPath,
|
||||
collectActivity: data.collectActivity,
|
||||
collectMetrics: data.collectMetrics,
|
||||
highlightChanges: data.highlightChanges,
|
||||
labels: labels,
|
||||
connectionType: data.connectionType,
|
||||
hawserToken: data.hawserToken
|
||||
});
|
||||
|
||||
if (!env) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Notify subprocesses if collectActivity or collectMetrics setting changed
|
||||
if (data.collectActivity !== undefined || data.collectMetrics !== undefined) {
|
||||
refreshSubprocessEnvironments();
|
||||
}
|
||||
|
||||
// Handle public IP - update if provided in request
|
||||
if (data.publicIp !== undefined) {
|
||||
await setEnvironmentPublicIp(id, data.publicIp || null);
|
||||
}
|
||||
|
||||
// Get current public IP for response
|
||||
const publicIps = await getEnvironmentPublicIps();
|
||||
const publicIp = publicIps[id.toString()] || null;
|
||||
|
||||
// Parse labels from JSON string to array
|
||||
return json({
|
||||
...env,
|
||||
labels: parseLabels(env.labels as string | null),
|
||||
publicIp
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update environment:', error);
|
||||
return json({ error: 'Failed to update environment' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('environments', 'delete')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
|
||||
// Close Edge connection if this is a Hawser Edge environment
|
||||
// This rejects any pending requests and closes the WebSocket
|
||||
closeEdgeConnection(id);
|
||||
|
||||
// Clear cached Docker client before deleting
|
||||
clearDockerClientCache(id);
|
||||
|
||||
// Clean up git stacks for this environment
|
||||
const gitStacks = await getGitStacksForEnvironmentOnly(id);
|
||||
for (const stack of gitStacks) {
|
||||
// Unregister schedule if auto-update was enabled
|
||||
if (stack.autoUpdate) {
|
||||
unregisterSchedule(stack.id, 'git_stack_sync');
|
||||
}
|
||||
// Delete git stack files from filesystem
|
||||
deleteGitStackFiles(stack.id);
|
||||
// Delete git stack from database
|
||||
await deleteGitStack(stack.id);
|
||||
}
|
||||
|
||||
const success = await deleteEnvironment(id);
|
||||
|
||||
if (!success) {
|
||||
return json({ error: 'Cannot delete this environment' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Clean up public IP entry for this environment
|
||||
await deleteEnvironmentPublicIp(id);
|
||||
|
||||
// Clean up update check settings and unregister schedule
|
||||
await deleteEnvUpdateCheckSettings(id);
|
||||
unregisterSchedule(id, 'env_update_check');
|
||||
|
||||
// Notify subprocesses to stop collecting from deleted environment
|
||||
refreshSubprocessEnvironments();
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete environment:', error);
|
||||
return json({ error: 'Failed to delete environment' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
78
routes/api/environments/[id]/notifications/+server.ts
Normal file
78
routes/api/environments/[id]/notifications/+server.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import {
|
||||
getEnvironmentNotifications,
|
||||
createEnvironmentNotification,
|
||||
getEnvironment,
|
||||
getNotificationSettings,
|
||||
type NotificationEventType
|
||||
} from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// GET /api/environments/[id]/notifications - List all notification configurations for an environment
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('notifications', 'view')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const envId = parseInt(params.id);
|
||||
if (isNaN(envId)) {
|
||||
return json({ error: 'Invalid environment ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const env = await getEnvironment(envId);
|
||||
if (!env) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const notifications = await getEnvironmentNotifications(envId);
|
||||
return json(notifications);
|
||||
} catch (error) {
|
||||
console.error('Error fetching environment notifications:', error);
|
||||
return json({ error: 'Failed to fetch environment notifications' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
// POST /api/environments/[id]/notifications - Add a notification channel to an environment
|
||||
export const POST: RequestHandler = async ({ params, request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('notifications', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const envId = parseInt(params.id);
|
||||
if (isNaN(envId)) {
|
||||
return json({ error: 'Invalid environment ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const env = await getEnvironment(envId);
|
||||
if (!env) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { notificationId, enabled, eventTypes } = body;
|
||||
|
||||
if (!notificationId) {
|
||||
return json({ error: 'notificationId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const notification = await createEnvironmentNotification({
|
||||
environmentId: envId,
|
||||
notificationId,
|
||||
enabled: enabled !== false,
|
||||
eventTypes: eventTypes as NotificationEventType[]
|
||||
});
|
||||
|
||||
return json(notification);
|
||||
} catch (error: any) {
|
||||
console.error('Error creating environment notification:', error);
|
||||
if (error.message?.includes('UNIQUE constraint failed')) {
|
||||
return json({ error: 'This notification channel is already configured for this environment' }, { status: 409 });
|
||||
}
|
||||
return json({ error: error.message || 'Failed to create environment notification' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import {
|
||||
getEnvironmentNotification,
|
||||
updateEnvironmentNotification,
|
||||
deleteEnvironmentNotification,
|
||||
getEnvironment,
|
||||
type NotificationEventType
|
||||
} from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// GET /api/environments/[id]/notifications/[notificationId] - Get a specific environment notification
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('notifications', 'view')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const envId = parseInt(params.id);
|
||||
const notifId = parseInt(params.notificationId);
|
||||
if (isNaN(envId) || isNaN(notifId)) {
|
||||
return json({ error: 'Invalid ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const env = await getEnvironment(envId);
|
||||
if (!env) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const notification = await getEnvironmentNotification(envId, notifId);
|
||||
if (!notification) {
|
||||
return json({ error: 'Environment notification not found' }, { status: 404 });
|
||||
}
|
||||
return json(notification);
|
||||
} catch (error) {
|
||||
console.error('Error fetching environment notification:', error);
|
||||
return json({ error: 'Failed to fetch environment notification' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
// PUT /api/environments/[id]/notifications/[notificationId] - Update an environment notification
|
||||
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('notifications', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const envId = parseInt(params.id);
|
||||
const notifId = parseInt(params.notificationId);
|
||||
if (isNaN(envId) || isNaN(notifId)) {
|
||||
return json({ error: 'Invalid ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const env = await getEnvironment(envId);
|
||||
if (!env) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { enabled, eventTypes } = body;
|
||||
|
||||
const notification = await updateEnvironmentNotification(envId, notifId, {
|
||||
enabled,
|
||||
eventTypes: eventTypes as NotificationEventType[]
|
||||
});
|
||||
|
||||
if (!notification) {
|
||||
return json({ error: 'Environment notification not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return json(notification);
|
||||
} catch (error: any) {
|
||||
console.error('Error updating environment notification:', error);
|
||||
return json({ error: error.message || 'Failed to update environment notification' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE /api/environments/[id]/notifications/[notificationId] - Remove a notification from an environment
|
||||
export const DELETE: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('notifications', 'delete')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const envId = parseInt(params.id);
|
||||
const notifId = parseInt(params.notificationId);
|
||||
if (isNaN(envId) || isNaN(notifId)) {
|
||||
return json({ error: 'Invalid ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const deleted = await deleteEnvironmentNotification(envId, notifId);
|
||||
if (!deleted) {
|
||||
return json({ error: 'Environment notification not found' }, { status: 404 });
|
||||
}
|
||||
return json({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting environment notification:', error);
|
||||
return json({ error: error.message || 'Failed to delete environment notification' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
138
routes/api/environments/[id]/test/+server.ts
Normal file
138
routes/api/environments/[id]/test/+server.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getEnvironment, updateEnvironment } from '$lib/server/db';
|
||||
import { getDockerInfo } from '$lib/server/docker';
|
||||
import { edgeConnections, isEdgeConnected } from '$lib/server/hawser';
|
||||
|
||||
export const POST: RequestHandler = async ({ params }) => {
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
const env = await getEnvironment(id);
|
||||
|
||||
if (!env) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Edge mode - check connection status immediately without blocking
|
||||
if (env.connectionType === 'hawser-edge') {
|
||||
const edgeConn = edgeConnections.get(id);
|
||||
const connected = isEdgeConnected(id);
|
||||
|
||||
if (!connected) {
|
||||
console.log(`[Test] Edge environment ${id} (${env.name}) - agent not connected`);
|
||||
return json({
|
||||
success: false,
|
||||
error: 'Edge agent is not connected',
|
||||
isEdgeMode: true,
|
||||
hawser: env.hawserVersion ? {
|
||||
hawserVersion: env.hawserVersion,
|
||||
agentId: env.hawserAgentId,
|
||||
agentName: env.hawserAgentName
|
||||
} : null
|
||||
}, { status: 200 });
|
||||
}
|
||||
|
||||
// Agent is connected - try to get Docker info with shorter timeout
|
||||
console.log(`[Test] Edge environment ${id} (${env.name}) - agent connected, testing Docker...`);
|
||||
try {
|
||||
const info = await getDockerInfo(env.id) as any;
|
||||
return json({
|
||||
success: true,
|
||||
info: {
|
||||
serverVersion: info.ServerVersion,
|
||||
containers: info.Containers,
|
||||
images: info.Images,
|
||||
name: info.Name
|
||||
},
|
||||
isEdgeMode: true,
|
||||
hawser: edgeConn ? {
|
||||
hawserVersion: edgeConn.agentVersion,
|
||||
agentId: edgeConn.agentId,
|
||||
agentName: edgeConn.agentName,
|
||||
hostname: edgeConn.hostname,
|
||||
dockerVersion: edgeConn.dockerVersion,
|
||||
capabilities: edgeConn.capabilities
|
||||
} : null
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Docker API call failed';
|
||||
console.error(`[Test] Edge environment ${id} Docker test failed:`, message);
|
||||
return json({
|
||||
success: false,
|
||||
error: message,
|
||||
isEdgeMode: true,
|
||||
hawser: edgeConn ? {
|
||||
hawserVersion: edgeConn.agentVersion,
|
||||
agentId: edgeConn.agentId,
|
||||
agentName: edgeConn.agentName
|
||||
} : null
|
||||
}, { status: 200 });
|
||||
}
|
||||
}
|
||||
|
||||
const info = await getDockerInfo(env.id) as any;
|
||||
|
||||
// For Hawser Standard mode, fetch Hawser info (Edge mode handled above with early return)
|
||||
let hawserInfo = null;
|
||||
if (env.connectionType === 'hawser-standard') {
|
||||
// Standard mode: fetch via HTTP
|
||||
try {
|
||||
const protocol = env.useTls ? 'https' : 'http';
|
||||
const headers: Record<string, string> = {};
|
||||
if (env.hawserToken) {
|
||||
headers['X-Hawser-Token'] = env.hawserToken;
|
||||
}
|
||||
const hawserResp = await fetch(`${protocol}://${env.host}:${env.port || 2376}/_hawser/info`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
if (hawserResp.ok) {
|
||||
hawserInfo = await hawserResp.json();
|
||||
// Save hawser info to database
|
||||
if (hawserInfo?.hawserVersion) {
|
||||
await updateEnvironment(id, {
|
||||
hawserVersion: hawserInfo.hawserVersion,
|
||||
hawserAgentId: hawserInfo.agentId,
|
||||
hawserAgentName: hawserInfo.agentName,
|
||||
hawserLastSeen: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Hawser info fetch failed, continue without it
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
info: {
|
||||
serverVersion: info.ServerVersion,
|
||||
containers: info.Containers,
|
||||
images: info.Images,
|
||||
name: info.Name
|
||||
},
|
||||
hawser: hawserInfo
|
||||
});
|
||||
} catch (error) {
|
||||
const rawMessage = error instanceof Error ? error.message : 'Connection failed';
|
||||
console.error('Failed to test connection:', rawMessage);
|
||||
|
||||
// Provide more helpful error messages for Hawser connections
|
||||
let message = rawMessage;
|
||||
if (rawMessage.includes('401') || rawMessage.toLowerCase().includes('unauthorized')) {
|
||||
message = 'Invalid token - check that the Hawser token matches';
|
||||
} else if (rawMessage.includes('403') || rawMessage.toLowerCase().includes('forbidden')) {
|
||||
message = 'Access forbidden - check token permissions';
|
||||
} else if (rawMessage.includes('ECONNREFUSED') || rawMessage.includes('Connection refused')) {
|
||||
message = 'Connection refused - is Hawser running?';
|
||||
} else if (rawMessage.includes('ETIMEDOUT') || rawMessage.includes('timeout') || rawMessage.includes('Timeout')) {
|
||||
message = 'Connection timed out - check host and port';
|
||||
} else if (rawMessage.includes('ENOTFOUND') || rawMessage.includes('getaddrinfo')) {
|
||||
message = 'Host not found - check the hostname';
|
||||
} else if (rawMessage.includes('EHOSTUNREACH')) {
|
||||
message = 'Host unreachable - check network connectivity';
|
||||
}
|
||||
|
||||
return json({ success: false, error: message }, { status: 200 });
|
||||
}
|
||||
};
|
||||
75
routes/api/environments/[id]/timezone/+server.ts
Normal file
75
routes/api/environments/[id]/timezone/+server.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import {
|
||||
getEnvironmentTimezone,
|
||||
setEnvironmentTimezone,
|
||||
getEnvironment
|
||||
} from '$lib/server/db';
|
||||
import { refreshSchedulesForEnvironment } from '$lib/server/scheduler';
|
||||
|
||||
/**
|
||||
* Get timezone for an environment.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('environments', 'view')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
|
||||
// Verify environment exists
|
||||
const env = await getEnvironment(id);
|
||||
if (!env) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const timezone = await getEnvironmentTimezone(id);
|
||||
|
||||
return json({ timezone });
|
||||
} catch (error) {
|
||||
console.error('Failed to get environment timezone:', error);
|
||||
return json({ error: 'Failed to get environment timezone' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set timezone for an environment.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
|
||||
// Verify environment exists
|
||||
const env = await getEnvironment(id);
|
||||
if (!env) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
const timezone = data.timezone || 'UTC';
|
||||
|
||||
// Validate timezone
|
||||
const validTimezones = Intl.supportedValuesOf('timeZone');
|
||||
if (!validTimezones.includes(timezone) && timezone !== 'UTC') {
|
||||
return json({ error: 'Invalid timezone' }, { status: 400 });
|
||||
}
|
||||
|
||||
await setEnvironmentTimezone(id, timezone);
|
||||
|
||||
// Refresh all schedules for this environment to use the new timezone
|
||||
await refreshSchedulesForEnvironment(id);
|
||||
|
||||
return json({ success: true, timezone });
|
||||
} catch (error) {
|
||||
console.error('Failed to set environment timezone:', error);
|
||||
return json({ error: 'Failed to set environment timezone' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
87
routes/api/environments/[id]/update-check/+server.ts
Normal file
87
routes/api/environments/[id]/update-check/+server.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import {
|
||||
getEnvUpdateCheckSettings,
|
||||
setEnvUpdateCheckSettings,
|
||||
getEnvironment
|
||||
} from '$lib/server/db';
|
||||
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
|
||||
|
||||
/**
|
||||
* Get update check settings for an environment.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('environments', 'view')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
|
||||
// Verify environment exists
|
||||
const env = await getEnvironment(id);
|
||||
if (!env) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const settings = await getEnvUpdateCheckSettings(id);
|
||||
|
||||
return json({
|
||||
settings: settings || {
|
||||
enabled: false,
|
||||
cron: '0 4 * * *',
|
||||
autoUpdate: false,
|
||||
vulnerabilityCriteria: 'never'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get update check settings:', error);
|
||||
return json({ error: 'Failed to get update check settings' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Save update check settings for an environment.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
|
||||
// Verify environment exists
|
||||
const env = await getEnvironment(id);
|
||||
if (!env) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
|
||||
const settings = {
|
||||
enabled: data.enabled ?? false,
|
||||
cron: data.cron || '0 4 * * *',
|
||||
autoUpdate: data.autoUpdate ?? false,
|
||||
vulnerabilityCriteria: data.vulnerabilityCriteria || 'never'
|
||||
};
|
||||
|
||||
// Save settings to database
|
||||
await setEnvUpdateCheckSettings(id, settings);
|
||||
|
||||
// Register or unregister schedule based on enabled state
|
||||
if (settings.enabled) {
|
||||
await registerSchedule(id, 'env_update_check', id);
|
||||
} else {
|
||||
unregisterSchedule(id, 'env_update_check');
|
||||
}
|
||||
|
||||
return json({ success: true, settings });
|
||||
} catch (error) {
|
||||
console.error('Failed to save update check settings:', error);
|
||||
return json({ error: 'Failed to save update check settings' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
46
routes/api/environments/detect-socket/+server.ts
Normal file
46
routes/api/environments/detect-socket/+server.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
interface DetectedSocket {
|
||||
path: string;
|
||||
name: string;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect available Docker sockets on the system
|
||||
*/
|
||||
export const GET: RequestHandler = async () => {
|
||||
const home = homedir();
|
||||
|
||||
// Common socket paths to check
|
||||
const socketPaths: { path: string; name: string }[] = [
|
||||
{ path: '/var/run/docker.sock', name: 'Docker (default)' },
|
||||
{ path: `${home}/.docker/run/docker.sock`, name: 'Docker Desktop' },
|
||||
{ path: `${home}/.orbstack/run/docker.sock`, name: 'OrbStack' },
|
||||
{ path: '/run/docker.sock', name: 'Docker (alternate)' },
|
||||
{ path: `${home}/.colima/default/docker.sock`, name: 'Colima' },
|
||||
{ path: `${home}/.rd/docker.sock`, name: 'Rancher Desktop' },
|
||||
{ path: '/run/user/1000/podman/podman.sock', name: 'Podman (user 1000)' },
|
||||
{ path: `${home}/.local/share/containers/podman/machine/podman.sock`, name: 'Podman Machine' },
|
||||
];
|
||||
|
||||
const detected: DetectedSocket[] = [];
|
||||
|
||||
for (const socket of socketPaths) {
|
||||
if (existsSync(socket.path)) {
|
||||
detected.push({
|
||||
path: socket.path,
|
||||
name: socket.name,
|
||||
exists: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
sockets: detected,
|
||||
homedir: home
|
||||
});
|
||||
};
|
||||
201
routes/api/environments/test/+server.ts
Normal file
201
routes/api/environments/test/+server.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
interface TestConnectionRequest {
|
||||
connectionType: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
|
||||
socketPath?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
protocol?: string;
|
||||
tlsCa?: string;
|
||||
tlsCert?: string;
|
||||
tlsKey?: string;
|
||||
tlsSkipVerify?: boolean;
|
||||
hawserToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Docker connection with provided configuration (without saving to database)
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
const config: TestConnectionRequest = await request.json();
|
||||
|
||||
// Build fetch options based on connection type
|
||||
let response: Response;
|
||||
|
||||
if (config.connectionType === 'socket') {
|
||||
const socketPath = config.socketPath || '/var/run/docker.sock';
|
||||
response = await fetch('http://localhost/info', {
|
||||
// @ts-ignore - Bun supports unix socket
|
||||
unix: socketPath,
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
} else if (config.connectionType === 'hawser-edge') {
|
||||
// Edge mode - cannot test directly, agent connects to us
|
||||
return json({
|
||||
success: true,
|
||||
info: {
|
||||
message: 'Edge mode environments are tested when the agent connects'
|
||||
},
|
||||
isEdgeMode: true
|
||||
});
|
||||
} else {
|
||||
// Direct or Hawser Standard - HTTP/HTTPS connection
|
||||
const protocol = config.protocol || 'http';
|
||||
const host = config.host;
|
||||
const port = config.port || 2375;
|
||||
|
||||
if (!host) {
|
||||
return json({ success: false, error: 'Host is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const url = `${protocol}://${host}:${port}/info`;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
// Add Hawser token if present
|
||||
if (config.connectionType === 'hawser-standard' && config.hawserToken) {
|
||||
headers['X-Hawser-Token'] = config.hawserToken;
|
||||
}
|
||||
|
||||
// For HTTPS with custom CA or skip verification, use subprocess to avoid Vite dev server TLS issues
|
||||
if (protocol === 'https' && (config.tlsCa || config.tlsSkipVerify)) {
|
||||
const fs = await import('node:fs');
|
||||
let tempCaPath = '';
|
||||
|
||||
// Clean the certificate - remove leading/trailing whitespace from each line
|
||||
let cleanedCa = '';
|
||||
if (config.tlsCa && !config.tlsSkipVerify) {
|
||||
cleanedCa = config.tlsCa
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.join('\n');
|
||||
|
||||
tempCaPath = `/tmp/dockhand-ca-${Date.now()}.pem`;
|
||||
fs.writeFileSync(tempCaPath, cleanedCa);
|
||||
}
|
||||
|
||||
// Build Bun script that runs outside Vite's process (Vite interferes with TLS)
|
||||
const tlsConfig = config.tlsSkipVerify
|
||||
? `tls: { rejectUnauthorized: false }`
|
||||
: `tls: { ca: await Bun.file('${tempCaPath}').text() }`;
|
||||
|
||||
const scriptContent = `
|
||||
const response = await fetch('https://${host}:${port}/info', {
|
||||
headers: ${JSON.stringify(headers)},
|
||||
${tlsConfig}
|
||||
});
|
||||
const body = await response.text();
|
||||
console.log(JSON.stringify({ status: response.status, body }));
|
||||
`;
|
||||
const scriptPath = `/tmp/dockhand-test-${Date.now()}.ts`;
|
||||
fs.writeFileSync(scriptPath, scriptContent);
|
||||
|
||||
const proc = Bun.spawn(['bun', scriptPath], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const output = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
|
||||
// Cleanup temp files
|
||||
if (tempCaPath) {
|
||||
try { fs.unlinkSync(tempCaPath); } catch {}
|
||||
}
|
||||
try { fs.unlinkSync(scriptPath); } catch {}
|
||||
|
||||
if (!output.trim()) {
|
||||
throw new Error(stderr || 'Empty response from TLS test subprocess');
|
||||
}
|
||||
const result = JSON.parse(output.trim());
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
response = new Response(result.body, {
|
||||
status: result.status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} else {
|
||||
response = await fetch(url, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Docker API error: ${response.status} - ${error}`);
|
||||
}
|
||||
|
||||
const info = await response.json();
|
||||
|
||||
// For Hawser Standard, also try to fetch Hawser info
|
||||
let hawserInfo = null;
|
||||
if (config.connectionType === 'hawser-standard' && config.host) {
|
||||
try {
|
||||
const protocol = config.protocol || 'http';
|
||||
const headers: Record<string, string> = {};
|
||||
if (config.hawserToken) {
|
||||
headers['X-Hawser-Token'] = config.hawserToken;
|
||||
}
|
||||
const hawserResp = await fetch(
|
||||
`${protocol}://${config.host}:${config.port || 2375}/_hawser/info`,
|
||||
{
|
||||
headers,
|
||||
signal: AbortSignal.timeout(5000)
|
||||
}
|
||||
);
|
||||
if (hawserResp.ok) {
|
||||
hawserInfo = await hawserResp.json();
|
||||
}
|
||||
} catch {
|
||||
// Hawser info fetch failed, continue without it
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
info: {
|
||||
serverVersion: info.ServerVersion,
|
||||
containers: info.Containers,
|
||||
images: info.Images,
|
||||
name: info.Name
|
||||
},
|
||||
hawser: hawserInfo
|
||||
});
|
||||
} catch (error) {
|
||||
const rawMessage = error instanceof Error ? error.message : 'Connection failed';
|
||||
console.error('Failed to test connection:', rawMessage);
|
||||
|
||||
// Provide more helpful error messages
|
||||
let message = rawMessage;
|
||||
if (rawMessage.includes('401') || rawMessage.toLowerCase().includes('unauthorized')) {
|
||||
message = 'Invalid token - check that the Hawser token matches';
|
||||
} else if (rawMessage.includes('403') || rawMessage.toLowerCase().includes('forbidden')) {
|
||||
message = 'Access forbidden - check token permissions';
|
||||
} else if (rawMessage.includes('ECONNREFUSED') || rawMessage.includes('Connection refused')) {
|
||||
message = 'Connection refused - is Docker/Hawser running?';
|
||||
} else if (rawMessage.includes('ETIMEDOUT') || rawMessage.includes('timeout') || rawMessage.includes('Timeout')) {
|
||||
message = 'Connection timed out - check host and port';
|
||||
} else if (rawMessage.includes('ENOTFOUND') || rawMessage.includes('getaddrinfo')) {
|
||||
message = 'Host not found - check the hostname';
|
||||
} else if (rawMessage.includes('EHOSTUNREACH')) {
|
||||
message = 'Host unreachable - check network connectivity';
|
||||
} else if (rawMessage.includes('ENOENT') || rawMessage.includes('no such file')) {
|
||||
message = 'Socket not found - check the socket path';
|
||||
} else if (rawMessage.includes('EACCES') || rawMessage.includes('permission denied')) {
|
||||
message = 'Permission denied - check socket permissions';
|
||||
} else if (rawMessage.includes('typo in the url') || rawMessage.includes('Was there a typo')) {
|
||||
message = 'Connection failed - check host and port';
|
||||
} else if (rawMessage.includes('self signed certificate') || rawMessage.includes('UNABLE_TO_VERIFY_LEAF_SIGNATURE')) {
|
||||
message = 'TLS certificate error - provide CA certificate for self-signed certs';
|
||||
} else if (rawMessage.includes('certificate') || rawMessage.includes('SSL') || rawMessage.includes('TLS')) {
|
||||
message = 'TLS/SSL error - check certificate configuration';
|
||||
}
|
||||
|
||||
return json({ success: false, error: message }, { status: 200 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user