OpenAPI spec at /api/docs with a Scalar viewer, opt-in via FEAT_API_DOCS

This commit is contained in:
jarek committed 2026-08-17 09:50:34 +02:00
1 parent ba8670af66
commit da26f7f764
251 files changed
+32497 -66

No files matched your search

+21
View File
@@ -10,6 +10,15 @@ import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-color
import { cleanPem } from '$lib/utils/pem';
import { validateEnvName } from '$lib/utils/env-name';
/**
* @openapi
* summary: List all Docker environments (hosts) known to Dockhand
* resp-200: array<{id:integer!, name:string!, connectionType:string!, host:string, port:integer, protocol:string, icon:string, publicIp:string, timezone:string}>
* resp-200-desc: Environments accessible to the caller (filtered by RBAC in Enterprise mode; all environments in Free edition)
* resp-200-example: [{"id":1,"name":"hhdocker01","connectionType":"socket","host":null,"port":2375,"protocol":"http","icon":"server","publicIp":"203.0.113.10","timezone":"Europe/Berlin"},{"id":2,"name":"hhdocker02","connectionType":"hawser-edge","host":null,"port":2375,"protocol":"http","icon":"server","publicIp":null,"timezone":"Europe/Berlin"}]
* resp-403: Permission denied (RBAC 'environments:view' missing)
* resp-500: Unexpected error while loading environments
*/
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'view')) {
@@ -63,6 +72,18 @@ export const GET: RequestHandler = async ({ cookies }) => {
}
};
/**
* @openapi
* summary: Register a new Docker environment (host)
* body: {name:string!, connectionType:string, host:string, port:integer, protocol:string, socketPath:string, icon:string, publicIp:string, labels:string}
* body-example: {"name":"hhdocker03","connectionType":"socket","socketPath":"/var/run/docker.sock","icon":"server"}
* resp-200: {id:integer!, name:string!, connectionType:string!}
* resp-200-example: {"id":3,"name":"hhdocker03","connectionType":"socket"}
* resp-400: Invalid name, invalid connectionType, or host missing for direct/hawser-standard
* resp-403: Permission denied (RBAC 'environments:create' missing)
* resp-409: An environment with this name already exists
* resp-500: Unexpected error while creating the environment
*/
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
@@ -18,6 +18,15 @@ import { closeEdgeConnection } from '$lib/server/hawser';
import { computeAuditDiff } from '$lib/utils/diff';
import { deleteEnvironmentIcon } from '$lib/server/env-icons';
/**
* @openapi
* summary: Get a single environment by id, including its parsed labels and public IP
* path: id:integer! Environment id (from GET /api/environments)
* resp-200: {id:integer!, name:string!, connectionType:string!, labels:array<string>, publicIp:string}
* resp-403: Permission denied (RBAC 'environments:view' missing)
* resp-404: Environment not found
* resp-500: Unexpected error while loading the environment
*/
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'view')) {
@@ -48,6 +57,19 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
}
};
/**
* @openapi
* summary: Update an environment; renaming also renames its on-disk stacks/git-repos directories
* path: id:integer! Environment id (from GET /api/environments)
* body: {name:string, host:string, port:integer, protocol:string, tlsCa:string, tlsCert:string, tlsKey:string, tlsSkipVerify:boolean, icon:string, socketPath:string, collectActivity:boolean, collectMetrics:boolean, highlightChanges:boolean, labels:string, connectionType:string, hawserToken:string, publicIp:string}
* body-example: {"name":"hhdocker03","collectMetrics":true}
* resp-200: {id:integer!, name:string!, connectionType:string!, labels:array<string>, publicIp:string}
* resp-400: Invalid new name (rename validation)
* resp-403: Permission denied (RBAC 'environments:edit' missing)
* resp-404: Environment not found
* resp-409: Rename target directory already exists, or the on-disk rename failed (e.g. EXDEV across filesystems)
* resp-500: Unexpected error while updating the environment
*/
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
@@ -195,6 +217,16 @@ export const PUT: RequestHandler = async (event) => {
}
};
/**
* @openapi
* summary: Delete an environment and all its associated git stacks, schedules, icons, and on-disk directories
* path: id:integer! Environment id (from GET /api/environments)
* resp-200: {success:boolean!}
* resp-400: Invalid environment id, or the environment could not be deleted
* resp-403: Permission denied (RBAC 'environments:delete' missing)
* resp-404: Environment not found
* resp-500: Environment name is empty/whitespace (refuses to delete to avoid an unsafe directory cleanup), or an unexpected error
*/
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
@@ -3,6 +3,16 @@ import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { getEnvironment, getEnvSetting, setEnvSetting } from '$lib/server/db';
/**
* @openapi
* summary: Get the disk-space warning thresholds for an environment
* path: id:integer! Environment id (from GET /api/environments)
* resp-200: {enabled:boolean!, mode:string!, threshold:integer!, thresholdGb:integer!}
* resp-200-example: {"enabled":true,"mode":"percentage","threshold":80,"thresholdGb":50}
* resp-403: Permission denied (RBAC 'environments:view' missing)
* resp-404: Environment not found
* resp-500: Unexpected error while loading the settings
*/
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !(await auth.can('environments', 'view'))) {
@@ -28,6 +38,17 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
}
};
/**
* @openapi
* summary: Save the disk-space warning thresholds for an environment (each field optional/independent)
* path: id:integer! Environment id (from GET /api/environments)
* body: {enabled:boolean, mode:string, threshold:integer, thresholdGb:integer}
* body-example: {"enabled":true,"mode":"percentage","threshold":85}
* resp-200: {success:boolean!}
* resp-403: Permission denied (RBAC 'environments:edit' missing)
* resp-404: Environment not found
* resp-500: Unexpected error while saving the settings
*/
export const POST: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !(await auth.can('environments', 'edit'))) {
@@ -4,6 +4,13 @@ import { getEnvironment, updateEnvironment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { saveEnvironmentIcon, deleteEnvironmentIcon, getEnvironmentIconBuffer } from '$lib/server/env-icons';
/**
* @openapi
* summary: Get the custom icon image for an environment (raw image/webp bytes, not JSON)
* path: id:integer! Environment id (from GET /api/environments)
* resp-200: Binary image/webp response body, Cache-Control public max-age=3600
* resp-404: No custom icon set for this environment
*/
export const GET: RequestHandler = async ({ params }) => {
const id = parseInt(params.id);
const buffer = getEnvironmentIconBuffer(id);
@@ -20,6 +27,17 @@ export const GET: RequestHandler = async ({ params }) => {
});
};
/**
* @openapi
* summary: Upload a custom icon for an environment (base64-encoded image, ~200KB limit)
* path: id:integer! Environment id (from GET /api/environments)
* body: {image:string!}
* resp-200: {success:boolean!, icon:string!}
* resp-200-example: {"success":true,"icon":"custom:env-3.webp"}
* resp-400: Missing image data, or image exceeds the ~300000-char base64 size limit
* resp-403: Permission denied (RBAC 'environments:edit' missing)
* resp-404: Environment not found
*/
export const POST: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
@@ -49,6 +67,15 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => {
return json({ success: true, icon: iconValue });
};
/**
* @openapi
* summary: Remove an environment's custom icon and reset it to the default "globe" icon
* path: id:integer! Environment id (from GET /api/environments)
* resp-200: {success:boolean!, icon:string!}
* resp-200-example: {"success":true,"icon":"globe"}
* resp-403: Permission denied (RBAC 'environments:edit' missing)
* resp-404: Environment not found
*/
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
@@ -10,6 +10,15 @@ import { registerSchedule, unregisterSchedule, triggerImagePrune } from '$lib/se
/**
* Get image prune settings for an environment.
*
* @openapi
* summary: Get the automatic image-prune schedule settings for an environment
* path: id:integer! Environment id (from GET /api/environments)
* resp-200: {settings:{enabled:boolean!, cronExpression:string!, pruneMode:string!}!}
* resp-200-example: {"settings":{"enabled":false,"cronExpression":"0 3 * * 0","pruneMode":"dangling"}}
* resp-403: Permission denied (RBAC 'environments:view' missing)
* resp-404: Environment not found
* resp-500: Unexpected error while loading the settings
*/
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -43,6 +52,16 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
/**
* Save image prune settings for an environment.
*
* @openapi
* summary: Save the automatic image-prune schedule for an environment (registers/unregisters the croner job)
* path: id:integer! Environment id (from GET /api/environments)
* body: {enabled:boolean, cronExpression:string, pruneMode:string}
* body-example: {"enabled":true,"cronExpression":"0 3 * * 0","pruneMode":"dangling"}
* resp-200: {success:boolean!, settings:{enabled:boolean!, cronExpression:string!, pruneMode:string!}!}
* resp-403: Permission denied (RBAC 'environments:edit' missing)
* resp-404: Environment not found
* resp-500: Unexpected error while saving the settings
*/
export const POST: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
@@ -91,6 +110,15 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => {
/**
* Manually trigger image prune for an environment.
*
* @openapi
* summary: Immediately run an image prune for an environment (outside its schedule)
* path: id:integer! Environment id (from GET /api/environments)
* resp-200: {success:boolean!}
* resp-400: The prune operation itself failed (Docker error)
* resp-403: Permission denied (RBAC 'environments:edit' missing)
* resp-404: Environment not found
* resp-500: Unexpected error while triggering the prune
*/
export const PUT: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -10,6 +10,16 @@ import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
// GET /api/environments/[id]/notifications - List all notification configurations for an environment
/**
* @openapi
* summary: List notification channel configurations attached to an environment
* path: id:integer! Environment id (from GET /api/environments)
* resp-200: array<{id:integer!, notificationId:integer!, enabled:boolean!, eventTypes:array<string>}>
* resp-400: Invalid environment id
* resp-403: Permission denied (RBAC 'notifications:view' missing)
* resp-404: Environment not found
* resp-500: Unexpected error while loading notifications
*/
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('notifications', 'view')) {
@@ -36,6 +46,20 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
};
// POST /api/environments/[id]/notifications - Add a notification channel to an environment
/**
* @openapi
* summary: Attach a notification channel to an environment
* description: notificationId from GET /api/notifications.
* path: id:integer! Environment id (from GET /api/environments)
* body: {notificationId:integer!, enabled:boolean, eventTypes:array<string>}
* body-example: {"notificationId":1,"enabled":true,"eventTypes":["stack_deploy_failed"]}
* resp-200: {id:integer!, notificationId:integer!, enabled:boolean!}
* resp-400: Invalid environment id, or notificationId missing
* resp-403: Permission denied (RBAC 'notifications:edit' missing)
* resp-404: Environment not found
* resp-409: This notification channel is already configured for this environment
* resp-500: Unexpected error while creating the notification link
*/
export const POST: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('notifications', 'edit')) {
@@ -10,6 +10,17 @@ import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
// GET /api/environments/[id]/notifications/[notificationId] - Get a specific environment notification
/**
* @openapi
* summary: Get one notification-channel link for an environment
* path: id:integer! Environment id (from GET /api/environments)
* path: notificationId:integer! Notification channel id (from GET /api/notifications)
* resp-200: {id:integer!, notificationId:integer!, enabled:boolean!, eventTypes:array<string>}
* resp-400: Invalid id or notificationId
* resp-403: Permission denied (RBAC 'notifications:view' missing)
* resp-404: Environment not found, or no such notification link for this environment
* resp-500: Unexpected error while loading the notification link
*/
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('notifications', 'view')) {
@@ -40,6 +51,19 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
};
// PUT /api/environments/[id]/notifications/[notificationId] - Update an environment notification
/**
* @openapi
* summary: Update a notification-channel link (enabled state and/or event type filter)
* path: id:integer! Environment id (from GET /api/environments)
* path: notificationId:integer! Notification channel id (from GET /api/notifications)
* body: {enabled:boolean, eventTypes:array<string>}
* body-example: {"enabled":false}
* resp-200: {id:integer!, notificationId:integer!, enabled:boolean!, eventTypes:array<string>}
* resp-400: Invalid id or notificationId
* resp-403: Permission denied (RBAC 'notifications:edit' missing)
* resp-404: Environment not found, or no such notification link for this environment
* resp-500: Unexpected error while updating the notification link
*/
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('notifications', 'edit')) {
@@ -78,6 +102,17 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
};
// DELETE /api/environments/[id]/notifications/[notificationId] - Remove a notification from an environment
/**
* @openapi
* summary: Remove a notification-channel link from an environment
* path: id:integer! Environment id (from GET /api/environments)
* path: notificationId:integer! Notification channel id (from GET /api/notifications)
* resp-200: {success:boolean!}
* resp-400: Invalid id or notificationId
* resp-403: Permission denied (RBAC 'notifications:delete' missing)
* resp-404: No such notification link for this environment
* resp-500: Unexpected error while deleting the notification link
*/
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('notifications', 'delete')) {
@@ -9,6 +9,15 @@ import { getEnvironment, getEnvSetting, setEnvSetting } from '$lib/server/db';
// remote host under this dir before `docker compose up`. Empty/unset = current behavior.
// Stored via the generic per-env settings store (no schema column).
/**
* @openapi
* summary: Get the per-environment remote stacks directory used to stage stack files onto a direct (agentless) daemon
* path: id:integer! Environment ID (from GET /api/environments)
* resp-200: {remoteStacksDir:string}
* resp-200-example: {"remoteStacksDir":"/mnt/dockhand/stacks"}
* resp-403: Permission denied (requires environments:view)
* resp-404: Environment not found
*/
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !(await auth.can('environments', 'view'))) {
@@ -23,6 +32,18 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
return json({ remoteStacksDir });
};
/**
* @openapi
* summary: Set (or clear) the per-environment remote stacks directory for a direct (agentless) daemon
* path: id:integer! Environment ID (from GET /api/environments)
* body: {remoteStacksDir:string}
* body-example: {"remoteStacksDir":"/mnt/dockhand/stacks"}
* resp-200: {success:boolean!, remoteStacksDir:string}
* resp-200-example: {"success":true,"remoteStacksDir":"/mnt/dockhand/stacks"}
* resp-400: Invalid input — remoteStacksDir must be a string or null, or must be an absolute path with no ".."
* resp-403: Permission denied (requires environments:edit)
* resp-404: Environment not found
*/
export const POST: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !(await auth.can('environments', 'edit'))) {
@@ -5,6 +5,14 @@ import { getDockerInfo, getHawserInfo } from '$lib/server/docker';
import { edgeConnections, isEdgeConnected } from '$lib/server/hawser';
import { daemonIsPodman } from '$lib/server/scanner-socket-detect';
/**
* @openapi
* summary: Test connectivity to a saved environment's Docker/Hawser endpoint
* path: id:integer! Environment id (from GET /api/environments)
* resp-200: {success:boolean!, info:{serverVersion:string, containers:integer, images:integer, name:string, engine:string}, isEdgeMode:boolean, hawser:{}}
* resp-200-desc: success:false with a human-readable error message is also returned as HTTP 200 (connection/agent-not-connected states are not transport errors)
* resp-404: Environment not found
*/
export const POST: RequestHandler = async ({ params }) => {
try {
const id = parseInt(params.id);
@@ -38,6 +38,15 @@ function normalizeTimezone(tz: string): string {
/**
* Get timezone for an environment.
*
* @openapi
* summary: Get the IANA timezone used for scheduling on an environment
* path: id:integer! Environment id (from GET /api/environments)
* resp-200: {timezone:string!}
* resp-200-example: {"timezone":"Europe/Berlin"}
* resp-403: Permission denied (RBAC 'environments:view' missing)
* resp-404: Environment not found
* resp-500: Unexpected error while loading the timezone
*/
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -66,6 +75,17 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
/**
* Set timezone for an environment.
*
* @openapi
* summary: Set the IANA timezone for an environment and refresh its schedules to use it
* path: id:integer! Environment id (from GET /api/environments)
* body: {timezone:string!}
* body-example: {"timezone":"Europe/Berlin"}
* resp-200: {success:boolean!, timezone:string!}
* resp-400: Not a recognized IANA timezone (Intl.supportedValuesOf('timeZone'))
* resp-403: Permission denied (RBAC 'environments:edit' missing)
* resp-404: Environment not found
* resp-500: Unexpected error while saving the timezone
*/
export const POST: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
@@ -11,6 +11,11 @@ interface DetectedSocket {
/**
* Detect available Docker sockets on the system
*
* @openapi
* summary: Detect common Docker/Podman socket paths that exist on the Dockhand host
* resp-200: {sockets:array<{path:string!, name:string!, exists:boolean!}>!, homedir:string!}
* resp-200-example: {"sockets":[{"path":"/var/run/docker.sock","name":"Docker (default)","exists":true}],"homedir":"/home/dockhand"}
*/
export const GET: RequestHandler = async () => {
const home = homedir();
@@ -41,6 +41,14 @@ function buildDockerClientConfig(config: TestConnectionRequest): DockerClientCon
/**
* Test Docker connection with provided configuration (without saving to database)
*
* @openapi
* summary: Test a Docker/Hawser connection configuration WITHOUT saving it as an environment
* body: {connectionType:string!, socketPath:string, host:string, port:integer, protocol:string, tlsCa:string, tlsCert:string, tlsKey:string, tlsSkipVerify:boolean, hawserToken:string}
* body-example: {"connectionType":"socket","socketPath":"/var/run/docker.sock"}
* resp-200: {success:boolean!, info:{serverVersion:string, containers:integer, images:integer, name:string}, hawser:{}}
* resp-200-desc: success:false with a human-readable error message is also returned as HTTP 200 (connection failures are not transport errors)
* resp-400: Host is required for direct/hawser-standard connection types
*/
export const POST: RequestHandler = async ({ request }) => {
try {