From 62e3c6439ed6dec85c72fbb4fe386dade3888523 Mon Sep 17 00:00:00 2001 From: Jarek Krochmalski Date: Sun, 28 Dec 2025 21:16:03 +0100 Subject: [PATCH] Initial commit --- LICENSE.txt | 128 + README.md | 1 + app.css | 1717 +++++++ app.d.ts | 23 + app.html | 17 + hooks.server.ts | 168 + images/logo.webp | Bin 0 -> 9926 bytes lib/.DS_Store | Bin 0 -> 8196 bytes lib/actions/column-resize.ts | 77 + lib/assets/favicon.svg | 1 + lib/components/AvatarCropper.svelte | 274 ++ lib/components/BatchOperationModal.svelte | 332 ++ lib/components/CodeEditor.svelte | 821 ++++ lib/components/ColumnSettingsPopover.svelte | 138 + lib/components/CommandPalette.svelte | 355 ++ lib/components/ConfirmPopover.svelte | 114 + lib/components/ExecutionLogViewer.svelte | 94 + lib/components/MultiSelectFilter.svelte | 93 + lib/components/PageHeader.svelte | 83 + .../PasswordStrengthIndicator.svelte | 62 + lib/components/PullTab.svelte | 506 ++ lib/components/PushTab.svelte | 308 ++ lib/components/ScanTab.svelte | 390 ++ lib/components/ScannerSeverityPills.svelte | 39 + lib/components/Sidebar.svelte | 106 + lib/components/StackEnvVarsEditor.svelte | 234 + lib/components/StackEnvVarsPanel.svelte | 236 + lib/components/ThemeSelector.svelte | 247 + lib/components/TimezoneSelector.svelte | 142 + lib/components/UpdateContainerRow.svelte | 106 + lib/components/UpdateStepIndicator.svelte | 26 + lib/components/UpdateSummaryStats.svelte | 69 + .../VulnerabilityCriteriaBadge.svelte | 27 + .../VulnerabilityCriteriaSelector.svelte | 85 + lib/components/WhatsNewModal.svelte | 81 + lib/components/app-sidebar.svelte | 195 + lib/components/cron-editor.svelte | 308 ++ lib/components/data-grid/DataGrid.svelte | 850 ++++ lib/components/data-grid/context.ts | 28 + lib/components/data-grid/index.ts | 16 + lib/components/data-grid/types.ts | 112 + lib/components/host-info.svelte | 466 ++ lib/components/icon-picker.svelte | 65 + lib/components/main-content.svelte | 9 + lib/components/permission-guard.svelte | 22 + lib/components/theme-toggle.svelte | 44 + .../ui/accordion/accordion-content.svelte | 22 + .../ui/accordion/accordion-item.svelte | 17 + .../ui/accordion/accordion-trigger.svelte | 32 + lib/components/ui/accordion/accordion.svelte | 16 + lib/components/ui/accordion/index.ts | 16 + .../ui/alert/alert-description.svelte | 23 + lib/components/ui/alert/alert-title.svelte | 20 + lib/components/ui/alert/alert.svelte | 50 + lib/components/ui/alert/index.ts | 14 + .../ui/avatar/avatar-fallback.svelte | 17 + lib/components/ui/avatar/avatar-image.svelte | 17 + lib/components/ui/avatar/avatar.svelte | 19 + lib/components/ui/avatar/index.ts | 13 + lib/components/ui/badge/badge.svelte | 50 + lib/components/ui/badge/index.ts | 2 + lib/components/ui/button/button.svelte | 82 + lib/components/ui/button/index.ts | 17 + .../ui/calendar/calendar-caption.svelte | 76 + .../ui/calendar/calendar-cell.svelte | 19 + .../ui/calendar/calendar-day.svelte | 35 + .../ui/calendar/calendar-grid-body.svelte | 12 + .../ui/calendar/calendar-grid-head.svelte | 12 + .../ui/calendar/calendar-grid-row.svelte | 12 + .../ui/calendar/calendar-grid.svelte | 16 + .../ui/calendar/calendar-head-cell.svelte | 19 + .../ui/calendar/calendar-header.svelte | 19 + .../ui/calendar/calendar-heading.svelte | 16 + .../ui/calendar/calendar-month-select.svelte | 44 + .../ui/calendar/calendar-month.svelte | 15 + .../ui/calendar/calendar-months.svelte | 19 + .../ui/calendar/calendar-nav.svelte | 19 + .../ui/calendar/calendar-next-button.svelte | 31 + .../ui/calendar/calendar-prev-button.svelte | 31 + .../ui/calendar/calendar-year-select.svelte | 43 + lib/components/ui/calendar/calendar.svelte | 115 + lib/components/ui/calendar/index.ts | 40 + lib/components/ui/card/card-action.svelte | 20 + lib/components/ui/card/card-content.svelte | 15 + .../ui/card/card-description.svelte | 20 + lib/components/ui/card/card-footer.svelte | 20 + lib/components/ui/card/card-header.svelte | 23 + lib/components/ui/card/card-title.svelte | 20 + lib/components/ui/card/card.svelte | 23 + lib/components/ui/card/index.ts | 25 + lib/components/ui/checkbox/checkbox.svelte | 36 + lib/components/ui/checkbox/index.ts | 6 + .../ui/command/command-dialog.svelte | 40 + .../ui/command/command-empty.svelte | 17 + .../ui/command/command-group.svelte | 32 + .../ui/command/command-input.svelte | 26 + lib/components/ui/command/command-item.svelte | 20 + .../ui/command/command-link-item.svelte | 20 + lib/components/ui/command/command-list.svelte | 17 + .../ui/command/command-loading.svelte | 7 + .../ui/command/command-separator.svelte | 17 + .../ui/command/command-shortcut.svelte | 20 + lib/components/ui/command/command.svelte | 28 + lib/components/ui/command/index.ts | 37 + .../ui/date-picker/date-picker.svelte | 73 + lib/components/ui/date-picker/index.ts | 3 + lib/components/ui/dialog/dialog-close.svelte | 7 + .../ui/dialog/dialog-content.svelte | 46 + .../ui/dialog/dialog-description.svelte | 17 + lib/components/ui/dialog/dialog-footer.svelte | 20 + lib/components/ui/dialog/dialog-header.svelte | 20 + .../ui/dialog/dialog-overlay.svelte | 20 + lib/components/ui/dialog/dialog-portal.svelte | 7 + lib/components/ui/dialog/dialog-title.svelte | 17 + .../ui/dialog/dialog-trigger.svelte | 7 + lib/components/ui/dialog/dialog.svelte | 7 + lib/components/ui/dialog/index.ts | 34 + .../dropdown-menu-checkbox-group.svelte | 16 + .../dropdown-menu-checkbox-item.svelte | 42 + .../dropdown-menu-content.svelte | 29 + .../dropdown-menu-group-heading.svelte | 22 + .../dropdown-menu/dropdown-menu-group.svelte | 7 + .../dropdown-menu/dropdown-menu-item.svelte | 27 + .../dropdown-menu/dropdown-menu-label.svelte | 24 + .../dropdown-menu/dropdown-menu-portal.svelte | 7 + .../dropdown-menu-radio-group.svelte | 16 + .../dropdown-menu-radio-item.svelte | 33 + .../dropdown-menu-separator.svelte | 17 + .../dropdown-menu-shortcut.svelte | 20 + .../dropdown-menu-sub-content.svelte | 20 + .../dropdown-menu-sub-trigger.svelte | 29 + .../ui/dropdown-menu/dropdown-menu-sub.svelte | 7 + .../dropdown-menu-trigger.svelte | 7 + .../ui/dropdown-menu/dropdown-menu.svelte | 7 + lib/components/ui/dropdown-menu/index.ts | 54 + .../ui/empty-state/empty-state.svelte | 32 + lib/components/ui/empty-state/index.ts | 4 + .../ui/empty-state/no-environment.svelte | 28 + lib/components/ui/input/index.ts | 7 + lib/components/ui/input/input.svelte | 52 + lib/components/ui/label/index.ts | 7 + lib/components/ui/label/label.svelte | 20 + lib/components/ui/popover/index.ts | 17 + .../ui/popover/popover-content.svelte | 29 + .../ui/popover/popover-trigger.svelte | 17 + lib/components/ui/progress/index.ts | 7 + lib/components/ui/progress/progress.svelte | 27 + lib/components/ui/select/index.ts | 37 + .../ui/select/select-content.svelte | 42 + .../ui/select/select-group-heading.svelte | 21 + lib/components/ui/select/select-group.svelte | 7 + lib/components/ui/select/select-item.svelte | 38 + lib/components/ui/select/select-label.svelte | 20 + .../select/select-scroll-down-button.svelte | 20 + .../ui/select/select-scroll-up-button.svelte | 20 + .../ui/select/select-separator.svelte | 18 + .../ui/select/select-trigger.svelte | 29 + lib/components/ui/separator/index.ts | 7 + lib/components/ui/separator/separator.svelte | 21 + lib/components/ui/sheet/index.ts | 36 + lib/components/ui/sheet/sheet-close.svelte | 7 + lib/components/ui/sheet/sheet-content.svelte | 58 + .../ui/sheet/sheet-description.svelte | 17 + lib/components/ui/sheet/sheet-footer.svelte | 20 + lib/components/ui/sheet/sheet-header.svelte | 20 + lib/components/ui/sheet/sheet-overlay.svelte | 20 + lib/components/ui/sheet/sheet-title.svelte | 17 + lib/components/ui/sheet/sheet-trigger.svelte | 7 + lib/components/ui/sidebar/constants.ts | 6 + lib/components/ui/sidebar/context.svelte.ts | 81 + lib/components/ui/sidebar/index.ts | 75 + .../ui/sidebar/sidebar-content.svelte | 24 + .../ui/sidebar/sidebar-footer.svelte | 21 + .../ui/sidebar/sidebar-group-action.svelte | 36 + .../ui/sidebar/sidebar-group-content.svelte | 21 + .../ui/sidebar/sidebar-group-label.svelte | 34 + .../ui/sidebar/sidebar-group.svelte | 21 + .../ui/sidebar/sidebar-header.svelte | 21 + .../ui/sidebar/sidebar-input.svelte | 21 + .../ui/sidebar/sidebar-inset.svelte | 29 + .../ui/sidebar/sidebar-menu-action.svelte | 43 + .../ui/sidebar/sidebar-menu-badge.svelte | 29 + .../ui/sidebar/sidebar-menu-button.svelte | 111 + .../ui/sidebar/sidebar-menu-item.svelte | 21 + .../ui/sidebar/sidebar-menu-skeleton.svelte | 36 + .../ui/sidebar/sidebar-menu-sub-button.svelte | 43 + .../ui/sidebar/sidebar-menu-sub-item.svelte | 21 + .../ui/sidebar/sidebar-menu-sub.svelte | 25 + lib/components/ui/sidebar/sidebar-menu.svelte | 21 + .../ui/sidebar/sidebar-provider.svelte | 64 + lib/components/ui/sidebar/sidebar-rail.svelte | 36 + .../ui/sidebar/sidebar-separator.svelte | 19 + .../ui/sidebar/sidebar-trigger.svelte | 35 + lib/components/ui/sidebar/sidebar.svelte | 104 + lib/components/ui/skeleton/index.ts | 7 + lib/components/ui/skeleton/skeleton.svelte | 17 + lib/components/ui/sonner/index.ts | 1 + lib/components/ui/sonner/sonner.svelte | 38 + lib/components/ui/switch/index.ts | 3 + lib/components/ui/switch/switch.svelte | 23 + lib/components/ui/table/index.ts | 28 + lib/components/ui/table/table-body.svelte | 20 + lib/components/ui/table/table-caption.svelte | 20 + lib/components/ui/table/table-cell.svelte | 23 + lib/components/ui/table/table-footer.svelte | 20 + lib/components/ui/table/table-head.svelte | 23 + lib/components/ui/table/table-header.svelte | 20 + lib/components/ui/table/table-row.svelte | 23 + lib/components/ui/table/table.svelte | 22 + lib/components/ui/tabs/index.ts | 16 + lib/components/ui/tabs/tabs-content.svelte | 17 + lib/components/ui/tabs/tabs-list.svelte | 20 + lib/components/ui/tabs/tabs-trigger.svelte | 20 + lib/components/ui/tabs/tabs.svelte | 19 + lib/components/ui/textarea/index.ts | 7 + lib/components/ui/textarea/textarea.svelte | 23 + lib/components/ui/toggle-pill/index.ts | 5 + .../ui/toggle-pill/toggle-group.svelte | 93 + .../ui/toggle-pill/toggle-pill.svelte | 35 + .../ui/toggle-pill/toggle-switch.svelte | 67 + lib/components/ui/tooltip/index.ts | 21 + .../ui/tooltip/tooltip-content.svelte | 30 + .../ui/tooltip/tooltip-trigger.svelte | 7 + lib/config/grid-columns.ts | 145 + lib/data/changelog.json | 105 + lib/data/dependencies.json | 872 ++++ lib/hooks/is-mobile.svelte.ts | 35 + lib/index.ts | 1 + lib/server/.DS_Store | Bin 0 -> 8196 bytes lib/server/audit-events.ts | 43 + lib/server/audit.ts | 307 ++ lib/server/auth.ts | 1346 ++++++ lib/server/authorize.ts | 256 + lib/server/db.ts | 4225 +++++++++++++++++ lib/server/db/.DS_Store | Bin 0 -> 6148 bytes lib/server/db/connection.ts | 175 + lib/server/db/drizzle.ts | 1013 ++++ lib/server/db/schema/index.ts | 567 +++ lib/server/db/schema/pg-schema.ts | 482 ++ lib/server/docker.ts | 3236 +++++++++++++ lib/server/event-collector.ts | 18 + lib/server/git.ts | 1135 +++++ lib/server/hawser.ts | 945 ++++ lib/server/license.ts | 253 + lib/server/metrics-collector.ts | 271 ++ lib/server/notifications.ts | 499 ++ lib/server/scanner.ts | 829 ++++ lib/server/scheduler/index.ts | 632 +++ .../scheduler/tasks/container-update.ts | 575 +++ .../scheduler/tasks/env-update-check.ts | 509 ++ lib/server/scheduler/tasks/git-stack-sync.ts | 102 + lib/server/scheduler/tasks/system-cleanup.ts | 202 + lib/server/scheduler/tasks/update-utils.ts | 114 + lib/server/stacks.ts | 1109 +++++ lib/server/subprocess-manager.ts | 593 +++ lib/server/subprocesses/event-subprocess.ts | 446 ++ lib/server/subprocesses/metrics-subprocess.ts | 419 ++ lib/server/uptime.ts | 15 + lib/stores/audit-events.ts | 121 + lib/stores/auth.ts | 209 + lib/stores/dashboard.ts | 209 + lib/stores/environment.ts | 163 + lib/stores/events.ts | 221 + lib/stores/grid-preferences.ts | 226 + lib/stores/license.ts | 75 + lib/stores/settings.ts | 365 ++ lib/stores/stats.ts | 134 + lib/stores/theme.ts | 260 + lib/themes.ts | 139 + lib/types.ts | 177 + lib/utils.ts | 27 + lib/utils/icons.ts | 46 + lib/utils/ip.ts | 15 + lib/utils/label-colors.ts | 107 + lib/utils/update-steps.ts | 160 + lib/utils/version.ts | 35 + routes/+layout.server.ts | 55 + routes/+layout.svelte | 175 + routes/+layout.ts | 3 + routes/+page.svelte | 1000 ++++ routes/activity/+page.svelte | 931 ++++ routes/alerts/+page.svelte | 14 + routes/api/activity/+server.ts | 88 + routes/api/activity/containers/+server.ts | 42 + routes/api/activity/events/+server.ts | 107 + routes/api/activity/stats/+server.ts | 42 + routes/api/audit/+server.ts | 68 + routes/api/audit/events/+server.ts | 79 + routes/api/audit/export/+server.ts | 182 + routes/api/audit/users/+server.ts | 26 + routes/api/auth/ldap/+server.ts | 81 + routes/api/auth/ldap/[id]/+server.ts | 131 + routes/api/auth/ldap/[id]/test/+server.ts | 37 + routes/api/auth/login/+server.ts | 117 + routes/api/auth/logout/+server.ts | 14 + routes/api/auth/oidc/+server.ts | 88 + routes/api/auth/oidc/[id]/+server.ts | 136 + routes/api/auth/oidc/[id]/initiate/+server.ts | 77 + routes/api/auth/oidc/[id]/test/+server.ts | 28 + routes/api/auth/oidc/callback/+server.ts | 53 + routes/api/auth/providers/+server.ts | 54 + routes/api/auth/session/+server.ts | 46 + routes/api/auth/settings/+server.ts | 71 + routes/api/auto-update/+server.ts | 40 + .../auto-update/[containerName]/+server.ts | 126 + routes/api/batch/+server.ts | 463 ++ routes/api/changelog/+server.ts | 7 + routes/api/config-sets/+server.ts | 53 + routes/api/config-sets/[id]/+server.ts | 91 + routes/api/containers/+server.ts | 123 + routes/api/containers/[id]/+server.ts | 93 + routes/api/containers/[id]/exec/+server.ts | 59 + routes/api/containers/[id]/files/+server.ts | 32 + .../containers/[id]/files/chmod/+server.ts | 57 + .../containers/[id]/files/content/+server.ts | 116 + .../containers/[id]/files/create/+server.ts | 55 + .../containers/[id]/files/delete/+server.ts | 51 + .../containers/[id]/files/download/+server.ts | 98 + .../containers/[id]/files/rename/+server.ts | 54 + .../containers/[id]/files/upload/+server.ts | 154 + routes/api/containers/[id]/inspect/+server.ts | 24 + routes/api/containers/[id]/logs/+server.ts | 25 + .../containers/[id]/logs/stream/+server.ts | 452 ++ routes/api/containers/[id]/pause/+server.ts | 32 + routes/api/containers/[id]/rename/+server.ts | 53 + routes/api/containers/[id]/restart/+server.ts | 45 + routes/api/containers/[id]/start/+server.ts | 38 + routes/api/containers/[id]/stats/+server.ts | 91 + routes/api/containers/[id]/stop/+server.ts | 38 + routes/api/containers/[id]/top/+server.ts | 73 + routes/api/containers/[id]/unpause/+server.ts | 32 + routes/api/containers/[id]/update/+server.ts | 43 + .../containers/batch-update-stream/+server.ts | 548 +++ routes/api/containers/batch-update/+server.ts | 154 + .../api/containers/check-updates/+server.ts | 111 + .../api/containers/pending-updates/+server.ts | 67 + routes/api/containers/sizes/+server.ts | 24 + routes/api/containers/stats/+server.ts | 148 + routes/api/dashboard/preferences/+server.ts | 53 + routes/api/dashboard/stats/+server.ts | 307 ++ routes/api/dashboard/stats/stream/+server.ts | 531 +++ routes/api/dependencies/+server.ts | 26 + routes/api/environments/+server.ts | 129 + routes/api/environments/[id]/+server.ts | 158 + .../[id]/notifications/+server.ts | 78 + .../notifications/[notificationId]/+server.ts | 103 + routes/api/environments/[id]/test/+server.ts | 138 + .../api/environments/[id]/timezone/+server.ts | 75 + .../environments/[id]/update-check/+server.ts | 87 + .../api/environments/detect-socket/+server.ts | 46 + routes/api/environments/test/+server.ts | 201 + routes/api/events/+server.ts | 137 + routes/api/git/credentials/+server.ts | 88 + routes/api/git/credentials/[id]/+server.ts | 122 + routes/api/git/repositories/+server.ts | 70 + routes/api/git/repositories/[id]/+server.ts | 112 + .../git/repositories/[id]/deploy/+server.ts | 24 + .../api/git/repositories/[id]/sync/+server.ts | 45 + .../api/git/repositories/[id]/test/+server.ts | 24 + routes/api/git/repositories/test/+server.ts | 41 + routes/api/git/stacks/+server.ts | 144 + routes/api/git/stacks/[id]/+server.ts | 112 + .../git/stacks/[id]/deploy-stream/+server.ts | 54 + routes/api/git/stacks/[id]/deploy/+server.ts | 28 + .../api/git/stacks/[id]/env-files/+server.ts | 75 + routes/api/git/stacks/[id]/sync/+server.ts | 28 + routes/api/git/stacks/[id]/test/+server.ts | 28 + routes/api/git/stacks/[id]/webhook/+server.ts | 97 + routes/api/git/webhook/[id]/+server.ts | 103 + routes/api/hawser/connect/+server.ts | 61 + routes/api/hawser/tokens/+server.ts | 122 + routes/api/health/+server.ts | 6 + routes/api/health/database/+server.ts | 58 + routes/api/host/+server.ts | 158 + routes/api/images/+server.ts | 39 + routes/api/images/[id]/+server.ts | 61 + routes/api/images/[id]/export/+server.ts | 79 + routes/api/images/[id]/history/+server.ts | 24 + routes/api/images/[id]/tag/+server.ts | 28 + routes/api/images/pull/+server.ts | 265 ++ routes/api/images/push/+server.ts | 303 ++ routes/api/images/scan/+server.ts | 149 + routes/api/legal/license/+server.ts | 21 + routes/api/legal/privacy/+server.ts | 21 + routes/api/license/+server.ts | 92 + routes/api/logs/merged/+server.ts | 667 +++ routes/api/metrics/+server.ts | 24 + routes/api/networks/+server.ts | 99 + routes/api/networks/[id]/+server.ts | 71 + routes/api/networks/[id]/connect/+server.ts | 53 + .../api/networks/[id]/disconnect/+server.ts | 53 + routes/api/networks/[id]/inspect/+server.ts | 24 + routes/api/notifications/+server.ts | 81 + routes/api/notifications/[id]/+server.ts | 135 + routes/api/notifications/[id]/test/+server.ts | 31 + routes/api/notifications/test/+server.ts | 61 + .../api/notifications/trigger-test/+server.ts | 130 + .../preferences/favorite-groups/+server.ts | 134 + routes/api/preferences/favorites/+server.ts | 103 + routes/api/preferences/grid/+server.ts | 81 + routes/api/profile/+server.ts | 117 + routes/api/profile/avatar/+server.ts | 70 + routes/api/profile/preferences/+server.ts | 101 + routes/api/prune/all/+server.ts | 24 + routes/api/prune/containers/+server.ts | 24 + routes/api/prune/images/+server.ts | 25 + routes/api/prune/networks/+server.ts | 24 + routes/api/prune/volumes/+server.ts | 24 + routes/api/registries/+server.ts | 62 + routes/api/registries/[id]/+server.ts | 96 + routes/api/registries/[id]/default/+server.ts | 23 + routes/api/registry/catalog/+server.ts | 90 + routes/api/registry/image/+server.ts | 109 + routes/api/registry/search/+server.ts | 136 + routes/api/registry/tags/+server.ts | 143 + routes/api/roles/+server.ts | 65 + routes/api/roles/[id]/+server.ts | 126 + routes/api/schedules/+server.ts | 207 + routes/api/schedules/[type]/[id]/+server.ts | 62 + .../api/schedules/[type]/[id]/run/+server.ts | 52 + .../schedules/[type]/[id]/toggle/+server.ts | 88 + routes/api/schedules/executions/+server.ts | 62 + .../api/schedules/executions/[id]/+server.ts | 45 + routes/api/schedules/settings/+server.ts | 61 + routes/api/schedules/stream/+server.ts | 306 ++ .../schedules/system/[id]/toggle/+server.ts | 42 + routes/api/settings/general/+server.ts | 329 ++ routes/api/settings/scanner/+server.ts | 195 + routes/api/stacks/+server.ts | 131 + routes/api/stacks/[name]/+server.ts | 46 + routes/api/stacks/[name]/compose/+server.ts | 72 + routes/api/stacks/[name]/down/+server.ts | 54 + routes/api/stacks/[name]/env/+server.ts | 122 + .../api/stacks/[name]/env/validate/+server.ts | 148 + routes/api/stacks/[name]/restart/+server.ts | 45 + routes/api/stacks/[name]/start/+server.ts | 45 + routes/api/stacks/[name]/stop/+server.ts | 45 + routes/api/stacks/sources/+server.ts | 34 + routes/api/system/+server.ts | 218 + routes/api/system/disk/+server.ts | 40 + routes/api/users/+server.ts | 136 + routes/api/users/[id]/+server.ts | 299 ++ routes/api/users/[id]/mfa/+server.ts | 96 + routes/api/users/[id]/roles/+server.ts | 110 + routes/api/volumes/+server.ts | 86 + routes/api/volumes/[name]/+server.ts | 63 + routes/api/volumes/[name]/browse/+server.ts | 52 + .../volumes/[name]/browse/content/+server.ts | 53 + .../volumes/[name]/browse/release/+server.ts | 33 + routes/api/volumes/[name]/clone/+server.ts | 55 + routes/api/volumes/[name]/export/+server.ts | 73 + routes/api/volumes/[name]/inspect/+server.ts | 24 + routes/audit/+page.svelte | 1142 +++++ routes/audit/+server.ts | 53 + routes/audit/users/+server.ts | 26 + routes/containers/+page.svelte | 2173 +++++++++ routes/containers/AutoUpdateSettings.svelte | 81 + routes/containers/BatchUpdateModal.svelte | 604 +++ .../containers/ContainerInspectModal.svelte | 1230 +++++ routes/containers/ContainerTerminal.svelte | 346 ++ routes/containers/ContainerTile.svelte | 46 + routes/containers/CreateContainerModal.svelte | 1657 +++++++ routes/containers/EditContainerModal.svelte | 1299 +++++ routes/containers/FileBrowserModal.svelte | 39 + routes/containers/FileBrowserPanel.svelte | 1283 +++++ routes/dashboard/DraggableGrid.svelte | 575 +++ routes/dashboard/EnvironmentTile.svelte | 707 +++ .../dashboard/EnvironmentTileSkeleton.svelte | 639 +++ .../dashboard-container-stats.svelte | 150 + .../dashboard-cpu-memory-bars.svelte | 176 + .../dashboard-cpu-memory-charts.svelte | 142 + routes/dashboard/dashboard-disk-usage.svelte | 213 + .../dashboard/dashboard-events-summary.svelte | 21 + routes/dashboard/dashboard-header.svelte | 174 + .../dashboard/dashboard-health-banner.svelte | 27 + routes/dashboard/dashboard-labels.svelte | 45 + .../dashboard/dashboard-offline-state.svelte | 25 + .../dashboard/dashboard-recent-events.svelte | 133 + .../dashboard/dashboard-resource-stats.svelte | 87 + .../dashboard/dashboard-status-icons.svelte | 47 + .../dashboard/dashboard-top-containers.svelte | 86 + routes/dashboard/index.ts | 14 + routes/environments/+page.svelte | 592 +++ routes/images/+page.server.ts | 9 + routes/images/+page.svelte | 1044 ++++ routes/images/ImageHistoryModal.svelte | 37 + routes/images/ImageLayersView.svelte | 288 ++ routes/images/ImagePullProgressPopover.svelte | 369 ++ routes/images/ImageScanModal.svelte | 285 ++ routes/images/PushToRegistryModal.svelte | 292 ++ routes/images/ScanResultsView.svelte | 214 + routes/images/VulnerabilityScanModal.svelte | 756 +++ routes/login/+page.svelte | 320 ++ routes/logs/+page.svelte | 2190 +++++++++ routes/logs/LogViewer.svelte | 321 ++ routes/logs/LogsPanel.svelte | 926 ++++ routes/networks/+page.svelte | 721 +++ routes/networks/ConnectContainerModal.svelte | 172 + routes/networks/CreateNetworkModal.svelte | 633 +++ routes/networks/NetworkInspectModal.svelte | 220 + routes/profile/+page.svelte | 641 +++ routes/profile/ChangePasswordModal.svelte | 133 + routes/profile/DisableMfaModal.svelte | 63 + routes/profile/MfaSetupModal.svelte | 117 + routes/registry/+page.svelte | 633 +++ routes/registry/CopyToRegistryModal.svelte | 497 ++ routes/registry/ImagePullModal.svelte | 230 + routes/schedules/+page.svelte | 1632 +++++++ routes/settings/+page.svelte | 129 + routes/settings/about/AboutTab.svelte | 869 ++++ routes/settings/about/LicenseModal.svelte | 66 + routes/settings/about/PrivacyModal.svelte | 66 + routes/settings/auth/AuthTab.svelte | 326 ++ routes/settings/auth/ldap/LdapModal.svelte | 508 ++ routes/settings/auth/ldap/LdapSubTab.svelte | 320 ++ routes/settings/auth/oidc/OidcModal.svelte | 511 ++ routes/settings/auth/oidc/SsoSubTab.svelte | 289 ++ routes/settings/auth/roles/RoleModal.svelte | 632 +++ routes/settings/auth/roles/RolesSubTab.svelte | 444 ++ routes/settings/auth/users/UserModal.svelte | 598 +++ routes/settings/auth/users/UsersSubTab.svelte | 490 ++ .../config-sets/ConfigSetModal.svelte | 387 ++ .../settings/config-sets/ConfigSetsTab.svelte | 195 + .../environments/EnvironmentModal.svelte | 2530 ++++++++++ .../environments/EnvironmentsTab.svelte | 641 +++ .../environments/EventTypesEditor.svelte | 210 + routes/settings/general/GeneralTab.svelte | 441 ++ routes/settings/git/GitCredentialModal.svelte | 251 + routes/settings/git/GitCredentialsTab.svelte | 176 + routes/settings/git/GitRepositoriesTab.svelte | 244 + routes/settings/git/GitRepositoryModal.svelte | 330 ++ routes/settings/git/GitTab.svelte | 36 + routes/settings/license/LicenseTab.svelte | 257 + .../notifications/NotificationModal.svelte | 479 ++ .../notifications/NotificationsTab.svelte | 278 ++ .../settings/registries/RegistriesTab.svelte | 213 + .../settings/registries/RegistryModal.svelte | 153 + routes/stacks/+page.svelte | 1934 ++++++++ routes/stacks/ComposeGraphViewer.svelte | 3018 ++++++++++++ routes/stacks/GitDeployProgressPopover.svelte | 265 ++ routes/stacks/GitStackModal.svelte | 805 ++++ routes/stacks/StackModal.svelte | 740 +++ routes/terminal/+page.svelte | 349 ++ routes/terminal/Terminal.svelte | 285 ++ routes/terminal/TerminalEmulator.svelte | 343 ++ routes/terminal/TerminalPanel.svelte | 242 + routes/terminal/[id]/+page.svelte | 251 + routes/volumes/+page.svelte | 663 +++ routes/volumes/CloneVolumeModal.svelte | 119 + routes/volumes/CreateVolumeModal.svelte | 295 ++ routes/volumes/VolumeBrowserModal.svelte | 93 + routes/volumes/VolumeInspectModal.svelte | 167 + 552 files changed, 104858 insertions(+) create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 app.css create mode 100644 app.d.ts create mode 100644 app.html create mode 100644 hooks.server.ts create mode 100644 images/logo.webp create mode 100644 lib/.DS_Store create mode 100644 lib/actions/column-resize.ts create mode 100644 lib/assets/favicon.svg create mode 100644 lib/components/AvatarCropper.svelte create mode 100644 lib/components/BatchOperationModal.svelte create mode 100644 lib/components/CodeEditor.svelte create mode 100644 lib/components/ColumnSettingsPopover.svelte create mode 100644 lib/components/CommandPalette.svelte create mode 100644 lib/components/ConfirmPopover.svelte create mode 100644 lib/components/ExecutionLogViewer.svelte create mode 100644 lib/components/MultiSelectFilter.svelte create mode 100644 lib/components/PageHeader.svelte create mode 100644 lib/components/PasswordStrengthIndicator.svelte create mode 100644 lib/components/PullTab.svelte create mode 100644 lib/components/PushTab.svelte create mode 100644 lib/components/ScanTab.svelte create mode 100644 lib/components/ScannerSeverityPills.svelte create mode 100644 lib/components/Sidebar.svelte create mode 100644 lib/components/StackEnvVarsEditor.svelte create mode 100644 lib/components/StackEnvVarsPanel.svelte create mode 100644 lib/components/ThemeSelector.svelte create mode 100644 lib/components/TimezoneSelector.svelte create mode 100644 lib/components/UpdateContainerRow.svelte create mode 100644 lib/components/UpdateStepIndicator.svelte create mode 100644 lib/components/UpdateSummaryStats.svelte create mode 100644 lib/components/VulnerabilityCriteriaBadge.svelte create mode 100644 lib/components/VulnerabilityCriteriaSelector.svelte create mode 100644 lib/components/WhatsNewModal.svelte create mode 100644 lib/components/app-sidebar.svelte create mode 100644 lib/components/cron-editor.svelte create mode 100644 lib/components/data-grid/DataGrid.svelte create mode 100644 lib/components/data-grid/context.ts create mode 100644 lib/components/data-grid/index.ts create mode 100644 lib/components/data-grid/types.ts create mode 100644 lib/components/host-info.svelte create mode 100644 lib/components/icon-picker.svelte create mode 100644 lib/components/main-content.svelte create mode 100644 lib/components/permission-guard.svelte create mode 100644 lib/components/theme-toggle.svelte create mode 100644 lib/components/ui/accordion/accordion-content.svelte create mode 100644 lib/components/ui/accordion/accordion-item.svelte create mode 100644 lib/components/ui/accordion/accordion-trigger.svelte create mode 100644 lib/components/ui/accordion/accordion.svelte create mode 100644 lib/components/ui/accordion/index.ts create mode 100644 lib/components/ui/alert/alert-description.svelte create mode 100644 lib/components/ui/alert/alert-title.svelte create mode 100644 lib/components/ui/alert/alert.svelte create mode 100644 lib/components/ui/alert/index.ts create mode 100644 lib/components/ui/avatar/avatar-fallback.svelte create mode 100644 lib/components/ui/avatar/avatar-image.svelte create mode 100644 lib/components/ui/avatar/avatar.svelte create mode 100644 lib/components/ui/avatar/index.ts create mode 100644 lib/components/ui/badge/badge.svelte create mode 100644 lib/components/ui/badge/index.ts create mode 100644 lib/components/ui/button/button.svelte create mode 100644 lib/components/ui/button/index.ts create mode 100644 lib/components/ui/calendar/calendar-caption.svelte create mode 100644 lib/components/ui/calendar/calendar-cell.svelte create mode 100644 lib/components/ui/calendar/calendar-day.svelte create mode 100644 lib/components/ui/calendar/calendar-grid-body.svelte create mode 100644 lib/components/ui/calendar/calendar-grid-head.svelte create mode 100644 lib/components/ui/calendar/calendar-grid-row.svelte create mode 100644 lib/components/ui/calendar/calendar-grid.svelte create mode 100644 lib/components/ui/calendar/calendar-head-cell.svelte create mode 100644 lib/components/ui/calendar/calendar-header.svelte create mode 100644 lib/components/ui/calendar/calendar-heading.svelte create mode 100644 lib/components/ui/calendar/calendar-month-select.svelte create mode 100644 lib/components/ui/calendar/calendar-month.svelte create mode 100644 lib/components/ui/calendar/calendar-months.svelte create mode 100644 lib/components/ui/calendar/calendar-nav.svelte create mode 100644 lib/components/ui/calendar/calendar-next-button.svelte create mode 100644 lib/components/ui/calendar/calendar-prev-button.svelte create mode 100644 lib/components/ui/calendar/calendar-year-select.svelte create mode 100644 lib/components/ui/calendar/calendar.svelte create mode 100644 lib/components/ui/calendar/index.ts create mode 100644 lib/components/ui/card/card-action.svelte create mode 100644 lib/components/ui/card/card-content.svelte create mode 100644 lib/components/ui/card/card-description.svelte create mode 100644 lib/components/ui/card/card-footer.svelte create mode 100644 lib/components/ui/card/card-header.svelte create mode 100644 lib/components/ui/card/card-title.svelte create mode 100644 lib/components/ui/card/card.svelte create mode 100644 lib/components/ui/card/index.ts create mode 100644 lib/components/ui/checkbox/checkbox.svelte create mode 100644 lib/components/ui/checkbox/index.ts create mode 100644 lib/components/ui/command/command-dialog.svelte create mode 100644 lib/components/ui/command/command-empty.svelte create mode 100644 lib/components/ui/command/command-group.svelte create mode 100644 lib/components/ui/command/command-input.svelte create mode 100644 lib/components/ui/command/command-item.svelte create mode 100644 lib/components/ui/command/command-link-item.svelte create mode 100644 lib/components/ui/command/command-list.svelte create mode 100644 lib/components/ui/command/command-loading.svelte create mode 100644 lib/components/ui/command/command-separator.svelte create mode 100644 lib/components/ui/command/command-shortcut.svelte create mode 100644 lib/components/ui/command/command.svelte create mode 100644 lib/components/ui/command/index.ts create mode 100644 lib/components/ui/date-picker/date-picker.svelte create mode 100644 lib/components/ui/date-picker/index.ts create mode 100644 lib/components/ui/dialog/dialog-close.svelte create mode 100644 lib/components/ui/dialog/dialog-content.svelte create mode 100644 lib/components/ui/dialog/dialog-description.svelte create mode 100644 lib/components/ui/dialog/dialog-footer.svelte create mode 100644 lib/components/ui/dialog/dialog-header.svelte create mode 100644 lib/components/ui/dialog/dialog-overlay.svelte create mode 100644 lib/components/ui/dialog/dialog-portal.svelte create mode 100644 lib/components/ui/dialog/dialog-title.svelte create mode 100644 lib/components/ui/dialog/dialog-trigger.svelte create mode 100644 lib/components/ui/dialog/dialog.svelte create mode 100644 lib/components/ui/dialog/index.ts create mode 100644 lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte create mode 100644 lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte create mode 100644 lib/components/ui/dropdown-menu/dropdown-menu-content.svelte create mode 100644 lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte create mode 100644 lib/components/ui/dropdown-menu/dropdown-menu-group.svelte create mode 100644 lib/components/ui/dropdown-menu/dropdown-menu-item.svelte create mode 100644 lib/components/ui/dropdown-menu/dropdown-menu-label.svelte create mode 100644 lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte create mode 100644 lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte create mode 100644 lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte create mode 100644 lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte create mode 100644 lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte create mode 100644 lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte create mode 100644 lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte create mode 100644 lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte create mode 100644 lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte create mode 100644 lib/components/ui/dropdown-menu/dropdown-menu.svelte create mode 100644 lib/components/ui/dropdown-menu/index.ts create mode 100644 lib/components/ui/empty-state/empty-state.svelte create mode 100644 lib/components/ui/empty-state/index.ts create mode 100644 lib/components/ui/empty-state/no-environment.svelte create mode 100644 lib/components/ui/input/index.ts create mode 100644 lib/components/ui/input/input.svelte create mode 100644 lib/components/ui/label/index.ts create mode 100644 lib/components/ui/label/label.svelte create mode 100644 lib/components/ui/popover/index.ts create mode 100644 lib/components/ui/popover/popover-content.svelte create mode 100644 lib/components/ui/popover/popover-trigger.svelte create mode 100644 lib/components/ui/progress/index.ts create mode 100644 lib/components/ui/progress/progress.svelte create mode 100644 lib/components/ui/select/index.ts create mode 100644 lib/components/ui/select/select-content.svelte create mode 100644 lib/components/ui/select/select-group-heading.svelte create mode 100644 lib/components/ui/select/select-group.svelte create mode 100644 lib/components/ui/select/select-item.svelte create mode 100644 lib/components/ui/select/select-label.svelte create mode 100644 lib/components/ui/select/select-scroll-down-button.svelte create mode 100644 lib/components/ui/select/select-scroll-up-button.svelte create mode 100644 lib/components/ui/select/select-separator.svelte create mode 100644 lib/components/ui/select/select-trigger.svelte create mode 100644 lib/components/ui/separator/index.ts create mode 100644 lib/components/ui/separator/separator.svelte create mode 100644 lib/components/ui/sheet/index.ts create mode 100644 lib/components/ui/sheet/sheet-close.svelte create mode 100644 lib/components/ui/sheet/sheet-content.svelte create mode 100644 lib/components/ui/sheet/sheet-description.svelte create mode 100644 lib/components/ui/sheet/sheet-footer.svelte create mode 100644 lib/components/ui/sheet/sheet-header.svelte create mode 100644 lib/components/ui/sheet/sheet-overlay.svelte create mode 100644 lib/components/ui/sheet/sheet-title.svelte create mode 100644 lib/components/ui/sheet/sheet-trigger.svelte create mode 100644 lib/components/ui/sidebar/constants.ts create mode 100644 lib/components/ui/sidebar/context.svelte.ts create mode 100644 lib/components/ui/sidebar/index.ts create mode 100644 lib/components/ui/sidebar/sidebar-content.svelte create mode 100644 lib/components/ui/sidebar/sidebar-footer.svelte create mode 100644 lib/components/ui/sidebar/sidebar-group-action.svelte create mode 100644 lib/components/ui/sidebar/sidebar-group-content.svelte create mode 100644 lib/components/ui/sidebar/sidebar-group-label.svelte create mode 100644 lib/components/ui/sidebar/sidebar-group.svelte create mode 100644 lib/components/ui/sidebar/sidebar-header.svelte create mode 100644 lib/components/ui/sidebar/sidebar-input.svelte create mode 100644 lib/components/ui/sidebar/sidebar-inset.svelte create mode 100644 lib/components/ui/sidebar/sidebar-menu-action.svelte create mode 100644 lib/components/ui/sidebar/sidebar-menu-badge.svelte create mode 100644 lib/components/ui/sidebar/sidebar-menu-button.svelte create mode 100644 lib/components/ui/sidebar/sidebar-menu-item.svelte create mode 100644 lib/components/ui/sidebar/sidebar-menu-skeleton.svelte create mode 100644 lib/components/ui/sidebar/sidebar-menu-sub-button.svelte create mode 100644 lib/components/ui/sidebar/sidebar-menu-sub-item.svelte create mode 100644 lib/components/ui/sidebar/sidebar-menu-sub.svelte create mode 100644 lib/components/ui/sidebar/sidebar-menu.svelte create mode 100644 lib/components/ui/sidebar/sidebar-provider.svelte create mode 100644 lib/components/ui/sidebar/sidebar-rail.svelte create mode 100644 lib/components/ui/sidebar/sidebar-separator.svelte create mode 100644 lib/components/ui/sidebar/sidebar-trigger.svelte create mode 100644 lib/components/ui/sidebar/sidebar.svelte create mode 100644 lib/components/ui/skeleton/index.ts create mode 100644 lib/components/ui/skeleton/skeleton.svelte create mode 100644 lib/components/ui/sonner/index.ts create mode 100644 lib/components/ui/sonner/sonner.svelte create mode 100644 lib/components/ui/switch/index.ts create mode 100644 lib/components/ui/switch/switch.svelte create mode 100644 lib/components/ui/table/index.ts create mode 100644 lib/components/ui/table/table-body.svelte create mode 100644 lib/components/ui/table/table-caption.svelte create mode 100644 lib/components/ui/table/table-cell.svelte create mode 100644 lib/components/ui/table/table-footer.svelte create mode 100644 lib/components/ui/table/table-head.svelte create mode 100644 lib/components/ui/table/table-header.svelte create mode 100644 lib/components/ui/table/table-row.svelte create mode 100644 lib/components/ui/table/table.svelte create mode 100644 lib/components/ui/tabs/index.ts create mode 100644 lib/components/ui/tabs/tabs-content.svelte create mode 100644 lib/components/ui/tabs/tabs-list.svelte create mode 100644 lib/components/ui/tabs/tabs-trigger.svelte create mode 100644 lib/components/ui/tabs/tabs.svelte create mode 100644 lib/components/ui/textarea/index.ts create mode 100644 lib/components/ui/textarea/textarea.svelte create mode 100644 lib/components/ui/toggle-pill/index.ts create mode 100644 lib/components/ui/toggle-pill/toggle-group.svelte create mode 100644 lib/components/ui/toggle-pill/toggle-pill.svelte create mode 100644 lib/components/ui/toggle-pill/toggle-switch.svelte create mode 100644 lib/components/ui/tooltip/index.ts create mode 100644 lib/components/ui/tooltip/tooltip-content.svelte create mode 100644 lib/components/ui/tooltip/tooltip-trigger.svelte create mode 100644 lib/config/grid-columns.ts create mode 100644 lib/data/changelog.json create mode 100644 lib/data/dependencies.json create mode 100644 lib/hooks/is-mobile.svelte.ts create mode 100644 lib/index.ts create mode 100644 lib/server/.DS_Store create mode 100644 lib/server/audit-events.ts create mode 100644 lib/server/audit.ts create mode 100644 lib/server/auth.ts create mode 100644 lib/server/authorize.ts create mode 100644 lib/server/db.ts create mode 100644 lib/server/db/.DS_Store create mode 100644 lib/server/db/connection.ts create mode 100644 lib/server/db/drizzle.ts create mode 100644 lib/server/db/schema/index.ts create mode 100644 lib/server/db/schema/pg-schema.ts create mode 100644 lib/server/docker.ts create mode 100644 lib/server/event-collector.ts create mode 100644 lib/server/git.ts create mode 100644 lib/server/hawser.ts create mode 100644 lib/server/license.ts create mode 100644 lib/server/metrics-collector.ts create mode 100644 lib/server/notifications.ts create mode 100644 lib/server/scanner.ts create mode 100644 lib/server/scheduler/index.ts create mode 100644 lib/server/scheduler/tasks/container-update.ts create mode 100644 lib/server/scheduler/tasks/env-update-check.ts create mode 100644 lib/server/scheduler/tasks/git-stack-sync.ts create mode 100644 lib/server/scheduler/tasks/system-cleanup.ts create mode 100644 lib/server/scheduler/tasks/update-utils.ts create mode 100644 lib/server/stacks.ts create mode 100644 lib/server/subprocess-manager.ts create mode 100644 lib/server/subprocesses/event-subprocess.ts create mode 100644 lib/server/subprocesses/metrics-subprocess.ts create mode 100644 lib/server/uptime.ts create mode 100644 lib/stores/audit-events.ts create mode 100644 lib/stores/auth.ts create mode 100644 lib/stores/dashboard.ts create mode 100644 lib/stores/environment.ts create mode 100644 lib/stores/events.ts create mode 100644 lib/stores/grid-preferences.ts create mode 100644 lib/stores/license.ts create mode 100644 lib/stores/settings.ts create mode 100644 lib/stores/stats.ts create mode 100644 lib/stores/theme.ts create mode 100644 lib/themes.ts create mode 100644 lib/types.ts create mode 100644 lib/utils.ts create mode 100644 lib/utils/icons.ts create mode 100644 lib/utils/ip.ts create mode 100644 lib/utils/label-colors.ts create mode 100644 lib/utils/update-steps.ts create mode 100644 lib/utils/version.ts create mode 100644 routes/+layout.server.ts create mode 100644 routes/+layout.svelte create mode 100644 routes/+layout.ts create mode 100644 routes/+page.svelte create mode 100644 routes/activity/+page.svelte create mode 100644 routes/alerts/+page.svelte create mode 100644 routes/api/activity/+server.ts create mode 100644 routes/api/activity/containers/+server.ts create mode 100644 routes/api/activity/events/+server.ts create mode 100644 routes/api/activity/stats/+server.ts create mode 100644 routes/api/audit/+server.ts create mode 100644 routes/api/audit/events/+server.ts create mode 100644 routes/api/audit/export/+server.ts create mode 100644 routes/api/audit/users/+server.ts create mode 100644 routes/api/auth/ldap/+server.ts create mode 100644 routes/api/auth/ldap/[id]/+server.ts create mode 100644 routes/api/auth/ldap/[id]/test/+server.ts create mode 100644 routes/api/auth/login/+server.ts create mode 100644 routes/api/auth/logout/+server.ts create mode 100644 routes/api/auth/oidc/+server.ts create mode 100644 routes/api/auth/oidc/[id]/+server.ts create mode 100644 routes/api/auth/oidc/[id]/initiate/+server.ts create mode 100644 routes/api/auth/oidc/[id]/test/+server.ts create mode 100644 routes/api/auth/oidc/callback/+server.ts create mode 100644 routes/api/auth/providers/+server.ts create mode 100644 routes/api/auth/session/+server.ts create mode 100644 routes/api/auth/settings/+server.ts create mode 100644 routes/api/auto-update/+server.ts create mode 100644 routes/api/auto-update/[containerName]/+server.ts create mode 100644 routes/api/batch/+server.ts create mode 100644 routes/api/changelog/+server.ts create mode 100644 routes/api/config-sets/+server.ts create mode 100644 routes/api/config-sets/[id]/+server.ts create mode 100644 routes/api/containers/+server.ts create mode 100644 routes/api/containers/[id]/+server.ts create mode 100644 routes/api/containers/[id]/exec/+server.ts create mode 100644 routes/api/containers/[id]/files/+server.ts create mode 100644 routes/api/containers/[id]/files/chmod/+server.ts create mode 100644 routes/api/containers/[id]/files/content/+server.ts create mode 100644 routes/api/containers/[id]/files/create/+server.ts create mode 100644 routes/api/containers/[id]/files/delete/+server.ts create mode 100644 routes/api/containers/[id]/files/download/+server.ts create mode 100644 routes/api/containers/[id]/files/rename/+server.ts create mode 100644 routes/api/containers/[id]/files/upload/+server.ts create mode 100644 routes/api/containers/[id]/inspect/+server.ts create mode 100644 routes/api/containers/[id]/logs/+server.ts create mode 100644 routes/api/containers/[id]/logs/stream/+server.ts create mode 100644 routes/api/containers/[id]/pause/+server.ts create mode 100644 routes/api/containers/[id]/rename/+server.ts create mode 100644 routes/api/containers/[id]/restart/+server.ts create mode 100644 routes/api/containers/[id]/start/+server.ts create mode 100644 routes/api/containers/[id]/stats/+server.ts create mode 100644 routes/api/containers/[id]/stop/+server.ts create mode 100644 routes/api/containers/[id]/top/+server.ts create mode 100644 routes/api/containers/[id]/unpause/+server.ts create mode 100644 routes/api/containers/[id]/update/+server.ts create mode 100644 routes/api/containers/batch-update-stream/+server.ts create mode 100644 routes/api/containers/batch-update/+server.ts create mode 100644 routes/api/containers/check-updates/+server.ts create mode 100644 routes/api/containers/pending-updates/+server.ts create mode 100644 routes/api/containers/sizes/+server.ts create mode 100644 routes/api/containers/stats/+server.ts create mode 100644 routes/api/dashboard/preferences/+server.ts create mode 100644 routes/api/dashboard/stats/+server.ts create mode 100644 routes/api/dashboard/stats/stream/+server.ts create mode 100644 routes/api/dependencies/+server.ts create mode 100644 routes/api/environments/+server.ts create mode 100644 routes/api/environments/[id]/+server.ts create mode 100644 routes/api/environments/[id]/notifications/+server.ts create mode 100644 routes/api/environments/[id]/notifications/[notificationId]/+server.ts create mode 100644 routes/api/environments/[id]/test/+server.ts create mode 100644 routes/api/environments/[id]/timezone/+server.ts create mode 100644 routes/api/environments/[id]/update-check/+server.ts create mode 100644 routes/api/environments/detect-socket/+server.ts create mode 100644 routes/api/environments/test/+server.ts create mode 100644 routes/api/events/+server.ts create mode 100644 routes/api/git/credentials/+server.ts create mode 100644 routes/api/git/credentials/[id]/+server.ts create mode 100644 routes/api/git/repositories/+server.ts create mode 100644 routes/api/git/repositories/[id]/+server.ts create mode 100644 routes/api/git/repositories/[id]/deploy/+server.ts create mode 100644 routes/api/git/repositories/[id]/sync/+server.ts create mode 100644 routes/api/git/repositories/[id]/test/+server.ts create mode 100644 routes/api/git/repositories/test/+server.ts create mode 100644 routes/api/git/stacks/+server.ts create mode 100644 routes/api/git/stacks/[id]/+server.ts create mode 100644 routes/api/git/stacks/[id]/deploy-stream/+server.ts create mode 100644 routes/api/git/stacks/[id]/deploy/+server.ts create mode 100644 routes/api/git/stacks/[id]/env-files/+server.ts create mode 100644 routes/api/git/stacks/[id]/sync/+server.ts create mode 100644 routes/api/git/stacks/[id]/test/+server.ts create mode 100644 routes/api/git/stacks/[id]/webhook/+server.ts create mode 100644 routes/api/git/webhook/[id]/+server.ts create mode 100644 routes/api/hawser/connect/+server.ts create mode 100644 routes/api/hawser/tokens/+server.ts create mode 100644 routes/api/health/+server.ts create mode 100644 routes/api/health/database/+server.ts create mode 100644 routes/api/host/+server.ts create mode 100644 routes/api/images/+server.ts create mode 100644 routes/api/images/[id]/+server.ts create mode 100644 routes/api/images/[id]/export/+server.ts create mode 100644 routes/api/images/[id]/history/+server.ts create mode 100644 routes/api/images/[id]/tag/+server.ts create mode 100644 routes/api/images/pull/+server.ts create mode 100644 routes/api/images/push/+server.ts create mode 100644 routes/api/images/scan/+server.ts create mode 100644 routes/api/legal/license/+server.ts create mode 100644 routes/api/legal/privacy/+server.ts create mode 100644 routes/api/license/+server.ts create mode 100644 routes/api/logs/merged/+server.ts create mode 100644 routes/api/metrics/+server.ts create mode 100644 routes/api/networks/+server.ts create mode 100644 routes/api/networks/[id]/+server.ts create mode 100644 routes/api/networks/[id]/connect/+server.ts create mode 100644 routes/api/networks/[id]/disconnect/+server.ts create mode 100644 routes/api/networks/[id]/inspect/+server.ts create mode 100644 routes/api/notifications/+server.ts create mode 100644 routes/api/notifications/[id]/+server.ts create mode 100644 routes/api/notifications/[id]/test/+server.ts create mode 100644 routes/api/notifications/test/+server.ts create mode 100644 routes/api/notifications/trigger-test/+server.ts create mode 100644 routes/api/preferences/favorite-groups/+server.ts create mode 100644 routes/api/preferences/favorites/+server.ts create mode 100644 routes/api/preferences/grid/+server.ts create mode 100644 routes/api/profile/+server.ts create mode 100644 routes/api/profile/avatar/+server.ts create mode 100644 routes/api/profile/preferences/+server.ts create mode 100644 routes/api/prune/all/+server.ts create mode 100644 routes/api/prune/containers/+server.ts create mode 100644 routes/api/prune/images/+server.ts create mode 100644 routes/api/prune/networks/+server.ts create mode 100644 routes/api/prune/volumes/+server.ts create mode 100644 routes/api/registries/+server.ts create mode 100644 routes/api/registries/[id]/+server.ts create mode 100644 routes/api/registries/[id]/default/+server.ts create mode 100644 routes/api/registry/catalog/+server.ts create mode 100644 routes/api/registry/image/+server.ts create mode 100644 routes/api/registry/search/+server.ts create mode 100644 routes/api/registry/tags/+server.ts create mode 100644 routes/api/roles/+server.ts create mode 100644 routes/api/roles/[id]/+server.ts create mode 100644 routes/api/schedules/+server.ts create mode 100644 routes/api/schedules/[type]/[id]/+server.ts create mode 100644 routes/api/schedules/[type]/[id]/run/+server.ts create mode 100644 routes/api/schedules/[type]/[id]/toggle/+server.ts create mode 100644 routes/api/schedules/executions/+server.ts create mode 100644 routes/api/schedules/executions/[id]/+server.ts create mode 100644 routes/api/schedules/settings/+server.ts create mode 100644 routes/api/schedules/stream/+server.ts create mode 100644 routes/api/schedules/system/[id]/toggle/+server.ts create mode 100644 routes/api/settings/general/+server.ts create mode 100644 routes/api/settings/scanner/+server.ts create mode 100644 routes/api/stacks/+server.ts create mode 100644 routes/api/stacks/[name]/+server.ts create mode 100644 routes/api/stacks/[name]/compose/+server.ts create mode 100644 routes/api/stacks/[name]/down/+server.ts create mode 100644 routes/api/stacks/[name]/env/+server.ts create mode 100644 routes/api/stacks/[name]/env/validate/+server.ts create mode 100644 routes/api/stacks/[name]/restart/+server.ts create mode 100644 routes/api/stacks/[name]/start/+server.ts create mode 100644 routes/api/stacks/[name]/stop/+server.ts create mode 100644 routes/api/stacks/sources/+server.ts create mode 100644 routes/api/system/+server.ts create mode 100644 routes/api/system/disk/+server.ts create mode 100644 routes/api/users/+server.ts create mode 100644 routes/api/users/[id]/+server.ts create mode 100644 routes/api/users/[id]/mfa/+server.ts create mode 100644 routes/api/users/[id]/roles/+server.ts create mode 100644 routes/api/volumes/+server.ts create mode 100644 routes/api/volumes/[name]/+server.ts create mode 100644 routes/api/volumes/[name]/browse/+server.ts create mode 100644 routes/api/volumes/[name]/browse/content/+server.ts create mode 100644 routes/api/volumes/[name]/browse/release/+server.ts create mode 100644 routes/api/volumes/[name]/clone/+server.ts create mode 100644 routes/api/volumes/[name]/export/+server.ts create mode 100644 routes/api/volumes/[name]/inspect/+server.ts create mode 100644 routes/audit/+page.svelte create mode 100644 routes/audit/+server.ts create mode 100644 routes/audit/users/+server.ts create mode 100644 routes/containers/+page.svelte create mode 100644 routes/containers/AutoUpdateSettings.svelte create mode 100644 routes/containers/BatchUpdateModal.svelte create mode 100644 routes/containers/ContainerInspectModal.svelte create mode 100644 routes/containers/ContainerTerminal.svelte create mode 100644 routes/containers/ContainerTile.svelte create mode 100644 routes/containers/CreateContainerModal.svelte create mode 100644 routes/containers/EditContainerModal.svelte create mode 100644 routes/containers/FileBrowserModal.svelte create mode 100644 routes/containers/FileBrowserPanel.svelte create mode 100644 routes/dashboard/DraggableGrid.svelte create mode 100644 routes/dashboard/EnvironmentTile.svelte create mode 100644 routes/dashboard/EnvironmentTileSkeleton.svelte create mode 100644 routes/dashboard/dashboard-container-stats.svelte create mode 100644 routes/dashboard/dashboard-cpu-memory-bars.svelte create mode 100644 routes/dashboard/dashboard-cpu-memory-charts.svelte create mode 100644 routes/dashboard/dashboard-disk-usage.svelte create mode 100644 routes/dashboard/dashboard-events-summary.svelte create mode 100644 routes/dashboard/dashboard-header.svelte create mode 100644 routes/dashboard/dashboard-health-banner.svelte create mode 100644 routes/dashboard/dashboard-labels.svelte create mode 100644 routes/dashboard/dashboard-offline-state.svelte create mode 100644 routes/dashboard/dashboard-recent-events.svelte create mode 100644 routes/dashboard/dashboard-resource-stats.svelte create mode 100644 routes/dashboard/dashboard-status-icons.svelte create mode 100644 routes/dashboard/dashboard-top-containers.svelte create mode 100644 routes/dashboard/index.ts create mode 100644 routes/environments/+page.svelte create mode 100644 routes/images/+page.server.ts create mode 100644 routes/images/+page.svelte create mode 100644 routes/images/ImageHistoryModal.svelte create mode 100644 routes/images/ImageLayersView.svelte create mode 100644 routes/images/ImagePullProgressPopover.svelte create mode 100644 routes/images/ImageScanModal.svelte create mode 100644 routes/images/PushToRegistryModal.svelte create mode 100644 routes/images/ScanResultsView.svelte create mode 100644 routes/images/VulnerabilityScanModal.svelte create mode 100644 routes/login/+page.svelte create mode 100644 routes/logs/+page.svelte create mode 100644 routes/logs/LogViewer.svelte create mode 100644 routes/logs/LogsPanel.svelte create mode 100644 routes/networks/+page.svelte create mode 100644 routes/networks/ConnectContainerModal.svelte create mode 100644 routes/networks/CreateNetworkModal.svelte create mode 100644 routes/networks/NetworkInspectModal.svelte create mode 100644 routes/profile/+page.svelte create mode 100644 routes/profile/ChangePasswordModal.svelte create mode 100644 routes/profile/DisableMfaModal.svelte create mode 100644 routes/profile/MfaSetupModal.svelte create mode 100644 routes/registry/+page.svelte create mode 100644 routes/registry/CopyToRegistryModal.svelte create mode 100644 routes/registry/ImagePullModal.svelte create mode 100644 routes/schedules/+page.svelte create mode 100644 routes/settings/+page.svelte create mode 100644 routes/settings/about/AboutTab.svelte create mode 100644 routes/settings/about/LicenseModal.svelte create mode 100644 routes/settings/about/PrivacyModal.svelte create mode 100644 routes/settings/auth/AuthTab.svelte create mode 100644 routes/settings/auth/ldap/LdapModal.svelte create mode 100644 routes/settings/auth/ldap/LdapSubTab.svelte create mode 100644 routes/settings/auth/oidc/OidcModal.svelte create mode 100644 routes/settings/auth/oidc/SsoSubTab.svelte create mode 100644 routes/settings/auth/roles/RoleModal.svelte create mode 100644 routes/settings/auth/roles/RolesSubTab.svelte create mode 100644 routes/settings/auth/users/UserModal.svelte create mode 100644 routes/settings/auth/users/UsersSubTab.svelte create mode 100644 routes/settings/config-sets/ConfigSetModal.svelte create mode 100644 routes/settings/config-sets/ConfigSetsTab.svelte create mode 100644 routes/settings/environments/EnvironmentModal.svelte create mode 100644 routes/settings/environments/EnvironmentsTab.svelte create mode 100644 routes/settings/environments/EventTypesEditor.svelte create mode 100644 routes/settings/general/GeneralTab.svelte create mode 100644 routes/settings/git/GitCredentialModal.svelte create mode 100644 routes/settings/git/GitCredentialsTab.svelte create mode 100644 routes/settings/git/GitRepositoriesTab.svelte create mode 100644 routes/settings/git/GitRepositoryModal.svelte create mode 100644 routes/settings/git/GitTab.svelte create mode 100644 routes/settings/license/LicenseTab.svelte create mode 100644 routes/settings/notifications/NotificationModal.svelte create mode 100644 routes/settings/notifications/NotificationsTab.svelte create mode 100644 routes/settings/registries/RegistriesTab.svelte create mode 100644 routes/settings/registries/RegistryModal.svelte create mode 100644 routes/stacks/+page.svelte create mode 100644 routes/stacks/ComposeGraphViewer.svelte create mode 100644 routes/stacks/GitDeployProgressPopover.svelte create mode 100644 routes/stacks/GitStackModal.svelte create mode 100644 routes/stacks/StackModal.svelte create mode 100644 routes/terminal/+page.svelte create mode 100644 routes/terminal/Terminal.svelte create mode 100644 routes/terminal/TerminalEmulator.svelte create mode 100644 routes/terminal/TerminalPanel.svelte create mode 100644 routes/terminal/[id]/+page.svelte create mode 100644 routes/volumes/+page.svelte create mode 100644 routes/volumes/CloneVolumeModal.svelte create mode 100644 routes/volumes/CreateVolumeModal.svelte create mode 100644 routes/volumes/VolumeBrowserModal.svelte create mode 100644 routes/volumes/VolumeInspectModal.svelte diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..86472ee --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,128 @@ +Business Source License 1.1 + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +"Business Source License" is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Parameters + +Licensor: Finsys / Jarek Krochmalski + +Licensed Work: Dockhand + The Licensed Work is (c) 2025-2026 Finsys / Jarek Krochmalski. + +Additional Use Grant: You may use the Licensed Work for any purpose, including + production use, provided that you do not offer the Licensed + Work, or any derivative work of the Licensed Work, to third + parties as a commercial hosted service, managed service, or + software-as-a-service (SaaS) offering where the primary value + proposition to users is Docker container management + functionality substantially similar to the Licensed Work. + + For clarity, the following uses are explicitly permitted + without any restriction: + + (a) Personal use, including home labs and hobby projects + (b) Internal business use within your organization, regardless + of the number of Docker environments managed + (c) Use by non-profit organizations and charitable entities + (d) Educational, academic, and research purposes + (e) Evaluation, testing, development, and demonstration purposes + (f) Embedding or integrating the Licensed Work into internal + tools or platforms that are not offered commercially to + third parties + (g) Use by managed service providers (MSPs) to manage Docker + infrastructure on behalf of their clients, provided the + MSP does not offer Dockhand itself as the service + +Change Date: January 1, 2029 + +Change License: Apache License, Version 2.0 + +----------------------------------------------------------------------------- + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License's text to license +your works, and to refer to it using the trademark "Business Source License", +as long as you comply with the Covenants of Licensor below. + +----------------------------------------------------------------------------- + +Covenants of Licensor + +In consideration of the right to use this License's text and the "Business +Source License" name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where "compatible" means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text "None". + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Notice + +The Business Source License (this document, or the "License") is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +----------------------------------------------------------------------------- + +For licensing inquiries, commercial licensing, or enterprise features: + + Website: https://dockhand.io + +----------------------------------------------------------------------------- diff --git a/README.md b/README.md new file mode 100644 index 0000000..18a5ee4 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Dockhand diff --git a/app.css b/app.css new file mode 100644 index 0000000..bb9cf72 --- /dev/null +++ b/app.css @@ -0,0 +1,1717 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@source "../node_modules/layerchart/**/*.svelte"; + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme { + --radius: 0.5rem; + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + + --color-sidebar: hsl(var(--sidebar-background)); + --color-sidebar-foreground: hsl(var(--sidebar-foreground)); + --color-sidebar-primary: hsl(var(--sidebar-primary)); + --color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground)); + --color-sidebar-accent: hsl(var(--sidebar-accent)); + --color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground)); + --color-sidebar-border: hsl(var(--sidebar-border)); + --color-sidebar-ring: hsl(var(--sidebar-ring)); +} + +/* Font size scaling - set dynamically via JavaScript */ +html { + font-size: calc(16px * var(--font-size-scale, 1)); +} + +/* Grid/table font size scaling - uses px to be independent of global font size */ +.data-grid, +.data-grid th, +.data-grid td, +.data-grid span, +.data-grid div, +.data-grid p, +.data-grid button, +.data-grid code { + font-size: calc(12px * var(--grid-font-size-scale, 1)) !important; +} + +/* State badge - width scales with grid font size to stay consistent */ +.state-badge { + width: calc(70px * var(--grid-font-size-scale, 1)); + min-width: calc(70px * var(--grid-font-size-scale, 1)); + max-width: calc(70px * var(--grid-font-size-scale, 1)); + text-align: center; +} + +/* State column - width scales with grid font size */ +.data-grid th.state-col, +.data-grid td.state-col { + width: calc(90px * var(--grid-font-size-scale, 1)) !important; + min-width: calc(90px * var(--grid-font-size-scale, 1)) !important; + max-width: calc(90px * var(--grid-font-size-scale, 1)) !important; +} + +:root { + --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; +} + +.dark { + --background: 214.4 61.4% 17.3%; + --foreground: 210 40% 98%; + --card: 214.4 61.4% 17.3%; + --card-foreground: 210 40% 98%; + --popover: 214.4 61.4% 17.3%; + --popover-foreground: 210 40% 98%; + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 214 50% 23%; + --secondary-foreground: 210 40% 98%; + --muted: 214 50% 23%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 214 50% 23%; + --accent-foreground: 210 40% 98%; + --destructive: 0 91% 71%; + --destructive-foreground: 210 40% 98%; + --border: 214 50% 25%; + --input: 214 50% 25%; + --ring: 224.3 76.3% 48%; + + --sidebar-background: 214.4 50% 18%; + --sidebar-foreground: 210 40% 98%; + --sidebar-primary: 217.2 91.2% 59.8%; + --sidebar-primary-foreground: 222.2 47.4% 11.2%; + --sidebar-accent: 214 50% 20%; + --sidebar-accent-foreground: 210 40% 98%; + --sidebar-border: 214 50% 22%; + --sidebar-ring: 217.2 91.2% 59.8%; +} + +/* ============================================================================= + LIGHT THEMES - Official color palettes from theme specifications + ============================================================================= */ + +/* Catppuccin Latte - https://catppuccin.com/palette/ */ +.theme-light-catppuccin { + --background: 220 23% 95%; /* #eff1f5 Base */ + --foreground: 234 16% 35%; /* #4c4f69 Text */ + --card: 220 21% 98%; /* #fffaf3 Surface */ + --card-foreground: 234 16% 35%; + --popover: 220 21% 98%; + --popover-foreground: 234 16% 35%; + --primary: 266 85% 58%; /* #8839ef Mauve */ + --primary-foreground: 220 23% 98%; + --secondary: 223 16% 84%; /* #ccd0da Surface0 */ + --secondary-foreground: 234 16% 35%; + --muted: 223 16% 84%; + --muted-foreground: 233 10% 47%; /* #6c6f85 Subtext0 */ + --accent: 220 22% 87%; + --accent-foreground: 234 16% 35%; + --destructive: 347 87% 44%; /* #d20f39 Red */ + --destructive-foreground: 220 23% 98%; + --border: 223 16% 83%; + --input: 223 16% 83%; + --ring: 266 85% 58%; + --sidebar-background: 227 15% 90%; /* #e6e9ef Mantle */ + --sidebar-foreground: 234 16% 35%; + --sidebar-primary: 266 85% 58%; + --sidebar-primary-foreground: 220 23% 98%; + --sidebar-accent: 220 22% 85%; + --sidebar-accent-foreground: 234 16% 35%; + --sidebar-border: 223 16% 80%; + --sidebar-ring: 266 85% 58%; +} + +/* Rose Pine Dawn - https://rosepinetheme.com/palette/ */ +.theme-light-rose-pine { + --background: 32 57% 95%; /* #faf4ed Base */ + --foreground: 248 19% 40%; /* #575279 Text */ + --card: 39 100% 98%; /* #fffaf3 Surface */ + --card-foreground: 248 19% 40%; + --popover: 39 100% 98%; + --popover-foreground: 248 19% 40%; + --primary: 267 22% 57%; /* #907aa9 Iris */ + --primary-foreground: 32 57% 95%; + --secondary: 35 32% 92%; /* #f2e9e1 Overlay */ + --secondary-foreground: 248 19% 40%; + --muted: 35 32% 92%; + --muted-foreground: 250 12% 49%; /* #797593 Subtle */ + --accent: 33 35% 89%; + --accent-foreground: 248 19% 40%; + --destructive: 343 35% 55%; /* #b4637a Love */ + --destructive-foreground: 32 57% 95%; + --border: 33 24% 86%; + --input: 33 24% 86%; + --ring: 267 22% 57%; + --sidebar-background: 30 27% 94%; /* #f4ede8 Highlight Low */ + --sidebar-foreground: 248 19% 40%; + --sidebar-primary: 267 22% 57%; + --sidebar-primary-foreground: 32 57% 95%; + --sidebar-accent: 33 24% 88%; + --sidebar-accent-foreground: 248 19% 40%; + --sidebar-border: 33 24% 84%; + --sidebar-ring: 267 22% 57%; +} + +/* Nord Light - https://www.nordtheme.com/ */ +.theme-light-nord { + --background: 219 28% 96%; /* #eceff4 Nord6 */ + --foreground: 220 16% 22%; /* #2e3440 Nord0 */ + --card: 218 27% 94%; /* #e5e9f0 Nord5 */ + --card-foreground: 220 16% 22%; + --popover: 218 27% 94%; + --popover-foreground: 220 16% 22%; + --primary: 213 32% 52%; /* #5e81ac Nord10 */ + --primary-foreground: 219 28% 96%; + --secondary: 218 27% 88%; /* #d8dee9 Nord4 */ + --secondary-foreground: 220 16% 22%; + --muted: 218 27% 88%; + --muted-foreground: 220 17% 32%; /* #434c5e Nord2 */ + --accent: 218 27% 84%; + --accent-foreground: 220 16% 22%; + --destructive: 354 42% 56%; /* #bf616a Nord11 */ + --destructive-foreground: 219 28% 96%; + --border: 218 27% 85%; + --input: 218 27% 85%; + --ring: 213 32% 52%; + --sidebar-background: 218 27% 92%; + --sidebar-foreground: 220 16% 22%; + --sidebar-primary: 213 32% 52%; + --sidebar-primary-foreground: 219 28% 96%; + --sidebar-accent: 218 27% 86%; + --sidebar-accent-foreground: 220 16% 22%; + --sidebar-border: 218 27% 83%; + --sidebar-ring: 213 32% 52%; +} + +/* Solarized Light - https://ethanschoonover.com/solarized/ */ +.theme-light-solarized { + --background: 44 87% 94%; /* #fdf6e3 Base3 */ + --foreground: 192 81% 14%; /* #073642 Base02 */ + --card: 44 87% 97%; + --card-foreground: 192 81% 14%; + --popover: 44 87% 97%; + --popover-foreground: 192 81% 14%; + --primary: 205 69% 49%; /* #268bd2 Blue */ + --primary-foreground: 44 87% 97%; + --secondary: 46 42% 88%; /* #eee8d5 Base2 */ + --secondary-foreground: 192 81% 14%; + --muted: 46 42% 88%; + --muted-foreground: 194 14% 40%; /* #657b83 Base00 */ + --accent: 46 42% 84%; + --accent-foreground: 192 81% 14%; + --destructive: 1 71% 52%; /* #dc322f Red */ + --destructive-foreground: 44 87% 97%; + --border: 46 42% 84%; + --input: 46 42% 84%; + --ring: 205 69% 49%; + --sidebar-background: 46 42% 90%; + --sidebar-foreground: 192 81% 14%; + --sidebar-primary: 205 69% 49%; + --sidebar-primary-foreground: 44 87% 97%; + --sidebar-accent: 46 42% 82%; + --sidebar-accent-foreground: 192 81% 14%; + --sidebar-border: 46 42% 80%; + --sidebar-ring: 205 69% 49%; +} + +/* Gruvbox Light - https://github.com/morhetz/gruvbox */ +.theme-light-gruvbox { + --background: 48 87% 94%; /* #fbf1c7 bg0 */ + --foreground: 0 0% 16%; /* #282828 fg0 */ + --card: 49 87% 96%; + --card-foreground: 0 0% 16%; + --popover: 49 87% 96%; + --popover-foreground: 0 0% 16%; + --primary: 189 48% 40%; /* #458588 Aqua */ + --primary-foreground: 48 87% 94%; + --secondary: 48 45% 85%; /* #ebdbb2 bg1 */ + --secondary-foreground: 0 0% 16%; + --muted: 48 45% 85%; + --muted-foreground: 0 0% 36%; + --accent: 48 45% 80%; + --accent-foreground: 0 0% 16%; + --destructive: 6 96% 40%; /* #cc241d Red */ + --destructive-foreground: 48 87% 94%; + --border: 48 45% 80%; + --input: 48 45% 80%; + --ring: 189 48% 40%; + --sidebar-background: 48 45% 90%; + --sidebar-foreground: 0 0% 16%; + --sidebar-primary: 189 48% 40%; + --sidebar-primary-foreground: 48 87% 94%; + --sidebar-accent: 48 45% 82%; + --sidebar-accent-foreground: 0 0% 16%; + --sidebar-border: 48 45% 78%; + --sidebar-ring: 189 48% 40%; +} + +/* Alucard (Dracula Light) - https://draculatheme.com/spec */ +.theme-light-alucard { + --background: 47 100% 96%; /* #fffbeb Background */ + --foreground: 0 0% 12%; /* #1f1f1f Foreground */ + --card: 47 100% 98%; + --card-foreground: 0 0% 12%; + --popover: 47 100% 98%; + --popover-foreground: 0 0% 12%; + --primary: 253 63% 54%; /* #644ac9 Purple */ + --primary-foreground: 47 100% 96%; + --secondary: 240 13% 87%; /* #cfcfde Selection */ + --secondary-foreground: 0 0% 12%; + --muted: 240 13% 87%; + --muted-foreground: 45 17% 36%; /* #6c664b Comment */ + --accent: 240 13% 82%; + --accent-foreground: 0 0% 12%; + --destructive: 8 71% 48%; /* #cb3a2a Red */ + --destructive-foreground: 47 100% 96%; + --border: 240 13% 82%; + --input: 240 13% 82%; + --ring: 253 63% 54%; + --sidebar-background: 47 100% 93%; + --sidebar-foreground: 0 0% 12%; + --sidebar-primary: 253 63% 54%; + --sidebar-primary-foreground: 47 100% 96%; + --sidebar-accent: 240 13% 85%; + --sidebar-accent-foreground: 0 0% 12%; + --sidebar-border: 240 13% 80%; + --sidebar-ring: 253 63% 54%; +} + +/* GitHub Light - https://primer.style/primitives/colors */ +.theme-light-github { + --background: 210 17% 98%; /* #f6f8fa */ + --foreground: 210 12% 16%; /* #24292f */ + --card: 0 0% 100%; /* #ffffff */ + --card-foreground: 210 12% 16%; + --popover: 0 0% 100%; + --popover-foreground: 210 12% 16%; + --primary: 212 92% 45%; /* #0969da */ + --primary-foreground: 0 0% 100%; + --secondary: 210 14% 93%; /* #eaeef2 */ + --secondary-foreground: 210 12% 16%; + --muted: 210 14% 93%; + --muted-foreground: 210 9% 40%; /* #57606a */ + --accent: 210 14% 89%; + --accent-foreground: 210 12% 16%; + --destructive: 354 70% 44%; /* #cf222e */ + --destructive-foreground: 0 0% 100%; + --border: 210 14% 89%; + --input: 210 14% 89%; + --ring: 212 92% 45%; + --sidebar-background: 210 17% 95%; + --sidebar-foreground: 210 12% 16%; + --sidebar-primary: 212 92% 45%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 210 14% 91%; + --sidebar-accent-foreground: 210 12% 16%; + --sidebar-border: 210 14% 87%; + --sidebar-ring: 212 92% 45%; +} + +/* Material Light - https://material.io/design/color */ +.theme-light-material { + --background: 0 0% 98%; /* #fafafa */ + --foreground: 0 0% 13%; /* #212121 */ + --card: 0 0% 100%; /* #ffffff */ + --card-foreground: 0 0% 13%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 13%; + --primary: 187 100% 42%; /* #00acc1 Cyan 600 */ + --primary-foreground: 0 0% 100%; + --secondary: 0 0% 93%; /* #eeeeee */ + --secondary-foreground: 0 0% 13%; + --muted: 0 0% 93%; + --muted-foreground: 0 0% 46%; /* #757575 */ + --accent: 0 0% 88%; + --accent-foreground: 0 0% 13%; + --destructive: 4 90% 58%; /* #f44336 Red 500 */ + --destructive-foreground: 0 0% 100%; + --border: 0 0% 88%; + --input: 0 0% 88%; + --ring: 187 100% 42%; + --sidebar-background: 0 0% 96%; + --sidebar-foreground: 0 0% 13%; + --sidebar-primary: 187 100% 42%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 0 0% 90%; + --sidebar-accent-foreground: 0 0% 13%; + --sidebar-border: 0 0% 85%; + --sidebar-ring: 187 100% 42%; +} + +/* Atom One Light - https://github.com/atom/atom/tree/master/packages/one-light-syntax */ +.theme-light-atom-one { + --background: 230 8% 98%; /* #fafafa */ + --foreground: 230 8% 24%; /* #383a42 */ + --card: 0 0% 100%; + --card-foreground: 230 8% 24%; + --popover: 0 0% 100%; + --popover-foreground: 230 8% 24%; + --primary: 230 100% 66%; /* #4078f2 */ + --primary-foreground: 0 0% 100%; + --secondary: 230 8% 93%; /* #e5e5e6 */ + --secondary-foreground: 230 8% 24%; + --muted: 230 8% 93%; + --muted-foreground: 230 4% 50%; /* #a0a1a7 */ + --accent: 230 8% 88%; + --accent-foreground: 230 8% 24%; + --destructive: 5 74% 59%; /* #e45649 */ + --destructive-foreground: 0 0% 100%; + --border: 230 8% 88%; + --input: 230 8% 88%; + --ring: 230 100% 66%; + --sidebar-background: 230 8% 95%; + --sidebar-foreground: 230 8% 24%; + --sidebar-primary: 230 100% 66%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 230 8% 90%; + --sidebar-accent-foreground: 230 8% 24%; + --sidebar-border: 230 8% 85%; + --sidebar-ring: 230 100% 66%; +} + +/* ============================================================================= + DARK THEMES - Official color palettes from theme specifications + ============================================================================= */ + +/* Catppuccin Mocha - https://catppuccin.com/palette/ */ +.dark.theme-dark-catppuccin { + --background: 240 21% 15%; /* #1e1e2e Base */ + --foreground: 226 64% 88%; /* #cdd6f4 Text */ + --card: 240 21% 17%; /* #313244 Surface0 */ + --card-foreground: 226 64% 88%; + --popover: 240 21% 17%; + --popover-foreground: 226 64% 88%; + --primary: 267 84% 81%; /* #cba6f7 Mauve */ + --primary-foreground: 240 21% 15%; + --secondary: 237 16% 23%; /* #313244 Surface0 */ + --secondary-foreground: 226 64% 88%; + --muted: 237 16% 23%; + --muted-foreground: 228 24% 72%; /* #a6adc8 Subtext0 */ + --accent: 240 21% 24%; + --accent-foreground: 226 64% 88%; + --destructive: 343 81% 75%; /* #f38ba8 Red */ + --destructive-foreground: 226 64% 88%; + --border: 237 16% 25%; + --input: 237 16% 25%; + --ring: 267 84% 81%; + --sidebar-background: 240 23% 9%; /* #11111b Crust */ + --sidebar-foreground: 226 64% 88%; + --sidebar-primary: 267 84% 81%; + --sidebar-primary-foreground: 240 21% 15%; + --sidebar-accent: 240 21% 16%; + --sidebar-accent-foreground: 226 64% 88%; + --sidebar-border: 237 16% 20%; + --sidebar-ring: 267 84% 81%; +} + +/* Dracula - https://draculatheme.com/spec */ +.dark.theme-dark-dracula { + --background: 231 15% 18%; /* #282a36 Background */ + --foreground: 60 30% 96%; /* #f8f8f2 Foreground */ + --card: 232 14% 21%; /* #343746 Floating */ + --card-foreground: 60 30% 96%; + --popover: 232 14% 21%; + --popover-foreground: 60 30% 96%; + --primary: 265 89% 78%; /* #bd93f9 Purple */ + --primary-foreground: 231 15% 18%; + --secondary: 232 14% 31%; /* #44475a Current Line */ + --secondary-foreground: 60 30% 96%; + --muted: 232 14% 31%; + --muted-foreground: 225 14% 51%; /* #6272a4 Comment */ + --accent: 232 14% 28%; + --accent-foreground: 60 30% 96%; + --destructive: 0 100% 67%; /* #ff5555 Red */ + --destructive-foreground: 60 30% 96%; + --border: 232 14% 28%; + --input: 232 14% 28%; + --ring: 265 89% 78%; + --sidebar-background: 231 15% 14%; /* #191a21 Background Darker */ + --sidebar-foreground: 60 30% 96%; + --sidebar-primary: 265 89% 78%; + --sidebar-primary-foreground: 231 15% 18%; + --sidebar-accent: 232 14% 22%; + --sidebar-accent-foreground: 60 30% 96%; + --sidebar-border: 232 14% 24%; + --sidebar-ring: 265 89% 78%; +} + +/* Rose Pine - https://rosepinetheme.com/palette/ */ +.dark.theme-dark-rose-pine { + --background: 249 22% 12%; /* #191724 Base */ + --foreground: 245 50% 91%; /* #e0def4 Text */ + --card: 247 23% 15%; /* #1f1d2e Surface */ + --card-foreground: 245 50% 91%; + --popover: 247 23% 15%; + --popover-foreground: 245 50% 91%; + --primary: 267 57% 78%; /* #c4a7e7 Iris */ + --primary-foreground: 249 22% 12%; + --secondary: 248 25% 18%; /* #26233a Overlay */ + --secondary-foreground: 245 50% 91%; + --muted: 248 25% 18%; + --muted-foreground: 249 12% 52%; /* #6e6a86 Muted */ + --accent: 248 25% 22%; + --accent-foreground: 245 50% 91%; + --destructive: 343 76% 68%; /* #eb6f92 Love */ + --destructive-foreground: 245 50% 91%; + --border: 248 25% 22%; + --input: 248 25% 22%; + --ring: 267 57% 78%; + --sidebar-background: 249 22% 10%; + --sidebar-foreground: 245 50% 91%; + --sidebar-primary: 267 57% 78%; + --sidebar-primary-foreground: 249 22% 12%; + --sidebar-accent: 247 23% 16%; + --sidebar-accent-foreground: 245 50% 91%; + --sidebar-border: 248 25% 18%; + --sidebar-ring: 267 57% 78%; +} + +/* Rose Pine Moon - https://rosepinetheme.com/palette/ */ +.dark.theme-dark-rose-pine-moon { + --background: 246 24% 17%; /* #232136 Base */ + --foreground: 245 50% 91%; /* #e0def4 Text */ + --card: 248 24% 20%; /* #2a273f Surface */ + --card-foreground: 245 50% 91%; + --popover: 248 24% 20%; + --popover-foreground: 245 50% 91%; + --primary: 267 57% 78%; /* #c4a7e7 Iris */ + --primary-foreground: 246 24% 17%; + --secondary: 249 22% 26%; /* #393552 Overlay */ + --secondary-foreground: 245 50% 91%; + --muted: 249 22% 26%; + --muted-foreground: 249 12% 52%; /* #6e6a86 Muted */ + --accent: 249 22% 30%; + --accent-foreground: 245 50% 91%; + --destructive: 343 76% 68%; /* #eb6f92 Love */ + --destructive-foreground: 245 50% 91%; + --border: 249 22% 28%; + --input: 249 22% 28%; + --ring: 267 57% 78%; + --sidebar-background: 246 24% 14%; + --sidebar-foreground: 245 50% 91%; + --sidebar-primary: 267 57% 78%; + --sidebar-primary-foreground: 246 24% 17%; + --sidebar-accent: 248 24% 21%; + --sidebar-accent-foreground: 245 50% 91%; + --sidebar-border: 249 22% 24%; + --sidebar-ring: 267 57% 78%; +} + +/* Tokyo Night - https://github.com/tokyo-night/tokyo-night-vscode-theme */ +.dark.theme-dark-tokyo-night { + --background: 235 21% 14%; /* #1a1b26 Background */ + --foreground: 227 16% 73%; /* #a9b1d6 Foreground */ + --card: 234 22% 17%; /* #24283b Storm Background */ + --card-foreground: 227 16% 73%; + --popover: 234 22% 17%; + --popover-foreground: 227 16% 73%; + --primary: 220 91% 72%; /* #7aa2f7 Blue */ + --primary-foreground: 235 21% 14%; + --secondary: 234 22% 22%; + --secondary-foreground: 227 16% 73%; + --muted: 234 22% 22%; + --muted-foreground: 221 17% 52%; /* #565f89 Comment */ + --accent: 234 22% 26%; + --accent-foreground: 227 16% 73%; + --destructive: 348 86% 70%; /* #f7768e Red */ + --destructive-foreground: 227 16% 73%; + --border: 234 22% 26%; + --input: 234 22% 26%; + --ring: 220 91% 72%; + --sidebar-background: 235 21% 11%; + --sidebar-foreground: 227 16% 73%; + --sidebar-primary: 220 91% 72%; + --sidebar-primary-foreground: 235 21% 14%; + --sidebar-accent: 234 22% 18%; + --sidebar-accent-foreground: 227 16% 73%; + --sidebar-border: 234 22% 20%; + --sidebar-ring: 220 91% 72%; +} + +/* Nord Dark - https://www.nordtheme.com/ */ +.dark.theme-dark-nord { + --background: 220 16% 22%; /* #2e3440 Nord0 */ + --foreground: 219 28% 88%; /* #d8dee9 Nord4 */ + --card: 222 16% 26%; /* #3b4252 Nord1 */ + --card-foreground: 219 28% 88%; + --popover: 222 16% 26%; + --popover-foreground: 219 28% 88%; + --primary: 210 34% 63%; /* #81a1c1 Nord9 */ + --primary-foreground: 220 16% 22%; + --secondary: 220 17% 32%; /* #434c5e Nord2 */ + --secondary-foreground: 219 28% 88%; + --muted: 220 17% 32%; + --muted-foreground: 219 14% 64%; /* #8fbcbb Nord7 */ + --accent: 220 16% 36%; + --accent-foreground: 219 28% 88%; + --destructive: 354 42% 56%; /* #bf616a Nord11 */ + --destructive-foreground: 219 28% 88%; + --border: 220 16% 30%; + --input: 220 16% 30%; + --ring: 210 34% 63%; + --sidebar-background: 220 16% 18%; + --sidebar-foreground: 219 28% 88%; + --sidebar-primary: 210 34% 63%; + --sidebar-primary-foreground: 220 16% 22%; + --sidebar-accent: 222 16% 24%; + --sidebar-accent-foreground: 219 28% 88%; + --sidebar-border: 220 16% 26%; + --sidebar-ring: 210 34% 63%; +} + +/* One Dark - https://github.com/atom/one-dark-syntax */ +.dark.theme-dark-one-dark { + --background: 220 13% 18%; /* #282c34 Background */ + --foreground: 220 14% 71%; /* #abb2bf Foreground */ + --card: 220 13% 21%; + --card-foreground: 220 14% 71%; + --popover: 220 13% 21%; + --popover-foreground: 220 14% 71%; + --primary: 207 82% 66%; /* #61afef Blue */ + --primary-foreground: 220 13% 18%; + --secondary: 220 13% 25%; + --secondary-foreground: 220 14% 71%; + --muted: 220 13% 25%; + --muted-foreground: 220 9% 46%; /* #5c6370 Comment */ + --accent: 220 13% 28%; + --accent-foreground: 220 14% 71%; + --destructive: 355 65% 65%; /* #e06c75 Red */ + --destructive-foreground: 220 14% 71%; + --border: 220 13% 28%; + --input: 220 13% 28%; + --ring: 207 82% 66%; + --sidebar-background: 220 13% 15%; + --sidebar-foreground: 220 14% 71%; + --sidebar-primary: 207 82% 66%; + --sidebar-primary-foreground: 220 13% 18%; + --sidebar-accent: 220 13% 22%; + --sidebar-accent-foreground: 220 14% 71%; + --sidebar-border: 220 13% 24%; + --sidebar-ring: 207 82% 66%; +} + +/* Gruvbox Dark - https://github.com/morhetz/gruvbox */ +.dark.theme-dark-gruvbox { + --background: 0 0% 16%; /* #282828 bg0 */ + --foreground: 47 73% 85%; /* #ebdbb2 fg1 */ + --card: 10 6% 18%; /* #3c3836 bg1 */ + --card-foreground: 47 73% 85%; + --popover: 10 6% 18%; + --popover-foreground: 47 73% 85%; + --primary: 61 66% 44%; /* #b8bb26 Green */ + --primary-foreground: 0 0% 16%; + --secondary: 10 6% 22%; /* #504945 bg2 */ + --secondary-foreground: 47 73% 85%; + --muted: 10 6% 22%; + --muted-foreground: 36 33% 64%; /* #a89984 fg4 */ + --accent: 10 6% 26%; + --accent-foreground: 47 73% 85%; + --destructive: 0 73% 59%; /* #fb4934 Red */ + --destructive-foreground: 47 73% 85%; + --border: 10 6% 26%; + --input: 10 6% 26%; + --ring: 61 66% 44%; + --sidebar-background: 0 0% 13%; /* #1d2021 bg0_h */ + --sidebar-foreground: 47 73% 85%; + --sidebar-primary: 61 66% 44%; + --sidebar-primary-foreground: 0 0% 16%; + --sidebar-accent: 10 6% 20%; + --sidebar-accent-foreground: 47 73% 85%; + --sidebar-border: 10 6% 22%; + --sidebar-ring: 61 66% 44%; +} + +/* Solarized Dark - https://ethanschoonover.com/solarized/ */ +.dark.theme-dark-solarized { + --background: 192 100% 11%; /* #002b36 Base03 */ + --foreground: 186 8% 55%; /* #839496 Base0 */ + --card: 192 81% 14%; /* #073642 Base02 */ + --card-foreground: 186 8% 55%; + --popover: 192 81% 14%; + --popover-foreground: 186 8% 55%; + --primary: 205 69% 49%; /* #268bd2 Blue */ + --primary-foreground: 192 100% 11%; + --secondary: 192 81% 18%; + --secondary-foreground: 186 8% 55%; + --muted: 192 81% 18%; + --muted-foreground: 186 13% 40%; /* #657b83 Base00 */ + --accent: 192 81% 22%; + --accent-foreground: 186 8% 55%; + --destructive: 1 71% 52%; /* #dc322f Red */ + --destructive-foreground: 186 8% 55%; + --border: 192 81% 22%; + --input: 192 81% 22%; + --ring: 205 69% 49%; + --sidebar-background: 192 100% 9%; + --sidebar-foreground: 186 8% 55%; + --sidebar-primary: 205 69% 49%; + --sidebar-primary-foreground: 192 100% 11%; + --sidebar-accent: 192 81% 16%; + --sidebar-accent-foreground: 186 8% 55%; + --sidebar-border: 192 81% 18%; + --sidebar-ring: 205 69% 49%; +} + +/* Everforest Dark - https://github.com/sainnhe/everforest */ +.dark.theme-dark-everforest { + --background: 150 10% 16%; /* #2d353b bg0 */ + --foreground: 75 11% 77%; /* #d3c6aa fg */ + --card: 150 9% 18%; /* #343f44 bg1 */ + --card-foreground: 75 11% 77%; + --popover: 150 9% 18%; + --popover-foreground: 75 11% 77%; + --primary: 93 37% 63%; /* #a7c080 Green */ + --primary-foreground: 150 10% 16%; + --secondary: 150 9% 22%; /* #3d484d bg2 */ + --secondary-foreground: 75 11% 77%; + --muted: 150 9% 22%; + --muted-foreground: 75 6% 60%; /* #9da9a0 Grey1 */ + --accent: 150 9% 26%; + --accent-foreground: 75 11% 77%; + --destructive: 0 43% 63%; /* #e67e80 Red */ + --destructive-foreground: 75 11% 77%; + --border: 150 9% 26%; + --input: 150 9% 26%; + --ring: 93 37% 63%; + --sidebar-background: 150 10% 13%; + --sidebar-foreground: 75 11% 77%; + --sidebar-primary: 93 37% 63%; + --sidebar-primary-foreground: 150 10% 16%; + --sidebar-accent: 150 9% 19%; + --sidebar-accent-foreground: 75 11% 77%; + --sidebar-border: 150 9% 22%; + --sidebar-ring: 93 37% 63%; +} + +/* Kanagawa - https://github.com/rebelot/kanagawa.nvim */ +.dark.theme-dark-kanagawa { + --background: 225 17% 14%; /* #1f1f28 sumiInk1 */ + --foreground: 39 9% 80%; /* #dcd7ba fujiWhite */ + --card: 225 15% 17%; /* #2a2a37 sumiInk3 */ + --card-foreground: 39 9% 80%; + --popover: 225 15% 17%; + --popover-foreground: 39 9% 80%; + --primary: 223 47% 67%; /* #7e9cd8 crystalBlue */ + --primary-foreground: 225 17% 14%; + --secondary: 225 14% 21%; /* #363646 sumiInk4 */ + --secondary-foreground: 39 9% 80%; + --muted: 225 14% 21%; + --muted-foreground: 225 7% 52%; /* #727169 fujiGray */ + --accent: 225 14% 26%; + --accent-foreground: 39 9% 80%; + --destructive: 7 73% 66%; /* #e82424 samuraiRed */ + --destructive-foreground: 39 9% 80%; + --border: 225 14% 26%; + --input: 225 14% 26%; + --ring: 223 47% 67%; + --sidebar-background: 225 17% 11%; + --sidebar-foreground: 39 9% 80%; + --sidebar-primary: 223 47% 67%; + --sidebar-primary-foreground: 225 17% 14%; + --sidebar-accent: 225 15% 18%; + --sidebar-accent-foreground: 39 9% 80%; + --sidebar-border: 225 14% 22%; + --sidebar-ring: 223 47% 67%; +} + +/* Monokai - https://monokai.pro/spec */ +.dark.theme-dark-monokai { + --background: 70 8% 15%; /* #272822 */ + --foreground: 60 36% 96%; /* #f8f8f2 */ + --card: 70 8% 18%; + --card-foreground: 60 36% 96%; + --popover: 70 8% 18%; + --popover-foreground: 60 36% 96%; + --primary: 338 95% 56%; /* #f92672 Pink */ + --primary-foreground: 60 36% 96%; + --secondary: 70 8% 22%; + --secondary-foreground: 60 36% 96%; + --muted: 70 8% 22%; + --muted-foreground: 50 11% 53%; /* #75715e Comment */ + --accent: 70 8% 28%; + --accent-foreground: 60 36% 96%; + --destructive: 338 95% 56%; + --destructive-foreground: 60 36% 96%; + --border: 70 8% 25%; + --input: 70 8% 25%; + --ring: 338 95% 56%; + --sidebar-background: 70 8% 11%; + --sidebar-foreground: 60 36% 96%; + --sidebar-primary: 338 95% 56%; + --sidebar-primary-foreground: 60 36% 96%; + --sidebar-accent: 70 8% 18%; + --sidebar-accent-foreground: 60 36% 96%; + --sidebar-border: 70 8% 20%; + --sidebar-ring: 338 95% 56%; +} + +/* Monokai Pro - https://monokai.pro/ */ +.dark.theme-dark-monokai-pro { + --background: 220 10% 18%; /* #2d2a2e */ + --foreground: 20 5% 90%; /* #fcfcfa */ + --card: 220 10% 21%; + --card-foreground: 20 5% 90%; + --popover: 220 10% 21%; + --popover-foreground: 20 5% 90%; + --primary: 349 100% 73%; /* #ff6188 */ + --primary-foreground: 220 10% 18%; + --secondary: 220 10% 26%; + --secondary-foreground: 20 5% 90%; + --muted: 220 10% 26%; + --muted-foreground: 220 6% 50%; /* #727072 */ + --accent: 220 10% 32%; + --accent-foreground: 20 5% 90%; + --destructive: 349 100% 73%; + --destructive-foreground: 220 10% 18%; + --border: 220 10% 28%; + --input: 220 10% 28%; + --ring: 349 100% 73%; + --sidebar-background: 220 10% 14%; + --sidebar-foreground: 20 5% 90%; + --sidebar-primary: 349 100% 73%; + --sidebar-primary-foreground: 220 10% 18%; + --sidebar-accent: 220 10% 22%; + --sidebar-accent-foreground: 20 5% 90%; + --sidebar-border: 220 10% 24%; + --sidebar-ring: 349 100% 73%; +} + +/* Material Dark - https://material-theme.com/ */ +.dark.theme-dark-material { + --background: 200 19% 18%; /* #263238 Blue Grey 900 */ + --foreground: 0 0% 93%; /* #eeffff */ + --card: 200 18% 21%; + --card-foreground: 0 0% 93%; + --popover: 200 18% 21%; + --popover-foreground: 0 0% 93%; + --primary: 174 42% 65%; /* #80cbc4 Teal 200 */ + --primary-foreground: 200 19% 18%; + --secondary: 200 18% 26%; + --secondary-foreground: 0 0% 93%; + --muted: 200 18% 26%; + --muted-foreground: 199 18% 49%; /* #546e7a Blue Grey 500 */ + --accent: 200 18% 30%; + --accent-foreground: 0 0% 93%; + --destructive: 1 77% 55%; /* #f07178 */ + --destructive-foreground: 0 0% 93%; + --border: 200 18% 28%; + --input: 200 18% 28%; + --ring: 174 42% 65%; + --sidebar-background: 200 19% 14%; + --sidebar-foreground: 0 0% 93%; + --sidebar-primary: 174 42% 65%; + --sidebar-primary-foreground: 200 19% 18%; + --sidebar-accent: 200 18% 22%; + --sidebar-accent-foreground: 0 0% 93%; + --sidebar-border: 200 18% 24%; + --sidebar-ring: 174 42% 65%; +} + +/* Palenight - https://github.com/whizkydee/vscode-palenight-theme */ +.dark.theme-dark-palenight { + --background: 229 27% 20%; /* #292d3e */ + --foreground: 229 35% 88%; /* #bfc7d5 */ + --card: 229 26% 24%; + --card-foreground: 229 35% 88%; + --popover: 229 26% 24%; + --popover-foreground: 229 35% 88%; + --primary: 286 60% 67%; /* #c792ea Purple */ + --primary-foreground: 229 27% 20%; + --secondary: 229 24% 30%; + --secondary-foreground: 229 35% 88%; + --muted: 229 24% 30%; + --muted-foreground: 229 20% 50%; /* #676e95 */ + --accent: 229 24% 35%; + --accent-foreground: 229 35% 88%; + --destructive: 355 65% 65%; /* #f07178 */ + --destructive-foreground: 229 35% 88%; + --border: 229 24% 32%; + --input: 229 24% 32%; + --ring: 286 60% 67%; + --sidebar-background: 229 27% 16%; + --sidebar-foreground: 229 35% 88%; + --sidebar-primary: 286 60% 67%; + --sidebar-primary-foreground: 229 27% 20%; + --sidebar-accent: 229 26% 26%; + --sidebar-accent-foreground: 229 35% 88%; + --sidebar-border: 229 24% 28%; + --sidebar-ring: 286 60% 67%; +} + +/* GitHub Dark - https://primer.style/primitives/colors */ +.dark.theme-dark-github { + --background: 215 21% 11%; /* #0d1117 */ + --foreground: 210 17% 82%; /* #c9d1d9 */ + --card: 215 19% 14%; /* #161b22 */ + --card-foreground: 210 17% 82%; + --popover: 215 19% 14%; + --popover-foreground: 210 17% 82%; + --primary: 212 100% 67%; /* #58a6ff */ + --primary-foreground: 215 21% 11%; + --secondary: 215 18% 18%; + --secondary-foreground: 210 17% 82%; + --muted: 215 18% 18%; + --muted-foreground: 215 13% 47%; /* #8b949e */ + --accent: 215 18% 22%; + --accent-foreground: 210 17% 82%; + --destructive: 356 72% 65%; /* #f85149 */ + --destructive-foreground: 210 17% 82%; + --border: 215 18% 20%; + --input: 215 18% 20%; + --ring: 212 100% 67%; + --sidebar-background: 215 21% 8%; + --sidebar-foreground: 210 17% 82%; + --sidebar-primary: 212 100% 67%; + --sidebar-primary-foreground: 215 21% 11%; + --sidebar-accent: 215 19% 15%; + --sidebar-accent-foreground: 210 17% 82%; + --sidebar-border: 215 18% 16%; + --sidebar-ring: 212 100% 67%; +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + font-family: var(--font-sans); + } + button { + cursor: pointer; + } +} + +/* Custom animations */ +@keyframes speedy-entrance { + 0% { + opacity: 0.3; + transform: scale(0.9) translateY(-10px); + } + 40% { + opacity: 1; + transform: scale(1.03) translateY(0); + } + 70% { + transform: scale(0.99); + } + 100% { + transform: scale(1); + } +} + +@keyframes subtle-pulse { + 0%, 100% { + transform: scale(1); + } + 50% { + transform: scale(1.02); + } +} + +@keyframes sparkle-slide { + 0% { + opacity: 0; + transform: translateX(-20px) scale(0); + } + 20% { + opacity: 1; + transform: translateX(0) scale(1.2); + } + 40% { + transform: translateX(0) scale(1); + } + 60% { + opacity: 1; + transform: translateX(0) scale(1); + } + 80%, 100% { + opacity: 0; + transform: translateX(0) scale(0); + } +} + +.animate-speedy { + animation: speedy-entrance 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards, + subtle-pulse 2s ease-in-out 0.8s infinite; + position: relative; +} + +/* Sparkle base styles */ +.sparkle { + position: absolute; + color: rgba(251, 191, 36, 0.9); + text-shadow: 0 0 4px rgba(251, 191, 36, 0.6); + animation: sparkle-slide 3s ease-out infinite; + pointer-events: none; + font-size: 12px; + opacity: 0; +} + +/* Position sparkles around the DOCKHAND text (bottom half of logo) - sequentially from left to right */ +.sparkle-1 { + bottom: 10px; + left: 5%; + font-size: 8px; + animation-delay: 1s; +} + +.sparkle-2 { + bottom: 4px; + left: 18%; + font-size: 10px; + animation-delay: 1.2s; +} + +.sparkle-3 { + bottom: 14px; + left: 30%; + font-size: 7px; + animation-delay: 1.4s; +} + +.sparkle-4 { + bottom: 2px; + left: 42%; + font-size: 11px; + animation-delay: 1.6s; +} + +.sparkle-5 { + bottom: 8px; + left: 52%; + font-size: 9px; + animation-delay: 1.8s; +} + +.sparkle-6 { + bottom: 16px; + left: 65%; + font-size: 6px; + animation-delay: 2s; +} + +.sparkle-7 { + bottom: 6px; + left: 78%; + font-size: 10px; + animation-delay: 2.2s; +} + +.sparkle-8 { + bottom: 12px; + left: 90%; + font-size: 8px; + animation-delay: 2.4s; +} + +/* Click to jump animations - 3 levels */ +@keyframes jump-small { + 0%, 100% { + transform: translateY(0) rotate(0deg); + } + 30% { + transform: translateY(-10px) rotate(-2deg); + } + 60% { + transform: translateY(0) rotate(0deg); + } +} + +@keyframes jump-medium { + 0%, 100% { + transform: translateY(0) rotate(0deg); + } + 20% { + transform: translateY(-20px) rotate(-3deg); + } + 40% { + transform: translateY(0) rotate(0deg); + } + 60% { + transform: translateY(-10px) rotate(2deg); + } + 80% { + transform: translateY(0) rotate(0deg); + } +} + +@keyframes jump-big { + 0%, 100% { + transform: translateY(0) rotate(0deg); + } + 10% { + transform: translateY(-30px) rotate(-5deg); + } + 20% { + transform: translateY(0) rotate(0deg); + } + 30% { + transform: translateY(-20px) rotate(5deg); + } + 40% { + transform: translateY(0) rotate(0deg); + } + 50% { + transform: translateY(-10px) rotate(-3deg); + } + 60% { + transform: translateY(0) rotate(0deg); + } +} + +.animate-speedy.jumping-0 { + animation: jump-small 0.5s ease-out !important; + cursor: pointer; +} + +.animate-speedy.jumping-1 { + animation: jump-medium 0.6s ease-out !important; + cursor: pointer; +} + +.animate-speedy.jumping-2 { + animation: jump-big 0.8s ease-out !important; + cursor: pointer; +} + +@keyframes crazy-jump { + 0%, 100% { + transform: translateY(0) rotate(0deg) scale(1); + } + 5% { + transform: translateY(-50px) rotate(-15deg) scale(1.1); + } + 10% { + transform: translateY(0) rotate(10deg) scale(0.9); + } + 15% { + transform: translateY(-40px) rotate(20deg) scale(1.15); + } + 20% { + transform: translateY(0) rotate(-15deg) scale(0.85); + } + 25% { + transform: translateY(-35px) rotate(-25deg) scale(1.1); + } + 30% { + transform: translateY(0) rotate(15deg) scale(0.9); + } + 40% { + transform: translateY(-25px) rotate(10deg) scale(1.05); + } + 50% { + transform: translateY(0) rotate(-5deg) scale(0.95); + } + 60% { + transform: translateY(-15px) rotate(5deg) scale(1); + } + 70% { + transform: translateY(0) rotate(0deg) scale(1); + } +} + +.animate-speedy.crazy-jumping { + animation: crazy-jump 1.2s ease-out !important; + cursor: pointer; +} + +.animate-speedy.clicked { + animation: none; +} + +.animate-speedy { + cursor: pointer; +} + +/* Easter egg popup */ +.easter-egg-popup { + position: absolute; + top: calc(100% + 20px); + left: 50%; + transform: translateX(-50%); + background: linear-gradient(135deg, rgba(251, 191, 36, 0.95), rgba(245, 158, 11, 0.95)); + color: #1a1a1a; + padding: 0.75rem 1rem; + border-radius: 0.75rem; + font-weight: 600; + font-size: 0.85rem; + text-align: center; + white-space: nowrap; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2), 0 0 20px rgba(251, 191, 36, 0.3); + z-index: 9999; + animation: popup-jump 0.5s ease-out; +} + +.easter-egg-popup::before { + content: ''; + position: absolute; + top: -8px; + left: 50%; + transform: translateX(-50%); + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-bottom: 8px solid rgba(251, 191, 36, 0.95); +} + +@keyframes popup-jump { + 0% { + opacity: 0; + transform: translateX(-50%) translateY(-20px) scale(0.5); + } + 50% { + transform: translateX(-50%) translateY(10px) scale(1.1); + } + 70% { + transform: translateX(-50%) translateY(-5px) scale(0.95); + } + 100% { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + } +} + +/* Tagline text styling */ +.tagline-text { + min-height: 1.5em; +} + +@keyframes tagline-zoom-in { + 0% { + opacity: 0; + transform: scale(1.8); + filter: blur(4px); + } + 30% { + opacity: 1; + transform: scale(1.3); + filter: blur(1px); + } + 60% { + transform: scale(0.95); + filter: blur(0); + } + 80% { + transform: scale(1.02); + } + 100% { + opacity: 1; + transform: scale(1); + filter: blur(0); + } +} + +.tagline-zoom { + animation: tagline-zoom-in 0.5s ease-out; +} + +/* Stat box styling for About page */ +.stat-box { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.75rem 0.5rem; + border-radius: 0.75rem; + background: linear-gradient(135deg, hsl(var(--muted) / 0.3), hsl(var(--muted) / 0.5)); + transition: all 0.2s ease; +} + +.stat-box:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px hsl(var(--foreground) / 0.1); +} + +.stat-icon-wrapper { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 0.5rem; + margin-bottom: 0.5rem; +} + +.stat-box-blue:hover { + background: linear-gradient(135deg, rgb(59 130 246 / 0.08), rgb(59 130 246 / 0.15)); +} + +.stat-box-cyan:hover { + background: linear-gradient(135deg, rgb(6 182 212 / 0.08), rgb(6 182 212 / 0.15)); +} + +.stat-box-purple:hover { + background: linear-gradient(135deg, rgb(168 85 247 / 0.08), rgb(168 85 247 / 0.15)); +} + +.stat-box-green:hover { + background: linear-gradient(135deg, rgb(34 197 94 / 0.08), rgb(34 197 94 / 0.15)); +} + +.stat-box-orange:hover { + background: linear-gradient(135deg, rgb(249 115 22 / 0.08), rgb(249 115 22 / 0.15)); +} + +.stat-box-amber:hover { + background: linear-gradient(135deg, rgb(245 158 11 / 0.08), rgb(245 158 11 / 0.15)); +} + +/* Custom text size for very small labels (10px) */ +@layer utilities { + .text-2xs { + font-size: 10px; + line-height: 14px; + } + + /* Icon glow utilities - standard size (4px blur, 0.6 opacity) */ + .glow-green { filter: drop-shadow(0 0 4px rgba(34, 197, 94, 0.6)); } + .glow-green-sm { filter: drop-shadow(0 0 3px rgba(34, 197, 94, 0.5)); } + .glow-amber { filter: drop-shadow(0 0 4px rgba(245, 158, 11, 0.6)); } + .glow-amber-sm { filter: drop-shadow(0 0 3px rgba(245, 158, 11, 0.5)); } + .glow-blue { filter: drop-shadow(0 0 4px rgba(59, 130, 246, 0.6)); } + .glow-purple { filter: drop-shadow(0 0 4px rgba(168, 85, 247, 0.6)); } + .glow-cyan { filter: drop-shadow(0 0 4px rgba(6, 182, 212, 0.6)); } + .glow-sky { filter: drop-shadow(0 0 4px rgba(56, 189, 248, 0.6)); } +} + +/* Improved warning colors for dark mode readability */ +@layer utilities { + /* Yellow warning text - brighter in dark mode */ + .dark .text-yellow-500 { + color: rgb(253 224 71) !important; /* yellow-300 */ + } + .dark .text-yellow-600 { + color: rgb(250 204 21) !important; /* yellow-400 */ + } + + /* Amber warning text - brighter in dark mode */ + .dark .text-amber-500 { + color: rgb(252 211 77) !important; /* amber-300 */ + } + .dark .text-amber-600 { + color: rgb(251 191 36) !important; /* amber-400 */ + } + + /* Orange warning text - brighter in dark mode */ + .dark .text-orange-500 { + color: rgb(253 186 116) !important; /* orange-300 */ + } + .dark .text-orange-600 { + color: rgb(251 146 60) !important; /* orange-400 */ + } + + /* Red error text - brighter in dark mode */ + .dark .text-red-500 { + color: rgb(254 202 202) !important; /* red-200 */ + } + .dark .text-red-600 { + color: rgb(252 165 165) !important; /* red-300 */ + } +} + +/* Update available icon animation */ +@keyframes bounce-once { + 0%, 100% { + transform: scale(1); + } + 25% { + transform: scale(1.4); + } + 50% { + transform: scale(0.9); + } + 75% { + transform: scale(1.15); + } +} + +.animate-bounce-once { + animation: bounce-once 0.6s ease-out; +} + +/* CodeMirror selection - override theme defaults for dark mode */ +.cm-editor .cm-selectionBackground, +.cm-editor.cm-focused .cm-selectionBackground, +.cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground { + background-color: #3d4654 !important; +} + +/* ============================================ + Data Grid - Sticky Columns & Column Resize + ============================================ */ + +/* Container must have horizontal scroll */ +.data-grid-wrapper { + overflow-x: auto; + position: relative; +} + +/* Table uses fixed layout for resize to work */ +/* min-width ensures table fills container; inline style overrides when calculated */ +.data-grid { + table-layout: fixed; + border-collapse: separate; + border-spacing: 0; + min-width: 100%; +} + +/* Allow content to truncate with ellipsis when column is shrunk */ +.data-grid td, +.data-grid th { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Columns marked with noTruncate should not be truncated */ +.data-grid td.no-truncate, +.data-grid th.no-truncate { + overflow: visible; + text-overflow: clip; +} + +/* Allow flex containers inside cells to shrink below content width */ +.data-grid td .flex, +.data-grid th .flex { + min-width: 0; + overflow: hidden; +} + +/* Select column (checkbox) - fixed width, don't grow, centered */ +.data-grid th.select-col, +.data-grid td.select-col { + width: 32px !important; + min-width: 32px; + max-width: 32px; + text-align: center; +} + +.data-grid th.select-col > *, +.data-grid td.select-col > * { + margin: 0 auto; +} + +/* Expand column - fixed width, no border, centered */ +.data-grid th.expand-col, +.data-grid td.expand-col { + width: 24px !important; + min-width: 24px; + max-width: 24px; + border-right: none !important; + text-align: center; +} + +.data-grid th.expand-col > *, +.data-grid td.expand-col > * { + margin: 0 auto; +} + +/* Actions column - width controlled by DataGrid component */ +.data-grid .actions-col { + min-width: 150px; +} + +/* Subtle cell borders */ +.data-grid td { + border-right: 1px solid hsl(var(--border) / 0.3); + border-bottom: 1px solid hsl(var(--border) / 0.3); +} + +.data-grid th { + border-right: 1px solid hsl(var(--border) / 0.3); + border-bottom: 1px solid hsl(var(--border) / 0.5); +} + +/* Remove right border from actions column */ +.data-grid td.actions-col, +.data-grid th.actions-col { + border-right: none; +} + +/* Header cells need relative positioning for resize handles */ +.data-grid th { + position: relative; +} + +/* Ensure non-sticky cells don't create stacking context */ +.data-grid tbody td:not(.select-col):not(.actions-col) { + position: static; + z-index: auto; +} + +/* Sticky select column (checkbox) - body cells */ +.data-grid td.select-col { + position: sticky; + left: 0; + z-index: 2; +} + +/* Sticky actions column - body cells */ +.data-grid td.actions-col { + position: sticky; + right: 0; + z-index: 2; +} + +/* Sticky header cells need higher z-index than body */ +.data-grid th.select-col, +.data-grid th.actions-col { + position: sticky; + z-index: 12; +} + +.data-grid th.select-col { + left: 0; +} + +.data-grid th.actions-col { + right: 0; +} + +/* Actions header extension - covers content on both sides */ +.data-grid thead th.actions-col { + /* Extend the cell visually to cover scrolling content */ + position: sticky; + right: 0; + z-index: 12; + background: hsl(var(--muted)); + /* Use box-shadow to extend background in both directions */ + box-shadow: + -20px 0 0 0 hsl(var(--muted)), /* Left - covers scrolling headers */ + 20px 0 0 0 hsl(var(--muted)); /* Right - covers gap to edge */ + border-top-right-radius: 0.5rem; + /* Clip path allows shadows but keeps rounded corner */ + clip-path: inset(-1px -20px -1px -20px round 0 0.5rem 0 0); +} + +/* Body cell backgrounds for sticky columns */ +.data-grid td.select-col, +.data-grid td.actions-col { + background: hsl(var(--background)); +} + +/* Header cell backgrounds for sticky columns (match header bg-muted) */ +.data-grid thead th.select-col, +.data-grid thead th.actions-col { + background: hsl(var(--muted)); +} + +/* Header row stays on top of everything */ +.data-grid thead { + position: sticky; + top: 0; + z-index: 10; +} + +/* Header sticky columns need higher z-index */ +.data-grid thead th.select-col, +.data-grid thead th.actions-col { + z-index: 11; +} + + +/* Row hover - all cells same color (including sticky columns) */ +.data-grid tbody tr:hover td, +.data-grid tbody tr:hover td.select-col, +.data-grid tbody tr:hover td.actions-col { + background: hsl(var(--muted)); +} + +/* Selected row background - solid color using color-mix for sticky column compatibility */ +.data-grid tbody tr.selected td, +.data-grid tbody tr.selected td.select-col, +.data-grid tbody tr.selected td.actions-col { + background: color-mix(in srgb, hsl(var(--primary)) 10%, hsl(var(--background))); +} + +/* Selected row hover - slightly stronger tint */ +.data-grid tbody tr.selected:hover td, +.data-grid tbody tr.selected:hover td.select-col, +.data-grid tbody tr.selected:hover td.actions-col { + background: color-mix(in srgb, hsl(var(--primary)) 15%, hsl(var(--background))); +} + +/* Checkbox-selected row background - amber tint */ +.data-grid tbody tr.checkbox-selected td, +.data-grid tbody tr.checkbox-selected td.select-col, +.data-grid tbody tr.checkbox-selected td.actions-col { + background: color-mix(in srgb, #f59e0b 8%, hsl(var(--background))); +} + +/* Checkbox-selected row hover */ +.data-grid tbody tr.checkbox-selected:hover td, +.data-grid tbody tr.checkbox-selected:hover td.select-col, +.data-grid tbody tr.checkbox-selected:hover td.actions-col { + background: color-mix(in srgb, #f59e0b 12%, hsl(var(--background))); +} + +/* Row with expanded content below - subtle indicator */ +.data-grid tbody tr.row-expanded td, +.data-grid tbody tr.row-expanded td.select-col, +.data-grid tbody tr.row-expanded td.actions-col { + background: color-mix(in srgb, hsl(var(--muted)) 50%, hsl(var(--background))); + border-bottom: none; +} + +.data-grid tbody tr.row-expanded:hover td, +.data-grid tbody tr.row-expanded:hover td.select-col, +.data-grid tbody tr.row-expanded:hover td.actions-col { + background: color-mix(in srgb, hsl(var(--muted)) 70%, hsl(var(--background))); +} + +/* Expanded row - no background on td, let content control it */ +.data-grid tbody tr.expanded-row td, +.data-grid tbody tr.expanded-row td.select-col, +.data-grid tbody tr.expanded-row td.actions-col { + padding: 0; + background: transparent; + border-bottom: 1px solid hsl(var(--border)); + overflow: hidden; +} + +.data-grid tbody tr.expanded-row:hover td, +.data-grid tbody tr.expanded-row:hover td.select-col, +.data-grid tbody tr.expanded-row:hover td.actions-col { + background: transparent; +} + +/* Nested grid styling (inside expanded rows) */ +.data-grid.nested-grid { + border: none; + min-width: 0; + width: auto; +} + +.data-grid.nested-grid thead th { + background: transparent !important; + border-bottom: 1px solid hsl(var(--border) / 0.5); + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; +} + +.data-grid.nested-grid thead { + background: transparent !important; +} + +.data-grid.nested-grid thead th:first-child, +.data-grid.nested-grid thead th:last-child, +.data-grid.nested-grid thead th.actions-col { + background: transparent !important; + box-shadow: none !important; + clip-path: none !important; +} + +/* Nested grid first/last columns are NOT sticky - disable fixed sizing */ +.data-grid.nested-grid td:first-child { + width: unset !important; + min-width: 0 !important; + max-width: none !important; + position: static !important; +} + +/* Header cells still need relative for resize handles */ +.data-grid.nested-grid th:first-child { + width: unset !important; + min-width: 0 !important; + max-width: none !important; +} + +.data-grid.nested-grid td:last-child { + position: static !important; +} + +.data-grid.nested-grid tbody td { + background: transparent; + border-right: none; + border-bottom: 1px solid hsl(var(--border) / 0.3); +} + +.data-grid.nested-grid tbody td:first-child, +.data-grid.nested-grid tbody td:last-child, +.data-grid.nested-grid tbody td.actions-col { + background: transparent; +} + +.data-grid.nested-grid tbody tr:hover td, +.data-grid.nested-grid tbody tr:hover td:first-child, +.data-grid.nested-grid tbody tr:hover td:last-child, +.data-grid.nested-grid tbody tr:hover td.actions-col { + background: hsl(var(--muted) / 0.3); +} + +/* Resize handle styling */ +.resize-handle { + position: absolute; + top: 0; + right: -2px; + width: 5px; + height: 100%; + cursor: col-resize; + background: transparent; + z-index: 5; +} + +.resize-handle:hover, +.resize-handle:active { + background: hsl(var(--primary) / 0.4); +} + +/* Left-side resize handle for fixed end columns */ +.resize-handle-left { + right: auto; + left: -2px; + z-index: 15; +} + + diff --git a/app.d.ts b/app.d.ts new file mode 100644 index 0000000..b79d95c --- /dev/null +++ b/app.d.ts @@ -0,0 +1,23 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces + +import type { AuthenticatedUser } from '$lib/server/auth'; + +// Build-time constants injected by Vite +declare const __BUILD_DATE__: string | null; +declare const __BUILD_COMMIT__: string | null; + +declare global { + namespace App { + // interface Error {} + interface Locals { + user: AuthenticatedUser | null; + authEnabled: boolean; + } + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/app.html b/app.html new file mode 100644 index 0000000..5f6ec02 --- /dev/null +++ b/app.html @@ -0,0 +1,17 @@ + + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + + + diff --git a/hooks.server.ts b/hooks.server.ts new file mode 100644 index 0000000..0e07423 --- /dev/null +++ b/hooks.server.ts @@ -0,0 +1,168 @@ +import { initDatabase, hasAdminUser } from '$lib/server/db'; +import { startSubprocesses, stopSubprocesses } from '$lib/server/subprocess-manager'; +import { startScheduler } from '$lib/server/scheduler'; +import { isAuthEnabled, validateSession } from '$lib/server/auth'; +import { setServerStartTime } from '$lib/server/uptime'; +import { checkLicenseExpiry, getHostname } from '$lib/server/license'; +import type { HandleServerError, Handle } from '@sveltejs/kit'; +import { redirect } from '@sveltejs/kit'; + +// License expiry check interval (24 hours) +const LICENSE_CHECK_INTERVAL = 86400000; + +// HMR guard for license check interval +declare global { + var __licenseCheckInterval: ReturnType | undefined; +} + +// Initialize database on server start (synchronous with SQLite) +let initialized = false; + +if (!initialized) { + try { + setServerStartTime(); // Track when server started + initDatabase(); + // Log hostname for license validation (set by entrypoint in Docker, or os.hostname() outside) + console.log('Hostname for license validation:', getHostname()); + // Start background subprocesses for metrics and event collection (isolated processes) + startSubprocesses().catch(err => { + console.error('Failed to start background subprocesses:', err); + }); + startScheduler(); // Start unified scheduler for auto-updates and git syncs (async) + + // Check license expiry on startup and then daily (with HMR guard) + checkLicenseExpiry().catch(err => { + console.error('Failed to check license expiry:', err); + }); + if (!globalThis.__licenseCheckInterval) { + globalThis.__licenseCheckInterval = setInterval(() => { + checkLicenseExpiry().catch(err => { + console.error('Failed to check license expiry:', err); + }); + }, LICENSE_CHECK_INTERVAL); + } + + // Graceful shutdown handling + const shutdown = async () => { + console.log('[Server] Shutting down...'); + await stopSubprocesses(); + process.exit(0); + }; + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); + + initialized = true; + } catch (error) { + console.error('Failed to initialize database:', error); + } +} + +// Routes that don't require authentication +const PUBLIC_PATHS = [ + '/login', + '/api/auth/login', + '/api/auth/logout', + '/api/auth/session', + '/api/auth/settings', + '/api/auth/providers', + '/api/auth/oidc', + '/api/license', + '/api/changelog', + '/api/dependencies' +]; + +// Check if path is public +function isPublicPath(pathname: string): boolean { + return PUBLIC_PATHS.some(path => pathname === path || pathname.startsWith(path + '/')); +} + +// Check if path is a static asset +function isStaticAsset(pathname: string): boolean { + return pathname.startsWith('/_app/') || + pathname.startsWith('/favicon') || + pathname.endsWith('.webp') || + pathname.endsWith('.png') || + pathname.endsWith('.jpg') || + pathname.endsWith('.svg') || + pathname.endsWith('.ico') || + pathname.endsWith('.css') || + pathname.endsWith('.js'); +} + +export const handle: Handle = async ({ event, resolve }) => { + // Skip auth for static assets + if (isStaticAsset(event.url.pathname)) { + return resolve(event); + } + + // WebSocket upgrade for terminal connections is handled by the build patch (scripts/patch-build.ts) + // This is necessary because svelte-adapter-bun expects server.websocket() which doesn't exist in SvelteKit + + // Check if auth is enabled + const authEnabled = await isAuthEnabled(); + + // If auth is disabled, allow everything (app works as before) + if (!authEnabled) { + event.locals.user = null; + event.locals.authEnabled = false; + return resolve(event); + } + + // Auth is enabled - check session + const user = await validateSession(event.cookies); + event.locals.user = user; + event.locals.authEnabled = true; + + // Public paths don't require authentication + if (isPublicPath(event.url.pathname)) { + return resolve(event); + } + + // If not authenticated + if (!user) { + // Special case: allow user creation when auth is enabled but no admin exists yet + // This enables the first admin user to be created during initial setup + const noAdminSetupMode = !(await hasAdminUser()); + if (noAdminSetupMode && event.url.pathname === '/api/users' && event.request.method === 'POST') { + return resolve(event); + } + + // API routes return 401 + if (event.url.pathname.startsWith('/api/')) { + return new Response( + JSON.stringify({ error: 'Unauthorized', message: 'Authentication required' }), + { + status: 401, + headers: { 'Content-Type': 'application/json' } + } + ); + } + + // UI routes redirect to login + const redirectUrl = encodeURIComponent(event.url.pathname + event.url.search); + redirect(307, `/login?redirect=${redirectUrl}`); + } + + return resolve(event); +}; + +export const handleError: HandleServerError = ({ error, event }) => { + // Skip logging 404 errors - they're expected for missing routes + const status = (error as { status?: number })?.status; + if (status === 404) { + return { + message: 'Not found', + code: 'NOT_FOUND' + }; + } + + // Log only essential error info without code snippets + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error(`[Error] ${event.url.pathname}: ${message}`); + + return { + message, + code: 'INTERNAL_ERROR' + }; +}; +// CI trigger 1766327149 diff --git a/images/logo.webp b/images/logo.webp new file mode 100644 index 0000000000000000000000000000000000000000..421abdfeef7973d5afb18896fb1c0e4f5ca42ad8 GIT binary patch literal 9926 zcmV;%COO$sNk&G#CIA3eMM6+kP&il$0000G0000w0RS%n06|PpNMHv500FcKh=>?k zux*=`q?Ih&Rc+h0ZQHhOGdtV1ZQFL1ZJRG&zgQplu6_17H>vG1y(@8<7kQHQWP0SO;SqQP!vHQnh=idj|v8(F^W17ZBgh$%= zEPed<@!!XPZ=cDRvU9U%1*LI0}Pc9EQ3BTj33i!+2w0974K+pq{`u z#5Dww?SOFz?ghd-feC%UlrCUW3ox$%n7Mt--8-i590|6K9D7Ed{UX(Fk!_>MxJzW+ zArfy7$v1{2HidO|gXQ*tWfzYM){Kw;TVjT%Kw~^=7it@gdDJ1)9<+K;hfpg9CzFlB z;1~=|gAv#=Fdp;hoC--Vx_Z$w0a{TQrouvy$s=KM` zJvE^w=VmpdZ%ED8>f5{~^=(bSmNRF`hP-c7ceMI}vb1%`jcWgZ5n_How}s-d`l;f_ zfSrSQeBOO(`@Ef1qiqYtGooMv#cgs1syh(8tKt%wRj7AEY|}l|XQNQ3Qtpb!1y_qS zZHc7trusAvzEl@xdQz_(RyA1dmpMWWu1e@&>Sy40@-p@@PG5`3b=Xy1?=(g5t+aMf zv#aA=t)9;8q{hJUQFTq`7WF97*?gw@t!)JFfE6eo0qQ1;p;bQjekXWjQmy9;t{7)$ z^=;-9^}5GN>gmjj>ZF7QQ%+eP#kxxE4B!#LZG0M6Umzy;P6^+rUE-{({>faT9`@K* zy_NYu?URtM?23X75R>Wv?4%xY!li<*B=wp)IL=aPROSYCtH-wL^UT+3+XB3gf^A0m zFaXp}6jNI})&3}WP)d)e6XL9>hGwo*_jv54KFEBgb}0Z4E!e$krwkqtJkSYiEff=a zB=od8Ce9}6x6IkA){GMiyL;9BBDi-tO=_z+530K|tEqA)hcDIfne)^~ z3H5wW@vSPq18eOgJYG^ePO@rtax$A=FO@Rxo@aBs|Q?1 zeU<~xQJlI$Q9h4ywHr2UPU}YMJz{kGiTbp>He*$3H{0^Vsn^NdU-8dgrk$EqxvvLa zR{X79F_x)B8R&-_lRA!iWPOB9hfKDUfXjDe{-Yx@GFOe}Tq@YYH4@VEN&#YXG#_ z>k}HwYx!W^`HE*3;-5(O`C-#?8dYmtI-bTMZNpxF@x>Qk{p{~D&7W2&Y+W&sH_mdb0W-K z9e(ZpRQ#qswc7D&W7r$j%Mq|;CAG(^k{GrQ0&5@f`?((ix1H)XadSk#so#DLvg9`%YJiZ13U0VI9IHY?@$50ML@MfyAYz*9!$f(~@%`Vq z^`Op(y8iV)Ef7N<-?KF$b~^mI0hPRgPoK~k!7f;KisA<~34N??2&)<7yEa*pTXa759q(zJ!gNSdn5l@^$`9={g3}Y ztN;6N?!UkPOFyyS^S{b||NRX9qJP8u|M>v_|G-V;-|QcezX&`c`?vNV+W+jn==oCf zhqON()T;CI`DfVA&VO(H(ENt`$NbOvuMhYEepCE|`{&IsD1L?J1?r#B|K5Lu{EzsK z`0x0Bh5do{0RBDwFa5WcCy0mHzu$hbd;xzL{;B@o`=|I1+MkVY^*_Y^PWyWN)BIQb z-}0Z8Uts^o|D*rQ{_Flr-pkjA?vJpa>gW6K&-4AkSm}Mc!WJD+^8rRSvk~CBsn?an zSuK4S^TQ6K8swNV3}O&~_^tU<8LFB)b|Wwb14On(p*Tc=-^!@3Qx#@^9i2YcX7x`FUM{h-#NLy>uDX702?9Co7l@I!4c=N z!g>@b;w4N*M5FdxjPcPFOjHH8Kne`IoRT3%)zPgy#L`5r{tPrGh?VB+x}6mM`@l>h zPO1JkCKhL2i>+2H!B(cYBwV3Fc-RL`joBr%yH~U_@&SUSm|vm7NUo+U_B;4PC6gf9 z(9p`Q&BGG<1Yz$uS)>e_+9uNeCC`^0S7=7AgGcOrBG`%ApqWC}WSb|T3`WGN4%A70 zoIAvdz>ZdDJJS4hlo8!mP@xrs;i}np(Oplc%K4z*fZSq6ml=bou2;jXwD~1n<`%wn zZ9z+o*acyg;8Ue#; z)}~9sH2|9e0=0EE3L=Aw(pmUFEKpv+gG1NHm|<1>g)2k)mDS48RrsHT+h2(Q3)t96 zvtiJEB_zE$VzcNE#D)%Khs{KDEY(!WmC+blU(1zt+^^ya4R@Yg>K+5YDW}Ad&_%y7 z-kxGVcChr_KZBeFaTA-g!E72G`<*;juG(6&n~SB2<$$Trucp%!aN6IBVUMsL#ikQrnt3GJn-&q6F|!0@3^zSI$$Ck8n4@Jq!?z?Na3iLE2JjY;ap!Zsp+}dnSFrf%S5Q7N`Y_!4)>N~*hl<#op zy8r0nYwJTCY#b!MyXaU~c&^v7ejcC>{?B<6Y(D-;6i1t;9UOYQ+6$VwgN3S>n{$N| zsAc2?Yf!IwRRatmM!YDDr)XyY3h$rvM?Sd?XaVNyhO}x~6a}Ka^;~AftY>$>!oe>4 zJDEz1oNsEpYCv&)ugQm|Af=PSsTuq%mK7^K+{;c|qk%1N$SV@WpiSFhT)OA_R%v0T z;&*A#5L5IcId^PUa!;OQr^5i45f=2T+(rK5(hjd%8JaQAK{05x8Nxp_62Kksf9d_? z<J5>*s)oK1IG`qr6<|sCwbV%kB_i5NR=6G8;W27uw8ji3?0_I;4MBsJiN?hV37)5IU%?ig(Z)jVKwQ5I1f4xEIaK10La{i5YBx6kq0 z@X?)J{v|jb$?OM!Q<;&&naC}KcrH5`N)1ek%#8N3gcpDn$LEhRJJ9em|K#6!(IV@+ z#uEMrFUv&9%YD{saJ3>hF(mNllA8cMqD*z*h4gAX-uu%*hk+B_Wp=f_xEs+)IJ}VM zQh?qK%PdzkYKZ^%hbozVwEq($nV$nyK7U$&hQnX)mrgvOG-0ShgUv*gsw!v5Dyh(m zgq0Sj12K36eiLNs0YngoW?r#`7{hCa)V~zDA5?t@x;|6)I+)g_N$F7mRrVa;$N-i< zgX|Gi46AZgp$=(PR;97Vig5NaPc0^2E&efnR~%TV1E3b-o_XYd@r(0#(eUW_%z%hn z)9vkghJZC42PLziPalV&fPAJ7_GG;icCiZ=ae4*He>oA4GkPIX<|Exwv1}6X zL}GAseEwpR-Ji&hHv3^>)L|IP55W2jKF7g1?Koa?RJ>K6;%pb#g!sDse2AFSpdG*S z`pbyx4<|W-X{h0D;s2hyyXUe-yFB(#TdGha*D0MYrESvFTQGlE@AQCv|J(0F!7qyT zf2yYf;kcf%O3moBQAm6Y8w~redN?z^8$!LuCo3Xto2ly}h?TlAUtIHccMJYaVFQ-& zwTJ4p07N`pD_jqyC*q^W#}QpYdBKq%kM-S@@@^PvwtVoJ8&Pmzh6wf#k|aQ6b}DKI z7E7@?-1J-{qL-L)IM2XW7W+~00fNFjr!jsofBkOH{|0aW_#(o8HNpWOVYuaQ4e1sg zBPuj|O$O;yc(!SwaZw&{BtCqJYVU`tBE>yJP;)+{vCB2jQ}@1U6)!`7iEEi>{p-Da zu5Kuu%_ztPuw?h_+|-gtO5tOIyH85%{@lSTW@s1j3YFK(@4a@0{C$$(3}3kvwd|Ly zZe}q{iyB73F?UmC16IW7werb+HTV=fIo5S$tZVYry^Y6ud*FOU_s%5h6aBVDnPv4R zspj-+YATBtf?AF+(Ws)|DnQ0j;per?|Qw65+C;KCx7!H_BdwhAM<9xBY(3ZKhQc| zGY{35MuBMDmQGY>Efeh|=G)oeDT8VAelW~+)6P7`+ijtAS0;iEd847CrL?Mn$kQGB zOX<87*H@shPk>z04pf3vHkYe(6Y*ZsR-kG*LIQNd!SeW?6(%7#3Byl{?eYGzPZ?dI z6TkTuSo*56b<&;Hp42kKodBm_=oBM4F#mAb1(pTyxP1H52TJhdX>;!~ok+Z-2Zpgr z1hL)d4!O;ziS#=AqlU`D%IbKYntIZuW z{8~>xy|?6NGb__S`X+yGtz=$_qw)%En}-h8E7tT>K?1nVQt{O*i27c9@CzLGWh#I9 z5ojJEyf&JF7|{QI(+7Ozr2W!mWiZ^>SY1DiY`NH_uBd=s*1c}TW^9NjVM|S*j2+U& z)}@en6*@{^K9Rg!n&{yUaY8fcE~E^JQr+9VL6z{rUm!WC)I5 zcutpplv%?w4gTzRp1>~W>$@J*gR7e~=#50QkHu@utgP*qh}aTYZ)?1R3WM2W(bLUJ zuhydUWKy%R;?Ex|dBhjM_*#FieF>K(F z?3Fxyqv>TIy~+R$QhyH|Q6cUOO`uRw0xQH!O#*shkuG`G;UeAD5+7v5cLd#h z>e&v#kcV%O2AI&i#oEjt9rDyy`Gs$tKZ#-O*PyMG!-(1sH`tG34n;7qA6Bh&OL@#S z6s$%jJ`HCa111twpi58_^rxN42OB0TP*)`y)QvJrQB(wec!yTa+=J0>#-?_&g{|V% zf@E5%nG1E;+T&f#JmDl8v06}tpJY{4iMP=+kc(WAy=UzFoaNf1xyvbO=gt4WpC+he z@BM@p8G|Hg_$ANa(($lx}${Bss+mJ7~W3D7-2#$h}nmq0`6j|C(hN(dcE1wxUQoPD@DcD@Q;Hv7x%h-vvi2lg7}}I9Leg&<9ktJ+Sex|d z#sHc?1zW37n7b-lIGT+9XMJ< ztx6?$2M1GviNiJY!<`aB{*ig*%f4)SlV>rJuN-=km>zqR({0=t2tqhxo^ti?@-9U% zGhHVGM0K(q8zaaoqdn6x*U75U&(^WWAtoGBhcTbx3@h0hLPAxS&dW^6 z+z$ly?DEuYzcrwpG3c2K69{|6QL75>UUXYgfpV6r1a@#~+7t?Ok^-6C_QGmKY~^A!+A`=TaK zcQWr&7>$O%C{<|U0iSX$s`_KZLudO03@(*53rVp}0)GC}4{FM}#OF+veR6_dr_B1K z7b^FUreMis*acpuE!vVC=f@c5q-05>snB1qA^-^-ecK)&o6}chY0FBpZMu1K_nz<| zd<;SlmgM`Z%LPv9w|Uqlc~7@cTT4`AMm&=^^oW^hW~7XB+BxL1N(~_Lh;H?U{~RDNl(O1ZQHGozYDF(vCUDEK}YQ||W=`F)i?fx(u3XgVS>%NmBy(_G)x z&HXdaR(w;T=i>-i0-Ti(J9!lkF09ey*Iviee9&d1#wJm%RAzNkZtp-p3R1)k&xO^M z&^?2q$?`E>;$X6cJpcOhJ55E5_j|K6kFk~T23OAJ73UIhrPMY^RlrDs0`7dsQUbO* zvO-oouF8j!R02r+at+W_K+M{&u`F{1Pg|Wb>?A2V+~t%R5E)5?urudqRFYe%)+5?@ z+$6eNfF+_#|Csmjw*gXnv`a^r^SEl9P83W{j)st#4M*uz6qtN7C5h7 z@byORGPVRz*k6-mh6(;^zml|#j}^*@eOC8c7esHai48AXa1Lb~HwFeET+`KdWT;~n zRT>}xiX7~>S9j+0!M`=QiJxt$UbRUMJBq2G@B0lCIpW5VVl~s2NEu0s*JF*aJI_e6 zgl`G8SQWrCEx;&E6_<=0P$PKz#POmhF%+NObS|;yxy0ZHjpyqO3lD-0?q`Pb-F$RE ztUbB>BmS$dajnG^`7Q6aYfp57=0dMaGBA_ zI?B|-i-a>^7)tJN{Ui8!i)gt6#|XGA_mZ!YR8HYl9WH`}aWw`g_5UzbcEvCZoy zEDAZEtiEfY_NttpWLz_D>2ZvNTcXJmi4fy$ZY{Bnsr{a1w9|iBH%9<^nCb$?G(3I+ z;_aPvy%}ws!()dn!WYulRl0)F>nVfKNxz;Yh3I%nf!7ub_gLjt-#)!c`ih)#FRe9u z89x2JyguY2=amnk=;>BOx#&V~TyRB>CZ@edl(TS<& zvBj!kQOIu7iffl6nCg95Yf0**$pJcR4l-P@tPY0Z~IaLtv zc8i?`ZjyJa-OBjnyTPpNm}zd<%7X$jUGBiHk#qkhONC~K<4tLdlJUqpZ)@G|eUceK zxA9ZN^wsgGJR*|^yx3G+OVUt&oxntJz^+f|u4LXK>^V9xpRfqE4!my3spEcr`0S>&h zUm%4SeE-uFEQV&QDkyvJ-mqCRQjw7R%-_{|C>R{iWzWx39>}bO(2xdt4$5JpUzp$^ zY4^Q|%Y1h(Vtex&oD%&}R%OQ4YEDpjHVppqKHV;{Tv_#qN5l}P4&Qee?ZJ0oAEP#) z++Vx??A^|nr~Ykv8(}SDDvbUj_#7`jk^=knPd$oRuzGtyMq+Qlo}H+z~H_Qe-fi?lu-@gu6j#m%p>e45KMgA`+PV}wZ(vY0?cV&@W?l7&|ImE+8Y_7Kqr zuD~25t4ibCXcc~dS$(`K)BFN#5Cb_crvDP(oJGSg8MUo8feH89cp=3$>ZjigE75V& z7Qy5Na!gc9L;N>kT8BS%&chg#zhe1c)wYL2TVG2w5yD$m?w_$S{%T;-6xe{k3ElAH zi4WZ2`5G~eX;ep52M=V6&WQqTw9Zs_XvvqB3*PEff6vPAeAh}so)c;PREu(bOuWT; zqyix%%LuiIM*`l9oI zhq)SQo!rAEW9uwDF!vzj?YY0|U52%4 z;8VFEe|56FTMCxvCzqD`)n^3Y&9P=L*)7$vSfs1g;+hUZ^U4E;X}Jta<%r#C`P zgXGNvu2%|xjKocs1wZj&(^#Ovv_C)&c#x2vaf4qsP|ES}iT%$97k^~ZDA2b3kwijt`l3Q><%pQ;p zfXG#moL#;>5`uS@xD+aqm>>?X5e|N0FL?2z-8}BNqV-##7Ju0BG4fjT<(DrGC>80) zw;(PV;lt>t;XPm;^+=rp_ywf+kSha36-(sypc43{D%$wj=nlN7$1`|E;^HVQj%hY; zF4RdfTCzG($tk%5#CO=j2$Kq4&C9sLw|?}G7w~PA zjnZW|H|8vm7h)A-lB*Bu7t03hOYXK++G>6yijOu<&+ps)?#ih&h9-^oKmdQ?b-Yh|OWP@i%~4#^iP{7Jj*~?w#pO4$SxH zvxw}J*_RUDJX_p!;raU_Z4#LQ`ElyR;OtZSDOm2Dk08x%Dj1-R#B`!cl+cwWJ>9 z$$%rv<+v2Xi{n-!9D6i+)A4ou8|l`hyrlub@xd@F!-GiAFAF|?RE^YaG^J zeEcpY^7OE25%@jJR)-$IZrzuwZ$3K6!LT70IwkBY=u`%Je6q7m?% z#8jnP_otuq_w!YE&zlqH)I;OW?8CemQw+6WR}_z5*Rr@O&x>bR&dr(0^vTD zT*;UZZ{A7k6bZ|a)`g;AL?v{sCkB27EoE<(J`3Qyl|ko;?h&pq4K&G>+A>apaCY?s z|0d(#+NcBWsRbs+(Xuo~LKNr}6*0E!>knn-w98al1T5S|zAtY3Jk?h*k}wsp2E-ok zzODRH)uHwod385N96loZ%92`B`B?b!csmk;hxq~m3_tj5SGmAvp^Nav zHTt0$@CT=j$JQ*n$T+NE>~%oMe&RsF>g)xzD}!G+cu>J(5FHnR*5Ne46XJ9l-k&zq zMl193e_Xcf2K0=C4QZ&=C0sv$N?H|;t0dwy2ZN9w^}yVfOO!fVYYrhPIo)+PdaiZ2 zw*`WlOgJi!axg|wpXWGV=Q`B%Lx$NOdSFs-4L9KaB>#!T3eC6_%`PEK{4H?Uq1Z3j zV;OS%MpXOaor(IEl-X%UcLwxG%uAMO_)stYW#96@?Z?tt>%;z1V&$M`={12-_*6~k z4{Wf!5VRX!8|Tj-w+*DI0G-vBhd&^FusitrrFcQ$jnAvT_R1jBp%uWH)oF(lwI-@e z3yG%%Z$o2OfN(j#`_&1UP8rB{sj>Yr?yBom-LG7&#G_0XD}B9kRoWpbjjqi|pev_U zU)lQN?%~VMoekEmwcJ;#s`OUxEbT9MlZB$(mV^Le#ndxklK0}b^Os{koWPSxX@RJ2 zRg0k_@k#@DP~Q!g&7qF+$+)6aJpNn7E{u}qFm$TU~q#Y2KI6j~Jdck#?+Pn>n7WjjU&_#Xcm{DPhqea^Z zgYOU+J&m{;C@Sl!o>WdB z_#LW$!+V#e7181WL~5|MANn-LC{`4$oL_V^M*r3rRF6*P@3T57iKkpy93m zo1bto>@CCyKKS0nrK3SwYP#kNb`fS=Wik>No5?4^Ce&^a&!9|+Sry!Z&%ho}ZxV-d zEhxuTr=MQEg3oN8o(I_H6GKO-M$Cih`dG^p0rvZAa+eH?XQu`xWx*k26;!Ej-QQtq zT#%rYtN{;m5bqF_fM)u2T>6-4eBW@2{3QN&n)OmV#l^C2ZQp}ABIjEy%byieH4p9s z-{5>v>@q+2o`WGa?|bx_`nnpAIq|;b>n0z?8r<3@`d{~%8^XHAYFPU%RW*nB9z{dw zEcO!lxxFG~fd_>6{(s!9=EI+H_NItCIu(&>#`nZa0u3d-l2w5ONk_tbvcLcU00000 E0BGZP2mk;8 literal 0 HcmV?d00001 diff --git a/lib/.DS_Store b/lib/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..cd8d4957070919f36d185ccfaeff80292438ec34 GIT binary patch literal 8196 zcmeHMTWl0n7(U5v8j87WxJZUuYU_gm4ny83ADw<%74@8anf{DJUJowL?St46_Fd>@8Imw*={I_$? z|Ihc&>De;I(4N=VGFHbJ6Y24(Qcc|*ny>etDNP6^6+}V)tdPptmSZ30{1p305M?0B zK$L+f15pN|4E*;QpflTV;$6;tu8sOA15pP4ml^QC4{>^YnhfbAr@^O#y5I^xw4B62 zqp{)-h{i*j4Cy4Nq@f1xN|d`Iykda6lRO^UB||#NDR*ZGFCPe3MtDO(usY2j56l^o zoJM_=fhYr0GvMFd%bCtJ<}t10{$9ykX0RM9_X358x!%Srq+o>e7v=>fyNItO-xAQy!tg8_N0$m!?tsZ zhllVJ!Dg0(dz$#$VzS&q2al;NRgCHDFeXu&Q7z9@`}zk4_sMBxPJuSxJ>r>;?d~;l zF4447<5G}YvW~qgXZZTQtYhVeZ8xpV&T3XRZ)u*P%Ce6>Sl)P)~F=$e=V06;dZf!|`kj~GVzi8On zcz;Y)?`GPc&zQ&ZrgvPa7sX(W_+nMwuhX7(IJ&=zu3Qvp9$J0bVpZMeYdUfU^?K;A zVx=ngxD>pig@&uw$a2q7f`Q`tTOikUO|sf+X&J*(bcwIi(kiQc1)hIpTPWOAE35rk zXLy)GbVFHNyQ&`am))+pUiXM$)0VD;Yd=D|L3c>Y8Qr4~{c_kT3()sKr7oMFJ0^ z87*iGp#3?+5GdPQ9@e*Fat2l=@@HQ^sBHqFKxPnjd z8NR@Ee24Gx18(3C{E3@Fg)m>J6BYUQEy8+XgRoQR7WxHEI3hU07!N=w zmfHc&7klA2jY`Wc{DcVS?_6%NM>lWTx^4QD+qGG|z~XbZMT5Pky`}o z-%B9C{cY6};6A`b31+YIN|YBBtF*Kvd#PZIG10GErHWfGk(Y{8FiHhym&+?eDix)Y zvx(Shk&06h0#;rdTPINoDrKBa#F`~h0cY0B8zm}6rJS>>oRlPm1x&0X)+JIwDdN3C z`Zac){m6b{Hwo#pFc)=Ljt0W_!`O_Sgm53u2#ap272Y0WacZyoT2a?{DHQLi@Y8g!k|vKEh>u65{?FLjBM9wFJpKiWue;zm+1n z + */ + +export interface ColumnResizeParams { + onResize: (width: number) => void; + onResizeEnd: (width: number) => void; + minWidth?: number; +} + +export function columnResize(node: HTMLElement, params: ColumnResizeParams) { + let startX: number; + let startWidth: number; + let currentWidth: number; + let currentParams = params; + let isLeftHandle = false; + + function onMouseDown(e: MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + + // Get the parent th/td element's width + const parent = node.parentElement; + if (!parent) return; + + // Check if this is a left-side resize handle + isLeftHandle = node.classList.contains('resize-handle-left'); + + startX = e.clientX; + startWidth = parent.offsetWidth; + currentWidth = startWidth; + + // Set cursor for entire document during drag + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + } + + function onMouseMove(e: MouseEvent) { + let delta = e.clientX - startX; + // For left-side handles, invert the delta (drag left = wider) + if (isLeftHandle) { + delta = -delta; + } + currentWidth = Math.max(currentParams.minWidth ?? 50, startWidth + delta); + currentParams.onResize(currentWidth); + } + + function onMouseUp() { + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + + // Use the calculated width, not the rendered width + currentParams.onResizeEnd(currentWidth); + } + + node.addEventListener('mousedown', onMouseDown); + + return { + update(newParams: ColumnResizeParams) { + currentParams = newParams; + }, + destroy() { + node.removeEventListener('mousedown', onMouseDown); + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + } + }; +} diff --git a/lib/assets/favicon.svg b/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/lib/components/AvatarCropper.svelte b/lib/components/AvatarCropper.svelte new file mode 100644 index 0000000..0104650 --- /dev/null +++ b/lib/components/AvatarCropper.svelte @@ -0,0 +1,274 @@ + + + + +{#if show && imageUrl} +
+
+ +
+

Crop avatar

+

+ Drag to reposition. Use the slider to zoom. +

+
+ + +
+ +
+ + +
+
+ + + +
+
+ + +
+ + +
+
+
+{/if} diff --git a/lib/components/BatchOperationModal.svelte b/lib/components/BatchOperationModal.svelte new file mode 100644 index 0000000..fa25134 --- /dev/null +++ b/lib/components/BatchOperationModal.svelte @@ -0,0 +1,332 @@ + + + !isOpen && handleClose()}> + isRunning && e.preventDefault()}> + + {title} + + {#if isRunning} + Processing {items.length} {entityType}... + {:else if isComplete} + Completed: {successCount} succeeded{#if failCount > 0}, {failCount} failed{/if}{#if cancelledCount > 0}, {cancelledCount} cancelled{/if} + {:else} + Preparing to {operation} {items.length} {entityType}... + {/if} + + + + +
+ +
+ {progress()}% +
+
+ + +
+ {#each itemStates as item (item.id)} +
+
+ +
+ {#if item.status === 'pending'} + + {:else if item.status === 'processing'} + + {:else if item.status === 'success'} + + {:else if item.status === 'error'} + + {:else if item.status === 'cancelled'} + + {/if} +
+ + + + {item.name} + + + + + {#if item.status === 'pending'} + pending + {:else if item.status === 'processing'} + {progressText[operation] ?? operation}... + {:else if item.status === 'success'} + done + {:else if item.status === 'error'} + failed + {:else if item.status === 'cancelled'} + cancelled + {/if} + +
+ + {#if item.status === 'error' && item.error} +
+ {item.error} +
+ {/if} +
+ {/each} +
+ + +
+
+
+ + {successCount} +
+
+ + {failCount} +
+
+ + {cancelledCount} +
+
+ + {items.length - successCount - failCount - cancelledCount} +
+
+ {#if isRunning} + + {:else} + + {/if} +
+
+
diff --git a/lib/components/CodeEditor.svelte b/lib/components/CodeEditor.svelte new file mode 100644 index 0000000..f49406f --- /dev/null +++ b/lib/components/CodeEditor.svelte @@ -0,0 +1,821 @@ + + +
e.stopPropagation()} +>
+ + diff --git a/lib/components/ColumnSettingsPopover.svelte b/lib/components/ColumnSettingsPopover.svelte new file mode 100644 index 0000000..c08e56a --- /dev/null +++ b/lib/components/ColumnSettingsPopover.svelte @@ -0,0 +1,138 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + +
+
+ Columns + +
+
+ +
+ {#each columns as column, index (column.id)} +
+
+ + +
+ toggleColumn(index)} + /> + +
+ {/each} +
+ +
+
diff --git a/lib/components/CommandPalette.svelte b/lib/components/CommandPalette.svelte new file mode 100644 index 0000000..9934c7f --- /dev/null +++ b/lib/components/CommandPalette.svelte @@ -0,0 +1,355 @@ + + + + + + No results found. + + {#each filteredItems as item (item.href)} + handleSelect(item.href)} + > + + {item.name} + + {/each} + + {#if $licenseStore.isEnterprise && $authStore.authEnabled} + + + handleSelect('/audit')} + > + + Audit log + + + {/if} + + + {#each lightThemes as theme (theme.id)} + handleLightThemeSelect(theme.id)} + > + +
+
+ {theme.name} +
+ {#if $themeStore.lightTheme === theme.id} + + {/if} +
+ {/each} +
+ + + {#each darkThemes as theme (theme.id)} + handleDarkThemeSelect(theme.id)} + > + +
+
+ {theme.name} +
+ {#if $themeStore.darkTheme === theme.id} + + {/if} +
+ {/each} +
+ + + {#each fonts as font (font.id)} + handleFontSelect(font.id)} + > + + {font.name} + {#if $themeStore.font === font.id} + + {/if} + + {/each} + + {#if environments.length > 0} + + + {#each environments as env (env.id)} + handleEnvSelect(env)} + > + + {env.name} + {#if $currentEnvironment?.id === env.id} + + {/if} + + {/each} + + {/if} + {#if containers.length > 0} + + + {#each containers as container (container.id)} + handleContainerAction(container.id, 'logs')} + > + +
+ {container.name} + {container.envName} • {container.image} +
+
+ {#if container.state === 'running'} + + + + + {:else} + + {/if} +
+
+ {/each} +
+ {/if} +
+
diff --git a/lib/components/ConfirmPopover.svelte b/lib/components/ConfirmPopover.svelte new file mode 100644 index 0000000..ace900f --- /dev/null +++ b/lib/components/ConfirmPopover.svelte @@ -0,0 +1,114 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + +
+ {action} {itemType} {#if displayName}{displayName}{/if}? + +
+
+
diff --git a/lib/components/ExecutionLogViewer.svelte b/lib/components/ExecutionLogViewer.svelte new file mode 100644 index 0000000..7920616 --- /dev/null +++ b/lib/components/ExecutionLogViewer.svelte @@ -0,0 +1,94 @@ + + +
+
+ Logs + {#if onToggleTheme} + + {/if} +
+
+ {#if logs} + {#each logs.split('\n') as line} + {@const parsed = parseLogLine(line)} + {@const badge = getTypeBadge(parsed.type)} +
+ + {badge.label} + + {#if parsed.timestamp} + + {formatTimestamp(parsed.timestamp)} + + {/if} + {cleanContent(parsed.content, parsed.type)} +
+ {/each} + {:else} + No logs available + {/if} +
+
diff --git a/lib/components/MultiSelectFilter.svelte b/lib/components/MultiSelectFilter.svelte new file mode 100644 index 0000000..f7ce96c --- /dev/null +++ b/lib/components/MultiSelectFilter.svelte @@ -0,0 +1,93 @@ + + + + + {#if hasIcons || defaultIcon} + {@const opt = singleOption()} + {@const IconComponent = opt?.icon || defaultIcon} + {#if IconComponent} + + {/if} + {/if} + + {displayLabel()} + + + + {#if value.length > 0} + + {/if} + {#each options as option} + + {#if option.icon} + + {/if} + {option.label} + + {/each} + + diff --git a/lib/components/PageHeader.svelte b/lib/components/PageHeader.svelte new file mode 100644 index 0000000..f285f43 --- /dev/null +++ b/lib/components/PageHeader.svelte @@ -0,0 +1,83 @@ + + +
+ +

{title}

+ {#if countDisplay()} + + {countDisplay()} + + {/if} + {#if showConnection} + + + + {/if} + +
diff --git a/lib/components/PasswordStrengthIndicator.svelte b/lib/components/PasswordStrengthIndicator.svelte new file mode 100644 index 0000000..db2cff3 --- /dev/null +++ b/lib/components/PasswordStrengthIndicator.svelte @@ -0,0 +1,62 @@ + + +{#if password} +
+
+ {#each [1, 2, 3, 4] as level} +
+ {/each} +
+

{strengthLabel}

+
+{/if} diff --git a/lib/components/PullTab.svelte b/lib/components/PullTab.svelte new file mode 100644 index 0000000..6854f39 --- /dev/null +++ b/lib/components/PullTab.svelte @@ -0,0 +1,506 @@ + + +
+ + {#if showImageInput} +
+ +
+ + +
+
+ {/if} + + + {#if status !== 'idle'} +
+ +
+
+ {#if status === 'pulling'} + + Pulling layers... + {:else if status === 'complete'} + + Pull completed! + {:else if status === 'error'} + + Failed + {/if} +
+
+ {#if status === 'pulling' || status === 'complete'} + + {#if totalLayers > 0} + {completedLayers} / {totalLayers} layers + {:else} + ... + {/if} + + {/if} + + {#if duration > 0}{formatDuration(duration)}{/if} + +
+
+ + + {#if status === 'pulling'} +
+ +
+ {#if downloadStats.totalBytes > 0} + Downloaded: {formatBytes(downloadStats.downloadedBytes)} / {formatBytes(downloadStats.totalBytes)} + {/if} +
+
+ {/if} + + + {#if errorMessage} +
+
+ + {errorMessage} +
+
+ {/if} +
+ + + {#if status === 'pulling' || status === 'complete' || hasLayers} +
+ + + + + + + + + + {#each sortedLayers as layer (layer.id)} + {@const percentage = getProgressPercentage(layer)} + {@const statusLower = layer.status.toLowerCase()} + {@const isComplete = statusLower.includes('complete') || statusLower.includes('already exists')} + {@const isDownloading = statusLower.includes('downloading')} + {@const isExtracting = statusLower.includes('extracting')} + + + + + + {/each} + +
Layer IDStatusProgress
+ {layer.id.slice(0, 12)} + +
+ {#if isComplete} + + {:else if isDownloading || isExtracting} + + {:else} + + {/if} + + {layer.status} + +
+
+ {#if (isDownloading || isExtracting) && layer.current && layer.total} +
+
+
+
+ {percentage}% +
+ {:else if isComplete} + Done + {:else} + - + {/if} +
+
+ {/if} + + +
+
+
+ + Output ({outputLines.length} lines) +
+ +
+
+ {#each outputLines as line} +
+ {#if line.startsWith('[pull]')} + pull + {line.slice(7)} + {:else if line.startsWith('[layer]')} + layer + {line.slice(8)} + {:else if line.startsWith('[error]')} + error + {line.slice(8)} + {:else} + {line} + {/if} +
+ {/each} +
+
+ {/if} + + + {#if status === 'idle' && !showImageInput} +
+

Enter an image name to start pulling

+
+ {/if} +
diff --git a/lib/components/PushTab.svelte b/lib/components/PushTab.svelte new file mode 100644 index 0000000..edce295 --- /dev/null +++ b/lib/components/PushTab.svelte @@ -0,0 +1,308 @@ + + +
+ + {#if status !== 'idle'} +
+
+
+ {#if status === 'pushing'} + + {statusMessage} + {:else if status === 'complete'} + + Push complete! + {:else if status === 'error'} + + Push failed + {/if} +
+ {#if status === 'complete' && targetTag} + {targetTag} + {/if} +
+ + {#if errorMessage} +
+
+ + {errorMessage} +
+
+ {/if} +
+ + + {#if outputLines.length > 0 || status === 'pushing'} +
+
+
+ + Output ({outputLines.length} lines) +
+ +
+
+ {#each outputLines as line} +
+ {#if line.startsWith('[push]')} + push + {line.slice(7)} + {:else if line.startsWith('[layer')} + layer + {line.slice(line.indexOf(']') + 2)} + {:else if line.startsWith('[error]')} + error + {line.slice(8)} + {:else} + {line} + {/if} +
+ {/each} +
+
+ {/if} + {/if} + + + {#if status === 'idle'} +
+ +

Ready to push to {registryName}

+
+ {/if} +
diff --git a/lib/components/ScanTab.svelte b/lib/components/ScanTab.svelte new file mode 100644 index 0000000..9452d0f --- /dev/null +++ b/lib/components/ScanTab.svelte @@ -0,0 +1,390 @@ + + +
+ +
+
+
+ {#if status === 'idle'} + + Ready to scan + {:else if status === 'scanning'} + + Scanning for vulnerabilities... + {:else if status === 'complete'} + {#if hasCriticalOrHigh} + + Vulnerabilities found + {:else if totalVulnerabilities > 0} + + Some vulnerabilities found + {:else} + + No vulnerabilities! + {/if} + {:else if status === 'error'} + + Scan failed + {/if} +
+
+ {#if status === 'complete' && results.length > 0} + 0 ? 'secondary' : 'outline'} class="text-xs"> + {totalVulnerabilities} vulnerabilities + + {/if} + + {#if duration > 0}{formatDuration(duration)}{/if} + +
+
+ + + {#if scanMessage && status === 'scanning'} +

{scanMessage}

+ {/if} + + + {#if errorMessage} +
+
+ + {errorMessage} +
+
+ {/if} +
+ + + {#if status === 'idle'} +
+ +

Scan {imageName} for vulnerabilities

+ +
+ {/if} + + + {#if status !== 'idle'} + + {#if results.length > 0} +
+ + +
+ {/if} + + +
+ {#if activeTab === 'output' || results.length === 0} + +
+
+ + Output ({outputLines.length} lines) +
+ +
+
+ {#each outputLines as line} +
+ {#if line.startsWith('[grype]')} + grype + {line.slice(8)} + {:else if line.startsWith('[trivy]')} + trivy + {line.slice(8)} + {:else if line.startsWith('[dockhand]')} + dockhand + {line.slice(11)} + {:else if line.startsWith('[scan]')} + scan + {line.slice(7)} + {:else if line.startsWith('[error]')} + error + {line.slice(8)} + {:else} + {line} + {/if} +
+ {/each} +
+ {:else} + +
+ +
+ {/if} +
+ {/if} +
diff --git a/lib/components/ScannerSeverityPills.svelte b/lib/components/ScannerSeverityPills.svelte new file mode 100644 index 0000000..01bca0b --- /dev/null +++ b/lib/components/ScannerSeverityPills.svelte @@ -0,0 +1,39 @@ + + +
+ {#each results as result} +
+ {result.scanner === 'grype' ? 'Grype' : 'Trivy'}: + {#if (result.critical || 0) > 0} + {result.critical} + {/if} + {#if (result.high || 0) > 0} + {result.high} + {/if} + {#if (result.medium || 0) > 0} + {result.medium} + {/if} + {#if (result.low || 0) > 0} + {result.low} + {/if} +
+ {/each} +
diff --git a/lib/components/Sidebar.svelte b/lib/components/Sidebar.svelte new file mode 100644 index 0000000..c1687ab --- /dev/null +++ b/lib/components/Sidebar.svelte @@ -0,0 +1,106 @@ + + + diff --git a/lib/components/StackEnvVarsEditor.svelte b/lib/components/StackEnvVarsEditor.svelte new file mode 100644 index 0000000..76bb626 --- /dev/null +++ b/lib/components/StackEnvVarsEditor.svelte @@ -0,0 +1,234 @@ + + +
+ +
+ {#each variables as variable, index} + {@const source = getSource(variable.key)} + {@const isVarRequired = isRequired(variable.key)} + {@const isVarOptional = isOptional(variable.key)} + {@const isVarMissing = isMissing(variable.key)} + {@const isVarUnused = isUnused(variable.key)} +
+ + {#if showSource} +
+ {#if source === 'file'} + + + + +

From .env file

+
+ {:else if source === 'override'} + + + + +

Manual override

+
+ {/if} +
+ {/if} + + + {#if validation} +
+ {#if variable.key} + {#if isVarRequired && !isVarMissing} + + + + +

Required variable defined

+
+ {:else if isVarOptional} + + + + +

Optional variable (has default)

+
+ {:else if isVarUnused} + + + + +

Unused variable

+
+ {/if} + {/if} +
+ {/if} + + +
+ Name + +
+ + +
+ Value + +
+ + + {#if !readonly} + {@const existingSecret = isExistingSecret(variable.key, variable.isSecret)} + {#if existingSecret} + +
+ +
+ {:else} + + + {/if} + {:else if variable.isSecret} +
+ +
+ {/if} + + + {#if !readonly} + + {/if} +
+ {/each} + + + {#if variables.length === 0} +
+

No environment variables defined.

+ {#if !readonly} + + {/if} +
+ {/if} +
+
diff --git a/lib/components/StackEnvVarsPanel.svelte b/lib/components/StackEnvVarsPanel.svelte new file mode 100644 index 0000000..15446ec --- /dev/null +++ b/lib/components/StackEnvVarsPanel.svelte @@ -0,0 +1,236 @@ + + +
+ +
+
+
+ Environment variables + {#if infoText} + + + + + +

{infoText}

+
+
+ {/if} +
+ {#if !readonly} +
+ + + {#if hasVariables} + + + + {/if} +
+ + {/if} +
+ +
+ ${`{VAR}`} required + ${`{VAR:-default}`} optional + ${`{VAR:?error}`} required w/ error +
+ + {#if validation} +
+ {#if validation.missing.length > 0} + + {validation.missing.length} missing + + {/if} + {#if validation.required.length > 0} + + {validation.required.length - validation.missing.length} required + + {/if} + {#if validation.optional.length > 0} + + {validation.optional.length} optional + + {/if} + {#if validation.unused.length > 0} + + {validation.unused.length} unused + + {/if} +
+ {/if} + + {#if validation && validation.missing.length > 0 && !readonly} +
+ Add missing: + {#each validation.missing as missing} + + {/each} +
+ {/if} +
+ +
+ +
+
diff --git a/lib/components/ThemeSelector.svelte b/lib/components/ThemeSelector.svelte new file mode 100644 index 0000000..e202dda --- /dev/null +++ b/lib/components/ThemeSelector.svelte @@ -0,0 +1,247 @@ + + +
+ +
+
+ + +
+ + +
+ {#each lightThemes as theme} + {#if theme.id === selectedLightTheme} + + {theme.name} + {/if} + {/each} +
+
+ + {#each lightThemes as theme} + +
+ + {theme.name} +
+
+ {/each} +
+
+
+ + +
+
+ + +
+ + +
+ {#each darkThemes as theme} + {#if theme.id === selectedDarkTheme} + + {theme.name} + {/if} + {/each} +
+
+ + {#each darkThemes as theme} + +
+ + {theme.name} +
+
+ {/each} +
+
+
+ + +
+
+ + +
+ + + {#each fonts as font} + {#if font.id === selectedFont} + {font.name} + {/if} + {/each} + + + {#each fonts as font} + + {font.name} + + {/each} + + +
+ + +
+
+ + +
+ + + {#each fontSizes as size} + {#if size.id === selectedFontSize} + {size.name} + {/if} + {/each} + + + {#each fontSizes as size} + + {size.name} + + {/each} + + +
+ + +
+
+ + + + + + {#each fontSizes as size} + {#if size.id === selectedGridFontSize} + {size.name} + {/if} + {/each} + + + {#each fontSizes as size} + + {size.name} + + {/each} + + + + + +
+
+ + +
+ + + {#each monospaceFonts as font} + {#if font.id === selectedTerminalFont} + {font.name} + {/if} + {/each} + + + {#each monospaceFonts as font} + + {font.name} + + {/each} + + +
+ diff --git a/lib/components/TimezoneSelector.svelte b/lib/components/TimezoneSelector.svelte new file mode 100644 index 0000000..bb28fe4 --- /dev/null +++ b/lib/components/TimezoneSelector.svelte @@ -0,0 +1,142 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + + + + No timezone found. + {#if filteredCommon.length > 0} + + {#each filteredCommon as tz} + selectTimezone(tz)}> + + {formatTimezone(tz)} + + {/each} + + {/if} + {#if filteredOther.length > 0} + + {#each filteredOther as tz} + selectTimezone(tz)}> + + {formatTimezone(tz)} + + {/each} + + {/if} + + + + diff --git a/lib/components/UpdateContainerRow.svelte b/lib/components/UpdateContainerRow.svelte new file mode 100644 index 0000000..b094c16 --- /dev/null +++ b/lib/components/UpdateContainerRow.svelte @@ -0,0 +1,106 @@ + + +
+ +
+
{name}
+ {#if error} +
{error}
+ {:else if blockReason} +
{blockReason}
+ {:else} +
{stepLabel}
+ {/if} +
+ + + {#if scannerResults && scannerResults.length > 0} + + {/if} + + + {#if status === 'done' || status === 'updated'} + + {:else if status === 'failed'} + + {:else if status === 'blocked' && onForceUpdate} + {#if isForceUpdating} + + {:else} + + {/if} + {/if} + + + {#if hasToggle} + + {/if} +
diff --git a/lib/components/UpdateStepIndicator.svelte b/lib/components/UpdateStepIndicator.svelte new file mode 100644 index 0000000..59b5a8b --- /dev/null +++ b/lib/components/UpdateStepIndicator.svelte @@ -0,0 +1,26 @@ + + +
+ + {#if showLabel} + {label} + {/if} +
diff --git a/lib/components/UpdateSummaryStats.svelte b/lib/components/UpdateSummaryStats.svelte new file mode 100644 index 0000000..a54a175 --- /dev/null +++ b/lib/components/UpdateSummaryStats.svelte @@ -0,0 +1,69 @@ + + +{#if compact} + +
+ {#if checked !== undefined} +
+ {checked} + Checked +
+ {/if} + {#if updated > 0} +
+ + {updated} updated +
+ {/if} + {#if blocked > 0} +
+ + {blocked} blocked +
+ {/if} + {#if failed > 0} +
+ + {failed} failed +
+ {/if} +
+{:else} + +
+ {#if checked !== undefined} +
+ + {checked} + Checked +
+ {/if} +
+ + {updated} + Updated +
+
+ + {blocked} + Blocked +
+
+ + {failed} + Failed +
+
+{/if} diff --git a/lib/components/VulnerabilityCriteriaBadge.svelte b/lib/components/VulnerabilityCriteriaBadge.svelte new file mode 100644 index 0000000..d9a4f51 --- /dev/null +++ b/lib/components/VulnerabilityCriteriaBadge.svelte @@ -0,0 +1,27 @@ + + + + + {#if showLabel} + {label} + {/if} + diff --git a/lib/components/VulnerabilityCriteriaSelector.svelte b/lib/components/VulnerabilityCriteriaSelector.svelte new file mode 100644 index 0000000..5bfc5f1 --- /dev/null +++ b/lib/components/VulnerabilityCriteriaSelector.svelte @@ -0,0 +1,85 @@ + + + { + if (v) { + value = v as VulnerabilityCriteria; + onchange?.(value); + } + }} +> + + + {#if value === 'never'} + + Never block + {:else if value === 'any'} + + Any vulnerabilities + {:else if value === 'critical_high'} + + Critical or high + {:else if value === 'critical'} + + Critical only + {:else if value === 'more_than_current'} + + More than current image + {:else} + + Never block + {/if} + + + + +
+ + Never block +
+
+ +
+ + Any vulnerabilities +
+
+ +
+ + Critical or high +
+
+ +
+ + Critical only +
+
+ +
+ + More than current image +
+
+
+
diff --git a/lib/components/WhatsNewModal.svelte b/lib/components/WhatsNewModal.svelte new file mode 100644 index 0000000..6f715b3 --- /dev/null +++ b/lib/components/WhatsNewModal.svelte @@ -0,0 +1,81 @@ + + + + + + + + Dockhand has been updated to {version} + + + +
+ {#each missedReleases as release} +
+

+ v{release.version} + ({release.date}) +

+
+ {#each release.changes as change} + {@const { icon: Icon, class: iconClass } = getChangeIcon(change.type)} +
+ + {change.text} +
+ {/each} +
+
+ {/each} +
+ + + + +
+
diff --git a/lib/components/app-sidebar.svelte b/lib/components/app-sidebar.svelte new file mode 100644 index 0000000..a045e68 --- /dev/null +++ b/lib/components/app-sidebar.svelte @@ -0,0 +1,195 @@ + + + + + +
+ + Dockhand Logo + + {#if $licenseStore.isEnterprise} + + {/if} + + +
+ + +
+ + + + + {#each menuItems as item} + {#if canSeeMenuItem(item)} + + sidebar.setOpenMobile(false)}> + + + {/if} + {/each} + + + + + + {#if $authStore.authEnabled && $authStore.authenticated && $authStore.user} + + + + sidebar.setOpenMobile(false)} + class="flex items-center gap-2 px-2 py-1.5 group-data-[state=collapsed]:px-1 group-data-[state=collapsed]:py-1 rounded-md hover:bg-sidebar-accent transition-colors group-data-[state=collapsed]:justify-center" + title="View profile" + > + + + + {($authStore.user.displayName || $authStore.user.username)?.slice(0, 2).toUpperCase()} + + +
+ {$authStore.user.displayName || $authStore.user.username} + {$authStore.user.isAdmin ? 'Admin' : 'User'} +
+
+
+ + + +
+
+ {/if} +
diff --git a/lib/components/cron-editor.svelte b/lib/components/cron-editor.svelte new file mode 100644 index 0000000..c4e768a --- /dev/null +++ b/lib/components/cron-editor.svelte @@ -0,0 +1,308 @@ + + +
+ + + +
+ {#if scheduleType === 'daily'} + + Daily + {:else if scheduleType === 'weekly'} + + Weekly + {:else} + + Custom + {/if} +
+
+ + +
+ + Daily +
+
+ +
+ + Weekly +
+
+ +
+ + Custom +
+
+
+
+ + {#if scheduleType === 'daily' || scheduleType === 'weekly'} + + at + + + {hours.find((h: { value: string; label: string }) => h.value === hour)?.label || hour} + + + {#each hours as h} + + {/each} + + + + + {minutes.find(m => m.value === minute)?.label || `:${minute}`} + + + {#each minutes as m} + + {/each} + + + + {#if scheduleType === 'weekly'} + on + + + {daysOfWeek.find(d => d.value === dayOfWeek)?.label || dayOfWeek} + + + {#each daysOfWeek as d} + + {/each} + + + {/if} + + {:else} + + {@const readable = humanReadable()} + {@const isInvalid = readable === 'Invalid'} + + {/if} +
+ + +
+ {#if value} + {@const readable = humanReadable()} + {@const isInvalid = readable === 'Invalid'} +

+ {readable} +

+ {/if} +
diff --git a/lib/components/data-grid/DataGrid.svelte b/lib/components/data-grid/DataGrid.svelte new file mode 100644 index 0000000..6c76fc2 --- /dev/null +++ b/lib/components/data-grid/DataGrid.svelte @@ -0,0 +1,850 @@ + + +{#snippet skeletonContent()} +
+ + + + {#each fixedStartCols as colId (colId)} + + {/each} + + + {#each orderedColumns as colId (colId)} + {@const colConfig = columnConfigMap.get(colId)} + {#if colConfig} + + {/if} + {/each} + + + {#each fixedEndCols as colId (colId)} + + {/each} + + + + {#each skeletonIndices as i (i)} + + + {#each fixedStartCols as colId (colId)} + + {/each} + + + {#each orderedColumns as colId (colId)} + {@const colConfig = columnConfigMap.get(colId)} + {#if colConfig} + {@const width = getDisplayWidth(colId)} + + {/if} + {/each} + + + {#each fixedEndCols as colId (colId)} + + {/each} + + {/each} + +
+ {colConfig.label} + + {#if colId === 'actions'} +
+ Actions + +
+ {/if} +
+ + + + + +
+{/snippet} + +{#snippet tableHeader()} + + + + {#each fixedStartCols as colId (colId)} + {@const colConfig = columnConfigMap.get(colId)} + + {#if colId === 'select' && selectable} + + {:else if colId === 'expand' && expandable} + + {:else if headerCell} + {@render headerCell(colConfig!, sortState)} + {:else} + {colConfig?.label ?? ''} + {/if} + + {/each} + + + {#each orderedColumns as colId (colId)} + {@const colConfig = columnConfigMap.get(colId)} + {#if colConfig} + + {#if headerCell} + {@render headerCell(colConfig, sortState)} + {:else if isSortable(colId)} + + {:else} + {colConfig.label} + {/if} + + + {#if isResizable(colId)} +
handleResize(colId, w), + onResizeEnd: (w) => handleResizeEnd(colId, w), + minWidth: colConfig.minWidth + }} + >
+ {/if} + + {/if} + {/each} + + + {#each fixedEndCols as colId (colId)} + {@const colConfig = columnConfigMap.get(colId)} + + {#if colId === 'actions'} +
+ Actions + +
+ {:else if headerCell} + {@render headerCell(colConfig!, sortState)} + {:else} + {colConfig?.label ?? ''} + {/if} + + + {#if isResizable(colId)} +
handleResize(colId, w), + onResizeEnd: (w) => handleResizeEnd(colId, w), + minWidth: colConfig?.minWidth + }} + >
+ {/if} + + {/each} + + +{/snippet} + +{#snippet tableBody()} + + {#each visibleData as item, index (item[keyField])} + {@const rowState = getRowState(item, index)} + onRowClick?.(item, e)} + > + + {#each fixedStartCols as colId (colId)} + {@const colConfig = columnConfigMap.get(colId)} + + {#if colId === 'select' && selectable} + {#if rowState.isSelectable} + + {/if} + {:else if colId === 'expand' && expandable} + + {:else if cell} + {@render cell(colConfig!, item, rowState)} + {/if} + + {/each} + + + {#each orderedColumns as colId (colId)} + {@const colConfig = columnConfigMap.get(colId)} + {#if colConfig} + + {#if cell} + {@render cell(colConfig, item, rowState)} + {:else} + + {String(item[colId as keyof T] ?? '')} + {/if} + + {/if} + {/each} + + + {#each fixedEndCols as colId (colId)} + {@const colConfig = columnConfigMap.get(colId)} + e.stopPropagation()}> + {#if cell} + {@render cell(colConfig!, item, rowState)} + {/if} + + {/each} + + + + {#if rowState.isExpanded && expandedRow} + + + {@render expandedRow(item, rowState)} + + + {/if} + {/each} + +{/snippet} + +{#snippet tableContent()} + + {@render tableHeader()} + {@render tableBody()} +
+{/snippet} + +
+ {#if loading && data.length === 0} + {#if loadingState} + {@render loadingState()} + {:else} + {@render skeletonContent()} + {/if} + {:else if data.length === 0 && emptyState} + {@render emptyState()} + {:else if virtualScroll} + + + {@render tableHeader()} + + + {#if offsetY > 0} + + {/if} + + {#each visibleData as item, index (item[keyField])} + {@const rowState = getRowState(item, index)} + onRowClick?.(item, e)} + > + {#each fixedStartCols as colId (colId)} + {@const colConfig = columnConfigMap.get(colId)} + + {/each} + {#each orderedColumns as colId (colId)} + {@const colConfig = columnConfigMap.get(colId)} + {#if colConfig} + + {/if} + {/each} + {#each fixedEndCols as colId (colId)} + {@const colConfig = columnConfigMap.get(colId)} + + {/each} + + {#if rowState.isExpanded && expandedRow} + + + + {/if} + {/each} + + {#if totalHeight - offsetY - (visibleData.length * rowHeight) > 0} + + {/if} + +
+ {#if colId === 'select' && selectable} + {#if rowState.isSelectable} + + {/if} + {:else if colId === 'expand' && expandable} + + {:else if cell} + {@render cell(colConfig!, item, rowState)} + {/if} + + {#if cell} + {@render cell(colConfig, item, rowState)} + {:else} + {String(item[colId as keyof T] ?? '')} + {/if} + e.stopPropagation()}> + {#if cell} + {@render cell(colConfig!, item, rowState)} + {/if} +
+ {@render expandedRow(item, rowState)} +
+ {:else} + + {@render tableContent()} + {/if} +
diff --git a/lib/components/data-grid/context.ts b/lib/components/data-grid/context.ts new file mode 100644 index 0000000..88aeaea --- /dev/null +++ b/lib/components/data-grid/context.ts @@ -0,0 +1,28 @@ +/** + * DataGrid Context + * + * Provides shared state to child components via Svelte context. + */ + +import { getContext, setContext } from 'svelte'; +import type { DataGridContext } from './types'; + +const DATA_GRID_CONTEXT_KEY = Symbol('data-grid'); + +/** + * Set the DataGrid context (called by DataGrid.svelte) + */ +export function setDataGridContext(ctx: DataGridContext): void { + setContext(DATA_GRID_CONTEXT_KEY, ctx); +} + +/** + * Get the DataGrid context (called by child components) + */ +export function getDataGridContext(): DataGridContext { + const ctx = getContext>(DATA_GRID_CONTEXT_KEY); + if (!ctx) { + throw new Error('DataGrid context not found. Ensure component is used within a DataGrid.'); + } + return ctx; +} diff --git a/lib/components/data-grid/index.ts b/lib/components/data-grid/index.ts new file mode 100644 index 0000000..051c906 --- /dev/null +++ b/lib/components/data-grid/index.ts @@ -0,0 +1,16 @@ +/** + * DataGrid Component + * + * A reusable, feature-rich data grid with: + * - Column resizing, hiding, reordering + * - Sticky first/last columns + * - Multi-row selection + * - Sortable headers + * - Virtual scrolling (optional) + * - Preference persistence + */ + +import DataGrid from './DataGrid.svelte'; +export { DataGrid }; +export * from './types'; +export * from './context'; diff --git a/lib/components/data-grid/types.ts b/lib/components/data-grid/types.ts new file mode 100644 index 0000000..b6de4d1 --- /dev/null +++ b/lib/components/data-grid/types.ts @@ -0,0 +1,112 @@ +/** + * DataGrid Component Types + * + * Extends the base grid types with component-specific interfaces + * for the reusable DataGrid component. + */ + +import type { Snippet } from 'svelte'; +import type { GridId, ColumnConfig, ColumnPreference } from '$lib/types'; + +// Re-export base types for convenience +export type { GridId, ColumnConfig, ColumnPreference }; + +/** + * Sort state for the grid + */ +export interface DataGridSortState { + field: string; + direction: 'asc' | 'desc'; +} + +/** + * Row state passed to cell snippets + */ +export interface DataGridRowState { + isSelected: boolean; + isHighlighted: boolean; + isSelectable: boolean; + isExpanded: boolean; + index: number; +} + +/** + * Main DataGrid component props + */ +export interface DataGridProps { + // Required + data: T[]; + keyField: keyof T; + gridId: GridId; + + // Virtual Scroll Mode (OFF by default) + virtualScroll?: boolean; + rowHeight?: number; + bufferRows?: number; + + // Selection + selectable?: boolean; + selectedKeys?: Set; + onSelectionChange?: (keys: Set) => void; + + // Sorting + sortState?: DataGridSortState; + onSortChange?: (state: DataGridSortState) => void; + + // Infinite scroll (virtual mode) + hasMore?: boolean; + onLoadMore?: () => void; + loadMoreThreshold?: number; + + // Row interaction + onRowClick?: (item: T, event: MouseEvent) => void; + highlightedKey?: unknown; + rowClass?: (item: T) => string; + + // State + loading?: boolean; + + // CSS + class?: string; + wrapperClass?: string; + + // Snippets for customization + headerCell?: Snippet<[ColumnConfig, DataGridSortState | undefined]>; + cell?: Snippet<[ColumnConfig, T, DataGridRowState]>; + emptyState?: Snippet; + loadingState?: Snippet; +} + +/** + * Context provided to child components + */ +export interface DataGridContext { + // Grid configuration + gridId: GridId; + keyField: keyof T; + + // Column state + orderedColumns: string[]; + getDisplayWidth: (colId: string) => number; + getColumnConfig: (colId: string) => ColumnConfig | undefined; + + // Selection helpers + selectable: boolean; + isSelected: (key: unknown) => boolean; + toggleSelection: (key: unknown) => void; + selectAll: () => void; + selectNone: () => void; + allSelected: boolean; + someSelected: boolean; + + // Sort helpers + sortState: DataGridSortState | undefined; + toggleSort: (field: string) => void; + + // Resize helpers + handleResize: (colId: string, width: number) => void; + handleResizeEnd: (colId: string, width: number) => void; + + // Row state + highlightedKey: unknown; +} diff --git a/lib/components/host-info.svelte b/lib/components/host-info.svelte new file mode 100644 index 0000000..d13820d --- /dev/null +++ b/lib/components/host-info.svelte @@ -0,0 +1,466 @@ + + +
+ +
+ + + {#if showDropdown && envList.length > 0} +
+
+ {#each envList as env (env.id)} + {@const EnvIcon = getIconComponent(env.icon || 'globe')} + {@const isOffline = offlineEnvIds.has(env.id)} + {@const isSwitching = switchingEnvId === env.id} + + {/each} +
+
+ {/if} +
+ + {#if hostInfo} + | + + + + + + + + + + + + + + + + + + {#if hostInfo.cpus > 0} + + + {/if} + + + {#if hostInfo.totalMemory > 0} + + + {/if} + + + {#if diskUsage && !diskUsageLoading} + + + {/if} + + + {#if hostInfo.uptime > 0} + + + {/if} + + +
+ {lastUpdated.toLocaleTimeString()} + {#if isConnected} + + Live + {:else} + + {/if} +
+ {/if} +
diff --git a/lib/components/icon-picker.svelte b/lib/components/icon-picker.svelte new file mode 100644 index 0000000..0ea4a53 --- /dev/null +++ b/lib/components/icon-picker.svelte @@ -0,0 +1,65 @@ + + + + + + + +
+ +
+ {#each filteredIcons as iconName} + {@const IconComponent = iconMap[iconName]} + + {/each} +
+ {#if filteredIcons.length === 0} +

No icons found

+ {/if} +
+
+
diff --git a/lib/components/main-content.svelte b/lib/components/main-content.svelte new file mode 100644 index 0000000..8ffd6ca --- /dev/null +++ b/lib/components/main-content.svelte @@ -0,0 +1,9 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/permission-guard.svelte b/lib/components/permission-guard.svelte new file mode 100644 index 0000000..406d663 --- /dev/null +++ b/lib/components/permission-guard.svelte @@ -0,0 +1,22 @@ + + +{#if hasAccess} + {@render children()} +{:else if fallback} + {@render fallback()} +{/if} diff --git a/lib/components/theme-toggle.svelte b/lib/components/theme-toggle.svelte new file mode 100644 index 0000000..bebec2e --- /dev/null +++ b/lib/components/theme-toggle.svelte @@ -0,0 +1,44 @@ + + + diff --git a/lib/components/ui/accordion/accordion-content.svelte b/lib/components/ui/accordion/accordion-content.svelte new file mode 100644 index 0000000..cf7045e --- /dev/null +++ b/lib/components/ui/accordion/accordion-content.svelte @@ -0,0 +1,22 @@ + + + +
+ {@render children?.()} +
+
diff --git a/lib/components/ui/accordion/accordion-item.svelte b/lib/components/ui/accordion/accordion-item.svelte new file mode 100644 index 0000000..780545c --- /dev/null +++ b/lib/components/ui/accordion/accordion-item.svelte @@ -0,0 +1,17 @@ + + + diff --git a/lib/components/ui/accordion/accordion-trigger.svelte b/lib/components/ui/accordion/accordion-trigger.svelte new file mode 100644 index 0000000..c60922e --- /dev/null +++ b/lib/components/ui/accordion/accordion-trigger.svelte @@ -0,0 +1,32 @@ + + + + svg]:rotate-180", + className + )} + {...restProps} + > + {@render children?.()} + + + diff --git a/lib/components/ui/accordion/accordion.svelte b/lib/components/ui/accordion/accordion.svelte new file mode 100644 index 0000000..117ee37 --- /dev/null +++ b/lib/components/ui/accordion/accordion.svelte @@ -0,0 +1,16 @@ + + + diff --git a/lib/components/ui/accordion/index.ts b/lib/components/ui/accordion/index.ts new file mode 100644 index 0000000..ac343a1 --- /dev/null +++ b/lib/components/ui/accordion/index.ts @@ -0,0 +1,16 @@ +import Root from "./accordion.svelte"; +import Content from "./accordion-content.svelte"; +import Item from "./accordion-item.svelte"; +import Trigger from "./accordion-trigger.svelte"; + +export { + Root, + Content, + Item, + Trigger, + // + Root as Accordion, + Content as AccordionContent, + Item as AccordionItem, + Trigger as AccordionTrigger, +}; diff --git a/lib/components/ui/alert/alert-description.svelte b/lib/components/ui/alert/alert-description.svelte new file mode 100644 index 0000000..8b56aed --- /dev/null +++ b/lib/components/ui/alert/alert-description.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/ui/alert/alert-title.svelte b/lib/components/ui/alert/alert-title.svelte new file mode 100644 index 0000000..77e45ad --- /dev/null +++ b/lib/components/ui/alert/alert-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/ui/alert/alert.svelte b/lib/components/ui/alert/alert.svelte new file mode 100644 index 0000000..f7bf833 --- /dev/null +++ b/lib/components/ui/alert/alert.svelte @@ -0,0 +1,50 @@ + + + + + diff --git a/lib/components/ui/alert/index.ts b/lib/components/ui/alert/index.ts new file mode 100644 index 0000000..97e21b4 --- /dev/null +++ b/lib/components/ui/alert/index.ts @@ -0,0 +1,14 @@ +import Root from "./alert.svelte"; +import Description from "./alert-description.svelte"; +import Title from "./alert-title.svelte"; +export { alertVariants, type AlertVariant } from "./alert.svelte"; + +export { + Root, + Description, + Title, + // + Root as Alert, + Description as AlertDescription, + Title as AlertTitle, +}; diff --git a/lib/components/ui/avatar/avatar-fallback.svelte b/lib/components/ui/avatar/avatar-fallback.svelte new file mode 100644 index 0000000..249d4a4 --- /dev/null +++ b/lib/components/ui/avatar/avatar-fallback.svelte @@ -0,0 +1,17 @@ + + + diff --git a/lib/components/ui/avatar/avatar-image.svelte b/lib/components/ui/avatar/avatar-image.svelte new file mode 100644 index 0000000..2bb9db4 --- /dev/null +++ b/lib/components/ui/avatar/avatar-image.svelte @@ -0,0 +1,17 @@ + + + diff --git a/lib/components/ui/avatar/avatar.svelte b/lib/components/ui/avatar/avatar.svelte new file mode 100644 index 0000000..e37214d --- /dev/null +++ b/lib/components/ui/avatar/avatar.svelte @@ -0,0 +1,19 @@ + + + diff --git a/lib/components/ui/avatar/index.ts b/lib/components/ui/avatar/index.ts new file mode 100644 index 0000000..d06457b --- /dev/null +++ b/lib/components/ui/avatar/index.ts @@ -0,0 +1,13 @@ +import Root from "./avatar.svelte"; +import Image from "./avatar-image.svelte"; +import Fallback from "./avatar-fallback.svelte"; + +export { + Root, + Image, + Fallback, + // + Root as Avatar, + Image as AvatarImage, + Fallback as AvatarFallback, +}; diff --git a/lib/components/ui/badge/badge.svelte b/lib/components/ui/badge/badge.svelte new file mode 100644 index 0000000..bfaa9c5 --- /dev/null +++ b/lib/components/ui/badge/badge.svelte @@ -0,0 +1,50 @@ + + + + + + {@render children?.()} + diff --git a/lib/components/ui/badge/index.ts b/lib/components/ui/badge/index.ts new file mode 100644 index 0000000..64e0aa9 --- /dev/null +++ b/lib/components/ui/badge/index.ts @@ -0,0 +1,2 @@ +export { default as Badge } from "./badge.svelte"; +export { badgeVariants, type BadgeVariant } from "./badge.svelte"; diff --git a/lib/components/ui/button/button.svelte b/lib/components/ui/button/button.svelte new file mode 100644 index 0000000..182e1aa --- /dev/null +++ b/lib/components/ui/button/button.svelte @@ -0,0 +1,82 @@ + + + + +{#if href} + + {@render children?.()} + +{:else} + +{/if} diff --git a/lib/components/ui/button/index.ts b/lib/components/ui/button/index.ts new file mode 100644 index 0000000..fb585d7 --- /dev/null +++ b/lib/components/ui/button/index.ts @@ -0,0 +1,17 @@ +import Root, { + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants, +} from "./button.svelte"; + +export { + Root, + type ButtonProps as Props, + // + Root as Button, + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant, +}; diff --git a/lib/components/ui/calendar/calendar-caption.svelte b/lib/components/ui/calendar/calendar-caption.svelte new file mode 100644 index 0000000..5c93037 --- /dev/null +++ b/lib/components/ui/calendar/calendar-caption.svelte @@ -0,0 +1,76 @@ + + +{#snippet MonthSelect()} + { + if (!placeholder) return; + const v = Number.parseInt(e.currentTarget.value); + const newPlaceholder = placeholder.set({ month: v }); + placeholder = newPlaceholder.subtract({ months: monthIndex }); + }} + /> +{/snippet} + +{#snippet YearSelect()} + +{/snippet} + +{#if captionLayout === "dropdown"} + {@render MonthSelect()} + {@render YearSelect()} +{:else if captionLayout === "dropdown-months"} + {@render MonthSelect()} + {#if placeholder} + {formatYear(placeholder)} + {/if} +{:else if captionLayout === "dropdown-years"} + {#if placeholder} + {formatMonth(placeholder)} + {/if} + {@render YearSelect()} +{:else} + {formatMonth(month)} {formatYear(month)} +{/if} diff --git a/lib/components/ui/calendar/calendar-cell.svelte b/lib/components/ui/calendar/calendar-cell.svelte new file mode 100644 index 0000000..fad6c36 --- /dev/null +++ b/lib/components/ui/calendar/calendar-cell.svelte @@ -0,0 +1,19 @@ + + + diff --git a/lib/components/ui/calendar/calendar-day.svelte b/lib/components/ui/calendar/calendar-day.svelte new file mode 100644 index 0000000..32e9c83 --- /dev/null +++ b/lib/components/ui/calendar/calendar-day.svelte @@ -0,0 +1,35 @@ + + +span]:text-xs [&>span]:opacity-70", + className + )} + {...restProps} +/> diff --git a/lib/components/ui/calendar/calendar-grid-body.svelte b/lib/components/ui/calendar/calendar-grid-body.svelte new file mode 100644 index 0000000..8cd86de --- /dev/null +++ b/lib/components/ui/calendar/calendar-grid-body.svelte @@ -0,0 +1,12 @@ + + + diff --git a/lib/components/ui/calendar/calendar-grid-head.svelte b/lib/components/ui/calendar/calendar-grid-head.svelte new file mode 100644 index 0000000..333edc4 --- /dev/null +++ b/lib/components/ui/calendar/calendar-grid-head.svelte @@ -0,0 +1,12 @@ + + + diff --git a/lib/components/ui/calendar/calendar-grid-row.svelte b/lib/components/ui/calendar/calendar-grid-row.svelte new file mode 100644 index 0000000..9032236 --- /dev/null +++ b/lib/components/ui/calendar/calendar-grid-row.svelte @@ -0,0 +1,12 @@ + + + diff --git a/lib/components/ui/calendar/calendar-grid.svelte b/lib/components/ui/calendar/calendar-grid.svelte new file mode 100644 index 0000000..e0c8627 --- /dev/null +++ b/lib/components/ui/calendar/calendar-grid.svelte @@ -0,0 +1,16 @@ + + + diff --git a/lib/components/ui/calendar/calendar-head-cell.svelte b/lib/components/ui/calendar/calendar-head-cell.svelte new file mode 100644 index 0000000..131807e --- /dev/null +++ b/lib/components/ui/calendar/calendar-head-cell.svelte @@ -0,0 +1,19 @@ + + + diff --git a/lib/components/ui/calendar/calendar-header.svelte b/lib/components/ui/calendar/calendar-header.svelte new file mode 100644 index 0000000..5b7e397 --- /dev/null +++ b/lib/components/ui/calendar/calendar-header.svelte @@ -0,0 +1,19 @@ + + + diff --git a/lib/components/ui/calendar/calendar-heading.svelte b/lib/components/ui/calendar/calendar-heading.svelte new file mode 100644 index 0000000..a9b9810 --- /dev/null +++ b/lib/components/ui/calendar/calendar-heading.svelte @@ -0,0 +1,16 @@ + + + diff --git a/lib/components/ui/calendar/calendar-month-select.svelte b/lib/components/ui/calendar/calendar-month-select.svelte new file mode 100644 index 0000000..c17c59d --- /dev/null +++ b/lib/components/ui/calendar/calendar-month-select.svelte @@ -0,0 +1,44 @@ + + + + + {#snippet child({ props, monthItems, selectedMonthItem })} + + + {/snippet} + + diff --git a/lib/components/ui/calendar/calendar-month.svelte b/lib/components/ui/calendar/calendar-month.svelte new file mode 100644 index 0000000..e747fae --- /dev/null +++ b/lib/components/ui/calendar/calendar-month.svelte @@ -0,0 +1,15 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/ui/calendar/calendar-months.svelte b/lib/components/ui/calendar/calendar-months.svelte new file mode 100644 index 0000000..f717a9d --- /dev/null +++ b/lib/components/ui/calendar/calendar-months.svelte @@ -0,0 +1,19 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/ui/calendar/calendar-nav.svelte b/lib/components/ui/calendar/calendar-nav.svelte new file mode 100644 index 0000000..27f33d7 --- /dev/null +++ b/lib/components/ui/calendar/calendar-nav.svelte @@ -0,0 +1,19 @@ + + + diff --git a/lib/components/ui/calendar/calendar-next-button.svelte b/lib/components/ui/calendar/calendar-next-button.svelte new file mode 100644 index 0000000..d8eb4ef --- /dev/null +++ b/lib/components/ui/calendar/calendar-next-button.svelte @@ -0,0 +1,31 @@ + + +{#snippet Fallback()} + +{/snippet} + + diff --git a/lib/components/ui/calendar/calendar-prev-button.svelte b/lib/components/ui/calendar/calendar-prev-button.svelte new file mode 100644 index 0000000..3e4471a --- /dev/null +++ b/lib/components/ui/calendar/calendar-prev-button.svelte @@ -0,0 +1,31 @@ + + +{#snippet Fallback()} + +{/snippet} + + diff --git a/lib/components/ui/calendar/calendar-year-select.svelte b/lib/components/ui/calendar/calendar-year-select.svelte new file mode 100644 index 0000000..3ac691e --- /dev/null +++ b/lib/components/ui/calendar/calendar-year-select.svelte @@ -0,0 +1,43 @@ + + + + + {#snippet child({ props, yearItems, selectedYearItem })} + + + {/snippet} + + diff --git a/lib/components/ui/calendar/calendar.svelte b/lib/components/ui/calendar/calendar.svelte new file mode 100644 index 0000000..29b6fff --- /dev/null +++ b/lib/components/ui/calendar/calendar.svelte @@ -0,0 +1,115 @@ + + + + + {#snippet children({ months, weekdays })} + + + + + + {#each months as month, monthIndex (month)} + + + + + + + + {#each weekdays as weekday (weekday)} + + {weekday.slice(0, 2)} + + {/each} + + + + {#each month.weeks as weekDates (weekDates)} + + {#each weekDates as date (date)} + + {#if day} + {@render day({ + day: date, + outsideMonth: !isEqualMonth(date, month.value), + })} + {:else} + + {/if} + + {/each} + + {/each} + + + + {/each} + + {/snippet} + diff --git a/lib/components/ui/calendar/index.ts b/lib/components/ui/calendar/index.ts new file mode 100644 index 0000000..f3a16d2 --- /dev/null +++ b/lib/components/ui/calendar/index.ts @@ -0,0 +1,40 @@ +import Root from "./calendar.svelte"; +import Cell from "./calendar-cell.svelte"; +import Day from "./calendar-day.svelte"; +import Grid from "./calendar-grid.svelte"; +import Header from "./calendar-header.svelte"; +import Months from "./calendar-months.svelte"; +import GridRow from "./calendar-grid-row.svelte"; +import Heading from "./calendar-heading.svelte"; +import GridBody from "./calendar-grid-body.svelte"; +import GridHead from "./calendar-grid-head.svelte"; +import HeadCell from "./calendar-head-cell.svelte"; +import NextButton from "./calendar-next-button.svelte"; +import PrevButton from "./calendar-prev-button.svelte"; +import MonthSelect from "./calendar-month-select.svelte"; +import YearSelect from "./calendar-year-select.svelte"; +import Month from "./calendar-month.svelte"; +import Nav from "./calendar-nav.svelte"; +import Caption from "./calendar-caption.svelte"; + +export { + Day, + Cell, + Grid, + Header, + Months, + GridRow, + Heading, + GridBody, + GridHead, + HeadCell, + NextButton, + PrevButton, + Nav, + Month, + YearSelect, + MonthSelect, + Caption, + // + Root as Calendar, +}; diff --git a/lib/components/ui/card/card-action.svelte b/lib/components/ui/card/card-action.svelte new file mode 100644 index 0000000..cc36c56 --- /dev/null +++ b/lib/components/ui/card/card-action.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/ui/card/card-content.svelte b/lib/components/ui/card/card-content.svelte new file mode 100644 index 0000000..bc90b83 --- /dev/null +++ b/lib/components/ui/card/card-content.svelte @@ -0,0 +1,15 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/ui/card/card-description.svelte b/lib/components/ui/card/card-description.svelte new file mode 100644 index 0000000..9b20ac7 --- /dev/null +++ b/lib/components/ui/card/card-description.svelte @@ -0,0 +1,20 @@ + + +

+ {@render children?.()} +

diff --git a/lib/components/ui/card/card-footer.svelte b/lib/components/ui/card/card-footer.svelte new file mode 100644 index 0000000..cf43353 --- /dev/null +++ b/lib/components/ui/card/card-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/ui/card/card-header.svelte b/lib/components/ui/card/card-header.svelte new file mode 100644 index 0000000..8a91abb --- /dev/null +++ b/lib/components/ui/card/card-header.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/ui/card/card-title.svelte b/lib/components/ui/card/card-title.svelte new file mode 100644 index 0000000..22586e6 --- /dev/null +++ b/lib/components/ui/card/card-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/ui/card/card.svelte b/lib/components/ui/card/card.svelte new file mode 100644 index 0000000..99448cc --- /dev/null +++ b/lib/components/ui/card/card.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/ui/card/index.ts b/lib/components/ui/card/index.ts new file mode 100644 index 0000000..4d3fce4 --- /dev/null +++ b/lib/components/ui/card/index.ts @@ -0,0 +1,25 @@ +import Root from "./card.svelte"; +import Content from "./card-content.svelte"; +import Description from "./card-description.svelte"; +import Footer from "./card-footer.svelte"; +import Header from "./card-header.svelte"; +import Title from "./card-title.svelte"; +import Action from "./card-action.svelte"; + +export { + Root, + Content, + Description, + Footer, + Header, + Title, + Action, + // + Root as Card, + Content as CardContent, + Description as CardDescription, + Footer as CardFooter, + Header as CardHeader, + Title as CardTitle, + Action as CardAction, +}; diff --git a/lib/components/ui/checkbox/checkbox.svelte b/lib/components/ui/checkbox/checkbox.svelte new file mode 100644 index 0000000..7941f03 --- /dev/null +++ b/lib/components/ui/checkbox/checkbox.svelte @@ -0,0 +1,36 @@ + + + + {#snippet children({ checked, indeterminate })} +
+ {#if checked} + + {:else if indeterminate} + + {/if} +
+ {/snippet} +
diff --git a/lib/components/ui/checkbox/index.ts b/lib/components/ui/checkbox/index.ts new file mode 100644 index 0000000..6d92d94 --- /dev/null +++ b/lib/components/ui/checkbox/index.ts @@ -0,0 +1,6 @@ +import Root from "./checkbox.svelte"; +export { + Root, + // + Root as Checkbox, +}; diff --git a/lib/components/ui/command/command-dialog.svelte b/lib/components/ui/command/command-dialog.svelte new file mode 100644 index 0000000..5c9a82a --- /dev/null +++ b/lib/components/ui/command/command-dialog.svelte @@ -0,0 +1,40 @@ + + + + + {title} + {description} + + + + + diff --git a/lib/components/ui/command/command-empty.svelte b/lib/components/ui/command/command-empty.svelte new file mode 100644 index 0000000..6726cd8 --- /dev/null +++ b/lib/components/ui/command/command-empty.svelte @@ -0,0 +1,17 @@ + + + diff --git a/lib/components/ui/command/command-group.svelte b/lib/components/ui/command/command-group.svelte new file mode 100644 index 0000000..104f817 --- /dev/null +++ b/lib/components/ui/command/command-group.svelte @@ -0,0 +1,32 @@ + + + + {#if heading} + + {heading} + + {/if} + + diff --git a/lib/components/ui/command/command-input.svelte b/lib/components/ui/command/command-input.svelte new file mode 100644 index 0000000..e0dbd58 --- /dev/null +++ b/lib/components/ui/command/command-input.svelte @@ -0,0 +1,26 @@ + + +
+ + +
diff --git a/lib/components/ui/command/command-item.svelte b/lib/components/ui/command/command-item.svelte new file mode 100644 index 0000000..ef617e5 --- /dev/null +++ b/lib/components/ui/command/command-item.svelte @@ -0,0 +1,20 @@ + + + diff --git a/lib/components/ui/command/command-link-item.svelte b/lib/components/ui/command/command-link-item.svelte new file mode 100644 index 0000000..5863fb6 --- /dev/null +++ b/lib/components/ui/command/command-link-item.svelte @@ -0,0 +1,20 @@ + + + diff --git a/lib/components/ui/command/command-list.svelte b/lib/components/ui/command/command-list.svelte new file mode 100644 index 0000000..569f595 --- /dev/null +++ b/lib/components/ui/command/command-list.svelte @@ -0,0 +1,17 @@ + + + diff --git a/lib/components/ui/command/command-loading.svelte b/lib/components/ui/command/command-loading.svelte new file mode 100644 index 0000000..19dd298 --- /dev/null +++ b/lib/components/ui/command/command-loading.svelte @@ -0,0 +1,7 @@ + + + diff --git a/lib/components/ui/command/command-separator.svelte b/lib/components/ui/command/command-separator.svelte new file mode 100644 index 0000000..35c4c95 --- /dev/null +++ b/lib/components/ui/command/command-separator.svelte @@ -0,0 +1,17 @@ + + + diff --git a/lib/components/ui/command/command-shortcut.svelte b/lib/components/ui/command/command-shortcut.svelte new file mode 100644 index 0000000..f3d6928 --- /dev/null +++ b/lib/components/ui/command/command-shortcut.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/lib/components/ui/command/command.svelte b/lib/components/ui/command/command.svelte new file mode 100644 index 0000000..a1581f1 --- /dev/null +++ b/lib/components/ui/command/command.svelte @@ -0,0 +1,28 @@ + + + diff --git a/lib/components/ui/command/index.ts b/lib/components/ui/command/index.ts new file mode 100644 index 0000000..5435fbe --- /dev/null +++ b/lib/components/ui/command/index.ts @@ -0,0 +1,37 @@ +import Root from "./command.svelte"; +import Loading from "./command-loading.svelte"; +import Dialog from "./command-dialog.svelte"; +import Empty from "./command-empty.svelte"; +import Group from "./command-group.svelte"; +import Item from "./command-item.svelte"; +import Input from "./command-input.svelte"; +import List from "./command-list.svelte"; +import Separator from "./command-separator.svelte"; +import Shortcut from "./command-shortcut.svelte"; +import LinkItem from "./command-link-item.svelte"; + +export { + Root, + Dialog, + Empty, + Group, + Item, + LinkItem, + Input, + List, + Separator, + Shortcut, + Loading, + // + Root as Command, + Dialog as CommandDialog, + Empty as CommandEmpty, + Group as CommandGroup, + Item as CommandItem, + LinkItem as CommandLinkItem, + Input as CommandInput, + List as CommandList, + Separator as CommandSeparator, + Shortcut as CommandShortcut, + Loading as CommandLoading, +}; diff --git a/lib/components/ui/date-picker/date-picker.svelte b/lib/components/ui/date-picker/date-picker.svelte new file mode 100644 index 0000000..091a806 --- /dev/null +++ b/lib/components/ui/date-picker/date-picker.svelte @@ -0,0 +1,73 @@ + + + + + + {#if displayValue} + {displayValue} + {:else} + {placeholder} + {/if} + + + + + diff --git a/lib/components/ui/date-picker/index.ts b/lib/components/ui/date-picker/index.ts new file mode 100644 index 0000000..a66fcf5 --- /dev/null +++ b/lib/components/ui/date-picker/index.ts @@ -0,0 +1,3 @@ +import DatePicker from './date-picker.svelte'; + +export { DatePicker }; diff --git a/lib/components/ui/dialog/dialog-close.svelte b/lib/components/ui/dialog/dialog-close.svelte new file mode 100644 index 0000000..840b2f6 --- /dev/null +++ b/lib/components/ui/dialog/dialog-close.svelte @@ -0,0 +1,7 @@ + + + diff --git a/lib/components/ui/dialog/dialog-content.svelte b/lib/components/ui/dialog/dialog-content.svelte new file mode 100644 index 0000000..90b8d4c --- /dev/null +++ b/lib/components/ui/dialog/dialog-content.svelte @@ -0,0 +1,46 @@ + + + + + + {@render children?.()} + {#if showCloseButton} + + + Close + + {/if} + + diff --git a/lib/components/ui/dialog/dialog-description.svelte b/lib/components/ui/dialog/dialog-description.svelte new file mode 100644 index 0000000..3845023 --- /dev/null +++ b/lib/components/ui/dialog/dialog-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/lib/components/ui/dialog/dialog-footer.svelte b/lib/components/ui/dialog/dialog-footer.svelte new file mode 100644 index 0000000..e7ff446 --- /dev/null +++ b/lib/components/ui/dialog/dialog-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/ui/dialog/dialog-header.svelte b/lib/components/ui/dialog/dialog-header.svelte new file mode 100644 index 0000000..4e5c447 --- /dev/null +++ b/lib/components/ui/dialog/dialog-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/ui/dialog/dialog-overlay.svelte b/lib/components/ui/dialog/dialog-overlay.svelte new file mode 100644 index 0000000..f81ad83 --- /dev/null +++ b/lib/components/ui/dialog/dialog-overlay.svelte @@ -0,0 +1,20 @@ + + + diff --git a/lib/components/ui/dialog/dialog-portal.svelte b/lib/components/ui/dialog/dialog-portal.svelte new file mode 100644 index 0000000..ccfa79c --- /dev/null +++ b/lib/components/ui/dialog/dialog-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/lib/components/ui/dialog/dialog-title.svelte b/lib/components/ui/dialog/dialog-title.svelte new file mode 100644 index 0000000..067e55e --- /dev/null +++ b/lib/components/ui/dialog/dialog-title.svelte @@ -0,0 +1,17 @@ + + + diff --git a/lib/components/ui/dialog/dialog-trigger.svelte b/lib/components/ui/dialog/dialog-trigger.svelte new file mode 100644 index 0000000..9d1e801 --- /dev/null +++ b/lib/components/ui/dialog/dialog-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/lib/components/ui/dialog/dialog.svelte b/lib/components/ui/dialog/dialog.svelte new file mode 100644 index 0000000..211672c --- /dev/null +++ b/lib/components/ui/dialog/dialog.svelte @@ -0,0 +1,7 @@ + + + diff --git a/lib/components/ui/dialog/index.ts b/lib/components/ui/dialog/index.ts new file mode 100644 index 0000000..076cef5 --- /dev/null +++ b/lib/components/ui/dialog/index.ts @@ -0,0 +1,34 @@ +import Root from "./dialog.svelte"; +import Portal from "./dialog-portal.svelte"; +import Title from "./dialog-title.svelte"; +import Footer from "./dialog-footer.svelte"; +import Header from "./dialog-header.svelte"; +import Overlay from "./dialog-overlay.svelte"; +import Content from "./dialog-content.svelte"; +import Description from "./dialog-description.svelte"; +import Trigger from "./dialog-trigger.svelte"; +import Close from "./dialog-close.svelte"; + +export { + Root, + Title, + Portal, + Footer, + Header, + Trigger, + Overlay, + Content, + Description, + Close, + // + Root as Dialog, + Title as DialogTitle, + Portal as DialogPortal, + Footer as DialogFooter, + Header as DialogHeader, + Trigger as DialogTrigger, + Overlay as DialogOverlay, + Content as DialogContent, + Description as DialogDescription, + Close as DialogClose, +}; diff --git a/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte b/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte new file mode 100644 index 0000000..e0e1971 --- /dev/null +++ b/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte @@ -0,0 +1,16 @@ + + + diff --git a/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte new file mode 100644 index 0000000..198e0f9 --- /dev/null +++ b/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -0,0 +1,42 @@ + + + + {#snippet children({ checked, indeterminate })} + + {#if indeterminate} + + {:else} + + {/if} + + {@render childrenProp?.()} + {/snippet} + diff --git a/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte new file mode 100644 index 0000000..ab63976 --- /dev/null +++ b/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte @@ -0,0 +1,29 @@ + + + + + diff --git a/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte new file mode 100644 index 0000000..433540f --- /dev/null +++ b/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte @@ -0,0 +1,22 @@ + + + diff --git a/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte b/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte new file mode 100644 index 0000000..aca1f7b --- /dev/null +++ b/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte new file mode 100644 index 0000000..86f0d55 --- /dev/null +++ b/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte @@ -0,0 +1,27 @@ + + + diff --git a/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte new file mode 100644 index 0000000..9681c2b --- /dev/null +++ b/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte b/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte new file mode 100644 index 0000000..274cfef --- /dev/null +++ b/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte new file mode 100644 index 0000000..189aef4 --- /dev/null +++ b/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte @@ -0,0 +1,16 @@ + + + diff --git a/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte new file mode 100644 index 0000000..c61545d --- /dev/null +++ b/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte @@ -0,0 +1,33 @@ + + + + {#snippet children({ checked })} + + {#if checked} + + {/if} + + {@render childrenProp?.({ checked })} + {/snippet} + diff --git a/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte new file mode 100644 index 0000000..90f1b6f --- /dev/null +++ b/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte @@ -0,0 +1,17 @@ + + + diff --git a/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte new file mode 100644 index 0000000..7c6e9c6 --- /dev/null +++ b/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte new file mode 100644 index 0000000..d0eea9c --- /dev/null +++ b/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte @@ -0,0 +1,20 @@ + + + diff --git a/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte new file mode 100644 index 0000000..30c6126 --- /dev/null +++ b/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte b/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte new file mode 100644 index 0000000..f044581 --- /dev/null +++ b/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte @@ -0,0 +1,7 @@ + + + diff --git a/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte b/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte new file mode 100644 index 0000000..cb05344 --- /dev/null +++ b/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/lib/components/ui/dropdown-menu/dropdown-menu.svelte b/lib/components/ui/dropdown-menu/dropdown-menu.svelte new file mode 100644 index 0000000..cb4bc62 --- /dev/null +++ b/lib/components/ui/dropdown-menu/dropdown-menu.svelte @@ -0,0 +1,7 @@ + + + diff --git a/lib/components/ui/dropdown-menu/index.ts b/lib/components/ui/dropdown-menu/index.ts new file mode 100644 index 0000000..7850c6a --- /dev/null +++ b/lib/components/ui/dropdown-menu/index.ts @@ -0,0 +1,54 @@ +import Root from "./dropdown-menu.svelte"; +import Sub from "./dropdown-menu-sub.svelte"; +import CheckboxGroup from "./dropdown-menu-checkbox-group.svelte"; +import CheckboxItem from "./dropdown-menu-checkbox-item.svelte"; +import Content from "./dropdown-menu-content.svelte"; +import Group from "./dropdown-menu-group.svelte"; +import Item from "./dropdown-menu-item.svelte"; +import Label from "./dropdown-menu-label.svelte"; +import RadioGroup from "./dropdown-menu-radio-group.svelte"; +import RadioItem from "./dropdown-menu-radio-item.svelte"; +import Separator from "./dropdown-menu-separator.svelte"; +import Shortcut from "./dropdown-menu-shortcut.svelte"; +import Trigger from "./dropdown-menu-trigger.svelte"; +import SubContent from "./dropdown-menu-sub-content.svelte"; +import SubTrigger from "./dropdown-menu-sub-trigger.svelte"; +import GroupHeading from "./dropdown-menu-group-heading.svelte"; +import Portal from "./dropdown-menu-portal.svelte"; + +export { + CheckboxGroup, + CheckboxItem, + Content, + Portal, + Root as DropdownMenu, + CheckboxGroup as DropdownMenuCheckboxGroup, + CheckboxItem as DropdownMenuCheckboxItem, + Content as DropdownMenuContent, + Portal as DropdownMenuPortal, + Group as DropdownMenuGroup, + Item as DropdownMenuItem, + Label as DropdownMenuLabel, + RadioGroup as DropdownMenuRadioGroup, + RadioItem as DropdownMenuRadioItem, + Separator as DropdownMenuSeparator, + Shortcut as DropdownMenuShortcut, + Sub as DropdownMenuSub, + SubContent as DropdownMenuSubContent, + SubTrigger as DropdownMenuSubTrigger, + Trigger as DropdownMenuTrigger, + GroupHeading as DropdownMenuGroupHeading, + Group, + GroupHeading, + Item, + Label, + RadioGroup, + RadioItem, + Root, + Separator, + Shortcut, + Sub, + SubContent, + SubTrigger, + Trigger, +}; diff --git a/lib/components/ui/empty-state/empty-state.svelte b/lib/components/ui/empty-state/empty-state.svelte new file mode 100644 index 0000000..7f611ef --- /dev/null +++ b/lib/components/ui/empty-state/empty-state.svelte @@ -0,0 +1,32 @@ + + +
+ {#if Icon} +
+ +
+ {/if} +

{title}

+ {#if description} +

{description}

+ {/if} + {#if children} +
+ {@render children()} +
+ {/if} +
diff --git a/lib/components/ui/empty-state/index.ts b/lib/components/ui/empty-state/index.ts new file mode 100644 index 0000000..966e0cf --- /dev/null +++ b/lib/components/ui/empty-state/index.ts @@ -0,0 +1,4 @@ +import EmptyState from './empty-state.svelte'; +import NoEnvironment from './no-environment.svelte'; + +export { EmptyState, NoEnvironment }; diff --git a/lib/components/ui/empty-state/no-environment.svelte b/lib/components/ui/empty-state/no-environment.svelte new file mode 100644 index 0000000..1a137ef --- /dev/null +++ b/lib/components/ui/empty-state/no-environment.svelte @@ -0,0 +1,28 @@ + + +{#if hasEnvironments} + +{:else} + + + +{/if} diff --git a/lib/components/ui/input/index.ts b/lib/components/ui/input/index.ts new file mode 100644 index 0000000..f47b6d3 --- /dev/null +++ b/lib/components/ui/input/index.ts @@ -0,0 +1,7 @@ +import Root from "./input.svelte"; + +export { + Root, + // + Root as Input, +}; diff --git a/lib/components/ui/input/input.svelte b/lib/components/ui/input/input.svelte new file mode 100644 index 0000000..960167d --- /dev/null +++ b/lib/components/ui/input/input.svelte @@ -0,0 +1,52 @@ + + +{#if type === "file"} + +{:else} + +{/if} diff --git a/lib/components/ui/label/index.ts b/lib/components/ui/label/index.ts new file mode 100644 index 0000000..8bfca0b --- /dev/null +++ b/lib/components/ui/label/index.ts @@ -0,0 +1,7 @@ +import Root from "./label.svelte"; + +export { + Root, + // + Root as Label, +}; diff --git a/lib/components/ui/label/label.svelte b/lib/components/ui/label/label.svelte new file mode 100644 index 0000000..d0afda3 --- /dev/null +++ b/lib/components/ui/label/label.svelte @@ -0,0 +1,20 @@ + + + diff --git a/lib/components/ui/popover/index.ts b/lib/components/ui/popover/index.ts new file mode 100644 index 0000000..9f30922 --- /dev/null +++ b/lib/components/ui/popover/index.ts @@ -0,0 +1,17 @@ +import { Popover as PopoverPrimitive } from "bits-ui"; +import Content from "./popover-content.svelte"; +import Trigger from "./popover-trigger.svelte"; +const Root = PopoverPrimitive.Root; +const Close = PopoverPrimitive.Close; + +export { + Root, + Content, + Trigger, + Close, + // + Root as Popover, + Content as PopoverContent, + Trigger as PopoverTrigger, + Close as PopoverClose, +}; diff --git a/lib/components/ui/popover/popover-content.svelte b/lib/components/ui/popover/popover-content.svelte new file mode 100644 index 0000000..1a979cd --- /dev/null +++ b/lib/components/ui/popover/popover-content.svelte @@ -0,0 +1,29 @@ + + + + + diff --git a/lib/components/ui/popover/popover-trigger.svelte b/lib/components/ui/popover/popover-trigger.svelte new file mode 100644 index 0000000..586323c --- /dev/null +++ b/lib/components/ui/popover/popover-trigger.svelte @@ -0,0 +1,17 @@ + + + diff --git a/lib/components/ui/progress/index.ts b/lib/components/ui/progress/index.ts new file mode 100644 index 0000000..25eee61 --- /dev/null +++ b/lib/components/ui/progress/index.ts @@ -0,0 +1,7 @@ +import Root from "./progress.svelte"; + +export { + Root, + // + Root as Progress, +}; diff --git a/lib/components/ui/progress/progress.svelte b/lib/components/ui/progress/progress.svelte new file mode 100644 index 0000000..6833013 --- /dev/null +++ b/lib/components/ui/progress/progress.svelte @@ -0,0 +1,27 @@ + + + +
+
diff --git a/lib/components/ui/select/index.ts b/lib/components/ui/select/index.ts new file mode 100644 index 0000000..9e8d3e9 --- /dev/null +++ b/lib/components/ui/select/index.ts @@ -0,0 +1,37 @@ +import { Select as SelectPrimitive } from "bits-ui"; + +import Group from "./select-group.svelte"; +import Label from "./select-label.svelte"; +import Item from "./select-item.svelte"; +import Content from "./select-content.svelte"; +import Trigger from "./select-trigger.svelte"; +import Separator from "./select-separator.svelte"; +import ScrollDownButton from "./select-scroll-down-button.svelte"; +import ScrollUpButton from "./select-scroll-up-button.svelte"; +import GroupHeading from "./select-group-heading.svelte"; + +const Root = SelectPrimitive.Root; + +export { + Root, + Group, + Label, + Item, + Content, + Trigger, + Separator, + ScrollDownButton, + ScrollUpButton, + GroupHeading, + // + Root as Select, + Group as SelectGroup, + Label as SelectLabel, + Item as SelectItem, + Content as SelectContent, + Trigger as SelectTrigger, + Separator as SelectSeparator, + ScrollDownButton as SelectScrollDownButton, + ScrollUpButton as SelectScrollUpButton, + GroupHeading as SelectGroupHeading, +}; diff --git a/lib/components/ui/select/select-content.svelte b/lib/components/ui/select/select-content.svelte new file mode 100644 index 0000000..1a96d3c --- /dev/null +++ b/lib/components/ui/select/select-content.svelte @@ -0,0 +1,42 @@ + + + + + + + {@render children?.()} + + + + diff --git a/lib/components/ui/select/select-group-heading.svelte b/lib/components/ui/select/select-group-heading.svelte new file mode 100644 index 0000000..1fab5f0 --- /dev/null +++ b/lib/components/ui/select/select-group-heading.svelte @@ -0,0 +1,21 @@ + + + + {@render children?.()} + diff --git a/lib/components/ui/select/select-group.svelte b/lib/components/ui/select/select-group.svelte new file mode 100644 index 0000000..5454fdb --- /dev/null +++ b/lib/components/ui/select/select-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/lib/components/ui/select/select-item.svelte b/lib/components/ui/select/select-item.svelte new file mode 100644 index 0000000..45e7db5 --- /dev/null +++ b/lib/components/ui/select/select-item.svelte @@ -0,0 +1,38 @@ + + + + {#snippet children({ selected, highlighted })} + + {#if selected} + + {/if} + + {#if childrenProp} + {@render childrenProp({ selected, highlighted })} + {:else} + {label || value} + {/if} + {/snippet} + diff --git a/lib/components/ui/select/select-label.svelte b/lib/components/ui/select/select-label.svelte new file mode 100644 index 0000000..4696025 --- /dev/null +++ b/lib/components/ui/select/select-label.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/ui/select/select-scroll-down-button.svelte b/lib/components/ui/select/select-scroll-down-button.svelte new file mode 100644 index 0000000..56dc402 --- /dev/null +++ b/lib/components/ui/select/select-scroll-down-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/lib/components/ui/select/select-scroll-up-button.svelte b/lib/components/ui/select/select-scroll-up-button.svelte new file mode 100644 index 0000000..2f2e447 --- /dev/null +++ b/lib/components/ui/select/select-scroll-up-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/lib/components/ui/select/select-separator.svelte b/lib/components/ui/select/select-separator.svelte new file mode 100644 index 0000000..0eac3eb --- /dev/null +++ b/lib/components/ui/select/select-separator.svelte @@ -0,0 +1,18 @@ + + + diff --git a/lib/components/ui/select/select-trigger.svelte b/lib/components/ui/select/select-trigger.svelte new file mode 100644 index 0000000..ef483ff --- /dev/null +++ b/lib/components/ui/select/select-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/lib/components/ui/separator/index.ts b/lib/components/ui/separator/index.ts new file mode 100644 index 0000000..82442d2 --- /dev/null +++ b/lib/components/ui/separator/index.ts @@ -0,0 +1,7 @@ +import Root from "./separator.svelte"; + +export { + Root, + // + Root as Separator, +}; diff --git a/lib/components/ui/separator/separator.svelte b/lib/components/ui/separator/separator.svelte new file mode 100644 index 0000000..89b2695 --- /dev/null +++ b/lib/components/ui/separator/separator.svelte @@ -0,0 +1,21 @@ + + + diff --git a/lib/components/ui/sheet/index.ts b/lib/components/ui/sheet/index.ts new file mode 100644 index 0000000..01d40c8 --- /dev/null +++ b/lib/components/ui/sheet/index.ts @@ -0,0 +1,36 @@ +import { Dialog as SheetPrimitive } from "bits-ui"; +import Trigger from "./sheet-trigger.svelte"; +import Close from "./sheet-close.svelte"; +import Overlay from "./sheet-overlay.svelte"; +import Content from "./sheet-content.svelte"; +import Header from "./sheet-header.svelte"; +import Footer from "./sheet-footer.svelte"; +import Title from "./sheet-title.svelte"; +import Description from "./sheet-description.svelte"; + +const Root = SheetPrimitive.Root; +const Portal = SheetPrimitive.Portal; + +export { + Root, + Close, + Trigger, + Portal, + Overlay, + Content, + Header, + Footer, + Title, + Description, + // + Root as Sheet, + Close as SheetClose, + Trigger as SheetTrigger, + Portal as SheetPortal, + Overlay as SheetOverlay, + Content as SheetContent, + Header as SheetHeader, + Footer as SheetFooter, + Title as SheetTitle, + Description as SheetDescription, +}; diff --git a/lib/components/ui/sheet/sheet-close.svelte b/lib/components/ui/sheet/sheet-close.svelte new file mode 100644 index 0000000..ae382c1 --- /dev/null +++ b/lib/components/ui/sheet/sheet-close.svelte @@ -0,0 +1,7 @@ + + + diff --git a/lib/components/ui/sheet/sheet-content.svelte b/lib/components/ui/sheet/sheet-content.svelte new file mode 100644 index 0000000..f52c974 --- /dev/null +++ b/lib/components/ui/sheet/sheet-content.svelte @@ -0,0 +1,58 @@ + + + + + + + + {@render children?.()} + + + Close + + + diff --git a/lib/components/ui/sheet/sheet-description.svelte b/lib/components/ui/sheet/sheet-description.svelte new file mode 100644 index 0000000..333b17a --- /dev/null +++ b/lib/components/ui/sheet/sheet-description.svelte @@ -0,0 +1,17 @@ + + + diff --git a/lib/components/ui/sheet/sheet-footer.svelte b/lib/components/ui/sheet/sheet-footer.svelte new file mode 100644 index 0000000..dd9ed84 --- /dev/null +++ b/lib/components/ui/sheet/sheet-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/ui/sheet/sheet-header.svelte b/lib/components/ui/sheet/sheet-header.svelte new file mode 100644 index 0000000..757a6a5 --- /dev/null +++ b/lib/components/ui/sheet/sheet-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/ui/sheet/sheet-overlay.svelte b/lib/components/ui/sheet/sheet-overlay.svelte new file mode 100644 index 0000000..345e197 --- /dev/null +++ b/lib/components/ui/sheet/sheet-overlay.svelte @@ -0,0 +1,20 @@ + + + diff --git a/lib/components/ui/sheet/sheet-title.svelte b/lib/components/ui/sheet/sheet-title.svelte new file mode 100644 index 0000000..9fda327 --- /dev/null +++ b/lib/components/ui/sheet/sheet-title.svelte @@ -0,0 +1,17 @@ + + + diff --git a/lib/components/ui/sheet/sheet-trigger.svelte b/lib/components/ui/sheet/sheet-trigger.svelte new file mode 100644 index 0000000..e266975 --- /dev/null +++ b/lib/components/ui/sheet/sheet-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/lib/components/ui/sidebar/constants.ts b/lib/components/ui/sidebar/constants.ts new file mode 100644 index 0000000..bcd879c --- /dev/null +++ b/lib/components/ui/sidebar/constants.ts @@ -0,0 +1,6 @@ +export const SIDEBAR_COOKIE_NAME = "sidebar:state"; +export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +export const SIDEBAR_WIDTH = "9rem"; +export const SIDEBAR_WIDTH_MOBILE = "12rem"; +export const SIDEBAR_WIDTH_ICON = "3rem"; +export const SIDEBAR_KEYBOARD_SHORTCUT = "b"; diff --git a/lib/components/ui/sidebar/context.svelte.ts b/lib/components/ui/sidebar/context.svelte.ts new file mode 100644 index 0000000..15248ad --- /dev/null +++ b/lib/components/ui/sidebar/context.svelte.ts @@ -0,0 +1,81 @@ +import { IsMobile } from "$lib/hooks/is-mobile.svelte.js"; +import { getContext, setContext } from "svelte"; +import { SIDEBAR_KEYBOARD_SHORTCUT } from "./constants.js"; + +type Getter = () => T; + +export type SidebarStateProps = { + /** + * A getter function that returns the current open state of the sidebar. + * We use a getter function here to support `bind:open` on the `Sidebar.Provider` + * component. + */ + open: Getter; + + /** + * A function that sets the open state of the sidebar. To support `bind:open`, we need + * a source of truth for changing the open state to ensure it will be synced throughout + * the sub-components and any `bind:` references. + */ + setOpen: (open: boolean) => void; +}; + +class SidebarState { + readonly props: SidebarStateProps; + open = $derived.by(() => this.props.open()); + openMobile = $state(false); + setOpen: SidebarStateProps["setOpen"]; + #isMobile: IsMobile; + state = $derived.by(() => (this.open ? "expanded" : "collapsed")); + + constructor(props: SidebarStateProps) { + this.setOpen = props.setOpen; + this.#isMobile = new IsMobile(); + this.props = props; + } + + // Convenience getter for checking if the sidebar is mobile + // without this, we would need to use `sidebar.isMobile.current` everywhere + get isMobile() { + return this.#isMobile.current; + } + + // Event handler to apply to the `` + handleShortcutKeydown = (e: KeyboardEvent) => { + if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + this.toggle(); + } + }; + + setOpenMobile = (value: boolean) => { + this.openMobile = value; + }; + + toggle = () => { + return this.#isMobile.current + ? (this.openMobile = !this.openMobile) + : this.setOpen(!this.open); + }; +} + +const SYMBOL_KEY = "scn-sidebar"; + +/** + * Instantiates a new `SidebarState` instance and sets it in the context. + * + * @param props The constructor props for the `SidebarState` class. + * @returns The `SidebarState` instance. + */ +export function setSidebar(props: SidebarStateProps): SidebarState { + return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props)); +} + +/** + * Retrieves the `SidebarState` instance from the context. This is a class instance, + * so you cannot destructure it. + * @returns The `SidebarState` instance. + */ +export function useSidebar(): SidebarState { + return getContext(Symbol.for(SYMBOL_KEY)); +} diff --git a/lib/components/ui/sidebar/index.ts b/lib/components/ui/sidebar/index.ts new file mode 100644 index 0000000..318a341 --- /dev/null +++ b/lib/components/ui/sidebar/index.ts @@ -0,0 +1,75 @@ +import { useSidebar } from "./context.svelte.js"; +import Content from "./sidebar-content.svelte"; +import Footer from "./sidebar-footer.svelte"; +import GroupAction from "./sidebar-group-action.svelte"; +import GroupContent from "./sidebar-group-content.svelte"; +import GroupLabel from "./sidebar-group-label.svelte"; +import Group from "./sidebar-group.svelte"; +import Header from "./sidebar-header.svelte"; +import Input from "./sidebar-input.svelte"; +import Inset from "./sidebar-inset.svelte"; +import MenuAction from "./sidebar-menu-action.svelte"; +import MenuBadge from "./sidebar-menu-badge.svelte"; +import MenuButton from "./sidebar-menu-button.svelte"; +import MenuItem from "./sidebar-menu-item.svelte"; +import MenuSkeleton from "./sidebar-menu-skeleton.svelte"; +import MenuSubButton from "./sidebar-menu-sub-button.svelte"; +import MenuSubItem from "./sidebar-menu-sub-item.svelte"; +import MenuSub from "./sidebar-menu-sub.svelte"; +import Menu from "./sidebar-menu.svelte"; +import Provider from "./sidebar-provider.svelte"; +import Rail from "./sidebar-rail.svelte"; +import Separator from "./sidebar-separator.svelte"; +import Trigger from "./sidebar-trigger.svelte"; +import Root from "./sidebar.svelte"; + +export { + Content, + Footer, + Group, + GroupAction, + GroupContent, + GroupLabel, + Header, + Input, + Inset, + Menu, + MenuAction, + MenuBadge, + MenuButton, + MenuItem, + MenuSkeleton, + MenuSub, + MenuSubButton, + MenuSubItem, + Provider, + Rail, + Root, + Separator, + // + Root as Sidebar, + Content as SidebarContent, + Footer as SidebarFooter, + Group as SidebarGroup, + GroupAction as SidebarGroupAction, + GroupContent as SidebarGroupContent, + GroupLabel as SidebarGroupLabel, + Header as SidebarHeader, + Input as SidebarInput, + Inset as SidebarInset, + Menu as SidebarMenu, + MenuAction as SidebarMenuAction, + MenuBadge as SidebarMenuBadge, + MenuButton as SidebarMenuButton, + MenuItem as SidebarMenuItem, + MenuSkeleton as SidebarMenuSkeleton, + MenuSub as SidebarMenuSub, + MenuSubButton as SidebarMenuSubButton, + MenuSubItem as SidebarMenuSubItem, + Provider as SidebarProvider, + Rail as SidebarRail, + Separator as SidebarSeparator, + Trigger as SidebarTrigger, + Trigger, + useSidebar, +}; diff --git a/lib/components/ui/sidebar/sidebar-content.svelte b/lib/components/ui/sidebar/sidebar-content.svelte new file mode 100644 index 0000000..c073c82 --- /dev/null +++ b/lib/components/ui/sidebar/sidebar-content.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/ui/sidebar/sidebar-footer.svelte b/lib/components/ui/sidebar/sidebar-footer.svelte new file mode 100644 index 0000000..6259cb9 --- /dev/null +++ b/lib/components/ui/sidebar/sidebar-footer.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/ui/sidebar/sidebar-group-action.svelte b/lib/components/ui/sidebar/sidebar-group-action.svelte new file mode 100644 index 0000000..241e971 --- /dev/null +++ b/lib/components/ui/sidebar/sidebar-group-action.svelte @@ -0,0 +1,36 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/lib/components/ui/sidebar/sidebar-group-content.svelte b/lib/components/ui/sidebar/sidebar-group-content.svelte new file mode 100644 index 0000000..415255f --- /dev/null +++ b/lib/components/ui/sidebar/sidebar-group-content.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/ui/sidebar/sidebar-group-label.svelte b/lib/components/ui/sidebar/sidebar-group-label.svelte new file mode 100644 index 0000000..e292945 --- /dev/null +++ b/lib/components/ui/sidebar/sidebar-group-label.svelte @@ -0,0 +1,34 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/lib/components/ui/sidebar/sidebar-group.svelte b/lib/components/ui/sidebar/sidebar-group.svelte new file mode 100644 index 0000000..ec18a69 --- /dev/null +++ b/lib/components/ui/sidebar/sidebar-group.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/ui/sidebar/sidebar-header.svelte b/lib/components/ui/sidebar/sidebar-header.svelte new file mode 100644 index 0000000..a1b2db1 --- /dev/null +++ b/lib/components/ui/sidebar/sidebar-header.svelte @@ -0,0 +1,21 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/ui/sidebar/sidebar-input.svelte b/lib/components/ui/sidebar/sidebar-input.svelte new file mode 100644 index 0000000..19b3666 --- /dev/null +++ b/lib/components/ui/sidebar/sidebar-input.svelte @@ -0,0 +1,21 @@ + + + diff --git a/lib/components/ui/sidebar/sidebar-inset.svelte b/lib/components/ui/sidebar/sidebar-inset.svelte new file mode 100644 index 0000000..2d94c4b --- /dev/null +++ b/lib/components/ui/sidebar/sidebar-inset.svelte @@ -0,0 +1,29 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/ui/sidebar/sidebar-menu-action.svelte b/lib/components/ui/sidebar/sidebar-menu-action.svelte new file mode 100644 index 0000000..98d5c4a --- /dev/null +++ b/lib/components/ui/sidebar/sidebar-menu-action.svelte @@ -0,0 +1,43 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/lib/components/ui/sidebar/sidebar-menu-badge.svelte b/lib/components/ui/sidebar/sidebar-menu-badge.svelte new file mode 100644 index 0000000..66edc8c --- /dev/null +++ b/lib/components/ui/sidebar/sidebar-menu-badge.svelte @@ -0,0 +1,29 @@ + + +
+ {@render children?.()} +
diff --git a/lib/components/ui/sidebar/sidebar-menu-button.svelte b/lib/components/ui/sidebar/sidebar-menu-button.svelte new file mode 100644 index 0000000..465f1e1 --- /dev/null +++ b/lib/components/ui/sidebar/sidebar-menu-button.svelte @@ -0,0 +1,111 @@ + + + + +{#snippet Button({ props }: { props?: Record })} + {@const mergedProps = mergeProps(buttonProps, props)} + {#if child} + {@render child({ props: mergedProps })} + {:else if href} + + {@render children?.()} + + {:else} + + {/if} +{/snippet} + +{#if !tooltipContent} + {@render Button({})} +{:else} + + + {#snippet child({ props })} + {@render Button({ props })} + {/snippet} + + + +{/if} diff --git a/lib/components/ui/sidebar/sidebar-menu-item.svelte b/lib/components/ui/sidebar/sidebar-menu-item.svelte new file mode 100644 index 0000000..4db4453 --- /dev/null +++ b/lib/components/ui/sidebar/sidebar-menu-item.svelte @@ -0,0 +1,21 @@ + + +
  • + {@render children?.()} +
  • diff --git a/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte b/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte new file mode 100644 index 0000000..cc63b04 --- /dev/null +++ b/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte @@ -0,0 +1,36 @@ + + +
    + {#if showIcon} + + {/if} + + {@render children?.()} +
    diff --git a/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte b/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte new file mode 100644 index 0000000..987f104 --- /dev/null +++ b/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte @@ -0,0 +1,43 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte b/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte new file mode 100644 index 0000000..681d0f1 --- /dev/null +++ b/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte @@ -0,0 +1,21 @@ + + +
  • + {@render children?.()} +
  • diff --git a/lib/components/ui/sidebar/sidebar-menu-sub.svelte b/lib/components/ui/sidebar/sidebar-menu-sub.svelte new file mode 100644 index 0000000..76bd1d9 --- /dev/null +++ b/lib/components/ui/sidebar/sidebar-menu-sub.svelte @@ -0,0 +1,25 @@ + + +
      + {@render children?.()} +
    diff --git a/lib/components/ui/sidebar/sidebar-menu.svelte b/lib/components/ui/sidebar/sidebar-menu.svelte new file mode 100644 index 0000000..946ccce --- /dev/null +++ b/lib/components/ui/sidebar/sidebar-menu.svelte @@ -0,0 +1,21 @@ + + +
      + {@render children?.()} +
    diff --git a/lib/components/ui/sidebar/sidebar-provider.svelte b/lib/components/ui/sidebar/sidebar-provider.svelte new file mode 100644 index 0000000..c82f864 --- /dev/null +++ b/lib/components/ui/sidebar/sidebar-provider.svelte @@ -0,0 +1,64 @@ + + + + + +
    + {@render children?.()} +
    +
    diff --git a/lib/components/ui/sidebar/sidebar-rail.svelte b/lib/components/ui/sidebar/sidebar-rail.svelte new file mode 100644 index 0000000..1077527 --- /dev/null +++ b/lib/components/ui/sidebar/sidebar-rail.svelte @@ -0,0 +1,36 @@ + + + diff --git a/lib/components/ui/sidebar/sidebar-separator.svelte b/lib/components/ui/sidebar/sidebar-separator.svelte new file mode 100644 index 0000000..5a7deda --- /dev/null +++ b/lib/components/ui/sidebar/sidebar-separator.svelte @@ -0,0 +1,19 @@ + + + diff --git a/lib/components/ui/sidebar/sidebar-trigger.svelte b/lib/components/ui/sidebar/sidebar-trigger.svelte new file mode 100644 index 0000000..60b5933 --- /dev/null +++ b/lib/components/ui/sidebar/sidebar-trigger.svelte @@ -0,0 +1,35 @@ + + + diff --git a/lib/components/ui/sidebar/sidebar.svelte b/lib/components/ui/sidebar/sidebar.svelte new file mode 100644 index 0000000..b218f2d --- /dev/null +++ b/lib/components/ui/sidebar/sidebar.svelte @@ -0,0 +1,104 @@ + + +{#if collapsible === "none"} +
    + {@render children?.()} +
    +{:else if sidebar.isMobile} + sidebar.openMobile, (v) => sidebar.setOpenMobile(v)} + {...restProps} + > + + + Sidebar + Displays the mobile sidebar. + +
    + {@render children?.()} +
    +
    +
    +{:else} + +{/if} diff --git a/lib/components/ui/skeleton/index.ts b/lib/components/ui/skeleton/index.ts new file mode 100644 index 0000000..186db21 --- /dev/null +++ b/lib/components/ui/skeleton/index.ts @@ -0,0 +1,7 @@ +import Root from "./skeleton.svelte"; + +export { + Root, + // + Root as Skeleton, +}; diff --git a/lib/components/ui/skeleton/skeleton.svelte b/lib/components/ui/skeleton/skeleton.svelte new file mode 100644 index 0000000..c7e3d26 --- /dev/null +++ b/lib/components/ui/skeleton/skeleton.svelte @@ -0,0 +1,17 @@ + + +
    diff --git a/lib/components/ui/sonner/index.ts b/lib/components/ui/sonner/index.ts new file mode 100644 index 0000000..1ad9f4a --- /dev/null +++ b/lib/components/ui/sonner/index.ts @@ -0,0 +1 @@ +export { default as Toaster } from "./sonner.svelte"; diff --git a/lib/components/ui/sonner/sonner.svelte b/lib/components/ui/sonner/sonner.svelte new file mode 100644 index 0000000..0f75967 --- /dev/null +++ b/lib/components/ui/sonner/sonner.svelte @@ -0,0 +1,38 @@ + + + diff --git a/lib/components/ui/switch/index.ts b/lib/components/ui/switch/index.ts new file mode 100644 index 0000000..41cf6b0 --- /dev/null +++ b/lib/components/ui/switch/index.ts @@ -0,0 +1,3 @@ +import Switch from "./switch.svelte"; + +export { Switch }; diff --git a/lib/components/ui/switch/switch.svelte b/lib/components/ui/switch/switch.svelte new file mode 100644 index 0000000..ba3e9b3 --- /dev/null +++ b/lib/components/ui/switch/switch.svelte @@ -0,0 +1,23 @@ + + + + + diff --git a/lib/components/ui/table/index.ts b/lib/components/ui/table/index.ts new file mode 100644 index 0000000..14695c8 --- /dev/null +++ b/lib/components/ui/table/index.ts @@ -0,0 +1,28 @@ +import Root from "./table.svelte"; +import Body from "./table-body.svelte"; +import Caption from "./table-caption.svelte"; +import Cell from "./table-cell.svelte"; +import Footer from "./table-footer.svelte"; +import Head from "./table-head.svelte"; +import Header from "./table-header.svelte"; +import Row from "./table-row.svelte"; + +export { + Root, + Body, + Caption, + Cell, + Footer, + Head, + Header, + Row, + // + Root as Table, + Body as TableBody, + Caption as TableCaption, + Cell as TableCell, + Footer as TableFooter, + Head as TableHead, + Header as TableHeader, + Row as TableRow, +}; diff --git a/lib/components/ui/table/table-body.svelte b/lib/components/ui/table/table-body.svelte new file mode 100644 index 0000000..29e9687 --- /dev/null +++ b/lib/components/ui/table/table-body.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/lib/components/ui/table/table-caption.svelte b/lib/components/ui/table/table-caption.svelte new file mode 100644 index 0000000..4696cff --- /dev/null +++ b/lib/components/ui/table/table-caption.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/lib/components/ui/table/table-cell.svelte b/lib/components/ui/table/table-cell.svelte new file mode 100644 index 0000000..2e1036f --- /dev/null +++ b/lib/components/ui/table/table-cell.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/lib/components/ui/table/table-footer.svelte b/lib/components/ui/table/table-footer.svelte new file mode 100644 index 0000000..b9b14eb --- /dev/null +++ b/lib/components/ui/table/table-footer.svelte @@ -0,0 +1,20 @@ + + +tr]:last:border-b-0", className)} + {...restProps} +> + {@render children?.()} + diff --git a/lib/components/ui/table/table-head.svelte b/lib/components/ui/table/table-head.svelte new file mode 100644 index 0000000..b0fb68f --- /dev/null +++ b/lib/components/ui/table/table-head.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/lib/components/ui/table/table-header.svelte b/lib/components/ui/table/table-header.svelte new file mode 100644 index 0000000..f47d259 --- /dev/null +++ b/lib/components/ui/table/table-header.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/lib/components/ui/table/table-row.svelte b/lib/components/ui/table/table-row.svelte new file mode 100644 index 0000000..0df769e --- /dev/null +++ b/lib/components/ui/table/table-row.svelte @@ -0,0 +1,23 @@ + + +svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", + className + )} + {...restProps} +> + {@render children?.()} + diff --git a/lib/components/ui/table/table.svelte b/lib/components/ui/table/table.svelte new file mode 100644 index 0000000..a334956 --- /dev/null +++ b/lib/components/ui/table/table.svelte @@ -0,0 +1,22 @@ + + +
    + + {@render children?.()} +
    +
    diff --git a/lib/components/ui/tabs/index.ts b/lib/components/ui/tabs/index.ts new file mode 100644 index 0000000..12d4327 --- /dev/null +++ b/lib/components/ui/tabs/index.ts @@ -0,0 +1,16 @@ +import Root from "./tabs.svelte"; +import Content from "./tabs-content.svelte"; +import List from "./tabs-list.svelte"; +import Trigger from "./tabs-trigger.svelte"; + +export { + Root, + Content, + List, + Trigger, + // + Root as Tabs, + Content as TabsContent, + List as TabsList, + Trigger as TabsTrigger, +}; diff --git a/lib/components/ui/tabs/tabs-content.svelte b/lib/components/ui/tabs/tabs-content.svelte new file mode 100644 index 0000000..21717d4 --- /dev/null +++ b/lib/components/ui/tabs/tabs-content.svelte @@ -0,0 +1,17 @@ + + + diff --git a/lib/components/ui/tabs/tabs-list.svelte b/lib/components/ui/tabs/tabs-list.svelte new file mode 100644 index 0000000..c08aa0b --- /dev/null +++ b/lib/components/ui/tabs/tabs-list.svelte @@ -0,0 +1,20 @@ + + + diff --git a/lib/components/ui/tabs/tabs-trigger.svelte b/lib/components/ui/tabs/tabs-trigger.svelte new file mode 100644 index 0000000..a82f99f --- /dev/null +++ b/lib/components/ui/tabs/tabs-trigger.svelte @@ -0,0 +1,20 @@ + + + diff --git a/lib/components/ui/tabs/tabs.svelte b/lib/components/ui/tabs/tabs.svelte new file mode 100644 index 0000000..ef6cada --- /dev/null +++ b/lib/components/ui/tabs/tabs.svelte @@ -0,0 +1,19 @@ + + + diff --git a/lib/components/ui/textarea/index.ts b/lib/components/ui/textarea/index.ts new file mode 100644 index 0000000..ace797a --- /dev/null +++ b/lib/components/ui/textarea/index.ts @@ -0,0 +1,7 @@ +import Root from "./textarea.svelte"; + +export { + Root, + // + Root as Textarea, +}; diff --git a/lib/components/ui/textarea/textarea.svelte b/lib/components/ui/textarea/textarea.svelte new file mode 100644 index 0000000..7fcef1a --- /dev/null +++ b/lib/components/ui/textarea/textarea.svelte @@ -0,0 +1,23 @@ + + + diff --git a/lib/components/ui/toggle-pill/index.ts b/lib/components/ui/toggle-pill/index.ts new file mode 100644 index 0000000..93b94da --- /dev/null +++ b/lib/components/ui/toggle-pill/index.ts @@ -0,0 +1,5 @@ +import TogglePill from './toggle-pill.svelte'; +import ToggleSwitch from './toggle-switch.svelte'; +import ToggleGroup from './toggle-group.svelte'; + +export { TogglePill, ToggleSwitch, ToggleGroup }; diff --git a/lib/components/ui/toggle-pill/toggle-group.svelte b/lib/components/ui/toggle-pill/toggle-group.svelte new file mode 100644 index 0000000..45798d8 --- /dev/null +++ b/lib/components/ui/toggle-pill/toggle-group.svelte @@ -0,0 +1,93 @@ + + +
    + {#each options as option, i} + {@const isSelected = value === option.value} + {@const displayLabel = option.label ?? option.value} + + {/each} +
    diff --git a/lib/components/ui/toggle-pill/toggle-pill.svelte b/lib/components/ui/toggle-pill/toggle-pill.svelte new file mode 100644 index 0000000..3069fe8 --- /dev/null +++ b/lib/components/ui/toggle-pill/toggle-pill.svelte @@ -0,0 +1,35 @@ + + + diff --git a/lib/components/ui/toggle-pill/toggle-switch.svelte b/lib/components/ui/toggle-pill/toggle-switch.svelte new file mode 100644 index 0000000..371403f --- /dev/null +++ b/lib/components/ui/toggle-pill/toggle-switch.svelte @@ -0,0 +1,67 @@ + + +
    + + +
    diff --git a/lib/components/ui/tooltip/index.ts b/lib/components/ui/tooltip/index.ts new file mode 100644 index 0000000..313a7f0 --- /dev/null +++ b/lib/components/ui/tooltip/index.ts @@ -0,0 +1,21 @@ +import { Tooltip as TooltipPrimitive } from "bits-ui"; +import Trigger from "./tooltip-trigger.svelte"; +import Content from "./tooltip-content.svelte"; + +const Root = TooltipPrimitive.Root; +const Provider = TooltipPrimitive.Provider; +const Portal = TooltipPrimitive.Portal; + +export { + Root, + Trigger, + Content, + Provider, + Portal, + // + Root as Tooltip, + Content as TooltipContent, + Trigger as TooltipTrigger, + Provider as TooltipProvider, + Portal as TooltipPortal, +}; diff --git a/lib/components/ui/tooltip/tooltip-content.svelte b/lib/components/ui/tooltip/tooltip-content.svelte new file mode 100644 index 0000000..63c24df --- /dev/null +++ b/lib/components/ui/tooltip/tooltip-content.svelte @@ -0,0 +1,30 @@ + + + + {@render children?.()} + diff --git a/lib/components/ui/tooltip/tooltip-trigger.svelte b/lib/components/ui/tooltip/tooltip-trigger.svelte new file mode 100644 index 0000000..1acdaa4 --- /dev/null +++ b/lib/components/ui/tooltip/tooltip-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/lib/config/grid-columns.ts b/lib/config/grid-columns.ts new file mode 100644 index 0000000..222c0d7 --- /dev/null +++ b/lib/config/grid-columns.ts @@ -0,0 +1,145 @@ +import type { ColumnConfig, GridId } from '$lib/types'; + +// Container grid columns +export const containerColumns: ColumnConfig[] = [ + { id: 'select', label: '', fixed: 'start', width: 32, resizable: false }, + { id: 'name', label: 'Name', sortable: true, sortField: 'name', width: 140, minWidth: 80, grow: true }, + { id: 'image', label: 'Image', sortable: true, sortField: 'image', width: 180, minWidth: 100, grow: true }, + { id: 'state', label: 'State', sortable: true, sortField: 'state', width: 90, minWidth: 70, noTruncate: true }, + { id: 'health', label: 'Health', width: 55, minWidth: 40 }, + { id: 'uptime', label: 'Uptime', sortable: true, sortField: 'uptime', width: 80, minWidth: 60 }, + { id: 'restartCount', label: 'Restarts', width: 70, minWidth: 50 }, + { id: 'cpu', label: 'CPU', sortable: true, sortField: 'cpu', width: 50, minWidth: 40, align: 'right' }, + { id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 60, minWidth: 50, align: 'right' }, + { id: 'networkIO', label: 'Net I/O', width: 85, minWidth: 70, align: 'right' }, + { id: 'diskIO', label: 'Disk I/O', width: 85, minWidth: 70, align: 'right' }, + { id: 'ip', label: 'IP', sortable: true, sortField: 'ip', width: 100, minWidth: 80 }, + { id: 'ports', label: 'Ports', width: 120, minWidth: 60 }, + { id: 'autoUpdate', label: 'Auto-update', width: 95, minWidth: 70 }, + { id: 'stack', label: 'Stack', sortable: true, sortField: 'stack', width: 100, minWidth: 60 }, + { id: 'actions', label: '', fixed: 'end', width: 200, minWidth: 150, resizable: true } +]; + +// Image grid columns +export const imageColumns: ColumnConfig[] = [ + { id: 'select', label: '', fixed: 'start', width: 32, resizable: false }, + { id: 'expand', label: '', fixed: 'start', width: 24, resizable: false }, + { id: 'image', label: 'Image', sortable: true, sortField: 'name', width: 220, minWidth: 120, grow: true }, + { id: 'tags', label: 'Tags', sortable: true, sortField: 'tags', width: 80, minWidth: 50 }, + { id: 'size', label: 'Size', sortable: true, sortField: 'size', width: 80, minWidth: 60 }, + { id: 'updated', label: 'Updated', sortable: true, sortField: 'created', width: 140, minWidth: 100 }, + { id: 'actions', label: '', fixed: 'end', width: 120, resizable: false } +]; + +// Image tags grid columns (nested inside expanded image row) +export const imageTagColumns: ColumnConfig[] = [ + { id: 'tag', label: 'Tag', width: 180, minWidth: 60 }, + { id: 'id', label: 'ID', width: 120, minWidth: 80 }, + { id: 'size', label: 'Size', width: 80, minWidth: 60 }, + { id: 'created', label: 'Created', width: 140, minWidth: 100 }, + { id: 'actions', label: '', fixed: 'end', width: 100, resizable: false } +]; + +// Network grid columns +export const networkColumns: ColumnConfig[] = [ + { id: 'select', label: '', fixed: 'start', width: 32, resizable: false }, + { id: 'name', label: 'Name', sortable: true, sortField: 'name', width: 260, minWidth: 120, grow: true }, + { id: 'driver', label: 'Driver', sortable: true, sortField: 'driver', width: 100, resizable: false }, + { id: 'scope', label: 'Scope', width: 80, minWidth: 50 }, + { id: 'subnet', label: 'Subnet', sortable: true, sortField: 'subnet', width: 160, minWidth: 100 }, + { id: 'gateway', label: 'Gateway', sortable: true, sortField: 'gateway', width: 140, minWidth: 100 }, + { id: 'containers', label: 'Containers', sortable: true, sortField: 'containers', width: 100, minWidth: 70 }, + { id: 'actions', label: '', fixed: 'end', width: 160, resizable: false } +]; + +// Stack grid columns +export const stackColumns: ColumnConfig[] = [ + { id: 'select', label: '', fixed: 'start', width: 32, resizable: false }, + { id: 'expand', label: '', fixed: 'start', width: 24, resizable: false }, + { id: 'name', label: 'Name', sortable: true, sortField: 'name', width: 180, minWidth: 100, grow: true }, + { id: 'status', label: 'Status', sortable: true, sortField: 'status', width: 120, minWidth: 90 }, + { id: 'source', label: 'Source', width: 100, minWidth: 60 }, + { id: 'containers', label: 'Containers', sortable: true, sortField: 'containers', width: 100, minWidth: 70 }, + { id: 'cpu', label: 'CPU', sortable: true, sortField: 'cpu', width: 60, minWidth: 50, align: 'right' }, + { id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 70, minWidth: 50, align: 'right' }, + { id: 'networkIO', label: 'Net I/O', width: 100, minWidth: 70, align: 'right' }, + { id: 'diskIO', label: 'Disk I/O', width: 100, minWidth: 70, align: 'right' }, + { id: 'networks', label: 'Networks', width: 80, minWidth: 60 }, + { id: 'volumes', label: 'Volumes', width: 80, minWidth: 60 }, + { id: 'actions', label: '', fixed: 'end', width: 180, resizable: false } +]; + +// Volume grid columns +export const volumeColumns: ColumnConfig[] = [ + { id: 'select', label: '', fixed: 'start', width: 32, resizable: false }, + { id: 'name', label: 'Name', sortable: true, sortField: 'name', width: 400, minWidth: 150, grow: true }, + { id: 'driver', label: 'Driver', sortable: true, sortField: 'driver', width: 80, minWidth: 60 }, + { id: 'scope', label: 'Scope', width: 70, minWidth: 50 }, + { id: 'stack', label: 'Stack', sortable: true, sortField: 'stack', width: 120, minWidth: 80 }, + { id: 'usedBy', label: 'Used by', width: 150, minWidth: 80 }, + { id: 'created', label: 'Created', sortable: true, sortField: 'created', width: 160, minWidth: 120 }, + { id: 'actions', label: '', fixed: 'end', width: 160, resizable: false } +]; + +// Activity grid columns (no selection, no column reordering - simpler grid) +export const activityColumns: ColumnConfig[] = [ + { id: 'timestamp', label: 'Timestamp', width: 160, minWidth: 140 }, + { id: 'environment', label: 'Environment', width: 180, minWidth: 100 }, + { id: 'action', label: 'Action', width: 60, resizable: false }, + { id: 'container', label: 'Container', width: 240, minWidth: 120, grow: true }, + { id: 'image', label: 'Image', width: 260, minWidth: 120 }, + { id: 'exitCode', label: 'Exit', width: 50, minWidth: 40 }, + { id: 'actions', label: '', fixed: 'end', width: 50, resizable: false } +]; + +// Schedule grid columns +export const scheduleColumns: ColumnConfig[] = [ + { id: 'expand', label: '', fixed: 'start', width: 24, resizable: false }, + { id: 'schedule', label: 'Schedule', width: 450, minWidth: 300, grow: true }, + { id: 'environment', label: 'Environment', width: 140, minWidth: 100 }, + { id: 'cron', label: 'Schedule', width: 180, minWidth: 120 }, + { id: 'lastRun', label: 'Last run', width: 160, minWidth: 120 }, + { id: 'nextRun', label: 'Next run', width: 160, minWidth: 100 }, + { id: 'status', label: 'Status', width: 70, resizable: false }, + { id: 'actions', label: '', fixed: 'end', width: 100, resizable: false } +]; + +// Map of grid ID to column definitions +export const gridColumnConfigs: Record = { + containers: containerColumns, + images: imageColumns, + imageTags: imageTagColumns, + networks: networkColumns, + stacks: stackColumns, + volumes: volumeColumns, + activity: activityColumns, + schedules: scheduleColumns +}; + +// Get configurable columns (not fixed) +export function getConfigurableColumns(gridId: GridId): ColumnConfig[] { + return gridColumnConfigs[gridId].filter((col) => !col.fixed); +} + +// Get fixed columns at start +export function getFixedStartColumns(gridId: GridId): ColumnConfig[] { + return gridColumnConfigs[gridId].filter((col) => col.fixed === 'start'); +} + +// Get fixed columns at end +export function getFixedEndColumns(gridId: GridId): ColumnConfig[] { + return gridColumnConfigs[gridId].filter((col) => col.fixed === 'end'); +} + +// Get default column visibility preferences for a grid +export function getDefaultColumnPreferences(gridId: GridId): { id: string; visible: boolean }[] { + return getConfigurableColumns(gridId).map((col) => ({ + id: col.id, + visible: true + })); +} + +// Get all column configs (fixed + configurable in order) +export function getAllColumnConfigs(gridId: GridId): ColumnConfig[] { + return gridColumnConfigs[gridId]; +} diff --git a/lib/data/changelog.json b/lib/data/changelog.json new file mode 100644 index 0000000..74b1be9 --- /dev/null +++ b/lib/data/changelog.json @@ -0,0 +1,105 @@ +[ + { + "version": "1.0.4", + "date": "2025-12-28", + "changes": [ + { "type": "feature", "text": "Theme system with new light/dark themes and font customization" }, + { "type": "feature", "text": "Grid font size setting for data tables" }, + { "type": "feature", "text": "Column visibility, reordering, and resizing (persisted per user or globally)" }, + { "type": "feature", "text": "Auto-update containers with per-environment checks, batch updates, and vulnerability blocking" }, + { "type": "feature", "text": "Stack improvements: environment variables management and .env file support for git stacks" }, + { "type": "feature", "text": "Visual graph editor for Docker Compose stacks" }, + { "type": "feature", "text": "Timezone support for scheduled tasks" }, + { "type": "feature", "text": "Improved schedule execution history" }, + { "type": "fix", "text": "Fix duplicate ports in expanded stack containers (IPv4/IPv6)" }, + { "type": "fix", "text": "Fix registry seed crash when Docker Hub URL is modified" }, + { "type": "fix", "text": "Fix null ports crash for Docker Desktop containers" }, + { "type": "fix", "text": "Fix header layout overlap on small screens" }, + { "type": "fix", "text": "Fix TLS/mTLS support for remote Docker hosts" }, + { "type": "fix", "text": "Fix memory leaks (setTimeout cleanup, stream requests)" }, + { "type": "fix", "text": "Fix Edge mode connection issues" }, + { "type": "fix", "text": "Fix stack deletion with orphaned records" }, + { "type": "fix", "text": "Fix container editing breaking Compose stack association" }, + { "type": "fix", "text": "Many other minor bug fixes and improvements" } + ], + "imageTag": "fnsys/dockhand:v1.0.4" + }, + { + "version": "1.0.3", + "date": "2025-12-18", + "changes": [ + { "type": "fix", "text": "Fix infinite toast loop when environment is offline" } + ], + "imageTag": "fnsys/dockhand:v1.0.3" + }, + { + "version": "1.0.2", + "date": "2025-12-17", + "changes": [ + { "type": "fix", "text": "Fix stack git repository selection" } + ], + "imageTag": "fnsys/dockhand:v1.0.2" + }, + { + "version": "1.0.1", + "date": "2025-12-17", + "changes": [ + { "type": "feature", "text": "Public IP field for environment config (container port clickable links)" }, + { "type": "feature", "text": "Releases are now also published with 'latest' tag" }, + { "type": "fix", "text": "Server-side auth enforcement fix" }, + { "type": "fix", "text": "Docker production build dependencies fix" }, + { "type": "fix", "text": "Memory metrics calculation for remote Docker hosts" }, + { "type": "fix", "text": "Dashboard memory calculation (sum all containers memory usage)" }, + { "type": "fix", "text": "Form validation errors and error messages readability in dark theme" } + ], + "imageTag": "fnsys/dockhand:v1.0.1" + }, + { + "version": "1.0.0", + "date": "2025-12-16", + "changes": [ + { "type": "feature", "text": "First public release of Dockhand" }, + { "type": "feature", "text": "Real-time container management (start, stop, restart, remove)" }, + { "type": "feature", "text": "Container creation with advanced configuration (ports, volumes, env vars, labels)" }, + { "type": "feature", "text": "Docker Compose stack management with visual editor" }, + { "type": "feature", "text": "Git repository integration for stacks with webhooks and auto-sync" }, + { "type": "feature", "text": "Image management and registry browsing" }, + { "type": "feature", "text": "Vulnerability scanning with Grype and Trivy" }, + { "type": "feature", "text": "Container logs viewer with ANSI color rendering and auto-refresh" }, + { "type": "feature", "text": "Interactive shell terminal with xterm.js" }, + { "type": "feature", "text": "File browser for containers and volumes" }, + { "type": "feature", "text": "Multi-environment support (local and remote Docker hosts)" }, + { "type": "feature", "text": "Hawser agent for remote Docker management (Standard and Edge modes)" }, + { "type": "feature", "text": "Network and volume management" }, + { "type": "feature", "text": "Dashboard with real-time metrics and activity tracking" }, + { "type": "feature", "text": "Authentication with OIDC/SSO and local users" }, + { "type": "feature", "text": "SQLite and PostgreSQL database support" }, + { "type": "feature", "text": "Notification channels (SMTP, Apprise webhooks)" }, + { "type": "feature", "text": "Container auto-update scheduling with vulnerability criteria" }, + { "type": "feature", "text": "Enterprise edition with LDAP, MFA, and RBAC" } + ], + "imageTag": "fnsys/dockhand:v1.0.0" + }, + { + "version": "0.9.2", + "date": "2025-12-14", + "changes": [ + { "type": "feature", "text": "Hawser agent support - manage remote Docker hosts behind NAT/firewall" }, + { "type": "feature", "text": "Dashboard redesign with flexible tile sizes and real-time charts" }, + { "type": "feature", "text": "Multi-architecture Docker images (amd64 + arm64)" }, + { "type": "fix", "text": "Various bug fixes and performance improvements" } + ], + "imageTag": "fnsys/dockhand:v0.9.2" + }, + { + "version": "0.9.1", + "date": "2025-12-10", + "changes": [ + { "type": "feature", "text": "Git stack deployment with webhook triggers" }, + { "type": "feature", "text": "Container auto-update scheduling" }, + { "type": "fix", "text": "Fixed container logs not streaming on Edge environments" }, + { "type": "fix", "text": "Fixed memory leak in metrics collection" } + ], + "imageTag": "fnsys/dockhand:v0.9.1" + } +] diff --git a/lib/data/dependencies.json b/lib/data/dependencies.json new file mode 100644 index 0000000..da0d0de --- /dev/null +++ b/lib/data/dependencies.json @@ -0,0 +1,872 @@ +[ + { + "name": "@codemirror/autocomplete", + "version": "6.20.0", + "license": "MIT", + "repository": "https://github.com/codemirror/autocomplete" + }, + { + "name": "@codemirror/commands", + "version": "6.10.0", + "license": "MIT", + "repository": "https://github.com/codemirror/commands" + }, + { + "name": "@codemirror/lang-css", + "version": "6.3.1", + "license": "MIT", + "repository": "https://github.com/codemirror/lang-css" + }, + { + "name": "@codemirror/lang-html", + "version": "6.4.11", + "license": "MIT", + "repository": "https://github.com/codemirror/lang-html" + }, + { + "name": "@codemirror/lang-javascript", + "version": "6.2.4", + "license": "MIT", + "repository": "https://github.com/codemirror/lang-javascript" + }, + { + "name": "@codemirror/lang-json", + "version": "6.0.2", + "license": "MIT", + "repository": "https://github.com/codemirror/lang-json" + }, + { + "name": "@codemirror/lang-markdown", + "version": "6.5.0", + "license": "MIT", + "repository": "https://github.com/codemirror/lang-markdown" + }, + { + "name": "@codemirror/lang-python", + "version": "6.2.1", + "license": "MIT", + "repository": "https://github.com/codemirror/lang-python" + }, + { + "name": "@codemirror/lang-sql", + "version": "6.10.0", + "license": "MIT", + "repository": "https://github.com/codemirror/lang-sql" + }, + { + "name": "@codemirror/lang-xml", + "version": "6.1.0", + "license": "MIT", + "repository": "https://github.com/codemirror/lang-xml" + }, + { + "name": "@codemirror/language", + "version": "6.11.3", + "license": "MIT", + "repository": "https://github.com/codemirror/language" + }, + { + "name": "@codemirror/lint", + "version": "6.9.2", + "license": "MIT", + "repository": "https://github.com/codemirror/lint" + }, + { + "name": "@codemirror/search", + "version": "6.5.11", + "license": "MIT", + "repository": "https://github.com/codemirror/search" + }, + { + "name": "@codemirror/state", + "version": "6.5.2", + "license": "MIT", + "repository": "https://github.com/codemirror/state" + }, + { + "name": "@codemirror/view", + "version": "6.38.8", + "license": "MIT", + "repository": "https://github.com/codemirror/view" + }, + { + "name": "@jridgewell/gen-mapping", + "version": "0.3.13", + "license": "MIT", + "repository": "https://github.com/jridgewell/sourcemaps" + }, + { + "name": "@jridgewell/remapping", + "version": "2.3.5", + "license": "MIT", + "repository": "https://github.com/jridgewell/sourcemaps" + }, + { + "name": "@jridgewell/resolve-uri", + "version": "3.1.2", + "license": "MIT", + "repository": "https://github.com/jridgewell/resolve-uri" + }, + { + "name": "@jridgewell/sourcemap-codec", + "version": "1.5.5", + "license": "MIT", + "repository": "https://github.com/jridgewell/sourcemaps" + }, + { + "name": "@jridgewell/trace-mapping", + "version": "0.3.31", + "license": "MIT", + "repository": "https://github.com/jridgewell/sourcemaps" + }, + { + "name": "@lezer/common", + "version": "1.4.0", + "license": "MIT", + "repository": "https://github.com/lezer-parser/common" + }, + { + "name": "@lezer/css", + "version": "1.3.0", + "license": "MIT", + "repository": "https://github.com/lezer-parser/css" + }, + { + "name": "@lezer/highlight", + "version": "1.2.3", + "license": "MIT", + "repository": "https://github.com/lezer-parser/highlight" + }, + { + "name": "@lezer/html", + "version": "1.3.12", + "license": "MIT", + "repository": "https://github.com/lezer-parser/html" + }, + { + "name": "@lezer/javascript", + "version": "1.5.4", + "license": "MIT", + "repository": "https://github.com/lezer-parser/javascript" + }, + { + "name": "@lezer/json", + "version": "1.0.3", + "license": "MIT", + "repository": "https://github.com/lezer-parser/json" + }, + { + "name": "@lezer/lr", + "version": "1.4.4", + "license": "MIT", + "repository": "https://github.com/lezer-parser/lr" + }, + { + "name": "@lezer/markdown", + "version": "1.6.1", + "license": "MIT", + "repository": "https://github.com/lezer-parser/markdown" + }, + { + "name": "@lezer/python", + "version": "1.1.18", + "license": "MIT", + "repository": "https://github.com/lezer-parser/python" + }, + { + "name": "@lezer/xml", + "version": "1.0.6", + "license": "MIT", + "repository": "https://github.com/lezer-parser/xml" + }, + { + "name": "@lucide/lab", + "version": "0.1.2", + "license": "ISC", + "repository": "https://github.com/lucide-icons/lucide-lab" + }, + { + "name": "@marijn/find-cluster-break", + "version": "1.0.2", + "license": "MIT", + "repository": "https://github.com/marijnh/find-cluster-break" + }, + { + "name": "@noble/hashes", + "version": "1.8.0", + "license": "MIT", + "repository": "https://github.com/paulmillr/noble-hashes" + }, + { + "name": "@sveltejs/acorn-typescript", + "version": "1.0.8", + "license": "MIT", + "repository": "https://github.com/sveltejs/acorn-typescript" + }, + { + "name": "@types/asn1", + "version": "0.2.4", + "license": "MIT", + "repository": "https://github.com/DefinitelyTyped/DefinitelyTyped" + }, + { + "name": "@types/better-sqlite3", + "version": "7.6.13", + "license": "MIT", + "repository": "https://github.com/DefinitelyTyped/DefinitelyTyped" + }, + { + "name": "@types/estree", + "version": "1.0.8", + "license": "MIT", + "repository": "https://github.com/DefinitelyTyped/DefinitelyTyped" + }, + { + "name": "@types/node", + "version": "24.10.1", + "license": "MIT", + "repository": "https://github.com/DefinitelyTyped/DefinitelyTyped" + }, + { + "name": "acorn", + "version": "8.15.0", + "license": "MIT", + "repository": "https://github.com/acornjs/acorn" + }, + { + "name": "ansi-regex", + "version": "5.0.1", + "license": "MIT", + "repository": "https://github.com/chalk/ansi-regex" + }, + { + "name": "ansi-styles", + "version": "4.3.0", + "license": "MIT", + "repository": "https://github.com/chalk/ansi-styles" + }, + { + "name": "argparse", + "version": "2.0.1", + "license": "Python-2.0", + "repository": "https://github.com/nodeca/argparse" + }, + { + "name": "aria-query", + "version": "5.3.2", + "license": "Apache-2.0", + "repository": "https://github.com/A11yance/aria-query" + }, + { + "name": "asn1", + "version": "0.2.6", + "license": "MIT", + "repository": "https://github.com/joyent/node-asn1" + }, + { + "name": "axobject-query", + "version": "4.1.0", + "license": "Apache-2.0", + "repository": "https://github.com/A11yance/axobject-query" + }, + { + "name": "base64-js", + "version": "1.5.1", + "license": "MIT", + "repository": "https://github.com/beatgammit/base64-js" + }, + { + "name": "better-sqlite3", + "version": "12.5.0", + "license": "MIT", + "repository": "https://github.com/WiseLibs/better-sqlite3" + }, + { + "name": "bindings", + "version": "1.5.0", + "license": "MIT", + "repository": "https://github.com/TooTallNate/node-bindings" + }, + { + "name": "bl", + "version": "4.1.0", + "license": "MIT", + "repository": "https://github.com/rvagg/bl" + }, + { + "name": "buffer", + "version": "5.7.1", + "license": "MIT", + "repository": "https://github.com/feross/buffer" + }, + { + "name": "bun-types", + "version": "1.3.3", + "license": "MIT", + "repository": "https://github.com/oven-sh/bun" + }, + { + "name": "camelcase", + "version": "5.3.1", + "license": "MIT", + "repository": "https://github.com/sindresorhus/camelcase" + }, + { + "name": "chownr", + "version": "1.1.4", + "license": "ISC", + "repository": "https://github.com/isaacs/chownr" + }, + { + "name": "cliui", + "version": "6.0.0", + "license": "ISC", + "repository": "https://github.com/yargs/cliui" + }, + { + "name": "clsx", + "version": "2.1.1", + "license": "MIT", + "repository": "https://github.com/lukeed/clsx" + }, + { + "name": "color-convert", + "version": "2.0.1", + "license": "MIT", + "repository": "https://github.com/Qix-/color-convert" + }, + { + "name": "color-name", + "version": "1.1.4", + "license": "MIT", + "repository": "https://github.com/colorjs/color-name" + }, + { + "name": "crelt", + "version": "1.0.6", + "license": "MIT", + "repository": "https://github.com/marijnh/crelt" + }, + { + "name": "croner", + "version": "9.1.0", + "license": "MIT", + "repository": "https://github.com/hexagon/croner" + }, + { + "name": "cronstrue", + "version": "3.9.0", + "license": "MIT", + "repository": "https://github.com/bradymholt/cronstrue" + }, + { + "name": "debug", + "version": "4.4.3", + "license": "MIT", + "repository": "https://github.com/debug-js/debug" + }, + { + "name": "decamelize", + "version": "1.2.0", + "license": "MIT", + "repository": "https://github.com/sindresorhus/decamelize" + }, + { + "name": "decompress-response", + "version": "6.0.0", + "license": "MIT", + "repository": "https://github.com/sindresorhus/decompress-response" + }, + { + "name": "deep-extend", + "version": "0.6.0", + "license": "MIT", + "repository": "https://github.com/unclechu/node-deep-extend" + }, + { + "name": "detect-libc", + "version": "2.1.2", + "license": "Apache-2.0", + "repository": "https://github.com/lovell/detect-libc" + }, + { + "name": "devalue", + "version": "5.5.0", + "license": "MIT", + "repository": "https://github.com/sveltejs/devalue" + }, + { + "name": "dijkstrajs", + "version": "1.0.3", + "license": "MIT", + "repository": "https://github.com/tcort/dijkstrajs" + }, + { + "name": "dockhand", + "version": "1.0.3", + "license": "UNLICENSED", + "repository": null + }, + { + "name": "drizzle-orm", + "version": "0.45.0", + "license": "Apache-2.0", + "repository": "https://github.com/drizzle-team/drizzle-orm" + }, + { + "name": "emoji-regex", + "version": "8.0.0", + "license": "MIT", + "repository": "https://github.com/mathiasbynens/emoji-regex" + }, + { + "name": "end-of-stream", + "version": "1.4.5", + "license": "MIT", + "repository": "https://github.com/mafintosh/end-of-stream" + }, + { + "name": "esm-env", + "version": "1.2.2", + "license": "MIT", + "repository": "https://github.com/benmccann/esm-env" + }, + { + "name": "esrap", + "version": "2.2.1", + "license": "MIT", + "repository": "https://github.com/sveltejs/esrap" + }, + { + "name": "expand-template", + "version": "2.0.3", + "license": "(MIT OR WTFPL)", + "repository": "https://github.com/ralphtheninja/expand-template" + }, + { + "name": "file-uri-to-path", + "version": "1.0.0", + "license": "MIT", + "repository": "https://github.com/TooTallNate/file-uri-to-path" + }, + { + "name": "find-up", + "version": "4.1.0", + "license": "MIT", + "repository": "https://github.com/sindresorhus/find-up" + }, + { + "name": "fs-constants", + "version": "1.0.0", + "license": "MIT", + "repository": "https://github.com/mafintosh/fs-constants" + }, + { + "name": "get-caller-file", + "version": "2.0.5", + "license": "ISC", + "repository": "https://github.com/stefanpenner/get-caller-file" + }, + { + "name": "github-from-package", + "version": "0.0.0", + "license": "MIT", + "repository": "https://github.com/substack/github-from-package" + }, + { + "name": "ieee754", + "version": "1.2.1", + "license": "BSD-3-Clause", + "repository": "https://github.com/feross/ieee754" + }, + { + "name": "inherits", + "version": "2.0.4", + "license": "ISC", + "repository": "https://github.com/isaacs/inherits" + }, + { + "name": "ini", + "version": "1.3.8", + "license": "ISC", + "repository": "https://github.com/isaacs/ini" + }, + { + "name": "is-fullwidth-code-point", + "version": "3.0.0", + "license": "MIT", + "repository": "https://github.com/sindresorhus/is-fullwidth-code-point" + }, + { + "name": "is-reference", + "version": "3.0.3", + "license": "MIT", + "repository": "https://github.com/Rich-Harris/is-reference" + }, + { + "name": "js-yaml", + "version": "4.1.1", + "license": "MIT", + "repository": "https://github.com/nodeca/js-yaml" + }, + { + "name": "ldapts", + "version": "8.0.12", + "license": "MIT", + "repository": "https://github.com/ldapts/ldapts" + }, + { + "name": "locate-character", + "version": "3.0.0", + "license": "MIT", + "repository": "git+https://gitlab.com/Rich-Harris/locate-character" + }, + { + "name": "locate-path", + "version": "5.0.0", + "license": "MIT", + "repository": "https://github.com/sindresorhus/locate-path" + }, + { + "name": "magic-string", + "version": "0.30.21", + "license": "MIT", + "repository": "https://github.com/Rich-Harris/magic-string" + }, + { + "name": "mimic-response", + "version": "3.1.0", + "license": "MIT", + "repository": "https://github.com/sindresorhus/mimic-response" + }, + { + "name": "minimist", + "version": "1.2.8", + "license": "MIT", + "repository": "https://github.com/minimistjs/minimist" + }, + { + "name": "mkdirp-classic", + "version": "0.5.3", + "license": "MIT", + "repository": "https://github.com/mafintosh/mkdirp-classic" + }, + { + "name": "ms", + "version": "2.1.3", + "license": "MIT", + "repository": "https://github.com/vercel/ms" + }, + { + "name": "napi-build-utils", + "version": "2.0.0", + "license": "MIT", + "repository": "https://github.com/inspiredware/napi-build-utils" + }, + { + "name": "node-abi", + "version": "3.85.0", + "license": "MIT", + "repository": "https://github.com/electron/node-abi" + }, + { + "name": "nodemailer", + "version": "7.0.11", + "license": "MIT-0", + "repository": "https://github.com/nodemailer/nodemailer" + }, + { + "name": "once", + "version": "1.4.0", + "license": "ISC", + "repository": "https://github.com/isaacs/once" + }, + { + "name": "otpauth", + "version": "9.4.1", + "license": "MIT", + "repository": "https://github.com/hectorm/otpauth" + }, + { + "name": "p-limit", + "version": "2.3.0", + "license": "MIT", + "repository": "https://github.com/sindresorhus/p-limit" + }, + { + "name": "p-locate", + "version": "4.1.0", + "license": "MIT", + "repository": "https://github.com/sindresorhus/p-locate" + }, + { + "name": "p-try", + "version": "2.2.0", + "license": "MIT", + "repository": "https://github.com/sindresorhus/p-try" + }, + { + "name": "path-exists", + "version": "4.0.0", + "license": "MIT", + "repository": "https://github.com/sindresorhus/path-exists" + }, + { + "name": "pngjs", + "version": "5.0.0", + "license": "MIT", + "repository": "https://github.com/lukeapage/pngjs" + }, + { + "name": "postgres", + "version": "3.4.7", + "license": "Unlicense", + "repository": "https://github.com/porsager/postgres" + }, + { + "name": "prebuild-install", + "version": "7.1.3", + "license": "MIT", + "repository": "https://github.com/prebuild/prebuild-install" + }, + { + "name": "pump", + "version": "3.0.3", + "license": "MIT", + "repository": "https://github.com/mafintosh/pump" + }, + { + "name": "punycode", + "version": "2.3.1", + "license": "MIT", + "repository": "https://github.com/mathiasbynens/punycode.js" + }, + { + "name": "qrcode", + "version": "1.5.4", + "license": "MIT", + "repository": "https://github.com/soldair/node-qrcode" + }, + { + "name": "rc", + "version": "1.2.8", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "repository": "https://github.com/dominictarr/rc" + }, + { + "name": "readable-stream", + "version": "3.6.2", + "license": "MIT", + "repository": "https://github.com/nodejs/readable-stream" + }, + { + "name": "require-directory", + "version": "2.1.1", + "license": "MIT", + "repository": "https://github.com/troygoode/node-require-directory" + }, + { + "name": "require-main-filename", + "version": "2.0.0", + "license": "ISC", + "repository": "https://github.com/yargs/require-main-filename" + }, + { + "name": "runed", + "version": "0.28.0", + "license": "MIT", + "repository": "https://github.com/svecosystem/runed" + }, + { + "name": "safe-buffer", + "version": "5.2.1", + "license": "MIT", + "repository": "https://github.com/feross/safe-buffer" + }, + { + "name": "safer-buffer", + "version": "2.1.2", + "license": "MIT", + "repository": "https://github.com/ChALkeR/safer-buffer" + }, + { + "name": "semver", + "version": "7.7.3", + "license": "ISC", + "repository": "https://github.com/npm/node-semver" + }, + { + "name": "set-blocking", + "version": "2.0.0", + "license": "ISC", + "repository": "https://github.com/yargs/set-blocking" + }, + { + "name": "simple-concat", + "version": "1.0.1", + "license": "MIT", + "repository": "https://github.com/feross/simple-concat" + }, + { + "name": "simple-get", + "version": "4.0.1", + "license": "MIT", + "repository": "https://github.com/feross/simple-get" + }, + { + "name": "strict-event-emitter-types", + "version": "2.0.0", + "license": "ISC", + "repository": "https://github.com/bterlson/typed-event-emitter" + }, + { + "name": "string-width", + "version": "4.2.3", + "license": "MIT", + "repository": "https://github.com/sindresorhus/string-width" + }, + { + "name": "string_decoder", + "version": "1.3.0", + "license": "MIT", + "repository": "https://github.com/nodejs/string_decoder" + }, + { + "name": "strip-ansi", + "version": "6.0.1", + "license": "MIT", + "repository": "https://github.com/chalk/strip-ansi" + }, + { + "name": "strip-json-comments", + "version": "2.0.1", + "license": "MIT", + "repository": "https://github.com/sindresorhus/strip-json-comments" + }, + { + "name": "style-mod", + "version": "4.1.3", + "license": "MIT", + "repository": "https://github.com/marijnh/style-mod" + }, + { + "name": "svelte", + "version": "5.45.5", + "license": "MIT", + "repository": "https://github.com/sveltejs/svelte" + }, + { + "name": "svelte-dnd-action", + "version": "0.9.68", + "license": "MIT", + "repository": "https://github.com/isaacHagoel/svelte-dnd-action" + }, + { + "name": "svelte-sonner", + "version": "1.0.7", + "license": "MIT", + "repository": "https://github.com/wobsoriano/svelte-sonner" + }, + { + "name": "tar-fs", + "version": "2.1.4", + "license": "MIT", + "repository": "https://github.com/mafintosh/tar-fs" + }, + { + "name": "tar-stream", + "version": "2.2.0", + "license": "MIT", + "repository": "https://github.com/mafintosh/tar-stream" + }, + { + "name": "tr46", + "version": "6.0.0", + "license": "MIT", + "repository": "https://github.com/jsdom/tr46" + }, + { + "name": "tunnel-agent", + "version": "0.6.0", + "license": "Apache-2.0", + "repository": "https://github.com/mikeal/tunnel-agent" + }, + { + "name": "undici-types", + "version": "7.16.0", + "license": "MIT", + "repository": "https://github.com/nodejs/undici" + }, + { + "name": "util-deprecate", + "version": "1.0.2", + "license": "MIT", + "repository": "https://github.com/TooTallNate/util-deprecate" + }, + { + "name": "uuid", + "version": "13.0.0", + "license": "MIT", + "repository": "https://github.com/uuidjs/uuid" + }, + { + "name": "w3c-keyname", + "version": "2.2.8", + "license": "MIT", + "repository": "https://github.com/marijnh/w3c-keyname" + }, + { + "name": "webidl-conversions", + "version": "8.0.0", + "license": "BSD-2-Clause", + "repository": "https://github.com/jsdom/webidl-conversions" + }, + { + "name": "whatwg-url", + "version": "15.1.0", + "license": "MIT", + "repository": "https://github.com/jsdom/whatwg-url" + }, + { + "name": "which-module", + "version": "2.0.1", + "license": "ISC", + "repository": "https://github.com/nexdrew/which-module" + }, + { + "name": "wrap-ansi", + "version": "6.2.0", + "license": "MIT", + "repository": "https://github.com/chalk/wrap-ansi" + }, + { + "name": "wrappy", + "version": "1.0.2", + "license": "ISC", + "repository": "https://github.com/npm/wrappy" + }, + { + "name": "y18n", + "version": "4.0.3", + "license": "ISC", + "repository": "https://github.com/yargs/y18n" + }, + { + "name": "yargs", + "version": "15.4.1", + "license": "MIT", + "repository": "https://github.com/yargs/yargs" + }, + { + "name": "yargs-parser", + "version": "18.1.3", + "license": "ISC", + "repository": "https://github.com/yargs/yargs-parser" + }, + { + "name": "zimmerframe", + "version": "1.1.4", + "license": "MIT", + "repository": "https://github.com/sveltejs/zimmerframe" + } +] diff --git a/lib/hooks/is-mobile.svelte.ts b/lib/hooks/is-mobile.svelte.ts new file mode 100644 index 0000000..a60c2c7 --- /dev/null +++ b/lib/hooks/is-mobile.svelte.ts @@ -0,0 +1,35 @@ +import { browser } from '$app/environment'; + +const DEFAULT_MOBILE_BREAKPOINT = 768; + +export class IsMobile { + #breakpoint: number; + #current = $state(false); + + constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) { + this.#breakpoint = breakpoint; + + if (browser) { + // Set initial value + this.#current = window.innerWidth < this.#breakpoint; + + // Listen for resize events + const handleResize = () => { + this.#current = window.innerWidth < this.#breakpoint; + }; + + window.addEventListener('resize', handleResize); + + // Also use matchMedia for more reliable detection + const mql = window.matchMedia(`(max-width: ${this.#breakpoint - 1}px)`); + const handleMediaChange = (e: MediaQueryListEvent) => { + this.#current = e.matches; + }; + mql.addEventListener('change', handleMediaChange); + } + } + + get current() { + return this.#current; + } +} diff --git a/lib/index.ts b/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/lib/server/.DS_Store b/lib/server/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..7d5961e44a2a431a1eda307caa67862b2d8e46b3 GIT binary patch literal 8196 zcmeHMU2GIp6u#fI&>1?=0g5cR6BY_V$O5HQ+wxBVt|{IJQnCB0$Spfn==HL4+JYCxS_ybo#v1E z>I?}^!#0dS7=g(V@X|+M8p~!!oZ(yV?~Yqv{}qIa%4yT9s-&vws{P49dnD;*+=A1V z$?x^J9@{pv<$fl+$IwU8N^QGsxf#RK^S*(h>7=r+&#*Lmq|Gi^n&aCh`G6#eQd)_O zjWx71#AA((E#vXnSW81a^&e^+AD6^=b!#^4P9HIcEc+yXd<0tnn>oRqQ)ORFr`XPY zsYrU?B0OKd4tx=%8P)PkwJ+5_uvbnibBgrP-NUY7Th1Om?+{HZH4Zt~m9wp#dEFcD z%h_gO$a2!k>|Dmo70isQx0+_sIIfed#>wSv)7)u0hBr-r%GF0*Z*0;~YQVAcZo5BO z%AonVmQrx^-8xNb6!i|8+Nqu37^HKv<}X_Mz^aC(jfu{#?YpkjD6{9xt(D~gilXHj zj~RNtcf`=#!TyYs(=E-g4)qlc$H=VFNtG?g{54z`UQ zWO+8mPP6COS$3X%%r3Dn*|+Q`cAfnWU^*nsKs9Qy5K9rqgJ?oCTF{1W?80s&u@8eV za2O6oaU5effhTbiPvJDq;90zcSMVy%;tjlw^SFR_@IEf%Q+$Rma1Gz#d;Eaw_yd39 zhA>T-FGPd|!V)1atP)lWO+vGrDW1#FjyNEA)N5-2xcmk&g<7e void): this { + return super.on(event, listener); + } + + off(event: 'audit', listener: (data: AuditEventData) => void): this { + return super.off(event, listener); + } +} + +export const auditEvents = new AuditEventEmitter(); + +/** + * Broadcast a new audit event to all connected clients + */ +export function broadcastAuditEvent(data: AuditEventData): void { + auditEvents.emit('audit', data); +} diff --git a/lib/server/audit.ts b/lib/server/audit.ts new file mode 100644 index 0000000..f4e7f35 --- /dev/null +++ b/lib/server/audit.ts @@ -0,0 +1,307 @@ +/** + * Audit Logging Helper + * + * Provides easy-to-use functions for logging audit events from API endpoints. + * This is an Enterprise-only feature. + */ + +import type { RequestEvent } from '@sveltejs/kit'; +import { isEnterprise } from './license'; +import { logAuditEvent, type AuditAction, type AuditEntityType, type AuditLogCreateData } from './db'; +import { authorize } from './authorize'; + +export interface AuditContext { + userId?: number | null; + username: string; + ipAddress?: string | null; + userAgent?: string | null; +} + +/** + * Extract audit context from a request event + */ +export async function getAuditContext(event: RequestEvent): Promise { + const auth = await authorize(event.cookies); + + // Get IP address from various headers (proxied requests) + const forwardedFor = event.request.headers.get('x-forwarded-for'); + const realIp = event.request.headers.get('x-real-ip'); + let ipAddress = forwardedFor?.split(',')[0]?.trim() || realIp || event.getClientAddress?.() || null; + + // Convert IPv6 loopback to more readable format + if (ipAddress === '::1' || ipAddress === '::ffff:127.0.0.1') { + ipAddress = '127.0.0.1'; + } else if (ipAddress?.startsWith('::ffff:')) { + // Strip IPv6 prefix from IPv4-mapped addresses + ipAddress = ipAddress.substring(7); + } + + // Get user agent + const userAgent = event.request.headers.get('user-agent') || null; + + return { + userId: auth.user?.id ?? null, + username: auth.user?.username ?? 'anonymous', + ipAddress, + userAgent + }; +} + +/** + * Log an audit event (only logs if Enterprise license is active) + */ +export async function audit( + event: RequestEvent, + action: AuditAction, + entityType: AuditEntityType, + options: { + entityId?: string | null; + entityName?: string | null; + environmentId?: number | null; + description?: string | null; + details?: any | null; + } = {} +): Promise { + // Only log if enterprise + if (!(await isEnterprise())) return; + + const ctx = await getAuditContext(event); + + const data: AuditLogCreateData = { + userId: ctx.userId, + username: ctx.username, + action, + entityType: entityType, + entityId: options.entityId ?? null, + entityName: options.entityName ?? null, + environmentId: options.environmentId ?? null, + description: options.description ?? null, + details: options.details ?? null, + ipAddress: ctx.ipAddress ?? null, + userAgent: ctx.userAgent ?? null + }; + + try { + await logAuditEvent(data); + } catch (error) { + // Don't let audit logging errors break the main operation + console.error('Failed to log audit event:', error); + } +} + +/** + * Helper for container actions + */ +export async function auditContainer( + event: RequestEvent, + action: AuditAction, + containerId: string, + containerName: string, + environmentId?: number | null, + details?: any +): Promise { + await audit(event, action, 'container', { + entityId: containerId, + entityName: containerName, + environmentId, + description: `Container ${containerName} ${action}`, + details + }); +} + +/** + * Helper for image actions + */ +export async function auditImage( + event: RequestEvent, + action: AuditAction, + imageId: string, + imageName: string, + environmentId?: number | null, + details?: any +): Promise { + await audit(event, action, 'image', { + entityId: imageId, + entityName: imageName, + environmentId, + description: `Image ${imageName} ${action}`, + details + }); +} + +/** + * Helper for stack actions + */ +export async function auditStack( + event: RequestEvent, + action: AuditAction, + stackName: string, + environmentId?: number | null, + details?: any +): Promise { + await audit(event, action, 'stack', { + entityId: stackName, + entityName: stackName, + environmentId, + description: `Stack ${stackName} ${action}`, + details + }); +} + +/** + * Helper for volume actions + */ +export async function auditVolume( + event: RequestEvent, + action: AuditAction, + volumeId: string, + volumeName: string, + environmentId?: number | null, + details?: any +): Promise { + await audit(event, action, 'volume', { + entityId: volumeId, + entityName: volumeName, + environmentId, + description: `Volume ${volumeName} ${action}`, + details + }); +} + +/** + * Helper for network actions + */ +export async function auditNetwork( + event: RequestEvent, + action: AuditAction, + networkId: string, + networkName: string, + environmentId?: number | null, + details?: any +): Promise { + await audit(event, action, 'network', { + entityId: networkId, + entityName: networkName, + environmentId, + description: `Network ${networkName} ${action}`, + details + }); +} + +/** + * Helper for user actions + */ +export async function auditUser( + event: RequestEvent, + action: AuditAction, + userId: number, + username: string, + details?: any +): Promise { + await audit(event, action, 'user', { + entityId: String(userId), + entityName: username, + description: `User ${username} ${action}`, + details + }); +} + +/** + * Helper for settings actions + */ +export async function auditSettings( + event: RequestEvent, + action: AuditAction, + settingName: string, + details?: any +): Promise { + await audit(event, action, 'settings', { + entityId: settingName, + entityName: settingName, + description: `Settings ${settingName} ${action}`, + details + }); +} + +/** + * Helper for environment actions + */ +export async function auditEnvironment( + event: RequestEvent, + action: AuditAction, + environmentId: number, + environmentName: string, + details?: any +): Promise { + await audit(event, action, 'environment', { + entityId: String(environmentId), + entityName: environmentName, + environmentId, + description: `Environment ${environmentName} ${action}`, + details + }); +} + +/** + * Helper for registry actions + */ +export async function auditRegistry( + event: RequestEvent, + action: AuditAction, + registryId: number, + registryName: string, + details?: any +): Promise { + await audit(event, action, 'registry', { + entityId: String(registryId), + entityName: registryName, + description: `Registry ${registryName} ${action}`, + details + }); +} + +/** + * Helper for auth actions (login/logout) + */ +export async function auditAuth( + event: RequestEvent, + action: 'login' | 'logout', + username: string, + details?: any +): Promise { + // For login/logout, we want to log even without a session + if (!(await isEnterprise())) return; + + const forwardedFor = event.request.headers.get('x-forwarded-for'); + const realIp = event.request.headers.get('x-real-ip'); + let ipAddress = forwardedFor?.split(',')[0]?.trim() || realIp || event.getClientAddress?.() || null; + + // Convert IPv6 loopback to more readable format + if (ipAddress === '::1' || ipAddress === '::ffff:127.0.0.1') { + ipAddress = '127.0.0.1'; + } else if (ipAddress?.startsWith('::ffff:')) { + ipAddress = ipAddress.substring(7); + } + + const userAgent = event.request.headers.get('user-agent') || null; + + const data: AuditLogCreateData = { + userId: null, // Will be set from details if available + username, + action, + entityType: 'user', + entityId: null, + entityName: username, + environmentId: null, + description: `User ${username} ${action}`, + details, + ipAddress: ipAddress, + userAgent: userAgent + }; + + try { + await logAuditEvent(data); + } catch (error) { + console.error('Failed to log audit event:', error); + } +} diff --git a/lib/server/auth.ts b/lib/server/auth.ts new file mode 100644 index 0000000..dd862bd --- /dev/null +++ b/lib/server/auth.ts @@ -0,0 +1,1346 @@ +/** + * Core Authentication Module + * + * Security features: + * - Argon2id password hashing via Bun.password (memory-hard, timing-attack resistant) + * - Cryptographically secure 32-byte random session tokens + * - HttpOnly cookies (prevents XSS from reading tokens) + * - Secure flag (HTTPS only in production) + * - SameSite=Strict (CSRF protection) + */ + +import { randomBytes } from 'node:crypto'; +import os from 'node:os'; +import type { Cookies } from '@sveltejs/kit'; +import { + getAuthSettings, + getUser, + getUserByUsername, + getSession as dbGetSession, + createSession as dbCreateSession, + deleteSession as dbDeleteSession, + deleteExpiredSessions, + updateUser, + createUser, + getUserRoles, + getUserRolesForEnvironment, + getUserAccessibleEnvironments, + userCanAccessEnvironment, + userHasAdminRole, + getRoleByName, + assignUserRole, + removeUserRole, + getLdapConfigs, + getLdapConfig, + getOidcConfigs, + getOidcConfig, + type User, + type Session, + type Permissions, + type LdapConfig, + type OidcConfig +} from './db'; +import { Client as LdapClient } from 'ldapts'; +import { isEnterprise } from './license'; + +// Session cookie name +const SESSION_COOKIE_NAME = 'dockhand_session'; + +// Default empty permissions +const EMPTY_PERMISSIONS: Permissions = { + containers: [], + images: [], + volumes: [], + networks: [], + stacks: [], + environments: [], + registries: [], + notifications: [], + configsets: [], + settings: [], + users: [], + git: [], + license: [], + audit_logs: [], + activity: [], + schedules: [] +}; + +/** + * Get Admin role permissions from the database. + * Falls back to EMPTY_PERMISSIONS if Admin role not found. + */ +async function getAdminPermissions(): Promise { + const adminRole = await getRoleByName('Admin'); + return adminRole?.permissions ?? EMPTY_PERMISSIONS; +} + +export interface AuthenticatedUser { + id: number; + username: string; + email?: string; + displayName?: string; + avatar?: string; + isAdmin: boolean; + provider: 'local' | 'ldap' | 'oidc'; + permissions: Permissions; +} + +export interface LoginResult { + success: boolean; + error?: string; + requiresMfa?: boolean; + user?: AuthenticatedUser; +} + +// ============================================ +// Password Hashing (Argon2id via Bun.password) +// ============================================ + +/** + * Hash a password using Argon2id via Bun's native password API + * Argon2id is the recommended variant - resistant to both side-channel and GPU attacks + */ +export async function hashPassword(password: string): Promise { + return Bun.password.hash(password, { + algorithm: 'argon2id', + memoryCost: 65536, // 64 MB + timeCost: 3 // 3 iterations + }); +} + +/** + * Verify a password against a hash + * Uses constant-time comparison internally + */ +export async function verifyPassword(password: string, hash: string): Promise { + try { + return await Bun.password.verify(password, hash); + } catch { + return false; + } +} + +// ============================================ +// Session Management +// ============================================ + +/** + * Generate a cryptographically secure session token + * 32 bytes = 256 bits of entropy + */ +function generateSessionToken(): string { + return randomBytes(32).toString('base64url'); +} + +/** + * Create a new session for a user + * @param provider - Auth provider: 'local', or provider name like 'Keycloak', 'Azure AD', etc. + */ +export async function createUserSession( + userId: number, + provider: string, + cookies: Cookies +): Promise { + // Clean up expired sessions periodically + await deleteExpiredSessions(); + + // Generate secure token + const sessionId = generateSessionToken(); + + // Get session timeout from settings + const settings = await getAuthSettings(); + const expiresAt = new Date(Date.now() + settings.sessionTimeout * 1000).toISOString(); + + // Create session in database + const session = await dbCreateSession(sessionId, userId, provider, expiresAt); + + // Set secure cookie + setSessionCookie(cookies, sessionId, settings.sessionTimeout); + + // Update user's last login time + await updateUser(userId, { lastLogin: new Date().toISOString() }); + + return session; +} + +/** + * Set the session cookie with secure attributes + */ +function setSessionCookie(cookies: Cookies, sessionId: string, maxAge: number): void { + cookies.set(SESSION_COOKIE_NAME, sessionId, { + path: '/', + httpOnly: true, // Prevents XSS attacks from reading cookie + secure: process.env.NODE_ENV === 'production', // HTTPS only in production + sameSite: 'strict', // CSRF protection + maxAge: maxAge // Session timeout in seconds + }); +} + +/** + * Get the session ID from cookies + */ +function getSessionIdFromCookies(cookies: Cookies): string | null { + return cookies.get(SESSION_COOKIE_NAME) || null; +} + +/** + * Validate a session and return the authenticated user + */ +export async function validateSession(cookies: Cookies): Promise { + const sessionId = getSessionIdFromCookies(cookies); + if (!sessionId) return null; + + const session = await dbGetSession(sessionId); + if (!session) return null; + + // Check if session is expired + const expiresAt = new Date(session.expiresAt); + if (expiresAt < new Date()) { + await dbDeleteSession(sessionId); + return null; + } + + const user = await getUser(session.userId); + if (!user || !user.isActive) return null; + + return await buildAuthenticatedUser(user, session.provider as 'local' | 'ldap' | 'oidc'); +} + +/** + * Destroy a session (logout) + */ +export async function destroySession(cookies: Cookies): Promise { + const sessionId = getSessionIdFromCookies(cookies); + if (sessionId) { + await dbDeleteSession(sessionId); + } + + // Clear the cookie + cookies.delete(SESSION_COOKIE_NAME, { path: '/' }); +} + +// ============================================ +// User Permissions +// ============================================ + +/** + * Build an authenticated user object with merged permissions + */ +async function buildAuthenticatedUser( + user: User, + provider: 'local' | 'ldap' | 'oidc' +): Promise { + const permissions = await getUserPermissionsById(user.id); + + // Determine admin status: + // - Free edition: all authenticated users are admins + // - Enterprise: check Admin role assignment + const enterprise = await isEnterprise(); + const isAdmin = enterprise ? await userHasAdminRole(user.id) : true; + + return { + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + avatar: user.avatar, + isAdmin, + provider, + permissions + }; +} + +/** + * Get merged permissions for a user from all their roles + */ +export async function getUserPermissionsById(userId: number): Promise { + const user = await getUser(userId); + if (!user) return EMPTY_PERMISSIONS; + + // Admins (those with Admin role) have all permissions + if (await userHasAdminRole(userId)) { + return getAdminPermissions(); + } + + // Get all roles for this user + const userRoles = await getUserRoles(userId); + + // Merge permissions from all roles + const merged: Permissions = { + containers: [], + images: [], + volumes: [], + networks: [], + stacks: [], + environments: [], + registries: [], + notifications: [], + configsets: [], + settings: [], + users: [], + git: [], + license: [], + audit_logs: [], + activity: [], + schedules: [] + }; + + for (const ur of userRoles) { + const perms = ur.role.permissions; + for (const key of Object.keys(merged) as (keyof Permissions)[]) { + if (perms[key]) { + merged[key] = [...new Set([...merged[key], ...perms[key]])]; + } + } + } + + return merged; +} + +/** + * Check if a user has a specific permission + * Note: Permission checks only apply in Enterprise edition + * In Free edition, all authenticated users have full access + * + * @param user - The authenticated user + * @param resource - The resource category (containers, images, etc.) + * @param action - The action to check (view, create, etc.) + * @param environmentId - Optional: check permission in context of specific environment + */ +export async function checkPermission( + user: AuthenticatedUser, + resource: keyof Permissions, + action: string, + environmentId?: number +): Promise { + // In free edition, all authenticated users have full access + if (!(await isEnterprise())) return true; + + // Admins (those with Admin role) can do anything + if (await userHasAdminRole(user.id)) return true; + + // If checking within an environment context, get environment-specific permissions + if (environmentId !== undefined) { + const permissions = await getUserPermissionsForEnvironment(user.id, environmentId); + return permissions[resource]?.includes(action) ?? false; + } + + // Otherwise use global permissions (from all roles) + return user.permissions[resource]?.includes(action) ?? false; +} + +/** + * Get merged permissions for a user for a specific environment. + * Only includes permissions from roles that apply to this environment + * (roles with null environmentId OR matching environmentId). + */ +export async function getUserPermissionsForEnvironment(userId: number, environmentId: number): Promise { + const user = await getUser(userId); + if (!user) return EMPTY_PERMISSIONS; + + // Admins (those with Admin role) have all permissions + if (await userHasAdminRole(userId)) { + return getAdminPermissions(); + } + + // Get roles that apply to this specific environment + const userRoles = await getUserRolesForEnvironment(userId, environmentId); + + // Merge permissions from applicable roles only + const merged: Permissions = { + containers: [], + images: [], + volumes: [], + networks: [], + stacks: [], + environments: [], + registries: [], + notifications: [], + configsets: [], + settings: [], + users: [], + git: [], + license: [], + audit_logs: [], + activity: [], + schedules: [] + }; + + for (const ur of userRoles) { + if (!ur.role) continue; + const perms = ur.role.permissions; + for (const key of Object.keys(merged) as (keyof Permissions)[]) { + if (perms[key]) { + merged[key] = [...new Set([...merged[key], ...perms[key]])]; + } + } + } + + return merged; +} + +// Re-export for convenience +export { getUserAccessibleEnvironments, userCanAccessEnvironment }; + +// ============================================ +// Authentication State +// ============================================ + +/** + * Check if authentication is enabled + */ +export async function isAuthEnabled(): Promise { + try { + const settings = await getAuthSettings(); + return settings.authEnabled; + } catch { + // If database is not initialized, auth is disabled + return false; + } +} + +/** + * Local authentication + */ +export async function authenticateLocal( + username: string, + password: string +): Promise { + const user = await getUserByUsername(username); + + if (!user) { + // Use constant time to prevent timing attacks + await Bun.password.hash('dummy', { algorithm: 'argon2id' }); + return { success: false, error: 'Invalid username or password' }; + } + + if (!user.isActive) { + return { success: false, error: 'Account is disabled' }; + } + + const validPassword = await verifyPassword(password, user.passwordHash); + if (!validPassword) { + return { success: false, error: 'Invalid username or password' }; + } + + // Check if MFA is required + if (user.mfaEnabled) { + return { success: true, requiresMfa: true }; + } + + return { + success: true, + user: await buildAuthenticatedUser(user, 'local') + }; +} + +// ============================================ +// LDAP Authentication +// ============================================ + +export interface LdapTestResult { + success: boolean; + error?: string; + userCount?: number; +} + +/** + * Get enabled LDAP configurations + */ +export async function getEnabledLdapConfigs(): Promise { + const configs = await getLdapConfigs(); + return configs.filter(config => config.enabled); +} + +/** + * Test LDAP connection and configuration + */ +export async function testLdapConnection(configId: number): Promise { + const config = await getLdapConfig(configId); + if (!config) { + return { success: false, error: 'LDAP configuration not found' }; + } + + const client = new LdapClient({ + url: config.serverUrl, + tlsOptions: config.tlsEnabled ? { + rejectUnauthorized: !config.tlsCa, // If CA provided, validate; otherwise trust + ca: config.tlsCa ? [config.tlsCa] : undefined + } : undefined + }); + + try { + // Bind with service account if configured + if (config.bindDn && config.bindPassword) { + await client.bind(config.bindDn, config.bindPassword); + } + + // Search for users to validate base_dn and filter + const filter = config.userFilter.replace('{{username}}', '*'); + const { searchEntries } = await client.search(config.baseDn, { + scope: 'sub', + filter: filter, + sizeLimit: 10, + attributes: [config.usernameAttribute] + }); + + await client.unbind(); + return { success: true, userCount: searchEntries.length }; + } catch (error: any) { + try { await client.unbind(); } catch {} + return { success: false, error: error.message || 'Connection failed' }; + } +} + +/** + * Authenticate user against LDAP + */ +export async function authenticateLdap( + username: string, + password: string, + configId?: number +): Promise { + // Get LDAP configurations + const configs = configId + ? [await getLdapConfig(configId)].filter(Boolean) as LdapConfig[] + : await getEnabledLdapConfigs(); + + if (configs.length === 0) { + return { success: false, error: 'No LDAP configuration available' }; + } + + // Try each LDAP configuration + for (const config of configs) { + const result = await tryLdapAuth(username, password, config); + if (result.success) { + return { ...result, providerName: config.name }; + } + } + + return { success: false, error: 'Invalid username or password' }; +} + +/** + * Try authentication against a specific LDAP configuration + */ +async function tryLdapAuth( + username: string, + password: string, + config: LdapConfig +): Promise { + const client = new LdapClient({ + url: config.serverUrl, + tlsOptions: config.tlsEnabled ? { + rejectUnauthorized: !config.tlsCa, + ca: config.tlsCa ? [config.tlsCa] : undefined + } : undefined + }); + + try { + // First, bind with service account to search for the user + if (config.bindDn && config.bindPassword) { + await client.bind(config.bindDn, config.bindPassword); + } + + // Search for the user + const filter = config.userFilter.replace('{{username}}', username); + const { searchEntries } = await client.search(config.baseDn, { + scope: 'sub', + filter: filter, + sizeLimit: 1, + attributes: [ + 'dn', + config.usernameAttribute, + config.emailAttribute, + config.displayNameAttribute + ] + }); + + if (searchEntries.length === 0) { + await client.unbind(); + return { success: false, error: 'User not found' }; + } + + const userEntry = searchEntries[0]; + const userDn = userEntry.dn; + + // Unbind service account + await client.unbind(); + + // Create new client and try to bind as the user (authentication) + const userClient = new LdapClient({ + url: config.serverUrl, + tlsOptions: config.tlsEnabled ? { + rejectUnauthorized: !config.tlsCa, + ca: config.tlsCa ? [config.tlsCa] : undefined + } : undefined + }); + + try { + await userClient.bind(userDn, password); + await userClient.unbind(); + } catch (bindError) { + return { success: false, error: 'Invalid username or password' }; + } + + // Authentication successful - get or create local user + const ldapUsername = getAttributeValue(userEntry, config.usernameAttribute) || username; + const email = getAttributeValue(userEntry, config.emailAttribute); + const displayName = getAttributeValue(userEntry, config.displayNameAttribute); + + // Check if user is in admin group + let shouldBeAdmin = false; + if (config.adminGroup) { + shouldBeAdmin = await checkLdapGroupMembership(config, userDn, config.adminGroup); + } + + // Build provider string for storage (e.g., "ldap:Active Directory") + const authProvider = `ldap:${config.name}`; + + // Get or create local user + let user = await getUserByUsername(ldapUsername); + if (!user) { + // Create new user from LDAP + user = await createUser({ + username: ldapUsername, + email: email || undefined, + displayName: displayName || undefined, + passwordHash: '', // No local password for LDAP users + authProvider + }); + } else { + // Update user info from LDAP + await updateUser(user.id, { + email: email || undefined, + displayName: displayName || undefined, + authProvider + }); + user = (await getUser(user.id))!; + } + + // Manage Admin role assignment based on LDAP group membership + const adminRole = await getRoleByName('Admin'); + if (adminRole) { + const hasAdminRole = await userHasAdminRole(user.id); + if (shouldBeAdmin && !hasAdminRole) { + // Assign Admin role + await assignUserRole(user.id, adminRole.id, null); + } + // Note: We don't remove Admin role if not in LDAP group anymore + // to prevent accidental lockouts (same behavior as before) + } + + // Process role mappings (Enterprise feature) + // Note: roleMappings is parsed from JSON by getLdapConfig, but TypeScript type is string + const roleMappings = typeof config.roleMappings === 'string' + ? JSON.parse(config.roleMappings) as { groupDn: string; roleId: number }[] + : config.roleMappings as { groupDn: string; roleId: number }[] | null | undefined; + + if (roleMappings && roleMappings.length > 0 && config.groupBaseDn && await isEnterprise()) { + const userExistingRoles = await getUserRoles(user.id); + const existingRoleIds = new Set(userExistingRoles.map(r => r.roleId)); + + for (const mapping of roleMappings) { + // Skip if user already has this role + if (existingRoleIds.has(mapping.roleId)) continue; + + // Check if user is a member of the LDAP group + const isInGroup = await checkLdapGroupMembership(config, userDn, mapping.groupDn); + if (isInGroup) { + await assignUserRole(user.id, mapping.roleId, undefined); + } + } + } + + if (!user.isActive) { + return { success: false, error: 'Account is disabled' }; + } + + // Check if MFA is required + if (user.mfaEnabled) { + return { success: true, requiresMfa: true }; + } + + return { + success: true, + user: await buildAuthenticatedUser(user, 'ldap') + }; + } catch (error: any) { + try { await client.unbind(); } catch {} + console.error('LDAP authentication error:', error); + return { success: false, error: 'LDAP authentication failed' }; + } +} + +/** + * Check if a user is a member of an LDAP group + */ +async function checkLdapGroupMembership( + config: LdapConfig, + userDn: string, + groupDnOrName: string +): Promise { + const client = new LdapClient({ + url: config.serverUrl, + tlsOptions: config.tlsEnabled ? { + rejectUnauthorized: !config.tlsCa, + ca: config.tlsCa ? [config.tlsCa] : undefined + } : undefined + }); + + try { + if (config.bindDn && config.bindPassword) { + await client.bind(config.bindDn, config.bindPassword); + } + + // Detect if groupDnOrName is a full DN (contains = and ,) + const isFullDn = groupDnOrName.includes('=') && groupDnOrName.includes(','); + + let searchBase: string; + let groupFilter: string; + + if (config.groupFilter) { + // User provided custom filter + searchBase = config.groupBaseDn || groupDnOrName; + groupFilter = config.groupFilter + .replace('{{username}}', userDn) + .replace('{{user_dn}}', userDn) + .replace('{{group}}', groupDnOrName); + } else if (isFullDn) { + // Full DN provided - search directly at that DN + searchBase = groupDnOrName; + groupFilter = `(member=${userDn})`; + } else { + // Just a group name - search in groupBaseDn + if (!config.groupBaseDn) { + await client.unbind(); + return false; + } + searchBase = config.groupBaseDn; + groupFilter = `(&(cn=${groupDnOrName})(member=${userDn}))`; + } + + const { searchEntries } = await client.search(searchBase, { + scope: isFullDn && !config.groupFilter ? 'base' : 'sub', + filter: groupFilter, + sizeLimit: 1 + }); + + await client.unbind(); + return searchEntries.length > 0; + } catch (error) { + console.error('LDAP group membership check failed:', error); + try { await client.unbind(); } catch {} + return false; + } +} + +/** + * Helper to get attribute value from LDAP entry + */ +function getAttributeValue(entry: any, attribute: string): string | undefined { + const value = entry[attribute]; + if (!value) return undefined; + if (Array.isArray(value)) return value[0]?.toString(); + return value.toString(); +} + +// ============================================ +// MFA (TOTP) +// ============================================ + +import * as OTPAuth from 'otpauth'; +import * as QRCode from 'qrcode'; + +/** + * Generate MFA secret and QR code for setup + */ +export async function generateMfaSetup(userId: number): Promise<{ + secret: string; + qrDataUrl: string; +} | null> { + const user = await getUser(userId); + if (!user) return null; + + // Build issuer name with hostname (same as license matching) + const hostname = process.env.DOCKHAND_HOSTNAME || os.hostname(); + const issuer = `Dockhand (${hostname})`; + + // Create a new TOTP secret + const totpSecret = new OTPAuth.Secret({ size: 20 }); + const totp = new OTPAuth.TOTP({ + issuer, + label: user.username, + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: totpSecret + }); + + const secretBase32 = totp.secret.base32; + const otpauthUrl = totp.toString(); + + // Generate QR code + const qrDataUrl = await QRCode.toDataURL(otpauthUrl, { + width: 200, + margin: 2 + }); + + // Store secret temporarily (user must verify before it's enabled) + await updateUser(userId, { mfaSecret: secretBase32 }); + + return { secret: secretBase32, qrDataUrl }; +} + +/** + * Verify MFA token and enable MFA if valid + */ +export async function verifyAndEnableMfa(userId: number, token: string): Promise { + const user = await getUser(userId); + if (!user || !user.mfaSecret) return false; + + const totp = new OTPAuth.TOTP({ + issuer: 'Dockhand', + label: user.username, + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: OTPAuth.Secret.fromBase32(user.mfaSecret) + }); + + const delta = totp.validate({ token, window: 1 }); + if (delta === null) return false; + + // Enable MFA + await updateUser(userId, { mfaEnabled: true }); + return true; +} + +/** + * Verify MFA token during login + */ +export async function verifyMfaToken(userId: number, token: string): Promise { + const user = await getUser(userId); + if (!user || !user.mfaEnabled || !user.mfaSecret) return false; + + const totp = new OTPAuth.TOTP({ + issuer: 'Dockhand', + label: user.username, + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: OTPAuth.Secret.fromBase32(user.mfaSecret) + }); + + const delta = totp.validate({ token, window: 1 }); + return delta !== null; +} + +/** + * Disable MFA for a user + */ +export async function disableMfa(userId: number): Promise { + const user = await getUser(userId); + if (!user) return false; + + await updateUser(userId, { mfaEnabled: false, mfaSecret: undefined }); + return true; +} + +// ============================================ +// Rate Limiting (Simple in-memory) +// ============================================ + +interface RateLimitEntry { + attempts: number; + lastAttempt: number; + lockedUntil: number | null; +} + +const rateLimitStore = new Map(); + +const RATE_LIMIT_MAX_ATTEMPTS = 5; +const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes +const RATE_LIMIT_LOCKOUT_MS = 15 * 60 * 1000; // 15 minute lockout + +// Guard against multiple intervals during HMR +declare global { + var __authRateLimitCleanupInterval: ReturnType | undefined; + var __authOidcStateCleanupInterval: ReturnType | undefined; +} + +// Cleanup expired rate limit entries every 5 minutes (guarded for HMR) +if (!globalThis.__authRateLimitCleanupInterval) { + globalThis.__authRateLimitCleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of rateLimitStore) { + if (now - entry.lastAttempt > RATE_LIMIT_WINDOW_MS) { + rateLimitStore.delete(key); + } + } + }, 5 * 60 * 1000); +} + +/** + * Check if a login attempt is rate limited + */ +export function isRateLimited(identifier: string): { limited: boolean; retryAfter?: number } { + const entry = rateLimitStore.get(identifier); + const now = Date.now(); + + if (!entry) return { limited: false }; + + // Check if locked out + if (entry.lockedUntil && entry.lockedUntil > now) { + return { + limited: true, + retryAfter: Math.ceil((entry.lockedUntil - now) / 1000) + }; + } + + // Reset if outside window + if (now - entry.lastAttempt > RATE_LIMIT_WINDOW_MS) { + rateLimitStore.delete(identifier); + return { limited: false }; + } + + return { limited: false }; +} + +/** + * Record a failed login attempt + */ +export function recordFailedAttempt(identifier: string): void { + const now = Date.now(); + const entry = rateLimitStore.get(identifier); + + if (!entry || now - entry.lastAttempt > RATE_LIMIT_WINDOW_MS) { + rateLimitStore.set(identifier, { + attempts: 1, + lastAttempt: now, + lockedUntil: null + }); + return; + } + + entry.attempts++; + entry.lastAttempt = now; + + if (entry.attempts >= RATE_LIMIT_MAX_ATTEMPTS) { + entry.lockedUntil = now + RATE_LIMIT_LOCKOUT_MS; + } +} + +/** + * Clear rate limit for an identifier (on successful login) + */ +export function clearRateLimit(identifier: string): void { + rateLimitStore.delete(identifier); +} + +// ============================================ +// OIDC/SSO Authentication +// ============================================ + +// In-memory store for OIDC state (nonce, code_verifier) +// In production, consider using Redis or database for multi-instance deployments +const oidcStateStore = new Map(); + +// Clean up expired OIDC states periodically (guarded for HMR) +if (!globalThis.__authOidcStateCleanupInterval) { + globalThis.__authOidcStateCleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [state, data] of oidcStateStore.entries()) { + if (data.expiresAt < now) { + oidcStateStore.delete(state); + } + } + }, 60000); // Every minute +} + +// OIDC Discovery document cache +const oidcDiscoveryCache = new Map(); + +export interface OidcDiscoveryDocument { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint?: string; + jwks_uri: string; + end_session_endpoint?: string; + scopes_supported?: string[]; + response_types_supported?: string[]; + code_challenge_methods_supported?: string[]; +} + +/** + * Get enabled OIDC configurations + */ +export async function getEnabledOidcConfigs(): Promise { + const configs = await getOidcConfigs(); + return configs.filter(config => config.enabled); +} + +/** + * Fetch and cache OIDC discovery document + */ +async function getOidcDiscovery(issuerUrl: string): Promise { + const cached = oidcDiscoveryCache.get(issuerUrl); + if (cached && cached.expiresAt > Date.now()) { + return cached.document; + } + + const wellKnownUrl = issuerUrl.endsWith('/') + ? `${issuerUrl}.well-known/openid-configuration` + : `${issuerUrl}/.well-known/openid-configuration`; + + const response = await fetch(wellKnownUrl); + if (!response.ok) { + throw new Error(`Failed to fetch OIDC discovery document: ${response.statusText}`); + } + + const document = await response.json() as OidcDiscoveryDocument; + + // Cache for 1 hour + oidcDiscoveryCache.set(issuerUrl, { + document, + expiresAt: Date.now() + 3600000 + }); + + return document; +} + +/** + * Generate PKCE code verifier and challenge + */ +function generatePkce(): { codeVerifier: string; codeChallenge: string } { + const codeVerifier = randomBytes(32).toString('base64url'); + const hasher = new Bun.CryptoHasher('sha256'); + hasher.update(codeVerifier); + const codeChallenge = hasher.digest('base64url') as string; + return { codeVerifier, codeChallenge }; +} + +/** + * Build OIDC authorization URL for SSO initiation + */ +export async function buildOidcAuthorizationUrl( + configId: number, + redirectUrl: string = '/' +): Promise<{ url: string; state: string } | { error: string }> { + const config = await getOidcConfig(configId); + if (!config || !config.enabled) { + return { error: 'OIDC configuration not found or disabled' }; + } + + try { + const discovery = await getOidcDiscovery(config.issuerUrl); + + // Generate state, nonce, and PKCE + const state = randomBytes(32).toString('base64url'); + const nonce = randomBytes(16).toString('base64url'); + const { codeVerifier, codeChallenge } = generatePkce(); + + // Store state for callback verification (expires in 10 minutes) + oidcStateStore.set(state, { + configId, + codeVerifier, + nonce, + redirectUrl, + expiresAt: Date.now() + 600000 + }); + + // Build authorization URL + const params = new URLSearchParams({ + response_type: 'code', + client_id: config.clientId, + redirect_uri: config.redirectUri, + scope: config.scopes || 'openid profile email', + state, + nonce, + code_challenge: codeChallenge, + code_challenge_method: 'S256' + }); + + const authUrl = `${discovery.authorization_endpoint}?${params.toString()}`; + return { url: authUrl, state }; + } catch (error: any) { + console.error('Failed to build OIDC authorization URL:', error); + return { error: error.message || 'Failed to initialize SSO' }; + } +} + +/** + * Exchange authorization code for tokens and authenticate user + */ +export async function handleOidcCallback( + code: string, + state: string +): Promise { + // Validate state + const stateData = oidcStateStore.get(state); + if (!stateData) { + return { success: false, error: 'Invalid or expired state' }; + } + + // Remove state immediately to prevent replay + oidcStateStore.delete(state); + + if (stateData.expiresAt < Date.now()) { + return { success: false, error: 'SSO session expired' }; + } + + const config = await getOidcConfig(stateData.configId); + if (!config || !config.enabled) { + return { success: false, error: 'OIDC configuration not found or disabled' }; + } + + try { + const discovery = await getOidcDiscovery(config.issuerUrl); + + // Exchange code for tokens + const tokenResponse = await fetch(discovery.token_endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: config.redirectUri, + client_id: config.clientId, + client_secret: config.clientSecret, + code_verifier: stateData.codeVerifier + }) + }); + + if (!tokenResponse.ok) { + const errorBody = await tokenResponse.text(); + console.error('Token exchange failed:', tokenResponse.status, errorBody); + console.error('Token endpoint:', discovery.token_endpoint); + console.error('Redirect URI:', config.redirectUri); + console.error('Client ID:', config.clientId); + return { success: false, error: `Failed to exchange authorization code: ${errorBody}` }; + } + + const tokens = await tokenResponse.json() as { + access_token: string; + id_token?: string; + token_type: string; + expires_in?: number; + }; + + // Decode and validate ID token (basic validation - in production use a JWT library) + let claims: Record = {}; + + if (tokens.id_token) { + const idTokenParts = tokens.id_token.split('.'); + if (idTokenParts.length === 3) { + try { + claims = JSON.parse(Buffer.from(idTokenParts[1], 'base64url').toString()); + } catch { + return { success: false, error: 'Invalid ID token' }; + } + } + } + + // If no ID token or need more info, fetch from userinfo endpoint + if (discovery.userinfo_endpoint && tokens.access_token) { + try { + const userinfoResponse = await fetch(discovery.userinfo_endpoint, { + headers: { + 'Authorization': `Bearer ${tokens.access_token}` + } + }); + if (userinfoResponse.ok) { + const userinfo = await userinfoResponse.json(); + claims = { ...claims, ...userinfo }; + } + } catch (e) { + console.warn('Failed to fetch userinfo:', e); + } + } + + // Validate nonce if present in ID token + if (claims.nonce && claims.nonce !== stateData.nonce) { + return { success: false, error: 'Invalid nonce' }; + } + + // Extract user information using configured claims + const username = claims[config.usernameClaim] || claims.preferred_username || claims.sub; + const email = claims[config.emailClaim] || claims.email; + const displayName = claims[config.displayNameClaim] || claims.name; + + if (!username) { + return { success: false, error: 'Username claim not found in token' }; + } + + // Determine if user should be admin based on claim + let shouldBeAdmin = false; + if (config.adminClaim && config.adminValue) { + const adminClaimValue = claims[config.adminClaim]; + // Support multiple comma-separated admin values + const adminValues = config.adminValue.split(',').map((v: string) => v.trim()); + if (Array.isArray(adminClaimValue)) { + shouldBeAdmin = adminClaimValue.some((v: string) => adminValues.includes(v)); + } else { + shouldBeAdmin = adminValues.includes(adminClaimValue); + } + } + + // Build provider string for storage (e.g., "oidc:Keycloak") + const authProvider = `oidc:${config.name}`; + + // Get or create local user + let user = await getUserByUsername(username); + if (!user) { + // Create new user from OIDC + user = await createUser({ + username, + email: email || undefined, + displayName: displayName || undefined, + passwordHash: '', // No local password for OIDC users + authProvider + }); + } else { + // Update user info from OIDC + await updateUser(user.id, { + email: email || undefined, + displayName: displayName || undefined, + authProvider + }); + user = (await getUser(user.id))!; + } + + // Manage Admin role assignment based on OIDC claim + const adminRole = await getRoleByName('Admin'); + if (adminRole) { + const hasAdminRole = await userHasAdminRole(user.id); + if (shouldBeAdmin && !hasAdminRole) { + // Assign Admin role + await assignUserRole(user.id, adminRole.id, null); + } + // Note: We don't remove Admin role if claim is not present anymore + // to prevent accidental lockouts (same behavior as before) + } + + // Process role mappings from OIDC config + if (config.roleMappings) { + try { + const roleMappings = typeof config.roleMappings === 'string' + ? JSON.parse(config.roleMappings) + : config.roleMappings; + + const roleMappingsClaim = config.roleMappingsClaim || 'groups'; + const claimValue = claims[roleMappingsClaim]; + + if (Array.isArray(roleMappings) && claimValue) { + const claimValues = Array.isArray(claimValue) ? claimValue : [claimValue]; + + // Get user's current roles to avoid duplicates + const userRoles = await getUserRoles(user.id); + + for (const mapping of roleMappings) { + if (mapping.claimValue && mapping.roleId && claimValues.includes(mapping.claimValue)) { + const hasRole = userRoles.some(r => r.roleId === mapping.roleId); + if (!hasRole) { + await assignUserRole(user.id, mapping.roleId, null); + } + } + } + } + } catch (e) { + console.warn('Failed to process OIDC role mappings:', e); + } + } + + if (!user.isActive) { + return { success: false, error: 'Account is disabled' }; + } + + // OIDC users bypass MFA (they authenticated through IdP) + return { + success: true, + user: await buildAuthenticatedUser(user, 'oidc'), + redirectUrl: stateData.redirectUrl, + providerName: config.name + }; + } catch (error: any) { + console.error('OIDC callback error:', error); + return { success: false, error: error.message || 'SSO authentication failed' }; + } +} + +export interface OidcTestResult { + success: boolean; + error?: string; + issuer?: string; + endpoints?: { + authorization: string; + token: string; + userinfo?: string; + }; +} + +/** + * Test OIDC configuration by fetching discovery document + */ +export async function testOidcConnection(configId: number): Promise { + const config = await getOidcConfig(configId); + if (!config) { + return { success: false, error: 'OIDC configuration not found' }; + } + + try { + const discovery = await getOidcDiscovery(config.issuerUrl); + return { + success: true, + issuer: discovery.issuer, + endpoints: { + authorization: discovery.authorization_endpoint, + token: discovery.token_endpoint, + userinfo: discovery.userinfo_endpoint + } + }; + } catch (error: any) { + return { success: false, error: error.message || 'Failed to connect to OIDC provider' }; + } +} + +/** + * Build the OIDC logout URL for single logout + */ +export async function buildOidcLogoutUrl( + configId: number, + postLogoutRedirectUri?: string +): Promise { + const config = await getOidcConfig(configId); + if (!config) return null; + + try { + const discovery = await getOidcDiscovery(config.issuerUrl); + if (!discovery.end_session_endpoint) return null; + + const params = new URLSearchParams({ + client_id: config.clientId + }); + + if (postLogoutRedirectUri) { + params.set('post_logout_redirect_uri', postLogoutRedirectUri); + } + + return `${discovery.end_session_endpoint}?${params.toString()}`; + } catch { + return null; + } +} diff --git a/lib/server/authorize.ts b/lib/server/authorize.ts new file mode 100644 index 0000000..7a3104c --- /dev/null +++ b/lib/server/authorize.ts @@ -0,0 +1,256 @@ +/** + * Centralized Authorization Service + * + * This module provides a unified interface for all authorization checks in the application. + * It consolidates the authorization logic that was previously scattered across API endpoints. + * + * Feature Access Model: + * - Free Edition: SSO/OIDC + local users, all authenticated users have full access + * - Enterprise Edition: LDAP, MFA, RBAC with fine-grained permissions + * + * Usage: + * import { authorize } from '$lib/server/authorize'; + * + * // In API handler: + * const auth = authorize(cookies); + * + * // Check authentication only + * if (!auth.isAuthenticated) { + * return json({ error: 'Authentication required' }, { status: 401 }); + * } + * + * // Check specific permission + * if (!await auth.can('settings', 'edit')) { + * return json({ error: 'Permission denied' }, { status: 403 }); + * } + * + * // Check permission in environment context + * if (!await auth.canAccessEnvironment(envId)) { + * return json({ error: 'Access denied' }, { status: 403 }); + * } + * + * // Require enterprise license + * if (!auth.isEnterprise) { + * return json({ error: 'Enterprise license required' }, { status: 403 }); + * } + */ + +import type { Cookies } from '@sveltejs/kit'; +import type { Permissions } from './db'; +import { getUserAccessibleEnvironments, userCanAccessEnvironment, userHasAdminRole } from './db'; +import { validateSession, isAuthEnabled, checkPermission, type AuthenticatedUser } from './auth'; +import { isEnterprise } from './license'; + +export interface AuthorizationContext { + /** Whether authentication is enabled globally */ + authEnabled: boolean; + + /** Whether the request is authenticated (has valid session) */ + isAuthenticated: boolean; + + /** The authenticated user, if any */ + user: AuthenticatedUser | null; + + /** Whether the user has admin privileges */ + isAdmin: boolean; + + /** Whether an enterprise license is active */ + isEnterprise: boolean; + + /** + * Check if the user has a specific permission. + * In free edition, all authenticated users have full access. + * In enterprise edition, checks RBAC permissions. + * @param environmentId - Optional: check permission in context of specific environment + */ + can: (resource: keyof Permissions, action: string, environmentId?: number) => Promise; + + /** + * Check if user can access a specific environment. + * Returns true if user has any role that applies to this environment. + */ + canAccessEnvironment: (environmentId: number) => Promise; + + /** + * Get list of environment IDs the user can access. + * Returns null if user has access to ALL environments. + * Returns empty array if user has no access. + */ + getAccessibleEnvironmentIds: () => Promise; + + /** + * Check if user can manage other users. + * Returns true if: + * - Auth is disabled (initial setup) + * - User is admin + * - Free edition (all users have full access) + * - Enterprise edition with users permission + */ + canManageUsers: () => Promise; + + /** + * Check if user can manage settings (OIDC, LDAP configs, etc). + * Returns true if: + * - Auth is disabled (initial setup) + * - User is authenticated and (free edition or has settings permission) + */ + canManageSettings: () => Promise; + + /** + * Check if user can view audit logs. + * Audit logs are an enterprise-only feature. + * Returns true if: + * - Enterprise license is active AND + * - (User is admin OR has audit_logs view permission) + */ + canViewAuditLog: () => Promise; +} + +/** + * Create an authorization context from cookies. + * This is the main entry point for authorization checks. + */ +export async function authorize(cookies: Cookies): Promise { + const authEnabled = await isAuthEnabled(); + const enterprise = await isEnterprise(); + const user = authEnabled ? await validateSession(cookies) : null; + + // Determine admin status: + // - Free edition: all authenticated users are effectively admins (full access) + // - Enterprise edition: check if user has Admin role assigned + let isAdmin = false; + if (user) { + if (!enterprise) { + // Free edition: everyone is admin + isAdmin = true; + } else { + // Enterprise: check for Admin role assignment + isAdmin = await userHasAdminRole(user.id); + } + } + + const ctx: AuthorizationContext = { + authEnabled, + isAuthenticated: !!user, + user, + isAdmin, + isEnterprise: enterprise, + + async can(resource: keyof Permissions, action: string, environmentId?: number): Promise { + // If auth is disabled, allow everything (initial setup) + if (!authEnabled) return true; + + // Must be authenticated + if (!user) return false; + + // Use the existing checkPermission which already handles free vs enterprise + // Pass environmentId for environment-scoped permission checks + return checkPermission(user, resource, action, environmentId); + }, + + async canAccessEnvironment(environmentId: number): Promise { + // If auth is disabled, allow everything (initial setup) + if (!authEnabled) return true; + + // Must be authenticated + if (!user) return false; + + // Admins can access all environments + if (user.isAdmin) return true; + + // In free edition, all authenticated users have full access + if (!enterprise) return true; + + // In enterprise, check if user has any role for this environment + return userCanAccessEnvironment(user.id, environmentId); + }, + + async getAccessibleEnvironmentIds(): Promise { + // If auth is disabled, return null (all environments) + if (!authEnabled) return null; + + // Must be authenticated + if (!user) return []; + + // Admins can access all environments + if (user.isAdmin) return null; + + // In free edition, all authenticated users have full access + if (!enterprise) return null; + + // In enterprise, get accessible environment IDs + return getUserAccessibleEnvironments(user.id); + }, + + async canManageUsers(): Promise { + // If auth is disabled, allow (initial setup when no users exist) + if (!authEnabled) return true; + + // Must be authenticated + if (!user) return false; + + // Admins can always manage users + if (user.isAdmin) return true; + + // In free edition, all authenticated users have full access + if (!enterprise) return true; + + // In enterprise, check RBAC + return checkPermission(user, 'users', 'create'); + }, + + async canManageSettings(): Promise { + // If auth is disabled, allow (initial setup) + if (!authEnabled) return true; + + // Must be authenticated + if (!user) return false; + + // In free edition, all authenticated users have full access + if (!enterprise) return true; + + // In enterprise, check RBAC + return checkPermission(user, 'settings', 'edit'); + }, + + async canViewAuditLog(): Promise { + // Audit logs are enterprise-only + if (!enterprise) return false; + + // If auth is disabled, allow access (enterprise-only protection is enough) + if (!authEnabled) return true; + + // Must be authenticated + if (!user) return false; + + // Admins can always view audit logs + if (user.isAdmin) return true; + + // Check for audit_logs permission + return checkPermission(user, 'audit_logs' as keyof Permissions, 'view'); + } + }; + + return ctx; +} + +/** + * Helper to create a standard 401 response + */ +export function unauthorized() { + return { error: 'Authentication required', status: 401 }; +} + +/** + * Helper to create a standard 403 response + */ +export function forbidden(reason: string = 'Permission denied') { + return { error: reason, status: 403 }; +} + +/** + * Helper to create enterprise required response + */ +export function enterpriseRequired() { + return { error: 'Enterprise license required', status: 403 }; +} diff --git a/lib/server/db.ts b/lib/server/db.ts new file mode 100644 index 0000000..fd2225c --- /dev/null +++ b/lib/server/db.ts @@ -0,0 +1,4225 @@ +/** + * Database Operations Module + * + * Provides all database operations using Drizzle ORM. + * Supports both SQLite and PostgreSQL. + */ + +import { + db, + rawClient, + isPostgres, + isSqlite, + eq, + and, + or, + desc, + asc, + like, + sql, + inArray, + isNull, + isNotNull, + // Schema tables + environments, + registries, + stackEvents, + settings, + configSets, + hostMetrics, + autoUpdateSettings, + notificationSettings, + environmentNotifications, + authSettings, + users, + sessions, + roles, + userRoles, + ldapConfig, + oidcConfig, + gitCredentials, + gitRepositories, + gitStacks, + stackSources, + vulnerabilityScans, + auditLogs, + containerEvents, + userPreferences, + scheduleExecutions, + stackEnvironmentVariables, + pendingContainerUpdates, + // Types + type Environment, + type Registry, + type StackEvent, + type Setting, + type ConfigSet, + type HostMetric, + type AutoUpdateSetting, + type NotificationSetting, + type EnvironmentNotification, + type AuthSetting, + type User, + type Session, + type Role, + type UserRole, + type LdapConfig, + type OidcConfig, + type GitCredential, + type GitRepository, + type GitStack, + type StackSource, + type VulnerabilityScan, + type AuditLog, + type ContainerEvent, + type ScheduleExecution, + type StackEnvironmentVariable, + type PendingContainerUpdate +} from './db/drizzle.js'; + +import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types'; + +// Re-export for backwards compatibility +export { db, isPostgres, isSqlite }; +export type { + Environment, + Registry, + ConfigSet, + HostMetric, + AutoUpdateSetting as AutoUpdateSettingType, + User, + Session, + Role, + UserRole, + LdapConfig, + OidcConfig, + GitCredential, + GitRepository, + GitStack, + StackSource, + VulnerabilityScan, + AuditLog, + ContainerEvent +}; + +// Initialize database (no-op now, kept for API compatibility) +export function initDatabase() { + // Database is already initialized by drizzle.ts +} + +// ============================================================================= +// ENVIRONMENT OPERATIONS +// ============================================================================= + +export async function getEnvironments(): Promise { + return db.select().from(environments).orderBy(asc(environments.name)); +} + +export async function hasEnvironments(): Promise { + const results = await db.select({ id: environments.id }).from(environments).limit(1); + return results.length > 0; +} + +export async function getEnvironment(id: number): Promise { + const results = await db.select().from(environments).where(eq(environments.id, id)); + return results[0]; +} + +export async function createEnvironment(env: Omit): Promise { + const result = await db.insert(environments).values({ + name: env.name, + host: env.host || null, + port: env.port || 2375, + protocol: env.protocol || 'http', + tlsCa: env.tlsCa || null, + tlsCert: env.tlsCert || null, + tlsKey: env.tlsKey || null, + icon: env.icon || 'globe', + socketPath: env.socketPath || '/var/run/docker.sock', + collectActivity: env.collectActivity !== false, + collectMetrics: env.collectMetrics !== false, + highlightChanges: env.highlightChanges !== false, + labels: env.labels || null, + connectionType: env.connectionType || 'socket', + hawserToken: env.hawserToken || null + }).returning(); + return result[0]; +} + +export async function updateEnvironment(id: number, env: Partial): Promise { + const updateData: Record = { updatedAt: new Date().toISOString() }; + + if (env.name !== undefined) updateData.name = env.name; + if (env.host !== undefined) updateData.host = env.host; + if (env.port !== undefined) updateData.port = env.port; + if (env.protocol !== undefined) updateData.protocol = env.protocol; + if (env.tlsCa !== undefined) updateData.tlsCa = env.tlsCa; + if (env.tlsCert !== undefined) updateData.tlsCert = env.tlsCert; + if (env.tlsKey !== undefined) updateData.tlsKey = env.tlsKey; + if (env.icon !== undefined) updateData.icon = env.icon; + if (env.socketPath !== undefined) updateData.socketPath = env.socketPath; + if (env.collectActivity !== undefined) updateData.collectActivity = env.collectActivity; + if (env.collectMetrics !== undefined) updateData.collectMetrics = env.collectMetrics; + if (env.highlightChanges !== undefined) updateData.highlightChanges = env.highlightChanges; + if (env.labels !== undefined) updateData.labels = env.labels; + if (env.connectionType !== undefined) updateData.connectionType = env.connectionType; + if (env.hawserToken !== undefined) updateData.hawserToken = env.hawserToken; + + await db.update(environments).set(updateData).where(eq(environments.id, id)); + return getEnvironment(id); +} + +export async function deleteEnvironment(id: number): Promise { + const env = await getEnvironment(id); + if (!env) return false; + + // Clean up related records that don't have cascade delete defined + try { + await db.delete(hostMetrics).where(eq(hostMetrics.environmentId, id)); + } catch (error) { + console.error('Failed to cleanup host metrics for environment:', error); + } + + try { + await db.delete(stackEvents).where(eq(stackEvents.environmentId, id)); + } catch (error) { + console.error('Failed to cleanup stack events for environment:', error); + } + + try { + await db.delete(autoUpdateSettings).where(eq(autoUpdateSettings.environmentId, id)); + } catch (error) { + console.error('Failed to cleanup auto-update schedules for environment:', error); + } + + await db.delete(environments).where(eq(environments.id, id)); + return true; +} + +// ============================================================================= +// REGISTRY OPERATIONS +// ============================================================================= + +export async function getRegistries(): Promise { + return db.select().from(registries).orderBy(desc(registries.isDefault), asc(registries.name)); +} + +export async function getRegistry(id: number): Promise { + const results = await db.select().from(registries).where(eq(registries.id, id)); + return results[0]; +} + +export async function getDefaultRegistry(): Promise { + const results = await db.select().from(registries).where(eq(registries.isDefault, true)); + return results[0]; +} + +export async function createRegistry(registry: Omit): Promise { + const result = await db.insert(registries).values({ + name: registry.name, + url: registry.url, + username: registry.username || null, + password: registry.password || null, + isDefault: registry.isDefault || false + }).returning(); + return result[0]; +} + +export async function updateRegistry(id: number, registry: Partial): Promise { + const updateData: Record = { updatedAt: new Date().toISOString() }; + + if (registry.name !== undefined) updateData.name = registry.name; + if (registry.url !== undefined) updateData.url = registry.url; + if (registry.username !== undefined) updateData.username = registry.username || null; + if (registry.password !== undefined) updateData.password = registry.password || null; + if (registry.isDefault !== undefined) updateData.isDefault = registry.isDefault; + + await db.update(registries).set(updateData).where(eq(registries.id, id)); + return getRegistry(id); +} + +export async function deleteRegistry(id: number): Promise { + const registry = await getRegistry(id); + if (!registry) return false; + + await db.delete(registries).where(eq(registries.id, id)); + return true; +} + +export async function setDefaultRegistry(id: number): Promise { + await db.update(registries).set({ isDefault: false }); + await db.update(registries).set({ isDefault: true }).where(eq(registries.id, id)); + return true; +} + +// ============================================================================= +// STACK EVENT LOGGING +// ============================================================================= + +export async function logStackEvent(stackName: string, eventType: string, metadata?: any, environmentId?: number) { + await db.insert(stackEvents).values({ + environmentId: environmentId || null, + stackName, + eventType, + metadata: metadata ? JSON.stringify(metadata) : null + }); +} + +export async function getStackEvents(limit = 50, environmentId?: number): Promise { + if (environmentId) { + return db.select().from(stackEvents) + .where(eq(stackEvents.environmentId, environmentId)) + .orderBy(desc(stackEvents.timestamp)) + .limit(limit); + } + return db.select().from(stackEvents) + .orderBy(desc(stackEvents.timestamp)) + .limit(limit); +} + +// ============================================================================= +// SETTINGS MANAGEMENT +// ============================================================================= + +export async function getSetting(key: string): Promise { + const results = await db.select().from(settings).where(eq(settings.key, key)); + if (!results[0]) return null; + try { + return JSON.parse(results[0].value); + } catch { + return results[0].value; + } +} + +export async function setSetting(key: string, value: any): Promise { + const jsonValue = JSON.stringify(value); + await db.insert(settings).values({ + key, + value: jsonValue + }).onConflictDoUpdate({ + target: settings.key, + set: { value: jsonValue, updatedAt: new Date().toISOString() } + }); +} + +export async function deleteSetting(key: string): Promise { + await db.delete(settings).where(eq(settings.key, key)); +} + +export async function getEnvSetting(key: string, envId?: number): Promise { + if (envId !== undefined) { + const envKey = `env_${envId}_${key}`; + const results = await db.select().from(settings).where(eq(settings.key, envKey)); + if (results[0]) { + try { + return JSON.parse(results[0].value); + } catch { + return results[0].value; + } + } + } + return getSetting(key); +} + +export async function setEnvSetting(key: string, value: any, envId?: number): Promise { + const actualKey = envId !== undefined ? `env_${envId}_${key}` : key; + await setSetting(actualKey, value); +} + +// ============================================================================= +// USER SETTINGS (for per-user preferences like themes) +// ============================================================================= + +export async function getUserSetting(userId: number, key: string): Promise { + const userKey = `user:${userId}:${key}`; + return getSetting(userKey); +} + +export async function setUserSetting(userId: number, key: string, value: any): Promise { + const userKey = `user:${userId}:${key}`; + await setSetting(userKey, value); +} + +export async function getUserThemePreferences(userId: number): Promise<{ + lightTheme: string; + darkTheme: string; + font: string; + fontSize: string; + gridFontSize: string; + terminalFont: string; +}> { + const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont] = await Promise.all([ + getUserSetting(userId, 'light_theme'), + getUserSetting(userId, 'dark_theme'), + getUserSetting(userId, 'font'), + getUserSetting(userId, 'font_size'), + getUserSetting(userId, 'grid_font_size'), + getUserSetting(userId, 'terminal_font') + ]); + return { + lightTheme: lightTheme || 'default', + darkTheme: darkTheme || 'default', + font: font || 'system', + fontSize: fontSize || 'normal', + gridFontSize: gridFontSize || 'normal', + terminalFont: terminalFont || 'system-mono' + }; +} + +export async function setUserThemePreferences( + userId: number, + prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string } +): Promise { + const updates: Promise[] = []; + if (prefs.lightTheme !== undefined) { + updates.push(setUserSetting(userId, 'light_theme', prefs.lightTheme)); + } + if (prefs.darkTheme !== undefined) { + updates.push(setUserSetting(userId, 'dark_theme', prefs.darkTheme)); + } + if (prefs.font !== undefined) { + updates.push(setUserSetting(userId, 'font', prefs.font)); + } + if (prefs.fontSize !== undefined) { + updates.push(setUserSetting(userId, 'font_size', prefs.fontSize)); + } + if (prefs.gridFontSize !== undefined) { + updates.push(setUserSetting(userId, 'grid_font_size', prefs.gridFontSize)); + } + if (prefs.terminalFont !== undefined) { + updates.push(setUserSetting(userId, 'terminal_font', prefs.terminalFont)); + } + await Promise.all(updates); +} + +// ============================================================================= +// GRID COLUMN PREFERENCES +// ============================================================================= + +export async function getGridPreferences(userId?: number): Promise { + const key = userId ? `user:${userId}:grid_preferences` : 'grid_preferences'; + const value = await getSetting(key); + return value || {}; +} + +export async function setGridPreferences( + gridId: GridId, + prefs: GridColumnPreferences, + userId?: number +): Promise { + const key = userId ? `user:${userId}:grid_preferences` : 'grid_preferences'; + const current = await getGridPreferences(userId); + current[gridId] = prefs; + await setSetting(key, current); +} + +export async function deleteGridPreferences(gridId: GridId, userId?: number): Promise { + const key = userId ? `user:${userId}:grid_preferences` : 'grid_preferences'; + const current = await getGridPreferences(userId); + delete current[gridId]; + await setSetting(key, current); +} + +export async function resetAllGridPreferences(userId?: number): Promise { + const key = userId ? `user:${userId}:grid_preferences` : 'grid_preferences'; + await deleteSetting(key); +} + +// ============================================================================= +// ENVIRONMENT PUBLIC IPS (for port links) +// ============================================================================= + +export async function getEnvironmentPublicIps(): Promise> { + const value = await getSetting('environment_public_ips'); + return value || {}; +} + +export async function setEnvironmentPublicIp(envId: number, publicIp: string | null): Promise { + const current = await getEnvironmentPublicIps(); + if (publicIp) { + current[envId.toString()] = publicIp; + } else { + delete current[envId.toString()]; + } + await setSetting('environment_public_ips', current); +} + +export async function deleteEnvironmentPublicIp(envId: number): Promise { + await setEnvironmentPublicIp(envId, null); +} + +// ============================================================================= +// CONFIG SET OPERATIONS +// ============================================================================= + +export interface ConfigSetData { + id: number; + name: string; + description?: string | null; + envVars?: { key: string; value: string }[]; + labels?: { key: string; value: string }[]; + ports?: { hostPort: string; containerPort: string; protocol: string }[]; + volumes?: { hostPath: string; containerPath: string; mode: string }[]; + networkMode: string; + restartPolicy: string; + createdAt: string; + updatedAt: string; +} + +export async function getConfigSets(): Promise { + const rows = await db.select().from(configSets).orderBy(asc(configSets.name)); + return rows.map(row => ({ + ...row, + envVars: row.envVars ? JSON.parse(row.envVars) : [], + labels: row.labels ? JSON.parse(row.labels) : [], + ports: row.ports ? JSON.parse(row.ports) : [], + volumes: row.volumes ? JSON.parse(row.volumes) : [] + })); +} + +export async function getConfigSet(id: number): Promise { + const results = await db.select().from(configSets).where(eq(configSets.id, id)); + if (!results[0]) return undefined; + const row = results[0]; + return { + ...row, + envVars: row.envVars ? JSON.parse(row.envVars) : [], + labels: row.labels ? JSON.parse(row.labels) : [], + ports: row.ports ? JSON.parse(row.ports) : [], + volumes: row.volumes ? JSON.parse(row.volumes) : [] + }; +} + +export async function createConfigSet(configSet: Omit): Promise { + const result = await db.insert(configSets).values({ + name: configSet.name, + description: configSet.description || null, + envVars: configSet.envVars ? JSON.stringify(configSet.envVars) : null, + labels: configSet.labels ? JSON.stringify(configSet.labels) : null, + ports: configSet.ports ? JSON.stringify(configSet.ports) : null, + volumes: configSet.volumes ? JSON.stringify(configSet.volumes) : null, + networkMode: configSet.networkMode || 'bridge', + restartPolicy: configSet.restartPolicy || 'no' + }).returning(); + return getConfigSet(result[0].id) as Promise; +} + +export async function updateConfigSet(id: number, configSet: Partial): Promise { + const updateData: Record = { updatedAt: new Date().toISOString() }; + + if (configSet.name !== undefined) updateData.name = configSet.name; + if (configSet.description !== undefined) updateData.description = configSet.description || null; + if (configSet.envVars !== undefined) updateData.envVars = JSON.stringify(configSet.envVars); + if (configSet.labels !== undefined) updateData.labels = JSON.stringify(configSet.labels); + if (configSet.ports !== undefined) updateData.ports = JSON.stringify(configSet.ports); + if (configSet.volumes !== undefined) updateData.volumes = JSON.stringify(configSet.volumes); + if (configSet.networkMode !== undefined) updateData.networkMode = configSet.networkMode; + if (configSet.restartPolicy !== undefined) updateData.restartPolicy = configSet.restartPolicy; + + await db.update(configSets).set(updateData).where(eq(configSets.id, id)); + return getConfigSet(id); +} + +export async function deleteConfigSet(id: number): Promise { + await db.delete(configSets).where(eq(configSets.id, id)); + return true; +} + +// ============================================================================= +// HOST METRICS OPERATIONS +// ============================================================================= + +export async function saveHostMetric( + cpuPercent: number, + memoryPercent: number, + memoryUsed: number, + memoryTotal: number, + environmentId?: number +): Promise { + // Verify environment exists before inserting (avoids FK violations on deleted envs) + if (environmentId) { + const env = await getEnvironment(environmentId); + if (!env) return; + } + + await db.insert(hostMetrics).values({ + environmentId: environmentId || null, + cpuPercent, + memoryPercent, + memoryUsed, + memoryTotal + }); + + // Cleanup old metrics (keep last 24 hours) + const cutoff24h = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + await db.delete(hostMetrics).where(sql`timestamp < ${cutoff24h}`); +} + +export async function getHostMetrics(limit = 60, environmentId?: number): Promise { + if (environmentId) { + return db.select().from(hostMetrics) + .where(eq(hostMetrics.environmentId, environmentId)) + .orderBy(desc(hostMetrics.timestamp)) + .limit(limit); + } + return db.select().from(hostMetrics) + .orderBy(desc(hostMetrics.timestamp)) + .limit(limit); +} + +export async function getLatestHostMetrics(environmentId: number): Promise { + const results = await db.select().from(hostMetrics) + .where(eq(hostMetrics.environmentId, environmentId)) + .orderBy(desc(hostMetrics.timestamp)) + .limit(1); + return results[0] ?? null; +} + +// ============================================================================= +// AUTO-UPDATE SETTINGS +// ============================================================================= + +export type VulnerabilityCriteria = 'never' | 'any' | 'critical_high' | 'critical' | 'more_than_current'; + +export interface AutoUpdateSettingData { + id: number; + environmentId: number | null; + containerName: string; + enabled: boolean; + scheduleType: 'daily' | 'weekly' | 'custom'; + cronExpression: string | null; + vulnerabilityCriteria: VulnerabilityCriteria | null; + lastChecked: string | null; + lastUpdated: string | null; + createdAt: string; + updatedAt: string; +} + +export async function getAutoUpdateSettings(environmentId?: number): Promise { + if (environmentId) { + return db.select().from(autoUpdateSettings) + .where(eq(autoUpdateSettings.environmentId, environmentId)) as Promise; + } + return db.select().from(autoUpdateSettings) as Promise; +} + +export async function getAutoUpdateSetting(containerName: string, environmentId?: number): Promise { + const results = await db.select().from(autoUpdateSettings) + .where(and( + eq(autoUpdateSettings.containerName, containerName), + environmentId ? eq(autoUpdateSettings.environmentId, environmentId) : isNull(autoUpdateSettings.environmentId) + )); + return results[0] as AutoUpdateSettingData | undefined; +} + +export async function getAutoUpdateSettingById(id: number): Promise { + const results = await db.select().from(autoUpdateSettings) + .where(eq(autoUpdateSettings.id, id)); + return results[0] as AutoUpdateSettingData | undefined; +} + +export async function updateAutoUpdateSettingById(id: number, data: Partial): Promise { + await db.update(autoUpdateSettings) + .set({ + ...data, + updatedAt: new Date().toISOString() + }) + .where(eq(autoUpdateSettings.id, id)); +} + +export async function getEnabledAutoUpdateSettings(): Promise { + return db.select().from(autoUpdateSettings) + .where(eq(autoUpdateSettings.enabled, true)) as Promise; +} + +export async function getAllAutoUpdateSettings(): Promise { + return db.select().from(autoUpdateSettings) + .orderBy(desc(autoUpdateSettings.containerName)) as Promise; +} + +export async function upsertAutoUpdateSetting( + containerName: string, + settingsData: { + enabled: boolean; + scheduleType: 'daily' | 'weekly' | 'custom'; + cronExpression?: string | null; + vulnerabilityCriteria?: VulnerabilityCriteria | null; + }, + environmentId?: number +): Promise { + const existing = await getAutoUpdateSetting(containerName, environmentId); + + if (existing) { + await db.update(autoUpdateSettings) + .set({ + enabled: settingsData.enabled, + scheduleType: settingsData.scheduleType, + cronExpression: settingsData.cronExpression || null, + vulnerabilityCriteria: settingsData.vulnerabilityCriteria || 'never', + updatedAt: new Date().toISOString() + }) + .where(eq(autoUpdateSettings.id, existing.id)); + return getAutoUpdateSetting(containerName, environmentId) as Promise; + } else { + await db.insert(autoUpdateSettings).values({ + environmentId: environmentId || null, + containerName, + enabled: settingsData.enabled, + scheduleType: settingsData.scheduleType, + cronExpression: settingsData.cronExpression || null, + vulnerabilityCriteria: settingsData.vulnerabilityCriteria || 'never' + }); + return getAutoUpdateSetting(containerName, environmentId) as Promise; + } +} + +export async function updateAutoUpdateLastChecked(containerName: string, environmentId?: number): Promise { + await db.update(autoUpdateSettings) + .set({ + lastChecked: new Date().toISOString(), + updatedAt: new Date().toISOString() + }) + .where(and( + eq(autoUpdateSettings.containerName, containerName), + environmentId ? eq(autoUpdateSettings.environmentId, environmentId) : isNull(autoUpdateSettings.environmentId) + )); +} + +export async function updateAutoUpdateLastUpdated(containerName: string, environmentId?: number): Promise { + await db.update(autoUpdateSettings) + .set({ + lastUpdated: new Date().toISOString(), + updatedAt: new Date().toISOString() + }) + .where(and( + eq(autoUpdateSettings.containerName, containerName), + environmentId ? eq(autoUpdateSettings.environmentId, environmentId) : isNull(autoUpdateSettings.environmentId) + )); +} + +export async function deleteAutoUpdateSetting(containerName: string, environmentId?: number): Promise { + await db.delete(autoUpdateSettings) + .where(and( + eq(autoUpdateSettings.containerName, containerName), + environmentId ? eq(autoUpdateSettings.environmentId, environmentId) : isNull(autoUpdateSettings.environmentId) + )); + return true; +} + +// Alias for consistency with plan +export const deleteAutoUpdateSchedule = deleteAutoUpdateSetting; + +export async function renameAutoUpdateSchedule( + oldName: string, + newName: string, + environmentId?: number +): Promise { + await db.update(autoUpdateSettings) + .set({ containerName: newName }) + .where(and( + eq(autoUpdateSettings.containerName, oldName), + environmentId + ? eq(autoUpdateSettings.environmentId, environmentId) + : isNull(autoUpdateSettings.environmentId) + )); + return true; +} + +// ============================================================================= +// NOTIFICATION SETTINGS +// ============================================================================= + +// Event scope: 'environment' = configurable per-environment, 'system' = global only (configured at channel level) +export const NOTIFICATION_EVENT_TYPES = [ + // Container lifecycle events (environment-scoped) + { id: 'container_started', label: 'Container started', description: 'When a container starts running', group: 'container', scope: 'environment' }, + { id: 'container_stopped', label: 'Container stopped', description: 'When a container is stopped', group: 'container', scope: 'environment' }, + { id: 'container_restarted', label: 'Container restarted', description: 'When a container restarts (manual or automatic)', group: 'container', scope: 'environment' }, + { id: 'container_exited', label: 'Container exited', description: 'When a container exits unexpectedly', group: 'container', scope: 'environment' }, + { id: 'container_unhealthy', label: 'Container unhealthy', description: 'When a container health check fails', group: 'container', scope: 'environment' }, + { id: 'container_oom', label: 'Out of memory', description: 'When a container is killed due to out of memory', group: 'container', scope: 'environment' }, + { id: 'container_updated', label: 'Container updated', description: 'When a container image is updated', group: 'container', scope: 'environment' }, + { id: 'image_pulled', label: 'Image pulled', description: 'When a new image is pulled', group: 'container', scope: 'environment' }, + + // Auto-update events (environment-scoped) + { id: 'auto_update_success', label: 'Auto-update success', description: 'Container successfully updated to new image', group: 'auto_update', scope: 'environment' }, + { id: 'auto_update_failed', label: 'Auto-update failed', description: 'Container auto-update failed (pull error, start error)', group: 'auto_update', scope: 'environment' }, + { id: 'auto_update_blocked', label: 'Auto-update blocked', description: 'Update blocked due to vulnerability criteria', group: 'auto_update', scope: 'environment' }, + { id: 'updates_detected', label: 'Updates detected', description: 'Container image updates are available (scheduled check)', group: 'auto_update', scope: 'environment' }, + { id: 'batch_update_success', label: 'Batch update completed', description: 'Scheduled container updates completed successfully', group: 'auto_update', scope: 'environment' }, + + // Git stack events (environment-scoped) + { id: 'git_sync_success', label: 'Git sync success', description: 'Git stack synced and deployed successfully', group: 'git_stack', scope: 'environment' }, + { id: 'git_sync_failed', label: 'Git sync failed', description: 'Git stack sync or deploy failed', group: 'git_stack', scope: 'environment' }, + { id: 'git_sync_skipped', label: 'Git sync skipped', description: 'Git stack sync skipped (no changes)', group: 'git_stack', scope: 'environment' }, + + // Stack events (environment-scoped) + { id: 'stack_started', label: 'Stack started', description: 'When a compose stack starts', group: 'stack', scope: 'environment' }, + { id: 'stack_stopped', label: 'Stack stopped', description: 'When a compose stack stops', group: 'stack', scope: 'environment' }, + { id: 'stack_deployed', label: 'Stack deployed', description: 'Stack deployed (new or update)', group: 'stack', scope: 'environment' }, + { id: 'stack_deploy_failed', label: 'Stack deploy failed', description: 'Stack deployment failed', group: 'stack', scope: 'environment' }, + + // Security events (environment-scoped) + { id: 'vulnerability_critical', label: 'Critical vulnerabilities', description: 'Critical vulnerabilities found in image scan', group: 'security', scope: 'environment' }, + { id: 'vulnerability_high', label: 'High vulnerabilities', description: 'High severity vulnerabilities found in image scan', group: 'security', scope: 'environment' }, + { id: 'vulnerability_any', label: 'Any vulnerabilities', description: 'Any vulnerabilities found in image scan (medium/low)', group: 'security', scope: 'environment' }, + + // System events (global - configured at channel level, not per-environment) + { id: 'environment_offline', label: 'Environment offline', description: 'Environment became unreachable', group: 'system', scope: 'environment' }, + { id: 'environment_online', label: 'Environment online', description: 'Environment came back online', group: 'system', scope: 'environment' }, + { id: 'disk_space_warning', label: 'Disk space warning', description: 'Docker disk usage exceeds threshold', group: 'system', scope: 'environment' }, + { id: 'license_expiring', label: 'License expiring', description: 'Enterprise license expiring soon (global)', group: 'system', scope: 'system' } +] as const; + +export const NOTIFICATION_EVENT_GROUPS = [ + { id: 'container', label: 'Container events' }, + { id: 'auto_update', label: 'Auto-update events' }, + { id: 'git_stack', label: 'Git stack events' }, + { id: 'stack', label: 'Stack events' }, + { id: 'security', label: 'Security events' }, + { id: 'system', label: 'System events' } +] as const; + +// Helper to get system-only events (configured at channel level, not per-environment) +export const SYSTEM_NOTIFICATION_EVENTS = NOTIFICATION_EVENT_TYPES.filter(e => e.scope === 'system'); + +// Helper to get environment-scoped events (configured per-environment) +export const ENVIRONMENT_NOTIFICATION_EVENTS = NOTIFICATION_EVENT_TYPES.filter(e => e.scope === 'environment'); + +export type NotificationEventType = typeof NOTIFICATION_EVENT_TYPES[number]['id']; + +export interface NotificationSettingData { + id: number; + type: 'smtp' | 'apprise'; + name: string; + enabled: boolean; + config: any; + eventTypes: NotificationEventType[]; + createdAt: string; + updatedAt: string; +} + +export interface SmtpConfig { + host: string; + port: number; + secure: boolean; + username?: string; + password?: string; + from_email: string; + from_name?: string; + to_emails: string[]; +} + +export interface AppriseConfig { + urls: string[]; +} + +export async function getNotificationSettings(): Promise { + const rows = await db.select().from(notificationSettings).orderBy(desc(notificationSettings.createdAt)); + return rows.map(row => ({ + ...row, + config: JSON.parse(row.config), + eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id) + })) as NotificationSettingData[]; +} + +export async function getNotificationSetting(id: number): Promise { + const results = await db.select().from(notificationSettings).where(eq(notificationSettings.id, id)); + if (!results[0]) return null; + const row = results[0]; + return { + ...row, + config: JSON.parse(row.config), + eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id) + } as NotificationSettingData; +} + +export async function getEnabledNotificationSettings(): Promise { + const rows = await db.select().from(notificationSettings).where(eq(notificationSettings.enabled, true)); + return rows.map(row => ({ + ...row, + config: JSON.parse(row.config), + eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id) + })) as NotificationSettingData[]; +} + +export async function createNotificationSetting(data: { + type: 'smtp' | 'apprise'; + name: string; + enabled?: boolean; + config: SmtpConfig | AppriseConfig; + eventTypes?: NotificationEventType[]; +}): Promise { + const eventTypes = data.eventTypes || NOTIFICATION_EVENT_TYPES.map(e => e.id); + const result = await db.insert(notificationSettings).values({ + type: data.type, + name: data.name, + enabled: data.enabled !== false, + config: JSON.stringify(data.config), + eventTypes: JSON.stringify(eventTypes) + }).returning(); + return getNotificationSetting(result[0].id) as Promise; +} + +export async function updateNotificationSetting(id: number, data: { + name?: string; + enabled?: boolean; + config?: SmtpConfig | AppriseConfig; + eventTypes?: NotificationEventType[]; +}): Promise { + const existing = await getNotificationSetting(id); + if (!existing) return null; + + const updateData: Record = { updatedAt: new Date().toISOString() }; + + if (data.name !== undefined) updateData.name = data.name; + if (data.enabled !== undefined) updateData.enabled = data.enabled; + if (data.config !== undefined) updateData.config = JSON.stringify(data.config); + if (data.eventTypes !== undefined) updateData.eventTypes = JSON.stringify(data.eventTypes); + + await db.update(notificationSettings).set(updateData).where(eq(notificationSettings.id, id)); + return getNotificationSetting(id); +} + +export async function deleteNotificationSetting(id: number): Promise { + // First delete all environment notifications that reference this notification channel + await db.delete(environmentNotifications).where(eq(environmentNotifications.notificationId, id)); + // Then delete the notification setting itself + await db.delete(notificationSettings).where(eq(notificationSettings.id, id)); + return true; +} + +// ============================================================================= +// ENVIRONMENT NOTIFICATION SETTINGS +// ============================================================================= + +export interface EnvironmentNotificationData { + id: number; + environmentId: number; + notificationId: number; + enabled: boolean; + eventTypes: NotificationEventType[]; + createdAt: string; + updatedAt: string; + channelName?: string; + channelType?: 'smtp' | 'apprise'; + channelEnabled?: boolean; +} + +export async function getEnvironmentNotifications(environmentId: number): Promise { + const rows = await db.select({ + id: environmentNotifications.id, + environmentId: environmentNotifications.environmentId, + notificationId: environmentNotifications.notificationId, + enabled: environmentNotifications.enabled, + eventTypes: environmentNotifications.eventTypes, + createdAt: environmentNotifications.createdAt, + updatedAt: environmentNotifications.updatedAt, + channelName: notificationSettings.name, + channelType: notificationSettings.type, + channelEnabled: notificationSettings.enabled + }) + .from(environmentNotifications) + .innerJoin(notificationSettings, eq(environmentNotifications.notificationId, notificationSettings.id)) + .where(eq(environmentNotifications.environmentId, environmentId)) + .orderBy(asc(notificationSettings.name)); + + return rows.map(row => ({ + ...row, + eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id) + })) as EnvironmentNotificationData[]; +} + +export async function getEnvironmentNotification(environmentId: number, notificationId: number): Promise { + const rows = await db.select({ + id: environmentNotifications.id, + environmentId: environmentNotifications.environmentId, + notificationId: environmentNotifications.notificationId, + enabled: environmentNotifications.enabled, + eventTypes: environmentNotifications.eventTypes, + createdAt: environmentNotifications.createdAt, + updatedAt: environmentNotifications.updatedAt, + channelName: notificationSettings.name, + channelType: notificationSettings.type, + channelEnabled: notificationSettings.enabled + }) + .from(environmentNotifications) + .innerJoin(notificationSettings, eq(environmentNotifications.notificationId, notificationSettings.id)) + .where(and( + eq(environmentNotifications.environmentId, environmentId), + eq(environmentNotifications.notificationId, notificationId) + )); + + if (!rows[0]) return null; + return { + ...rows[0], + eventTypes: rows[0].eventTypes ? JSON.parse(rows[0].eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id) + } as EnvironmentNotificationData; +} + +export async function createEnvironmentNotification(data: { + environmentId: number; + notificationId: number; + enabled?: boolean; + eventTypes?: NotificationEventType[]; +}): Promise { + const eventTypes = data.eventTypes || NOTIFICATION_EVENT_TYPES.map(e => e.id); + await db.insert(environmentNotifications).values({ + environmentId: data.environmentId, + notificationId: data.notificationId, + enabled: data.enabled !== false, + eventTypes: JSON.stringify(eventTypes) + }); + return getEnvironmentNotification(data.environmentId, data.notificationId) as Promise; +} + +export async function updateEnvironmentNotification(environmentId: number, notificationId: number, data: { + enabled?: boolean; + eventTypes?: NotificationEventType[]; +}): Promise { + const existing = await getEnvironmentNotification(environmentId, notificationId); + if (!existing) return null; + + const updateData: Record = { updatedAt: new Date().toISOString() }; + + if (data.enabled !== undefined) updateData.enabled = data.enabled; + if (data.eventTypes !== undefined) updateData.eventTypes = JSON.stringify(data.eventTypes); + + await db.update(environmentNotifications) + .set(updateData) + .where(and( + eq(environmentNotifications.environmentId, environmentId), + eq(environmentNotifications.notificationId, notificationId) + )); + return getEnvironmentNotification(environmentId, notificationId); +} + +export async function deleteEnvironmentNotification(environmentId: number, notificationId: number): Promise { + await db.delete(environmentNotifications) + .where(and( + eq(environmentNotifications.environmentId, environmentId), + eq(environmentNotifications.notificationId, notificationId) + )); + return true; +} + +export async function getEnabledEnvironmentNotifications( + environmentId: number, + eventType?: NotificationEventType +): Promise<(EnvironmentNotificationData & { config: any })[]> { + const rows = await db.select({ + id: environmentNotifications.id, + environmentId: environmentNotifications.environmentId, + notificationId: environmentNotifications.notificationId, + enabled: environmentNotifications.enabled, + eventTypes: environmentNotifications.eventTypes, + createdAt: environmentNotifications.createdAt, + updatedAt: environmentNotifications.updatedAt, + channelName: notificationSettings.name, + channelType: notificationSettings.type, + channelEnabled: notificationSettings.enabled, + config: notificationSettings.config + }) + .from(environmentNotifications) + .innerJoin(notificationSettings, eq(environmentNotifications.notificationId, notificationSettings.id)) + .where(and( + eq(environmentNotifications.environmentId, environmentId), + eq(environmentNotifications.enabled, true), + eq(notificationSettings.enabled, true) + )); + + return rows + .map(row => ({ + ...row, + eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id), + config: JSON.parse(row.config) + })) + .filter(row => !eventType || row.eventTypes.includes(eventType)) as (EnvironmentNotificationData & { config: any })[]; +} + +// ============================================================================= +// AUTHENTICATION TYPES AND OPERATIONS +// ============================================================================= + +export interface Permissions { + containers: string[]; + images: string[]; + volumes: string[]; + networks: string[]; + stacks: string[]; + environments: string[]; + registries: string[]; + notifications: string[]; + configsets: string[]; + settings: string[]; + users: string[]; + git: string[]; + license: string[]; + audit_logs: string[]; + activity: string[]; + schedules: string[]; +} + +export interface AuthSettingsData { + id: number; + authEnabled: boolean; + defaultProvider: 'local' | 'ldap' | 'oidc'; + sessionTimeout: number; + createdAt: string; + updatedAt: string; +} + +export async function getAuthSettings(): Promise { + const results = await db.select().from(authSettings).limit(1); + return results[0] as AuthSettingsData; +} + +export async function updateAuthSettings(data: Partial): Promise { + const updateData: Record = { updatedAt: new Date().toISOString() }; + + if (data.authEnabled !== undefined) updateData.authEnabled = data.authEnabled; + if (data.defaultProvider !== undefined) updateData.defaultProvider = data.defaultProvider; + if (data.sessionTimeout !== undefined) updateData.sessionTimeout = data.sessionTimeout; + + await db.update(authSettings).set(updateData).where(eq(authSettings.id, 1)); + return getAuthSettings(); +} + +// ============================================================================= +// USER OPERATIONS +// ============================================================================= + +export interface UserData { + id: number; + username: string; + email?: string | null; + passwordHash: string; + displayName?: string | null; + avatar?: string | null; + authProvider?: string | null; + mfaEnabled: boolean; + mfaSecret?: string | null; + isActive: boolean; + lastLogin?: string | null; + createdAt: string; + updatedAt: string; +} + +export async function getUsers(): Promise { + return db.select().from(users).orderBy(asc(users.username)) as Promise; +} + +export async function getUser(id: number): Promise { + const results = await db.select().from(users).where(eq(users.id, id)); + return results[0] as UserData || null; +} + +export async function hasAdminUser(): Promise { + // Check if any user has the Admin role assigned + const adminRole = await db.select().from(roles).where(eq(roles.name, 'Admin')).limit(1); + if (!adminRole[0]) return false; + + const result = await db.select({ id: userRoles.id }) + .from(userRoles) + .where(eq(userRoles.roleId, adminRole[0].id)) + .limit(1); + return result.length > 0; +} + +export async function countAdminUsers(): Promise { + // Import license check dynamically to avoid circular dependencies + const { isEnterprise } = await import('./license'); + const enterprise = await isEnterprise(); + + if (enterprise) { + // ENTERPRISE: Count users who have the Admin role assigned + const adminRole = await db.select().from(roles).where(eq(roles.name, 'Admin')).limit(1); + if (!adminRole[0]) return 0; + + const results = await db.select({ count: sql`count(DISTINCT ${userRoles.userId})` }) + .from(userRoles) + .where(eq(userRoles.roleId, adminRole[0].id)); + // PostgreSQL returns bigint for count, ensure we return a number + return Number(results[0]?.count ?? 0); + } else { + // FREE: Any user is effectively an admin (no RBAC), just count all users + const results = await db.select({ count: sql`count(*)` }).from(users); + // PostgreSQL returns bigint for count, ensure we return a number + return Number(results[0]?.count ?? 0); + } +} + +export async function getUserByUsername(username: string): Promise { + const results = await db.select().from(users).where(eq(users.username, username)); + return results[0] as UserData || null; +} + +export async function createUser(data: { + username: string; + email?: string; + passwordHash: string; + displayName?: string; + authProvider?: string; +}): Promise { + const result = await db.insert(users).values({ + username: data.username, + email: data.email || null, + passwordHash: data.passwordHash, + displayName: data.displayName || null, + authProvider: data.authProvider || 'local' + }).returning(); + return getUser(result[0].id) as Promise; +} + +export async function updateUser(id: number, data: Partial): Promise { + const updateData: Record = { updatedAt: new Date().toISOString() }; + + if (data.username !== undefined) updateData.username = data.username; + if (data.email !== undefined) updateData.email = data.email || null; + if (data.passwordHash !== undefined) updateData.passwordHash = data.passwordHash; + if (data.displayName !== undefined) updateData.displayName = data.displayName || null; + if (data.avatar !== undefined) updateData.avatar = data.avatar || null; + if (data.authProvider !== undefined) updateData.authProvider = data.authProvider; + if (data.mfaEnabled !== undefined) updateData.mfaEnabled = data.mfaEnabled; + if (data.mfaSecret !== undefined) updateData.mfaSecret = data.mfaSecret || null; + if (data.isActive !== undefined) updateData.isActive = data.isActive; + if (data.lastLogin !== undefined) updateData.lastLogin = data.lastLogin; + + await db.update(users).set(updateData).where(eq(users.id, id)); + return getUser(id); +} + +export async function deleteUser(id: number): Promise { + await db.delete(users).where(eq(users.id, id)); + return true; +} + +// ============================================================================= +// SESSION OPERATIONS +// ============================================================================= + +export interface SessionData { + id: string; + userId: number; + provider: string; + expiresAt: string; + createdAt: string; +} + +export async function createSession(id: string, userId: number, provider: string, expiresAt: string): Promise { + await db.insert(sessions).values({ + id, + userId, + provider, + expiresAt + }); + return getSession(id) as Promise; +} + +export async function getSession(id: string): Promise { + const results = await db.select().from(sessions).where(eq(sessions.id, id)); + return results[0] as SessionData || null; +} + +export async function deleteSession(id: string): Promise { + await db.delete(sessions).where(eq(sessions.id, id)); + return true; +} + +export async function deleteExpiredSessions(): Promise { + const now = new Date().toISOString(); + await db.delete(sessions).where(sql`expires_at < ${now}`); + return 0; // Drizzle doesn't return changes count easily +} + +export async function deleteUserSessions(userId: number): Promise { + await db.delete(sessions).where(eq(sessions.userId, userId)); + return 0; +} + +// ============================================================================= +// ROLE OPERATIONS +// ============================================================================= + +export interface RoleData { + id: number; + name: string; + description?: string | null; + isSystem: boolean; + permissions: Permissions; + environmentIds: number[] | null; // null = all environments, array = specific env IDs + createdAt: string; + updatedAt: string; +} + +export async function getRoles(): Promise { + const rows = await db.select().from(roles).orderBy(asc(roles.name)); + return rows.map(row => ({ + ...row, + permissions: JSON.parse(row.permissions), + environmentIds: row.environmentIds ? JSON.parse(row.environmentIds) : null + })) as RoleData[]; +} + +export async function getRole(id: number): Promise { + const results = await db.select().from(roles).where(eq(roles.id, id)); + if (!results[0]) return null; + return { + ...results[0], + permissions: JSON.parse(results[0].permissions), + environmentIds: results[0].environmentIds ? JSON.parse(results[0].environmentIds) : null + } as RoleData; +} + +export async function getRoleByName(name: string): Promise { + const results = await db.select().from(roles).where(eq(roles.name, name)); + if (!results[0]) return null; + return { + ...results[0], + permissions: JSON.parse(results[0].permissions), + environmentIds: results[0].environmentIds ? JSON.parse(results[0].environmentIds) : null + } as RoleData; +} + +export async function createRole(data: { + name: string; + description?: string; + permissions: Permissions; + environmentIds?: number[] | null; +}): Promise { + const result = await db.insert(roles).values({ + name: data.name, + description: data.description || null, + isSystem: false, + permissions: JSON.stringify(data.permissions), + environmentIds: data.environmentIds ? JSON.stringify(data.environmentIds) : null + }).returning(); + return getRole(result[0].id) as Promise; +} + +export async function updateRole(id: number, data: Partial): Promise { + const role = await getRole(id); + if (!role || role.isSystem) return null; + + const updateData: Record = { updatedAt: new Date().toISOString() }; + + if (data.name !== undefined) updateData.name = data.name; + if (data.description !== undefined) updateData.description = data.description || null; + if (data.permissions !== undefined) updateData.permissions = JSON.stringify(data.permissions); + if (data.environmentIds !== undefined) { + updateData.environmentIds = data.environmentIds ? JSON.stringify(data.environmentIds) : null; + } + + await db.update(roles).set(updateData).where(eq(roles.id, id)); + return getRole(id); +} + +export async function deleteRole(id: number): Promise { + const role = await getRole(id); + if (!role || role.isSystem) return false; + await db.delete(roles).where(and(eq(roles.id, id), eq(roles.isSystem, false))); + return true; +} + +// ============================================================================= +// USER-ROLE OPERATIONS +// ============================================================================= + +export interface UserRoleData { + id: number; + userId: number; + roleId: number; + environmentId?: number | null; + createdAt: string; + role?: RoleData; +} + +export async function getUserRoles(userId: number): Promise { + const rows = await db.select({ + id: userRoles.id, + userId: userRoles.userId, + roleId: userRoles.roleId, + environmentId: userRoles.environmentId, + createdAt: userRoles.createdAt, + roleName: roles.name, + roleDescription: roles.description, + roleIsSystem: roles.isSystem, + rolePermissions: roles.permissions + }) + .from(userRoles) + .innerJoin(roles, eq(userRoles.roleId, roles.id)) + .where(eq(userRoles.userId, userId)); + + return rows.map(row => ({ + id: row.id, + userId: row.userId, + roleId: row.roleId, + environmentId: row.environmentId, + createdAt: row.createdAt, + role: { + id: row.roleId, + name: row.roleName, + description: row.roleDescription, + isSystem: row.roleIsSystem, + permissions: JSON.parse(row.rolePermissions), + createdAt: row.createdAt, + updatedAt: row.createdAt + } + })) as UserRoleData[]; +} + +export async function assignUserRole(userId: number, roleId: number, environmentId?: number): Promise { + await db.insert(userRoles).values({ + userId, + roleId, + environmentId: environmentId || null + }).onConflictDoNothing(); + + const results = await db.select().from(userRoles) + .where(and( + eq(userRoles.userId, userId), + eq(userRoles.roleId, roleId), + environmentId ? eq(userRoles.environmentId, environmentId) : isNull(userRoles.environmentId) + )); + return results[0] as UserRoleData; +} + +export async function removeUserRole(userId: number, roleId: number, environmentId?: number): Promise { + await db.delete(userRoles) + .where(and( + eq(userRoles.userId, userId), + eq(userRoles.roleId, roleId), + environmentId ? eq(userRoles.environmentId, environmentId) : isNull(userRoles.environmentId) + )); + return true; +} + +/** + * Check if user has the Admin role assigned. + * This is the authoritative check for admin privileges (instead of users.isAdmin column). + */ +export async function userHasAdminRole(userId: number): Promise { + const result = await db.select({ id: roles.id }) + .from(userRoles) + .innerJoin(roles, eq(userRoles.roleId, roles.id)) + .where(and( + eq(userRoles.userId, userId), + eq(roles.name, 'Admin') + )) + .limit(1); + return result.length > 0; +} + +/** + * Get environment IDs that a user can access based on their role assignments. + * Returns null if user has access to ALL environments (has at least one role with null environmentIds). + * Returns array of environment IDs if user has limited access. + * Returns empty array if user has no environment access. + */ +export async function getUserAccessibleEnvironments(userId: number): Promise { + const rows = await db.select({ + roleEnvironmentIds: roles.environmentIds + }) + .from(userRoles) + .innerJoin(roles, eq(userRoles.roleId, roles.id)) + .where(eq(userRoles.userId, userId)); + + const accessibleEnvIds: number[] = []; + + for (const row of rows) { + // If any role has null environmentIds, user has access to all environments + if (row.roleEnvironmentIds === null) { + return null; // null means "all environments" + } + try { + const envIds: number[] = JSON.parse(row.roleEnvironmentIds); + accessibleEnvIds.push(...envIds); + } catch { + // If parsing fails, assume all environments + return null; + } + } + + // Return unique environment IDs + return [...new Set(accessibleEnvIds)]; +} + +/** + * Get roles for a user that apply to a specific environment. + * Returns roles where environmentId is null (global) OR matches the specified environment. + */ +interface RoleEnvRow { + id: number; + userId: number; + roleId: number; + environmentId: number | null; + createdAt: string | null; + roleName: string; + roleDescription: string | null; + roleIsSystem: boolean; + rolePermissions: string; + roleEnvironmentIds: string | null; +} + +/** + * Get user roles that apply to a specific environment. + * A role applies if: + * - role.environmentIds is NULL (applies to all environments), OR + * - role.environmentIds array contains the target environmentId + */ +export async function getUserRolesForEnvironment(userId: number, environmentId: number): Promise { + const rows = await db.select({ + id: userRoles.id, + userId: userRoles.userId, + roleId: userRoles.roleId, + environmentId: userRoles.environmentId, + createdAt: userRoles.createdAt, + roleName: roles.name, + roleDescription: roles.description, + roleIsSystem: roles.isSystem, + rolePermissions: roles.permissions, + roleEnvironmentIds: roles.environmentIds + }) + .from(userRoles) + .innerJoin(roles, eq(userRoles.roleId, roles.id)) + .where(eq(userRoles.userId, userId)) as RoleEnvRow[]; + + // Filter roles that apply to this environment + // Role applies if environmentIds is null OR contains the environmentId + const filteredRows = rows.filter((row: RoleEnvRow) => { + if (row.roleEnvironmentIds === null) { + return true; // null means all environments + } + try { + const envIds: number[] = JSON.parse(row.roleEnvironmentIds); + return envIds.includes(environmentId); + } catch { + return true; // If parsing fails, assume all environments + } + }); + + return filteredRows.map((row: RoleEnvRow) => ({ + id: row.id, + userId: row.userId, + roleId: row.roleId, + environmentId: row.environmentId, + createdAt: row.createdAt, + role: { + id: row.roleId, + name: row.roleName, + description: row.roleDescription, + isSystem: row.roleIsSystem, + permissions: JSON.parse(row.rolePermissions), + environmentIds: row.roleEnvironmentIds ? JSON.parse(row.roleEnvironmentIds) : null, + createdAt: row.createdAt, + updatedAt: row.createdAt + } + })) as UserRoleData[]; +} + +/** + * Check if a user can access a specific environment. + * Returns true if user has any role that applies to this environment. + * A role applies if role.environmentIds is null OR contains the environmentId. + */ +export async function userCanAccessEnvironment(userId: number, environmentId: number): Promise { + const rows = await db.select({ + id: userRoles.id, + roleEnvironmentIds: roles.environmentIds + }) + .from(userRoles) + .innerJoin(roles, eq(userRoles.roleId, roles.id)) + .where(eq(userRoles.userId, userId)); + + // Check if any assigned role applies to this environment + for (const row of rows) { + if (row.roleEnvironmentIds === null) { + return true; // null means all environments + } + try { + const envIds: number[] = JSON.parse(row.roleEnvironmentIds); + if (envIds.includes(environmentId)) { + return true; + } + } catch { + return true; // If parsing fails, assume all environments + } + } + + return false; +} + +// ============================================================================= +// LDAP CONFIG OPERATIONS +// ============================================================================= + +export interface LdapRoleMapping { + groupDn: string; + roleId: number; +} + +export interface LdapConfigData { + id: number; + name: string; + enabled: boolean; + serverUrl: string; + bindDn?: string | null; + bindPassword?: string | null; + baseDn: string; + userFilter: string; + usernameAttribute: string; + emailAttribute: string; + displayNameAttribute: string; + groupBaseDn?: string | null; + groupFilter?: string | null; + adminGroup?: string | null; + roleMappings?: LdapRoleMapping[] | null; + tlsEnabled: boolean; + tlsCa?: string | null; + createdAt: string; + updatedAt: string; +} + +export async function getLdapConfigs(): Promise { + const results = await db.select().from(ldapConfig).orderBy(asc(ldapConfig.name)); + return results.map((row: any) => ({ + ...row, + roleMappings: row.roleMappings ? JSON.parse(row.roleMappings) : null + })) as LdapConfigData[]; +} + +export async function getLdapConfig(id: number): Promise { + const results = await db.select().from(ldapConfig).where(eq(ldapConfig.id, id)); + if (!results[0]) return null; + const row = results[0] as any; + return { + ...row, + roleMappings: row.roleMappings ? JSON.parse(row.roleMappings) : null + } as LdapConfigData; +} + +export async function createLdapConfig(data: Omit): Promise { + const result = await db.insert(ldapConfig).values({ + name: data.name, + enabled: data.enabled, + serverUrl: data.serverUrl, + bindDn: data.bindDn || null, + bindPassword: data.bindPassword || null, + baseDn: data.baseDn, + userFilter: data.userFilter, + usernameAttribute: data.usernameAttribute, + emailAttribute: data.emailAttribute, + displayNameAttribute: data.displayNameAttribute, + groupBaseDn: data.groupBaseDn || null, + groupFilter: data.groupFilter || null, + adminGroup: data.adminGroup || null, + roleMappings: data.roleMappings ? JSON.stringify(data.roleMappings) : null, + tlsEnabled: data.tlsEnabled, + tlsCa: data.tlsCa || null + }).returning(); + return getLdapConfig(result[0].id) as Promise; +} + +export async function updateLdapConfig(id: number, data: Partial): Promise { + const updateData: Record = { updatedAt: new Date().toISOString() }; + + if (data.name !== undefined) updateData.name = data.name; + if (data.enabled !== undefined) updateData.enabled = data.enabled; + if (data.serverUrl !== undefined) updateData.serverUrl = data.serverUrl; + if (data.bindDn !== undefined) updateData.bindDn = data.bindDn || null; + if (data.bindPassword !== undefined) updateData.bindPassword = data.bindPassword || null; + if (data.baseDn !== undefined) updateData.baseDn = data.baseDn; + if (data.userFilter !== undefined) updateData.userFilter = data.userFilter; + if (data.usernameAttribute !== undefined) updateData.usernameAttribute = data.usernameAttribute; + if (data.emailAttribute !== undefined) updateData.emailAttribute = data.emailAttribute; + if (data.displayNameAttribute !== undefined) updateData.displayNameAttribute = data.displayNameAttribute; + if (data.groupBaseDn !== undefined) updateData.groupBaseDn = data.groupBaseDn || null; + if (data.groupFilter !== undefined) updateData.groupFilter = data.groupFilter || null; + if (data.adminGroup !== undefined) updateData.adminGroup = data.adminGroup || null; + if (data.roleMappings !== undefined) updateData.roleMappings = data.roleMappings ? JSON.stringify(data.roleMappings) : null; + if (data.tlsEnabled !== undefined) updateData.tlsEnabled = data.tlsEnabled; + if (data.tlsCa !== undefined) updateData.tlsCa = data.tlsCa || null; + + await db.update(ldapConfig).set(updateData).where(eq(ldapConfig.id, id)); + return getLdapConfig(id); +} + +export async function deleteLdapConfig(id: number): Promise { + await db.delete(ldapConfig).where(eq(ldapConfig.id, id)); + return true; +} + +// ============================================================================= +// OIDC CONFIG OPERATIONS +// ============================================================================= + +export interface OidcRoleMapping { + claimValue: string; + roleId: number; +} + +export interface OidcConfigData { + id: number; + name: string; + enabled: boolean; + issuerUrl: string; + clientId: string; + clientSecret: string; + redirectUri: string; + scopes: string; + usernameClaim: string; + emailClaim: string; + displayNameClaim: string; + adminClaim?: string | null; + adminValue?: string | null; + roleMappingsClaim?: string | null; + roleMappings?: OidcRoleMapping[] | null; + createdAt: string; + updatedAt: string; +} + +export async function getOidcConfigs(): Promise { + const rows = await db.select().from(oidcConfig).orderBy(asc(oidcConfig.name)); + return rows.map(row => ({ + ...row, + roleMappings: row.roleMappings ? JSON.parse(row.roleMappings) : undefined + })) as OidcConfigData[]; +} + +export async function getOidcConfig(id: number): Promise { + const results = await db.select().from(oidcConfig).where(eq(oidcConfig.id, id)); + if (!results[0]) return null; + return { + ...results[0], + roleMappings: results[0].roleMappings ? JSON.parse(results[0].roleMappings) : undefined + } as OidcConfigData; +} + +export async function createOidcConfig(data: Omit): Promise { + const result = await db.insert(oidcConfig).values({ + name: data.name, + enabled: data.enabled, + issuerUrl: data.issuerUrl, + clientId: data.clientId, + clientSecret: data.clientSecret, + redirectUri: data.redirectUri, + scopes: data.scopes, + usernameClaim: data.usernameClaim, + emailClaim: data.emailClaim, + displayNameClaim: data.displayNameClaim, + adminClaim: data.adminClaim || null, + adminValue: data.adminValue || null, + roleMappingsClaim: data.roleMappingsClaim || 'groups', + roleMappings: data.roleMappings ? JSON.stringify(data.roleMappings) : null + }).returning(); + return getOidcConfig(result[0].id) as Promise; +} + +export async function updateOidcConfig(id: number, data: Partial): Promise { + const updateData: Record = { updatedAt: new Date().toISOString() }; + + if (data.name !== undefined) updateData.name = data.name; + if (data.enabled !== undefined) updateData.enabled = data.enabled; + if (data.issuerUrl !== undefined) updateData.issuerUrl = data.issuerUrl; + if (data.clientId !== undefined) updateData.clientId = data.clientId; + if (data.clientSecret !== undefined) updateData.clientSecret = data.clientSecret; + if (data.redirectUri !== undefined) updateData.redirectUri = data.redirectUri; + if (data.scopes !== undefined) updateData.scopes = data.scopes; + if (data.usernameClaim !== undefined) updateData.usernameClaim = data.usernameClaim; + if (data.emailClaim !== undefined) updateData.emailClaim = data.emailClaim; + if (data.displayNameClaim !== undefined) updateData.displayNameClaim = data.displayNameClaim; + if (data.adminClaim !== undefined) updateData.adminClaim = data.adminClaim || null; + if (data.adminValue !== undefined) updateData.adminValue = data.adminValue || null; + if (data.roleMappingsClaim !== undefined) updateData.roleMappingsClaim = data.roleMappingsClaim || 'groups'; + if (data.roleMappings !== undefined) updateData.roleMappings = data.roleMappings ? JSON.stringify(data.roleMappings) : null; + + await db.update(oidcConfig).set(updateData).where(eq(oidcConfig.id, id)); + return getOidcConfig(id); +} + +export async function deleteOidcConfig(id: number): Promise { + await db.delete(oidcConfig).where(eq(oidcConfig.id, id)); + return true; +} + +// ============================================================================= +// GIT CREDENTIALS OPERATIONS +// ============================================================================= + +export type GitAuthType = 'none' | 'password' | 'ssh'; + +export interface GitCredentialData { + id: number; + name: string; + authType: GitAuthType; + username?: string | null; + password?: string | null; + sshPrivateKey?: string | null; + sshPassphrase?: string | null; + createdAt: string; + updatedAt: string; +} + +export async function getGitCredentials(): Promise { + return db.select().from(gitCredentials).orderBy(asc(gitCredentials.name)) as Promise; +} + +export async function getGitCredential(id: number): Promise { + const results = await db.select().from(gitCredentials).where(eq(gitCredentials.id, id)); + return results[0] as GitCredentialData || null; +} + +export async function createGitCredential(data: { + name: string; + authType: GitAuthType; + username?: string; + password?: string; + sshPrivateKey?: string; + sshPassphrase?: string; +}): Promise { + const result = await db.insert(gitCredentials).values({ + name: data.name, + authType: data.authType, + username: data.username || null, + password: data.password || null, + sshPrivateKey: data.sshPrivateKey || null, + sshPassphrase: data.sshPassphrase || null + }).returning(); + return getGitCredential(result[0].id) as Promise; +} + +export async function updateGitCredential(id: number, data: Partial): Promise { + const updateData: Record = { updatedAt: new Date().toISOString() }; + + if (data.name !== undefined) updateData.name = data.name; + if (data.authType !== undefined) updateData.authType = data.authType; + // Only update username if provided (empty string clears it) + if (data.username !== undefined) updateData.username = data.username || null; + // Only update password/ssh keys if they have actual values (preserve existing if empty) + if (data.password) updateData.password = data.password; + if (data.sshPrivateKey) updateData.sshPrivateKey = data.sshPrivateKey; + if (data.sshPassphrase) updateData.sshPassphrase = data.sshPassphrase; + + await db.update(gitCredentials).set(updateData).where(eq(gitCredentials.id, id)); + return getGitCredential(id); +} + +export async function deleteGitCredential(id: number): Promise { + await db.delete(gitCredentials).where(eq(gitCredentials.id, id)); + return true; +} + +// ============================================================================= +// GIT REPOSITORIES OPERATIONS +// ============================================================================= + +export type GitSyncStatus = 'pending' | 'syncing' | 'synced' | 'error'; + +export interface GitRepositoryData { + id: number; + name: string; + url: string; + branch: string; + composePath: string; + credentialId: number | null; + environmentId: number | null; + autoUpdate: boolean; + autoUpdateSchedule: 'daily' | 'weekly' | 'custom'; + autoUpdateCron: string; + webhookEnabled: boolean; + webhookSecret: string | null; + lastSync: string | null; + lastCommit: string | null; + syncStatus: GitSyncStatus; + syncError: string | null; + createdAt: string; + updatedAt: string; +} + +export interface GitRepositoryWithCredential extends GitRepositoryData { + credential?: GitCredentialData | null; +} + +export async function getGitRepositories(): Promise { + const rows = await db.select({ + id: gitRepositories.id, + name: gitRepositories.name, + url: gitRepositories.url, + branch: gitRepositories.branch, + composePath: gitRepositories.composePath, + credentialId: gitRepositories.credentialId, + environmentId: gitRepositories.environmentId, + autoUpdate: gitRepositories.autoUpdate, + autoUpdateSchedule: gitRepositories.autoUpdateSchedule, + autoUpdateCron: gitRepositories.autoUpdateCron, + webhookEnabled: gitRepositories.webhookEnabled, + webhookSecret: gitRepositories.webhookSecret, + lastSync: gitRepositories.lastSync, + lastCommit: gitRepositories.lastCommit, + syncStatus: gitRepositories.syncStatus, + syncError: gitRepositories.syncError, + createdAt: gitRepositories.createdAt, + updatedAt: gitRepositories.updatedAt, + credentialName: gitCredentials.name, + credentialAuthType: gitCredentials.authType + }) + .from(gitRepositories) + .leftJoin(gitCredentials, eq(gitRepositories.credentialId, gitCredentials.id)) + .orderBy(asc(gitRepositories.name)); + + return rows.map(row => ({ + ...row, + credential: row.credentialId ? { + id: row.credentialId, + name: row.credentialName, + authType: row.credentialAuthType + } : null + })) as GitRepositoryWithCredential[]; +} + +export async function getGitRepository(id: number): Promise { + const results = await db.select().from(gitRepositories).where(eq(gitRepositories.id, id)); + return results[0] as GitRepositoryData || null; +} + +export async function getGitRepositoryByName(name: string): Promise { + const results = await db.select().from(gitRepositories).where(eq(gitRepositories.name, name)); + return results[0] as GitRepositoryData || null; +} + +export async function createGitRepository(data: { + name: string; + url: string; + branch?: string; + composePath?: string; + credentialId?: number | null; + environmentId?: number | null; + autoUpdate?: boolean; + autoUpdateSchedule?: 'daily' | 'weekly' | 'custom'; + autoUpdateCron?: string; + webhookEnabled?: boolean; + webhookSecret?: string | null; +}): Promise { + const result = await db.insert(gitRepositories).values({ + name: data.name, + url: data.url, + branch: data.branch || 'main', + composePath: data.composePath || 'docker-compose.yml', + credentialId: data.credentialId || null, + environmentId: data.environmentId || null, + autoUpdate: data.autoUpdate || false, + autoUpdateSchedule: data.autoUpdateSchedule || 'daily', + autoUpdateCron: data.autoUpdateCron || '0 3 * * *', + webhookEnabled: data.webhookEnabled || false, + webhookSecret: data.webhookSecret || null + }).returning(); + return getGitRepository(result[0].id) as Promise; +} + +export async function updateGitRepository(id: number, data: Partial): Promise { + const updateData: Record = { updatedAt: new Date().toISOString() }; + + if (data.name !== undefined) updateData.name = data.name; + if (data.url !== undefined) updateData.url = data.url; + if (data.branch !== undefined) updateData.branch = data.branch; + if (data.composePath !== undefined) updateData.composePath = data.composePath; + if (data.credentialId !== undefined) updateData.credentialId = data.credentialId; + if (data.environmentId !== undefined) updateData.environmentId = data.environmentId; + if (data.autoUpdate !== undefined) updateData.autoUpdate = data.autoUpdate; + if (data.autoUpdateSchedule !== undefined) updateData.autoUpdateSchedule = data.autoUpdateSchedule; + if (data.autoUpdateCron !== undefined) updateData.autoUpdateCron = data.autoUpdateCron; + if (data.webhookEnabled !== undefined) updateData.webhookEnabled = data.webhookEnabled; + if (data.webhookSecret !== undefined) updateData.webhookSecret = data.webhookSecret; + if (data.lastSync !== undefined) updateData.lastSync = data.lastSync; + if (data.lastCommit !== undefined) updateData.lastCommit = data.lastCommit; + if (data.syncStatus !== undefined) updateData.syncStatus = data.syncStatus; + if (data.syncError !== undefined) updateData.syncError = data.syncError; + + await db.update(gitRepositories).set(updateData).where(eq(gitRepositories.id, id)); + return getGitRepository(id); +} + +export async function deleteGitRepository(id: number): Promise { + await db.delete(gitRepositories).where(eq(gitRepositories.id, id)); + return true; +} + +// ============================================================================= +// GIT STACKS OPERATIONS +// ============================================================================= + +export interface GitStackData { + id: number; + stackName: string; + environmentId: number | null; + repositoryId: number; + composePath: string; + envFilePath: string | null; + autoUpdate: boolean; + autoUpdateSchedule: 'daily' | 'weekly' | 'custom'; + autoUpdateCron: string; + webhookEnabled: boolean; + webhookSecret: string | null; + lastSync: string | null; + lastCommit: string | null; + syncStatus: GitSyncStatus; + syncError: string | null; + createdAt: string; + updatedAt: string; +} + +export interface GitStackWithRepo extends GitStackData { + repository: { + id: number; + name: string; + url: string; + branch: string; + credentialId: number | null; + }; +} + +export async function getGitStacks(environmentId?: number): Promise { + let rows; + if (environmentId !== undefined) { + rows = await db.select({ + id: gitStacks.id, + stackName: gitStacks.stackName, + environmentId: gitStacks.environmentId, + repositoryId: gitStacks.repositoryId, + composePath: gitStacks.composePath, + envFilePath: gitStacks.envFilePath, + autoUpdate: gitStacks.autoUpdate, + autoUpdateSchedule: gitStacks.autoUpdateSchedule, + autoUpdateCron: gitStacks.autoUpdateCron, + webhookEnabled: gitStacks.webhookEnabled, + webhookSecret: gitStacks.webhookSecret, + lastSync: gitStacks.lastSync, + lastCommit: gitStacks.lastCommit, + syncStatus: gitStacks.syncStatus, + syncError: gitStacks.syncError, + createdAt: gitStacks.createdAt, + updatedAt: gitStacks.updatedAt, + repoName: gitRepositories.name, + repoUrl: gitRepositories.url, + repoBranch: gitRepositories.branch, + repoCredentialId: gitRepositories.credentialId + }) + .from(gitStacks) + .innerJoin(gitRepositories, eq(gitStacks.repositoryId, gitRepositories.id)) + .where(or(eq(gitStacks.environmentId, environmentId), isNull(gitStacks.environmentId))) + .orderBy(asc(gitStacks.stackName)); + } else { + rows = await db.select({ + id: gitStacks.id, + stackName: gitStacks.stackName, + environmentId: gitStacks.environmentId, + repositoryId: gitStacks.repositoryId, + composePath: gitStacks.composePath, + envFilePath: gitStacks.envFilePath, + autoUpdate: gitStacks.autoUpdate, + autoUpdateSchedule: gitStacks.autoUpdateSchedule, + autoUpdateCron: gitStacks.autoUpdateCron, + webhookEnabled: gitStacks.webhookEnabled, + webhookSecret: gitStacks.webhookSecret, + lastSync: gitStacks.lastSync, + lastCommit: gitStacks.lastCommit, + syncStatus: gitStacks.syncStatus, + syncError: gitStacks.syncError, + createdAt: gitStacks.createdAt, + updatedAt: gitStacks.updatedAt, + repoName: gitRepositories.name, + repoUrl: gitRepositories.url, + repoBranch: gitRepositories.branch, + repoCredentialId: gitRepositories.credentialId + }) + .from(gitStacks) + .innerJoin(gitRepositories, eq(gitStacks.repositoryId, gitRepositories.id)) + .orderBy(asc(gitStacks.stackName)); + } + + return rows.map(row => ({ + id: row.id, + stackName: row.stackName, + environmentId: row.environmentId, + repositoryId: row.repositoryId, + composePath: row.composePath, + envFilePath: row.envFilePath, + autoUpdate: row.autoUpdate, + autoUpdateSchedule: row.autoUpdateSchedule, + autoUpdateCron: row.autoUpdateCron, + webhookEnabled: row.webhookEnabled, + webhookSecret: row.webhookSecret, + lastSync: row.lastSync, + lastCommit: row.lastCommit, + syncStatus: row.syncStatus, + syncError: row.syncError, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + repository: { + id: row.repositoryId, + name: row.repoName, + url: row.repoUrl, + branch: row.repoBranch, + credentialId: row.repoCredentialId + } + })) as GitStackWithRepo[]; +} + +// Get git stacks for a specific environment only (excludes stacks with null environment) +export async function getGitStacksForEnvironmentOnly(environmentId: number): Promise { + const rows = await db.select({ + id: gitStacks.id, + stackName: gitStacks.stackName, + environmentId: gitStacks.environmentId, + repositoryId: gitStacks.repositoryId, + composePath: gitStacks.composePath, + envFilePath: gitStacks.envFilePath, + autoUpdate: gitStacks.autoUpdate, + autoUpdateSchedule: gitStacks.autoUpdateSchedule, + autoUpdateCron: gitStacks.autoUpdateCron, + webhookEnabled: gitStacks.webhookEnabled, + webhookSecret: gitStacks.webhookSecret, + lastSync: gitStacks.lastSync, + lastCommit: gitStacks.lastCommit, + syncStatus: gitStacks.syncStatus, + syncError: gitStacks.syncError, + createdAt: gitStacks.createdAt, + updatedAt: gitStacks.updatedAt, + repoName: gitRepositories.name, + repoUrl: gitRepositories.url, + repoBranch: gitRepositories.branch, + repoCredentialId: gitRepositories.credentialId + }) + .from(gitStacks) + .innerJoin(gitRepositories, eq(gitStacks.repositoryId, gitRepositories.id)) + .where(eq(gitStacks.environmentId, environmentId)) + .orderBy(asc(gitStacks.stackName)); + + return rows.map((row) => ({ + id: row.id, + stackName: row.stackName, + environmentId: row.environmentId, + repositoryId: row.repositoryId, + composePath: row.composePath, + envFilePath: row.envFilePath, + autoUpdate: row.autoUpdate, + autoUpdateSchedule: row.autoUpdateSchedule, + autoUpdateCron: row.autoUpdateCron, + webhookEnabled: row.webhookEnabled, + webhookSecret: row.webhookSecret, + lastSync: row.lastSync, + lastCommit: row.lastCommit, + syncStatus: row.syncStatus, + syncError: row.syncError, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + repository: { + id: row.repositoryId, + name: row.repoName, + url: row.repoUrl, + branch: row.repoBranch, + credentialId: row.repoCredentialId + } + })) as GitStackWithRepo[]; +} + +export async function getGitStack(id: number): Promise { + const rows = await db.select({ + id: gitStacks.id, + stackName: gitStacks.stackName, + environmentId: gitStacks.environmentId, + repositoryId: gitStacks.repositoryId, + composePath: gitStacks.composePath, + envFilePath: gitStacks.envFilePath, + autoUpdate: gitStacks.autoUpdate, + autoUpdateSchedule: gitStacks.autoUpdateSchedule, + autoUpdateCron: gitStacks.autoUpdateCron, + webhookEnabled: gitStacks.webhookEnabled, + webhookSecret: gitStacks.webhookSecret, + lastSync: gitStacks.lastSync, + lastCommit: gitStacks.lastCommit, + syncStatus: gitStacks.syncStatus, + syncError: gitStacks.syncError, + createdAt: gitStacks.createdAt, + updatedAt: gitStacks.updatedAt, + repoName: gitRepositories.name, + repoUrl: gitRepositories.url, + repoBranch: gitRepositories.branch, + repoCredentialId: gitRepositories.credentialId + }) + .from(gitStacks) + .innerJoin(gitRepositories, eq(gitStacks.repositoryId, gitRepositories.id)) + .where(eq(gitStacks.id, id)); + + if (!rows[0]) return null; + const row = rows[0]; + return { + id: row.id, + stackName: row.stackName, + environmentId: row.environmentId, + repositoryId: row.repositoryId, + composePath: row.composePath, + envFilePath: row.envFilePath, + autoUpdate: row.autoUpdate, + autoUpdateSchedule: row.autoUpdateSchedule, + autoUpdateCron: row.autoUpdateCron, + webhookEnabled: row.webhookEnabled, + webhookSecret: row.webhookSecret, + lastSync: row.lastSync, + lastCommit: row.lastCommit, + syncStatus: row.syncStatus, + syncError: row.syncError, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + repository: { + id: row.repositoryId, + name: row.repoName, + url: row.repoUrl, + branch: row.repoBranch, + credentialId: row.repoCredentialId + } + } as GitStackWithRepo; +} + +export async function getGitStackByName(stackName: string, environmentId?: number | null): Promise { + const rows = await db.select({ + id: gitStacks.id, + stackName: gitStacks.stackName, + environmentId: gitStacks.environmentId, + repositoryId: gitStacks.repositoryId, + composePath: gitStacks.composePath, + envFilePath: gitStacks.envFilePath, + autoUpdate: gitStacks.autoUpdate, + autoUpdateSchedule: gitStacks.autoUpdateSchedule, + autoUpdateCron: gitStacks.autoUpdateCron, + webhookEnabled: gitStacks.webhookEnabled, + webhookSecret: gitStacks.webhookSecret, + lastSync: gitStacks.lastSync, + lastCommit: gitStacks.lastCommit, + syncStatus: gitStacks.syncStatus, + syncError: gitStacks.syncError, + createdAt: gitStacks.createdAt, + updatedAt: gitStacks.updatedAt, + repoName: gitRepositories.name, + repoUrl: gitRepositories.url, + repoBranch: gitRepositories.branch, + repoCredentialId: gitRepositories.credentialId + }) + .from(gitStacks) + .innerJoin(gitRepositories, eq(gitStacks.repositoryId, gitRepositories.id)) + .where(and( + eq(gitStacks.stackName, stackName), + environmentId !== undefined && environmentId !== null + ? eq(gitStacks.environmentId, environmentId) + : isNull(gitStacks.environmentId) + )); + + if (!rows[0]) return null; + const row = rows[0]; + return { + id: row.id, + stackName: row.stackName, + environmentId: row.environmentId, + repositoryId: row.repositoryId, + composePath: row.composePath, + envFilePath: row.envFilePath, + autoUpdate: row.autoUpdate, + autoUpdateSchedule: row.autoUpdateSchedule, + autoUpdateCron: row.autoUpdateCron, + webhookEnabled: row.webhookEnabled, + webhookSecret: row.webhookSecret, + lastSync: row.lastSync, + lastCommit: row.lastCommit, + syncStatus: row.syncStatus, + syncError: row.syncError, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + repository: { + id: row.repositoryId, + name: row.repoName, + url: row.repoUrl, + branch: row.repoBranch, + credentialId: row.repoCredentialId + } + } as GitStackWithRepo; +} + +export async function getGitStackByWebhookSecret(secret: string): Promise { + const rows = await db.select({ + id: gitStacks.id, + stackName: gitStacks.stackName, + environmentId: gitStacks.environmentId, + repositoryId: gitStacks.repositoryId, + composePath: gitStacks.composePath, + envFilePath: gitStacks.envFilePath, + autoUpdate: gitStacks.autoUpdate, + autoUpdateSchedule: gitStacks.autoUpdateSchedule, + autoUpdateCron: gitStacks.autoUpdateCron, + webhookEnabled: gitStacks.webhookEnabled, + webhookSecret: gitStacks.webhookSecret, + lastSync: gitStacks.lastSync, + lastCommit: gitStacks.lastCommit, + syncStatus: gitStacks.syncStatus, + syncError: gitStacks.syncError, + createdAt: gitStacks.createdAt, + updatedAt: gitStacks.updatedAt, + repoName: gitRepositories.name, + repoUrl: gitRepositories.url, + repoBranch: gitRepositories.branch, + repoCredentialId: gitRepositories.credentialId + }) + .from(gitStacks) + .innerJoin(gitRepositories, eq(gitStacks.repositoryId, gitRepositories.id)) + .where(and(eq(gitStacks.webhookSecret, secret), eq(gitStacks.webhookEnabled, true))); + + if (!rows[0]) return null; + const row = rows[0]; + return { + id: row.id, + stackName: row.stackName, + environmentId: row.environmentId, + repositoryId: row.repositoryId, + composePath: row.composePath, + envFilePath: row.envFilePath, + autoUpdate: row.autoUpdate, + autoUpdateSchedule: row.autoUpdateSchedule, + autoUpdateCron: row.autoUpdateCron, + webhookEnabled: row.webhookEnabled, + webhookSecret: row.webhookSecret, + lastSync: row.lastSync, + lastCommit: row.lastCommit, + syncStatus: row.syncStatus, + syncError: row.syncError, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + repository: { + id: row.repositoryId, + name: row.repoName, + url: row.repoUrl, + branch: row.repoBranch, + credentialId: row.repoCredentialId + } + } as GitStackWithRepo; +} + +export async function createGitStack(data: { + stackName: string; + environmentId?: number | null; + repositoryId: number; + composePath?: string; + envFilePath?: string | null; + autoUpdate?: boolean; + autoUpdateSchedule?: 'daily' | 'weekly' | 'custom'; + autoUpdateCron?: string; + webhookEnabled?: boolean; + webhookSecret?: string | null; +}): Promise { + const result = await db.insert(gitStacks).values({ + stackName: data.stackName, + environmentId: data.environmentId ?? null, + repositoryId: data.repositoryId, + composePath: data.composePath || 'docker-compose.yml', + envFilePath: data.envFilePath || null, + autoUpdate: data.autoUpdate || false, + autoUpdateSchedule: data.autoUpdateSchedule || 'daily', + autoUpdateCron: data.autoUpdateCron || '0 3 * * *', + webhookEnabled: data.webhookEnabled || false, + webhookSecret: data.webhookSecret || null + }).returning(); + return getGitStack(result[0].id) as Promise; +} + +export async function updateGitStack(id: number, data: Partial): Promise { + const updateData: Record = { updatedAt: new Date().toISOString() }; + + if (data.stackName !== undefined) updateData.stackName = data.stackName; + if (data.repositoryId !== undefined) updateData.repositoryId = data.repositoryId; + if (data.composePath !== undefined) updateData.composePath = data.composePath; + if (data.envFilePath !== undefined) updateData.envFilePath = data.envFilePath; + if (data.autoUpdate !== undefined) updateData.autoUpdate = data.autoUpdate; + if (data.autoUpdateSchedule !== undefined) updateData.autoUpdateSchedule = data.autoUpdateSchedule; + if (data.autoUpdateCron !== undefined) updateData.autoUpdateCron = data.autoUpdateCron; + if (data.webhookEnabled !== undefined) updateData.webhookEnabled = data.webhookEnabled; + if (data.webhookSecret !== undefined) updateData.webhookSecret = data.webhookSecret; + if (data.lastSync !== undefined) updateData.lastSync = data.lastSync; + if (data.lastCommit !== undefined) updateData.lastCommit = data.lastCommit; + if (data.syncStatus !== undefined) updateData.syncStatus = data.syncStatus; + if (data.syncError !== undefined) updateData.syncError = data.syncError; + + await db.update(gitStacks).set(updateData).where(eq(gitStacks.id, id)); + return getGitStack(id); +} + +export async function deleteGitStack(id: number): Promise { + await db.delete(gitStacks).where(eq(gitStacks.id, id)); + return true; +} + +export async function renameGitStack(id: number, newName: string): Promise { + await db.update(gitStacks) + .set({ stackName: newName, updatedAt: new Date().toISOString() }) + .where(eq(gitStacks.id, id)); + return true; +} + +export async function getEnabledAutoUpdateGitStacks(): Promise { + const rows = await db.select({ + id: gitStacks.id, + stackName: gitStacks.stackName, + environmentId: gitStacks.environmentId, + repositoryId: gitStacks.repositoryId, + composePath: gitStacks.composePath, + envFilePath: gitStacks.envFilePath, + autoUpdate: gitStacks.autoUpdate, + autoUpdateSchedule: gitStacks.autoUpdateSchedule, + autoUpdateCron: gitStacks.autoUpdateCron, + webhookEnabled: gitStacks.webhookEnabled, + webhookSecret: gitStacks.webhookSecret, + lastSync: gitStacks.lastSync, + lastCommit: gitStacks.lastCommit, + syncStatus: gitStacks.syncStatus, + syncError: gitStacks.syncError, + createdAt: gitStacks.createdAt, + updatedAt: gitStacks.updatedAt, + repoName: gitRepositories.name, + repoUrl: gitRepositories.url, + repoBranch: gitRepositories.branch, + repoCredentialId: gitRepositories.credentialId + }) + .from(gitStacks) + .innerJoin(gitRepositories, eq(gitStacks.repositoryId, gitRepositories.id)) + .where(eq(gitStacks.autoUpdate, true)); + + return rows.map(row => ({ + id: row.id, + stackName: row.stackName, + environmentId: row.environmentId, + repositoryId: row.repositoryId, + composePath: row.composePath, + envFilePath: row.envFilePath, + autoUpdate: row.autoUpdate, + autoUpdateSchedule: row.autoUpdateSchedule, + autoUpdateCron: row.autoUpdateCron, + webhookEnabled: row.webhookEnabled, + webhookSecret: row.webhookSecret, + lastSync: row.lastSync, + lastCommit: row.lastCommit, + syncStatus: row.syncStatus, + syncError: row.syncError, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + repository: { + id: row.repositoryId, + name: row.repoName, + url: row.repoUrl, + branch: row.repoBranch, + credentialId: row.repoCredentialId + } + })) as GitStackWithRepo[]; +} + +export async function getAllAutoUpdateGitStacks(): Promise { + const rows = await db.select({ + id: gitStacks.id, + stackName: gitStacks.stackName, + environmentId: gitStacks.environmentId, + repositoryId: gitStacks.repositoryId, + composePath: gitStacks.composePath, + autoUpdate: gitStacks.autoUpdate, + autoUpdateSchedule: gitStacks.autoUpdateSchedule, + autoUpdateCron: gitStacks.autoUpdateCron, + webhookEnabled: gitStacks.webhookEnabled, + webhookSecret: gitStacks.webhookSecret, + lastSync: gitStacks.lastSync, + lastCommit: gitStacks.lastCommit, + syncStatus: gitStacks.syncStatus, + syncError: gitStacks.syncError, + createdAt: gitStacks.createdAt, + updatedAt: gitStacks.updatedAt, + repoName: gitRepositories.name, + repoUrl: gitRepositories.url, + repoBranch: gitRepositories.branch, + repoCredentialId: gitRepositories.credentialId + }) + .from(gitStacks) + .innerJoin(gitRepositories, eq(gitStacks.repositoryId, gitRepositories.id)) + .where(eq(gitStacks.autoUpdate, true)); + + return rows.map(row => ({ + id: row.id, + stackName: row.stackName, + environmentId: row.environmentId, + repositoryId: row.repositoryId, + composePath: row.composePath, + autoUpdate: row.autoUpdate, + autoUpdateSchedule: row.autoUpdateSchedule, + autoUpdateCron: row.autoUpdateCron, + webhookEnabled: row.webhookEnabled, + webhookSecret: row.webhookSecret, + lastSync: row.lastSync, + lastCommit: row.lastCommit, + syncStatus: row.syncStatus, + syncError: row.syncError, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + repository: { + id: row.repositoryId, + name: row.repoName, + url: row.repoUrl, + branch: row.repoBranch, + credentialId: row.repoCredentialId + } + })) as GitStackWithRepo[]; +} + +// ============================================================================= +// STACK SOURCES OPERATIONS +// ============================================================================= + +export type StackSourceType = 'external' | 'internal' | 'git'; + +export interface StackSourceData { + id: number; + stackName: string; + environmentId: number | null; + sourceType: StackSourceType; + gitRepositoryId: number | null; + gitStackId: number | null; + createdAt: string; + updatedAt: string; +} + +export interface StackSourceWithRepo extends StackSourceData { + repository?: GitRepositoryData | null; + gitStack?: GitStackWithRepo | null; +} + +export async function getStackSource(stackName: string, environmentId?: number | null): Promise { + const results = await db.select().from(stackSources) + .where(and( + eq(stackSources.stackName, stackName), + environmentId !== undefined && environmentId !== null + ? eq(stackSources.environmentId, environmentId) + : isNull(stackSources.environmentId) + )); + + if (!results[0]) return null; + const row = results[0]; + + let repository = null; + let gitStackData = null; + + if (row.gitRepositoryId) { + repository = await getGitRepository(row.gitRepositoryId); + } + if (row.gitStackId) { + gitStackData = await getGitStack(row.gitStackId); + } + + return { + ...row, + repository, + gitStack: gitStackData + } as StackSourceWithRepo; +} + +export async function getStackSources(environmentId?: number | null): Promise { + let results; + if (environmentId !== undefined) { + results = await db.select().from(stackSources) + .where(or(eq(stackSources.environmentId, environmentId), isNull(stackSources.environmentId))) + .orderBy(asc(stackSources.stackName)); + } else { + results = await db.select().from(stackSources).orderBy(asc(stackSources.stackName)); + } + + const enrichedResults: StackSourceWithRepo[] = []; + for (const row of results) { + let repository = null; + let gitStackData = null; + + if (row.gitRepositoryId) { + repository = await getGitRepository(row.gitRepositoryId); + } + if (row.gitStackId) { + gitStackData = await getGitStack(row.gitStackId); + } + + enrichedResults.push({ + ...row, + repository, + gitStack: gitStackData + } as StackSourceWithRepo); + } + + return enrichedResults; +} + +export async function upsertStackSource(data: { + stackName: string; + environmentId?: number | null; + sourceType: StackSourceType; + gitRepositoryId?: number | null; + gitStackId?: number | null; +}): Promise { + const existing = await getStackSource(data.stackName, data.environmentId); + + if (existing) { + await db.update(stackSources) + .set({ + sourceType: data.sourceType, + gitRepositoryId: data.gitRepositoryId || null, + gitStackId: data.gitStackId || null, + updatedAt: new Date().toISOString() + }) + .where(eq(stackSources.id, existing.id)); + return getStackSource(data.stackName, data.environmentId) as Promise; + } else { + await db.insert(stackSources).values({ + stackName: data.stackName, + environmentId: data.environmentId ?? null, + sourceType: data.sourceType, + gitRepositoryId: data.gitRepositoryId || null, + gitStackId: data.gitStackId || null + }); + return getStackSource(data.stackName, data.environmentId) as Promise; + } +} + +export async function deleteStackSource(stackName: string, environmentId?: number | null): Promise { + // Delete matching record (either with specific envId or NULL) + await db.delete(stackSources) + .where(and( + eq(stackSources.stackName, stackName), + environmentId !== undefined && environmentId !== null + ? eq(stackSources.environmentId, environmentId) + : isNull(stackSources.environmentId) + )); + + // Also cleanup any orphaned records with NULL environment_id for this stack + // This handles cases where stacks were created with wrong/missing environment association + if (environmentId !== undefined && environmentId !== null) { + await db.delete(stackSources) + .where(and( + eq(stackSources.stackName, stackName), + isNull(stackSources.environmentId) + )); + } + return true; +} + +// ============================================================================= +// VULNERABILITY SCAN RESULTS +// ============================================================================= + +export interface VulnerabilityScanData { + id: number; + environmentId: number | null; + imageId: string; + imageName: string; + scanner: 'grype' | 'trivy'; + scannedAt: string; + scanDuration: number; + criticalCount: number; + highCount: number; + mediumCount: number; + lowCount: number; + negligibleCount: number; + unknownCount: number; + vulnerabilities: any[]; + error: string | null; + createdAt: string; +} + +export async function saveVulnerabilityScan(data: { + environmentId?: number | null; + imageId: string; + imageName: string; + scanner: 'grype' | 'trivy'; + scannedAt: string; + scanDuration: number; + criticalCount: number; + highCount: number; + mediumCount: number; + lowCount: number; + negligibleCount: number; + unknownCount: number; + vulnerabilities: any[]; + error?: string | null; +}): Promise { + const result = await db.insert(vulnerabilityScans).values({ + environmentId: data.environmentId ?? null, + imageId: data.imageId, + imageName: data.imageName, + scanner: data.scanner, + scannedAt: data.scannedAt, + scanDuration: data.scanDuration, + criticalCount: data.criticalCount, + highCount: data.highCount, + mediumCount: data.mediumCount, + lowCount: data.lowCount, + negligibleCount: data.negligibleCount, + unknownCount: data.unknownCount, + vulnerabilities: JSON.stringify(data.vulnerabilities), + error: data.error ?? null + }).returning(); + return getVulnerabilityScan(result[0].id) as Promise; +} + +export async function getVulnerabilityScan(id: number): Promise { + const results = await db.select().from(vulnerabilityScans).where(eq(vulnerabilityScans.id, id)); + if (!results[0]) return null; + return { + ...results[0], + vulnerabilities: results[0].vulnerabilities ? JSON.parse(results[0].vulnerabilities) : [] + } as VulnerabilityScanData; +} + +export async function getLatestScanForImage( + imageId: string, + scanner?: string, + environmentId?: number | null +): Promise { + let conditions = [eq(vulnerabilityScans.imageId, imageId)]; + + if (scanner) { + conditions.push(eq(vulnerabilityScans.scanner, scanner as 'grype' | 'trivy')); + } + + if (environmentId !== undefined) { + if (environmentId === null) { + conditions.push(isNull(vulnerabilityScans.environmentId)); + } else { + conditions.push(eq(vulnerabilityScans.environmentId, environmentId)); + } + } + + const results = await db.select().from(vulnerabilityScans) + .where(and(...conditions)) + .orderBy(desc(vulnerabilityScans.scannedAt)) + .limit(1); + + if (!results[0]) return null; + return { + ...results[0], + vulnerabilities: results[0].vulnerabilities ? JSON.parse(results[0].vulnerabilities) : [] + } as VulnerabilityScanData; +} + +export async function getScansForImage(imageId: string, limit = 10): Promise { + const results = await db.select().from(vulnerabilityScans) + .where(eq(vulnerabilityScans.imageId, imageId)) + .orderBy(desc(vulnerabilityScans.scannedAt)) + .limit(limit); + + return results.map(row => ({ + ...row, + vulnerabilities: row.vulnerabilities ? JSON.parse(row.vulnerabilities) : [] + })) as VulnerabilityScanData[]; +} + +/** + * Get the combined scan summary for an image across all scanners. + * When using "both" scanners, this returns the MAX counts per severity + * from the latest scan of each scanner type. + */ +export async function getCombinedScanForImage( + imageId: string, + environmentId?: number | null +): Promise<{ critical: number; high: number; medium: number; low: number; negligible: number; unknown: number } | null> { + let conditions = [eq(vulnerabilityScans.imageId, imageId)]; + + if (environmentId !== undefined) { + if (environmentId === null) { + conditions.push(isNull(vulnerabilityScans.environmentId)); + } else { + conditions.push(eq(vulnerabilityScans.environmentId, environmentId)); + } + } + + // Get all scans for this image (we'll group by scanner in JS) + const results = await db.select().from(vulnerabilityScans) + .where(and(...conditions)) + .orderBy(desc(vulnerabilityScans.scannedAt)); + + if (results.length === 0) return null; + + // Get the latest scan for each scanner + const latestByScanner = new Map(); + for (const scan of results) { + if (!latestByScanner.has(scan.scanner)) { + latestByScanner.set(scan.scanner, scan); + } + } + + // Combine using MAX per severity (same logic as combineScanSummaries) + let combined = { critical: 0, high: 0, medium: 0, low: 0, negligible: 0, unknown: 0 }; + for (const scan of latestByScanner.values()) { + combined.critical = Math.max(combined.critical, scan.criticalCount ?? 0); + combined.high = Math.max(combined.high, scan.highCount ?? 0); + combined.medium = Math.max(combined.medium, scan.mediumCount ?? 0); + combined.low = Math.max(combined.low, scan.lowCount ?? 0); + combined.negligible = Math.max(combined.negligible, scan.negligibleCount ?? 0); + combined.unknown = Math.max(combined.unknown, scan.unknownCount ?? 0); + } + + return combined; +} + +export async function getAllLatestScans(environmentId?: number | null): Promise { + // This complex query requires raw SQL or multiple queries + // For simplicity, we'll fetch all and filter in JS + let results; + if (environmentId !== undefined) { + if (environmentId === null) { + results = await db.select().from(vulnerabilityScans) + .where(isNull(vulnerabilityScans.environmentId)) + .orderBy(desc(vulnerabilityScans.scannedAt)); + } else { + results = await db.select().from(vulnerabilityScans) + .where(eq(vulnerabilityScans.environmentId, environmentId)) + .orderBy(desc(vulnerabilityScans.scannedAt)); + } + } else { + results = await db.select().from(vulnerabilityScans) + .orderBy(desc(vulnerabilityScans.scannedAt)); + } + + // Group by imageId + scanner and take latest + const latestMap = new Map(); + for (const row of results) { + const key = `${row.imageId}:${row.scanner}`; + if (!latestMap.has(key)) { + latestMap.set(key, row); + } + } + + return Array.from(latestMap.values()).map(row => ({ + ...row, + vulnerabilities: row.vulnerabilities ? JSON.parse(row.vulnerabilities) : [] + })) as VulnerabilityScanData[]; +} + +export async function deleteOldScans(keepDays = 30): Promise { + const cutoffDate = new Date(Date.now() - keepDays * 24 * 60 * 60 * 1000).toISOString(); + await db.delete(vulnerabilityScans) + .where(sql`scanned_at < ${cutoffDate}`); + return 0; +} + +// ============================================================================= +// AUDIT LOGGING (Enterprise Feature) +// ============================================================================= + +export type AuditAction = + | 'create' | 'update' | 'delete' | 'start' | 'stop' | 'restart' | 'down' + | 'pause' | 'unpause' | 'pull' | 'push' | 'prune' | 'login' + | 'logout' | 'view' | 'exec' | 'connect' | 'disconnect' | 'deploy' | 'sync' | 'rename'; + +export type AuditEntityType = + | 'container' | 'image' | 'stack' | 'volume' | 'network' + | 'user' | 'settings' | 'environment' | 'registry'; + +export interface AuditLogData { + id: number; + userId: number | null; + username: string; + action: AuditAction; + entityType: AuditEntityType; + entityId: string | null; + entityName: string | null; + environmentId: number | null; + description: string | null; + details: any | null; + ipAddress: string | null; + userAgent: string | null; + createdAt: string; +} + +export interface AuditLogCreateData { + userId?: number | null; + username: string; + action: AuditAction; + entityType: AuditEntityType; + entityId?: string | null; + entityName?: string | null; + environmentId?: number | null; + description?: string | null; + details?: any | null; + ipAddress?: string | null; + userAgent?: string | null; +} + +export interface AuditLogFilters { + username?: string; + usernames?: string[]; + entityType?: AuditEntityType; + entityTypes?: AuditEntityType[]; + action?: AuditAction; + actions?: AuditAction[]; + environmentId?: number; + labels?: string[]; // Filter by environment labels (audit entries from envs with ANY of these labels) + fromDate?: string; + toDate?: string; + limit?: number; + offset?: number; +} + +export interface AuditLogResult { + logs: AuditLogData[]; + total: number; + limit: number; + offset: number; +} + +export async function logAuditEvent(data: AuditLogCreateData): Promise { + const result = await db.insert(auditLogs).values({ + userId: data.userId ?? null, + username: data.username, + action: data.action, + entityType: data.entityType, + entityId: data.entityId ?? null, + entityName: data.entityName ?? null, + environmentId: data.environmentId ?? null, + description: data.description ?? null, + details: data.details ? JSON.stringify(data.details) : null, + ipAddress: data.ipAddress ?? null, + userAgent: data.userAgent ?? null + }).returning(); + + const auditLog = await getAuditLog(result[0].id); + + // Broadcast the new audit event to connected SSE clients + try { + const { broadcastAuditEvent } = await import('./audit-events.js'); + broadcastAuditEvent(auditLog!); + } catch (e) { + // Ignore broadcast errors + } + + return auditLog!; +} + +export async function getAuditLog(id: number): Promise { + const results = await db.select().from(auditLogs).where(eq(auditLogs.id, id)); + if (!results[0]) return undefined; + return { + ...results[0], + details: results[0].details ? JSON.parse(results[0].details) : null + } as AuditLogData; +} + +export async function getAuditLogs(filters: AuditLogFilters = {}): Promise { + let conditions: any[] = []; + + // Labels filter - find environments with matching labels first + let labelFilteredEnvIds: number[] | undefined; + if (filters.labels && filters.labels.length > 0) { + // Get environments that have ANY of the specified labels + const allEnvs = await db.select({ id: environments.id, labels: environments.labels }).from(environments); + labelFilteredEnvIds = allEnvs + .filter(env => { + if (!env.labels) return false; + try { + const envLabels = JSON.parse(env.labels) as string[]; + return filters.labels!.some(label => envLabels.includes(label)); + } catch { + return false; + } + }) + .map(env => env.id); + + // If no environments match the labels, return empty result + if (labelFilteredEnvIds.length === 0) { + return { logs: [], total: 0, limit: filters.limit || 50, offset: filters.offset || 0 }; + } + } + + if (filters.usernames && filters.usernames.length > 0) { + conditions.push(inArray(auditLogs.username, filters.usernames)); + } else if (filters.username) { + conditions.push(eq(auditLogs.username, filters.username)); + } + + if (filters.entityTypes && filters.entityTypes.length > 0) { + conditions.push(inArray(auditLogs.entityType, filters.entityTypes)); + } else if (filters.entityType) { + conditions.push(eq(auditLogs.entityType, filters.entityType)); + } + + if (filters.actions && filters.actions.length > 0) { + conditions.push(inArray(auditLogs.action, filters.actions)); + } else if (filters.action) { + conditions.push(eq(auditLogs.action, filters.action)); + } + + if (filters.environmentId !== undefined && filters.environmentId !== null) { + // If we also have label filtering, verify this environment has matching labels + if (labelFilteredEnvIds && !labelFilteredEnvIds.includes(filters.environmentId)) { + return { logs: [], total: 0, limit: filters.limit || 50, offset: filters.offset || 0 }; + } + conditions.push(eq(auditLogs.environmentId, filters.environmentId)); + } else if (labelFilteredEnvIds) { + // Only label filter (no specific environment filter) + conditions.push(inArray(auditLogs.environmentId, labelFilteredEnvIds)); + } + + if (filters.fromDate) { + conditions.push(sql`${auditLogs.createdAt} >= ${filters.fromDate}`); + } + + if (filters.toDate) { + conditions.push(sql`${auditLogs.createdAt} <= ${filters.toDate}`); + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + // Get total count + const countResult = await db.select({ count: sql`count(*)` }).from(auditLogs) + .where(whereClause); + const total = Number(countResult[0]?.count) || 0; + + // Get paginated results + const limit = filters.limit || 50; + const offset = filters.offset || 0; + + const rows = await db.select({ + id: auditLogs.id, + userId: auditLogs.userId, + username: auditLogs.username, + action: auditLogs.action, + entityType: auditLogs.entityType, + entityId: auditLogs.entityId, + entityName: auditLogs.entityName, + environmentId: auditLogs.environmentId, + description: auditLogs.description, + details: auditLogs.details, + ipAddress: auditLogs.ipAddress, + userAgent: auditLogs.userAgent, + createdAt: auditLogs.createdAt, + environmentName: environments.name, + environmentIcon: environments.icon + }) + .from(auditLogs) + .leftJoin(environments, eq(auditLogs.environmentId, environments.id)) + .where(whereClause) + .orderBy(desc(auditLogs.createdAt)) + .limit(limit) + .offset(offset); + + const logs = rows.map(row => ({ + ...row, + details: row.details ? JSON.parse(row.details) : null, + timestamp: row.createdAt + })) as AuditLogData[]; + + return { logs, total, limit, offset }; +} + +export async function getAuditLogUsers(): Promise { + const results = await db.selectDistinct({ username: auditLogs.username }).from(auditLogs).orderBy(asc(auditLogs.username)); + return results.map(row => row.username); +} + +export async function deleteOldAuditLogs(keepDays = 90): Promise { + const cutoffDate = new Date(Date.now() - keepDays * 24 * 60 * 60 * 1000).toISOString(); + await db.delete(auditLogs) + .where(sql`created_at < ${cutoffDate}`); + return 0; +} + +// ============================================================================= +// CONTAINER ACTIVITY (Docker Events) - Free Feature +// ============================================================================= + +export type ContainerEventAction = + | 'create' | 'start' | 'stop' | 'die' | 'kill' | 'restart' + | 'pause' | 'unpause' | 'destroy' | 'rename' | 'update' + | 'attach' | 'detach' | 'exec_create' | 'exec_start' | 'exec_die' + | 'health_status' | 'oom'; + +export interface ContainerEventData { + id: number; + environmentId: number | null; + containerId: string; + containerName: string | null; + image: string | null; + action: ContainerEventAction; + actorAttributes: Record | null; + timestamp: string; + createdAt: string; +} + +export interface ContainerEventCreateData { + environmentId?: number | null; + containerId: string; + containerName?: string | null; + image?: string | null; + action: ContainerEventAction; + actorAttributes?: Record | null; + timestamp: string; // ISO string with nanosecond precision for proper event ordering +} + +export interface ContainerEventFilters { + environmentId?: number | null; + environmentIds?: number[]; // Filter by multiple environments (for permission filtering) + labels?: string[]; // Filter by environment labels (events from envs with ANY of these labels) + containerId?: string; + containerName?: string; + actions?: ContainerEventAction[]; + fromDate?: string; + toDate?: string; + limit?: number; + offset?: number; +} + +export interface ContainerEventResult { + events: ContainerEventData[]; + total: number; + limit: number; + offset: number; +} + +export async function logContainerEvent(data: ContainerEventCreateData): Promise { + // Timestamp is always a string with nanosecond precision (stored as text in both SQLite and PostgreSQL) + // For PostgreSQL, we convert to Date since the schema uses native timestamp type + const timestamp = isPostgres ? new Date(data.timestamp) : data.timestamp; + + const result = await db.insert(containerEvents).values({ + environmentId: data.environmentId ?? null, + containerId: data.containerId, + containerName: data.containerName ?? null, + image: data.image ?? null, + action: data.action, + actorAttributes: data.actorAttributes ? JSON.stringify(data.actorAttributes) : null, + timestamp + }).returning(); + + return getContainerEvent(result[0].id) as Promise; +} + +export async function getContainerEvent(id: number): Promise { + const rows = await db.select({ + id: containerEvents.id, + environmentId: containerEvents.environmentId, + containerId: containerEvents.containerId, + containerName: containerEvents.containerName, + image: containerEvents.image, + action: containerEvents.action, + actorAttributes: containerEvents.actorAttributes, + timestamp: containerEvents.timestamp, + createdAt: containerEvents.createdAt, + environmentName: environments.name, + environmentIcon: environments.icon + }) + .from(containerEvents) + .leftJoin(environments, eq(containerEvents.environmentId, environments.id)) + .where(eq(containerEvents.id, id)); + + if (!rows[0]) return undefined; + return { + ...rows[0], + actorAttributes: rows[0].actorAttributes ? JSON.parse(rows[0].actorAttributes) : null + } as ContainerEventData; +} + +export async function getContainerEvents(filters: ContainerEventFilters = {}): Promise { + let conditions: any[] = []; + + // Labels filter - find environments with matching labels first + let labelFilteredEnvIds: number[] | undefined; + if (filters.labels && filters.labels.length > 0) { + // Get environments that have ANY of the specified labels + const allEnvs = await db.select({ id: environments.id, labels: environments.labels }).from(environments); + labelFilteredEnvIds = allEnvs + .filter(env => { + if (!env.labels) return false; + try { + const envLabels = JSON.parse(env.labels) as string[]; + return filters.labels!.some(label => envLabels.includes(label)); + } catch { + return false; + } + }) + .map(env => env.id); + + // If no environments match the labels, return empty result + if (labelFilteredEnvIds.length === 0) { + return { events: [], total: 0, limit: filters.limit || 100, offset: filters.offset || 0 }; + } + } + + // Single environment filter takes precedence + if (filters.environmentId !== undefined && filters.environmentId !== null) { + conditions.push(eq(containerEvents.environmentId, filters.environmentId)); + } else if (filters.environmentIds && filters.environmentIds.length > 0) { + // Multiple environments filter (for permission-based filtering) + // If we also have label filtering, intersect the two sets + if (labelFilteredEnvIds) { + const intersected = filters.environmentIds.filter(id => labelFilteredEnvIds!.includes(id)); + if (intersected.length === 0) { + return { events: [], total: 0, limit: filters.limit || 100, offset: filters.offset || 0 }; + } + conditions.push(inArray(containerEvents.environmentId, intersected)); + } else { + conditions.push(inArray(containerEvents.environmentId, filters.environmentIds)); + } + } else if (labelFilteredEnvIds) { + // Only label filter (no environment filter) + conditions.push(inArray(containerEvents.environmentId, labelFilteredEnvIds)); + } + + if (filters.containerId) { + conditions.push(eq(containerEvents.containerId, filters.containerId)); + } + + if (filters.containerName) { + conditions.push(like(containerEvents.containerName, `%${filters.containerName}%`)); + } + + if (filters.actions && filters.actions.length > 0) { + conditions.push(inArray(containerEvents.action, filters.actions)); + } + + if (filters.fromDate) { + conditions.push(sql`${containerEvents.timestamp} >= ${filters.fromDate}`); + } + + if (filters.toDate) { + conditions.push(sql`${containerEvents.timestamp} <= ${filters.toDate}`); + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + // Get total count + const countResult = await db.select({ count: sql`count(*)` }).from(containerEvents) + .where(whereClause); + const total = Number(countResult[0]?.count) || 0; + + // Get paginated results + const limit = filters.limit || 100; + const offset = filters.offset || 0; + + const rows = await db.select({ + id: containerEvents.id, + environmentId: containerEvents.environmentId, + containerId: containerEvents.containerId, + containerName: containerEvents.containerName, + image: containerEvents.image, + action: containerEvents.action, + actorAttributes: containerEvents.actorAttributes, + timestamp: containerEvents.timestamp, + createdAt: containerEvents.createdAt, + environmentName: environments.name, + environmentIcon: environments.icon + }) + .from(containerEvents) + .leftJoin(environments, eq(containerEvents.environmentId, environments.id)) + .where(whereClause) + .orderBy(desc(containerEvents.timestamp)) + .limit(limit) + .offset(offset); + + const events = rows.map(row => ({ + ...row, + actorAttributes: row.actorAttributes ? JSON.parse(row.actorAttributes) : null + })) as ContainerEventData[]; + + return { events, total, limit, offset }; +} + +export async function getContainerEventContainers(environmentId?: number | null, environmentIds?: number[]): Promise { + let whereClause; + if (environmentId !== undefined && environmentId !== null) { + whereClause = and(isNotNull(containerEvents.containerName), eq(containerEvents.environmentId, environmentId)); + } else if (environmentIds && environmentIds.length > 0) { + whereClause = and(isNotNull(containerEvents.containerName), inArray(containerEvents.environmentId, environmentIds)); + } else { + whereClause = isNotNull(containerEvents.containerName); + } + + const results = await db.selectDistinct({ containerName: containerEvents.containerName }) + .from(containerEvents) + .where(whereClause) + .orderBy(asc(containerEvents.containerName)); + + return results.map(row => row.containerName!).filter(Boolean); +} + +export async function getContainerEventActions(): Promise { + const results = await db.selectDistinct({ action: containerEvents.action }) + .from(containerEvents) + .orderBy(asc(containerEvents.action)); + + return results.map(row => row.action); +} + +export async function deleteOldContainerEvents(keepDays = 30): Promise { + const cutoffDate = new Date(Date.now() - keepDays * 24 * 60 * 60 * 1000).toISOString(); + await db.delete(containerEvents) + .where(sql`timestamp < ${cutoffDate}`); + return 0; +} + +/** + * Run volume helper cleanup (wrapper for scheduler). + * Dynamically imports docker.ts to avoid circular dependencies. + */ +export async function runVolumeHelperCleanup(): Promise { + const { cleanupStaleVolumeHelpers, cleanupExpiredVolumeHelpers } = await import('./docker'); + await cleanupStaleVolumeHelpers(); + await cleanupExpiredVolumeHelpers(); +} + +export async function clearContainerEvents(): Promise { + await db.delete(containerEvents); +} + +export async function getContainerEventStats(environmentId?: number | null, environmentIds?: number[]): Promise<{ + total: number; + today: number; + byAction: Record; +}> { + let baseConditions: any[] = []; + if (environmentId !== undefined && environmentId !== null) { + baseConditions.push(eq(containerEvents.environmentId, environmentId)); + } else if (environmentIds && environmentIds.length > 0) { + baseConditions.push(inArray(containerEvents.environmentId, environmentIds)); + } + + const baseWhere = baseConditions.length > 0 ? and(...baseConditions) : undefined; + + // Total count + const totalResult = await db.select({ count: sql`count(*)` }) + .from(containerEvents) + .where(baseWhere); + + // Today's count - use start of today in ISO format + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + const todayConditions = [...baseConditions, sql`timestamp >= ${todayStart.toISOString()}`]; + const todayResult = await db.select({ count: sql`count(*)` }) + .from(containerEvents) + .where(and(...todayConditions)); + + // Count by action + const actionResults = await db.select({ + action: containerEvents.action, + count: sql`count(*)` + }) + .from(containerEvents) + .where(baseWhere) + .groupBy(containerEvents.action); + + const byAction: Record = {}; + for (const row of actionResults) { + byAction[row.action] = Number(row.count) || 0; + } + + return { + total: Number(totalResult[0]?.count) || 0, + today: Number(todayResult[0]?.count) || 0, + byAction + }; +} + +// ============================================================================= +// DASHBOARD PREFERENCES +// ============================================================================= + +export interface GridItem { + id: number; + x: number; + y: number; + w: number; + h: number; +} + +// ============================================================================= +// USER PREFERENCES OPERATIONS (unified key-value store) +// ============================================================================= + +export interface UserPreferenceIdentifier { + userId?: number | null; // NULL = shared (free edition) + environmentId?: number | null; // NULL = global preference + key: string; +} + +/** + * Get a user preference value + */ +export async function getUserPreference( + identifier: UserPreferenceIdentifier +): Promise { + const { userId, environmentId, key } = identifier; + + let query = db.select().from(userPreferences).where(eq(userPreferences.key, key)); + + if (userId) { + query = query.where(eq(userPreferences.userId, userId)); + } else { + query = query.where(isNull(userPreferences.userId)); + } + + if (environmentId) { + query = query.where(eq(userPreferences.environmentId, environmentId)); + } else { + query = query.where(isNull(userPreferences.environmentId)); + } + + const results = await query; + if (!results[0]) return null; + + try { + return JSON.parse(results[0].value) as T; + } catch { + return results[0].value as T; + } +} + +/** + * Set a user preference value (upsert) + */ +export async function setUserPreference( + identifier: UserPreferenceIdentifier, + value: T +): Promise { + const { userId, environmentId, key } = identifier; + const jsonValue = JSON.stringify(value); + const now = new Date().toISOString(); + + // Check if exists + const existing = await getUserPreference(identifier); + + if (existing !== null) { + // Update + let updateQuery = db.update(userPreferences) + .set({ value: jsonValue, updatedAt: now }) + .where(eq(userPreferences.key, key)); + + if (userId) { + updateQuery = updateQuery.where(eq(userPreferences.userId, userId)); + } else { + updateQuery = updateQuery.where(isNull(userPreferences.userId)); + } + + if (environmentId) { + updateQuery = updateQuery.where(eq(userPreferences.environmentId, environmentId)); + } else { + updateQuery = updateQuery.where(isNull(userPreferences.environmentId)); + } + + await updateQuery; + } else { + // Insert + await db.insert(userPreferences).values({ + userId: userId ?? null, + environmentId: environmentId ?? null, + key, + value: jsonValue + }); + } +} + +/** + * Delete a user preference + */ +export async function deleteUserPreference( + identifier: UserPreferenceIdentifier +): Promise { + const { userId, environmentId, key } = identifier; + + let query = db.delete(userPreferences).where(eq(userPreferences.key, key)); + + if (userId) { + query = query.where(eq(userPreferences.userId, userId)); + } else { + query = query.where(isNull(userPreferences.userId)); + } + + if (environmentId) { + query = query.where(eq(userPreferences.environmentId, environmentId)); + } else { + query = query.where(isNull(userPreferences.environmentId)); + } + + await query; +} + +// ============================================================================= +// DASHBOARD PREFERENCES (uses unified userPreferences table) +// ============================================================================= + +export interface DashboardPreferencesData { + userId: number | null; + gridLayout: GridItem[]; +} + +const DASHBOARD_LAYOUT_KEY = 'dashboard_layout'; + +export async function getDashboardPreferences(userId?: number | null): Promise { + const gridLayout = await getUserPreference({ + userId, + environmentId: null, + key: DASHBOARD_LAYOUT_KEY + }); + + if (!gridLayout) return null; + + return { + userId: userId ?? null, + gridLayout + }; +} + +export async function saveDashboardPreferences(data: { + userId?: number | null; + gridLayout: GridItem[]; +}): Promise { + await setUserPreference( + { userId: data.userId, environmentId: null, key: DASHBOARD_LAYOUT_KEY }, + data.gridLayout + ); + + return { + userId: data.userId ?? null, + gridLayout: data.gridLayout + }; +} + +// ============================================================================= +// SCHEDULE EXECUTION OPERATIONS +// ============================================================================= + +export type ScheduleType = 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check'; +export type ScheduleTrigger = 'cron' | 'webhook' | 'manual' | 'startup'; +export type ScheduleStatus = 'queued' | 'running' | 'success' | 'failed' | 'skipped'; + +export interface ScheduleExecutionData { + id: number; + scheduleType: ScheduleType; + scheduleId: number; + environmentId: number | null; + entityName: string; + triggeredBy: ScheduleTrigger; + triggeredAt: string; + startedAt: string | null; + completedAt: string | null; + duration: number | null; + status: ScheduleStatus; + errorMessage: string | null; + details: any | null; + logs: string | null; + createdAt: string | null; +} + +export interface ScheduleExecutionCreateData { + scheduleType: ScheduleType; + scheduleId: number; + environmentId?: number | null; + entityName: string; + triggeredBy: ScheduleTrigger; + status?: ScheduleStatus; + details?: any; +} + +export interface ScheduleExecutionUpdateData { + status?: ScheduleStatus; + startedAt?: string; + completedAt?: string; + duration?: number; + errorMessage?: string | null; + details?: any; + logs?: string; +} + +export interface ScheduleExecutionFilters { + scheduleType?: ScheduleType; + scheduleId?: number; + environmentId?: number | null; + status?: ScheduleStatus; + statuses?: ScheduleStatus[]; + triggeredBy?: ScheduleTrigger; + fromDate?: string; + toDate?: string; + limit?: number; + offset?: number; +} + +export interface ScheduleExecutionResult { + executions: ScheduleExecutionData[]; + total: number; + limit: number; + offset: number; +} + +export async function createScheduleExecution(data: ScheduleExecutionCreateData): Promise { + const now = new Date().toISOString(); + const result = await db.insert(scheduleExecutions).values({ + scheduleType: data.scheduleType, + scheduleId: data.scheduleId, + environmentId: data.environmentId ?? null, + entityName: data.entityName, + triggeredBy: data.triggeredBy, + triggeredAt: now, + status: data.status || 'queued', + details: data.details ? JSON.stringify(data.details) : null + }).returning(); + + return { + ...result[0], + details: data.details || null + } as ScheduleExecutionData; +} + +export async function updateScheduleExecution(id: number, data: ScheduleExecutionUpdateData): Promise { + const updateData: Record = {}; + + if (data.status !== undefined) updateData.status = data.status; + if (data.startedAt !== undefined) updateData.startedAt = data.startedAt; + if (data.completedAt !== undefined) updateData.completedAt = data.completedAt; + if (data.duration !== undefined) updateData.duration = data.duration; + if (data.errorMessage !== undefined) updateData.errorMessage = data.errorMessage; + if (data.details !== undefined) updateData.details = JSON.stringify(data.details); + if (data.logs !== undefined) updateData.logs = data.logs; + + await db.update(scheduleExecutions).set(updateData).where(eq(scheduleExecutions.id, id)); + return getScheduleExecution(id); +} + +export async function appendScheduleExecutionLog(id: number, logLine: string): Promise { + const execution = await getScheduleExecution(id); + if (!execution) return; + + const newLogs = execution.logs ? execution.logs + '\n' + logLine : logLine; + await db.update(scheduleExecutions).set({ logs: newLogs }).where(eq(scheduleExecutions.id, id)); +} + +export async function getScheduleExecution(id: number): Promise { + const results = await db.select().from(scheduleExecutions).where(eq(scheduleExecutions.id, id)); + if (!results[0]) return undefined; + return { + ...results[0], + details: results[0].details ? JSON.parse(results[0].details) : null + } as ScheduleExecutionData; +} + +export async function deleteScheduleExecution(id: number): Promise { + await db.delete(scheduleExecutions).where(eq(scheduleExecutions.id, id)); +} + +export async function getScheduleExecutions(filters: ScheduleExecutionFilters = {}): Promise { + const conditions: any[] = []; + + if (filters.scheduleType) { + conditions.push(eq(scheduleExecutions.scheduleType, filters.scheduleType)); + } + if (filters.scheduleId !== undefined) { + conditions.push(eq(scheduleExecutions.scheduleId, filters.scheduleId)); + } + if (filters.environmentId !== undefined) { + if (filters.environmentId === null) { + conditions.push(isNull(scheduleExecutions.environmentId)); + } else { + conditions.push(eq(scheduleExecutions.environmentId, filters.environmentId)); + } + } + if (filters.status) { + conditions.push(eq(scheduleExecutions.status, filters.status)); + } + if (filters.statuses && filters.statuses.length > 0) { + conditions.push(inArray(scheduleExecutions.status, filters.statuses)); + } + if (filters.triggeredBy) { + conditions.push(eq(scheduleExecutions.triggeredBy, filters.triggeredBy)); + } + if (filters.fromDate) { + conditions.push(sql`triggered_at >= ${filters.fromDate}`); + } + if (filters.toDate) { + conditions.push(sql`triggered_at <= ${filters.toDate}`); + } + + const limit = filters.limit || 50; + const offset = filters.offset || 0; + + // Get total count + const countResult = await db + .select({ count: sql`count(*)` }) + .from(scheduleExecutions) + .where(conditions.length > 0 ? and(...conditions) : undefined); + const total = Number(countResult[0]?.count || 0); + + // Get paginated results (without full logs for list view) + const results = await db + .select({ + id: scheduleExecutions.id, + scheduleType: scheduleExecutions.scheduleType, + scheduleId: scheduleExecutions.scheduleId, + environmentId: scheduleExecutions.environmentId, + entityName: scheduleExecutions.entityName, + triggeredBy: scheduleExecutions.triggeredBy, + triggeredAt: scheduleExecutions.triggeredAt, + startedAt: scheduleExecutions.startedAt, + completedAt: scheduleExecutions.completedAt, + duration: scheduleExecutions.duration, + status: scheduleExecutions.status, + errorMessage: scheduleExecutions.errorMessage, + details: scheduleExecutions.details, + createdAt: scheduleExecutions.createdAt + }) + .from(scheduleExecutions) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(scheduleExecutions.triggeredAt)) + .limit(limit) + .offset(offset); + + return { + executions: results.map(row => ({ + ...row, + details: row.details ? JSON.parse(row.details) : null, + logs: null // Don't include logs in list view + })) as ScheduleExecutionData[], + total, + limit, + offset + }; +} + +export async function getLastExecutionForSchedule( + scheduleType: ScheduleType, + scheduleId: number +): Promise { + const results = await db + .select() + .from(scheduleExecutions) + .where(and( + eq(scheduleExecutions.scheduleType, scheduleType), + eq(scheduleExecutions.scheduleId, scheduleId) + )) + .orderBy(desc(scheduleExecutions.triggeredAt)) + .limit(1); + + if (!results[0]) return undefined; + return { + ...results[0], + details: results[0].details ? JSON.parse(results[0].details) : null + } as ScheduleExecutionData; +} + +export async function getRecentExecutionsForSchedule( + scheduleType: ScheduleType, + scheduleId: number, + limit = 5 +): Promise { + const results = await db + .select({ + id: scheduleExecutions.id, + scheduleType: scheduleExecutions.scheduleType, + scheduleId: scheduleExecutions.scheduleId, + environmentId: scheduleExecutions.environmentId, + entityName: scheduleExecutions.entityName, + triggeredBy: scheduleExecutions.triggeredBy, + triggeredAt: scheduleExecutions.triggeredAt, + startedAt: scheduleExecutions.startedAt, + completedAt: scheduleExecutions.completedAt, + duration: scheduleExecutions.duration, + status: scheduleExecutions.status, + errorMessage: scheduleExecutions.errorMessage, + details: scheduleExecutions.details, + createdAt: scheduleExecutions.createdAt + }) + .from(scheduleExecutions) + .where(and( + eq(scheduleExecutions.scheduleType, scheduleType), + eq(scheduleExecutions.scheduleId, scheduleId) + )) + .orderBy(desc(scheduleExecutions.triggeredAt)) + .limit(limit); + + return results.map(row => ({ + ...row, + details: row.details ? JSON.parse(row.details) : null, + logs: null + })) as ScheduleExecutionData[]; +} + +export async function cleanupOldExecutions(retentionDays: number): Promise { + const cutoffDate = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString(); + const result = await db.delete(scheduleExecutions) + .where(sql`triggered_at < ${cutoffDate}`); + return 0; // SQLite/PG don't return count consistently +} + +// Settings helpers for retention +const SCHEDULE_RETENTION_KEY = 'schedule_retention_days'; +const EVENT_RETENTION_KEY = 'event_retention_days'; +const DEFAULT_RETENTION_DAYS = 30; +const SCHEDULE_CLEANUP_CRON_KEY = 'schedule_cleanup_cron'; +const EVENT_CLEANUP_CRON_KEY = 'event_cleanup_cron'; +const SCHEDULE_CLEANUP_ENABLED_KEY = 'schedule_cleanup_enabled'; +const EVENT_CLEANUP_ENABLED_KEY = 'event_cleanup_enabled'; +const DEFAULT_SCHEDULE_CLEANUP_CRON = '0 3 * * *'; // Daily at 3 AM +const DEFAULT_EVENT_CLEANUP_CRON = '30 3 * * *'; // Daily at 3:30 AM + +export async function getScheduleRetentionDays(): Promise { + const result = await db.select().from(settings).where(eq(settings.key, SCHEDULE_RETENTION_KEY)); + if (result[0]) { + return parseInt(result[0].value, 10) || DEFAULT_RETENTION_DAYS; + } + return DEFAULT_RETENTION_DAYS; +} + +export async function setScheduleRetentionDays(days: number): Promise { + const existing = await db.select().from(settings).where(eq(settings.key, SCHEDULE_RETENTION_KEY)); + if (existing.length > 0) { + await db.update(settings) + .set({ value: String(days), updatedAt: new Date().toISOString() }) + .where(eq(settings.key, SCHEDULE_RETENTION_KEY)); + } else { + await db.insert(settings).values({ + key: SCHEDULE_RETENTION_KEY, + value: String(days) + }); + } +} + +export async function getEventRetentionDays(): Promise { + const result = await db.select().from(settings).where(eq(settings.key, EVENT_RETENTION_KEY)); + if (result[0]) { + return parseInt(result[0].value, 10) || DEFAULT_RETENTION_DAYS; + } + return DEFAULT_RETENTION_DAYS; +} + +export async function setEventRetentionDays(days: number): Promise { + const existing = await db.select().from(settings).where(eq(settings.key, EVENT_RETENTION_KEY)); + if (existing.length > 0) { + await db.update(settings) + .set({ value: String(days), updatedAt: new Date().toISOString() }) + .where(eq(settings.key, EVENT_RETENTION_KEY)); + } else { + await db.insert(settings).values({ + key: EVENT_RETENTION_KEY, + value: String(days) + }); + } +} + +export async function getScheduleCleanupCron(): Promise { + const result = await db.select().from(settings).where(eq(settings.key, SCHEDULE_CLEANUP_CRON_KEY)); + if (result[0]) { + return result[0].value || DEFAULT_SCHEDULE_CLEANUP_CRON; + } + return DEFAULT_SCHEDULE_CLEANUP_CRON; +} + +export async function setScheduleCleanupCron(cron: string): Promise { + const existing = await db.select().from(settings).where(eq(settings.key, SCHEDULE_CLEANUP_CRON_KEY)); + if (existing.length > 0) { + await db.update(settings) + .set({ value: cron, updatedAt: new Date().toISOString() }) + .where(eq(settings.key, SCHEDULE_CLEANUP_CRON_KEY)); + } else { + await db.insert(settings).values({ + key: SCHEDULE_CLEANUP_CRON_KEY, + value: cron + }); + } +} + +export async function getEventCleanupCron(): Promise { + const result = await db.select().from(settings).where(eq(settings.key, EVENT_CLEANUP_CRON_KEY)); + if (result[0]) { + return result[0].value || DEFAULT_EVENT_CLEANUP_CRON; + } + return DEFAULT_EVENT_CLEANUP_CRON; +} + +export async function setEventCleanupCron(cron: string): Promise { + const existing = await db.select().from(settings).where(eq(settings.key, EVENT_CLEANUP_CRON_KEY)); + if (existing.length > 0) { + await db.update(settings) + .set({ value: cron, updatedAt: new Date().toISOString() }) + .where(eq(settings.key, EVENT_CLEANUP_CRON_KEY)); + } else { + await db.insert(settings).values({ + key: EVENT_CLEANUP_CRON_KEY, + value: cron + }); + } +} + +export async function getScheduleCleanupEnabled(): Promise { + const result = await db.select().from(settings).where(eq(settings.key, SCHEDULE_CLEANUP_ENABLED_KEY)); + if (result[0]) { + return result[0].value === 'true'; + } + return true; // Enabled by default +} + +export async function setScheduleCleanupEnabled(enabled: boolean): Promise { + const existing = await db.select().from(settings).where(eq(settings.key, SCHEDULE_CLEANUP_ENABLED_KEY)); + if (existing.length > 0) { + await db.update(settings) + .set({ value: enabled ? 'true' : 'false', updatedAt: new Date().toISOString() }) + .where(eq(settings.key, SCHEDULE_CLEANUP_ENABLED_KEY)); + } else { + await db.insert(settings).values({ + key: SCHEDULE_CLEANUP_ENABLED_KEY, + value: enabled ? 'true' : 'false' + }); + } +} + +export async function getEventCleanupEnabled(): Promise { + const result = await db.select().from(settings).where(eq(settings.key, EVENT_CLEANUP_ENABLED_KEY)); + if (result[0]) { + return result[0].value === 'true'; + } + return true; // Enabled by default +} + +export async function setEventCleanupEnabled(enabled: boolean): Promise { + const existing = await db.select().from(settings).where(eq(settings.key, EVENT_CLEANUP_ENABLED_KEY)); + if (existing.length > 0) { + await db.update(settings) + .set({ value: enabled ? 'true' : 'false', updatedAt: new Date().toISOString() }) + .where(eq(settings.key, EVENT_CLEANUP_ENABLED_KEY)); + } else { + await db.insert(settings).values({ + key: EVENT_CLEANUP_ENABLED_KEY, + value: enabled ? 'true' : 'false' + }); + } +} + +// ============================================================================= +// ENVIRONMENT UPDATE CHECK SETTINGS +// ============================================================================= + +export interface EnvUpdateCheckSettings { + enabled: boolean; + cron: string; + autoUpdate: boolean; + vulnerabilityCriteria: VulnerabilityCriteria; +} + +export async function getEnvUpdateCheckSettings(envId: number): Promise { + const key = `env_${envId}_update_check`; + const result = await db.select().from(settings).where(eq(settings.key, key)); + if (!result[0]) return null; + try { + return JSON.parse(result[0].value); + } catch { + return null; + } +} + +export async function setEnvUpdateCheckSettings(envId: number, config: EnvUpdateCheckSettings): Promise { + const key = `env_${envId}_update_check`; + const value = JSON.stringify(config); + const existing = await db.select().from(settings).where(eq(settings.key, key)); + if (existing.length > 0) { + await db.update(settings) + .set({ value, updatedAt: new Date().toISOString() }) + .where(eq(settings.key, key)); + } else { + await db.insert(settings).values({ key, value }); + } +} + +export async function deleteEnvUpdateCheckSettings(envId: number): Promise { + const key = `env_${envId}_update_check`; + await db.delete(settings).where(eq(settings.key, key)); +} + +export async function getAllEnvUpdateCheckSettings(): Promise> { + const rows = await db.select().from(settings).where(sql`${settings.key} LIKE 'env_%_update_check'`); + const results: Array<{ envId: number; settings: EnvUpdateCheckSettings }> = []; + for (const row of rows) { + try { + const match = row.key.match(/^env_(\d+)_update_check$/); + if (!match) continue; + const envId = parseInt(match[1]); + const config = JSON.parse(row.value) as EnvUpdateCheckSettings; + if (config.enabled) { + results.push({ envId, settings: config }); + } + } catch { + // Skip invalid entries + } + } + return results; +} + +// ============================================================================= +// ENVIRONMENT TIMEZONE SETTINGS +// ============================================================================= + +export async function getEnvironmentTimezone(envId: number): Promise { + const value = await getSetting(`env_${envId}_timezone`); + return value || 'UTC'; +} + +export async function setEnvironmentTimezone(envId: number, timezone: string): Promise { + await setSetting(`env_${envId}_timezone`, timezone); +} + +// ============================================================================= +// GLOBAL DEFAULT TIMEZONE +// ============================================================================= + +/** + * Get the global default timezone (used as default for new environments). + * Falls back to 'UTC' if not set. + */ +export async function getDefaultTimezone(): Promise { + const value = await getSetting('default_timezone'); + return value || 'UTC'; +} + +/** + * Set the global default timezone. + */ +export async function setDefaultTimezone(timezone: string): Promise { + await setSetting('default_timezone', timezone); +} + +// ============================================================================= +// STACK ENVIRONMENT VARIABLES OPERATIONS +// ============================================================================= + +export interface StackEnvVarData { + id: number; + stackName: string; + environmentId: number | null; + key: string; + value: string; + isSecret: boolean; + createdAt: string; + updatedAt: string; +} + +/** + * Get all environment variables for a stack. + * @param stackName - Name of the stack + * @param environmentId - Optional environment ID to filter by + * @param maskSecrets - If true, masks secret values with '***' (default: true) + */ +export async function getStackEnvVars( + stackName: string, + environmentId?: number | null, + maskSecrets: boolean = true +): Promise { + let results; + + if (environmentId !== undefined) { + if (environmentId === null) { + results = await db.select().from(stackEnvironmentVariables) + .where(and( + eq(stackEnvironmentVariables.stackName, stackName), + isNull(stackEnvironmentVariables.environmentId) + )) + .orderBy(asc(stackEnvironmentVariables.key)); + } else { + results = await db.select().from(stackEnvironmentVariables) + .where(and( + eq(stackEnvironmentVariables.stackName, stackName), + eq(stackEnvironmentVariables.environmentId, environmentId) + )) + .orderBy(asc(stackEnvironmentVariables.key)); + } + } else { + results = await db.select().from(stackEnvironmentVariables) + .where(eq(stackEnvironmentVariables.stackName, stackName)) + .orderBy(asc(stackEnvironmentVariables.key)); + } + + return results.map(row => ({ + id: row.id, + stackName: row.stackName, + environmentId: row.environmentId, + key: row.key, + value: maskSecrets && row.isSecret ? '***' : row.value, + isSecret: row.isSecret ?? false, + createdAt: row.createdAt ?? new Date().toISOString(), + updatedAt: row.updatedAt ?? new Date().toISOString() + })); +} + +/** + * Get stack environment variables as a key-value record (for deployment). + * Does NOT mask secrets - returns raw values for use in Docker deployment. + * @param stackName - Name of the stack + * @param environmentId - Optional environment ID + */ +export async function getStackEnvVarsAsRecord( + stackName: string, + environmentId?: number | null +): Promise> { + const vars = await getStackEnvVars(stackName, environmentId, false); + return Object.fromEntries(vars.map(v => [v.key, v.value])); +} + +/** + * Set/replace all environment variables for a stack. + * Deletes existing vars and inserts new ones in a transaction-like manner. + * @param stackName - Name of the stack + * @param environmentId - Optional environment ID + * @param variables - Array of {key, value, isSecret} objects + */ +export async function setStackEnvVars( + stackName: string, + environmentId: number | null, + variables: Array<{ key: string; value: string; isSecret?: boolean }> +): Promise { + // Delete existing vars for this stack/environment combo + if (environmentId === null) { + await db.delete(stackEnvironmentVariables) + .where(and( + eq(stackEnvironmentVariables.stackName, stackName), + isNull(stackEnvironmentVariables.environmentId) + )); + } else { + await db.delete(stackEnvironmentVariables) + .where(and( + eq(stackEnvironmentVariables.stackName, stackName), + eq(stackEnvironmentVariables.environmentId, environmentId) + )); + } + + // Insert new vars + if (variables.length > 0) { + const now = new Date().toISOString(); + await db.insert(stackEnvironmentVariables).values( + variables.map(v => ({ + stackName, + environmentId, + key: v.key, + value: v.value, + isSecret: v.isSecret ?? false, + createdAt: now, + updatedAt: now + })) + ); + } +} + +/** + * Get count of environment variables for a stack. + * @param stackName - Name of the stack + * @param environmentId - Optional environment ID + */ +export async function getStackEnvVarsCount( + stackName: string, + environmentId?: number | null +): Promise { + const vars = await getStackEnvVars(stackName, environmentId, false); + return vars.length; +} + +/** + * Delete all environment variables for a stack. + * @param stackName - Name of the stack + * @param environmentId - Optional environment ID (null = delete for all envs) + */ +export async function deleteStackEnvVars( + stackName: string, + environmentId?: number | null +): Promise { + if (environmentId === undefined) { + // Delete all env vars for this stack (all environments) + await db.delete(stackEnvironmentVariables) + .where(eq(stackEnvironmentVariables.stackName, stackName)); + } else if (environmentId === null) { + await db.delete(stackEnvironmentVariables) + .where(and( + eq(stackEnvironmentVariables.stackName, stackName), + isNull(stackEnvironmentVariables.environmentId) + )); + } else { + await db.delete(stackEnvironmentVariables) + .where(and( + eq(stackEnvironmentVariables.stackName, stackName), + eq(stackEnvironmentVariables.environmentId, environmentId) + )); + } +} + +/** + * Get all stacks with their environment variable counts. + * Useful for displaying env var badges in the stacks list. + */ +export async function getAllStacksEnvVarsCounts(): Promise> { + const results = await db.select({ + stackName: stackEnvironmentVariables.stackName + }).from(stackEnvironmentVariables); + + const counts = new Map(); + for (const row of results) { + counts.set(row.stackName, (counts.get(row.stackName) || 0) + 1); + } + return counts; +} + +// ============================================================================= +// PENDING CONTAINER UPDATES OPERATIONS +// ============================================================================= + +/** + * Get all pending container updates for an environment. + */ +export async function getPendingContainerUpdates(environmentId: number): Promise { + return await db.select().from(pendingContainerUpdates) + .where(eq(pendingContainerUpdates.environmentId, environmentId)); +} + +/** + * Clear all pending container updates for an environment. + * Called before checking for updates to ensure fresh state. + */ +export async function clearPendingContainerUpdates(environmentId: number): Promise { + await db.delete(pendingContainerUpdates) + .where(eq(pendingContainerUpdates.environmentId, environmentId)); +} + +/** + * Add a pending container update. + * Uses upsert to avoid duplicates. + */ +export async function addPendingContainerUpdate( + environmentId: number, + containerId: string, + containerName: string, + currentImage: string +): Promise { + // Use insert with onConflictDoUpdate for upsert behavior + await db.insert(pendingContainerUpdates) + .values({ + environmentId, + containerId, + containerName, + currentImage, + checkedAt: new Date().toISOString() + }) + .onConflictDoUpdate({ + target: [pendingContainerUpdates.environmentId, pendingContainerUpdates.containerId], + set: { + containerName, + currentImage, + checkedAt: new Date().toISOString() + } + }); +} + +/** + * Remove a pending container update (after the container is updated). + */ +export async function removePendingContainerUpdate(environmentId: number, containerId: string): Promise { + await db.delete(pendingContainerUpdates) + .where(and( + eq(pendingContainerUpdates.environmentId, environmentId), + eq(pendingContainerUpdates.containerId, containerId) + )); +} diff --git a/lib/server/db/.DS_Store b/lib/server/db/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..cff8ab3a459be465be199d3ead71ef17b906b60f GIT binary patch literal 6148 zcmeHKISv9b4733uBpOP}e1RWC2wuPkI3)@Y1)|@IckwjFM*&*spa6{}XA;MgC{wK0 zBBImFb|NwokpbLLt~RvI_RU+?$%q2sIO8a*?Rj%ppH7EX_UnLg`*N0z>}B`Jw+$K< zpaN8Y3Qz$m@NosQ#14iZKbZ$o0V?q83fT9dzzu6+6X>4~4Bi3&dkDK>?!5%CSO8cP zn?OWh8dP9VHCqe~I^resYGM-@bkS@+G;h}IP}Fb7`Nh*kYamA|Kn0!^=*Dtn^?we3 z)Bit{xS|47;I9 { + try { + // Create schema (tables) + await sql.run(readSql('schema.sql')); + + // Create indexes + await sql.run(readSql('indexes.sql')); + + // Insert seed data + await sql.run(readSql('seed.sql')); + + // Update system roles + await sql.run(readSql('system-roles.sql')); + + // Run maintenance + await sql.run(readSql('maintenance.sql')); + + console.log(`Database initialized successfully (${isPostgres ? 'PostgreSQL' : 'SQLite'})`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error('Failed to initialize database schema:', message); + throw error; + } +} + +// Create and export the database connection +export const sql = createConnection(); + +// Initialize schema (runs async but we handle it) +initializeSchema(sql).catch((error) => { + console.error('Database initialization failed:', error); + process.exit(1); +}); + +/** + * Helper to convert SQLite integer booleans to JS booleans. + * PostgreSQL returns actual booleans, SQLite returns 0/1. + */ +export function toBool(value: any): boolean { + if (typeof value === 'boolean') return value; + return Boolean(value); +} + +/** + * Helper to convert JS boolean to database value. + * PostgreSQL uses boolean, SQLite uses 0/1. + */ +export function fromBool(value: boolean): boolean | number { + return isPostgres ? value : (value ? 1 : 0); +} diff --git a/lib/server/db/drizzle.ts b/lib/server/db/drizzle.ts new file mode 100644 index 0000000..a1e08d3 --- /dev/null +++ b/lib/server/db/drizzle.ts @@ -0,0 +1,1013 @@ +/** + * Drizzle Database Connection Module + * + * Provides a unified database connection using Drizzle ORM. + * Supports both SQLite (default) and PostgreSQL (via DATABASE_URL). + * + * Features: + * - Pre-flight connection test + * - Migration state introspection + * - Progress logging during startup + * - Fail-fast on migration errors (configurable) + * - Clear success/failure indicators + * + * Environment Variables: + * - DATABASE_URL: PostgreSQL connection string (omit for SQLite) + * - DATA_DIR: Data directory for SQLite database (default: ./data) + * - DB_FAIL_ON_MIGRATION_ERROR: Exit on migration failure (default: true) + * - DB_VERBOSE_LOGGING: Enable verbose logging (default: false) + * - SKIP_MIGRATIONS: Skip migrations entirely (default: false) + */ + +import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { eq } from 'drizzle-orm'; +import { createHash } from 'node:crypto'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// ============================================================================= +// CONFIGURATION +// ============================================================================= + +/** + * Environment variable configuration for database behavior. + */ +const getConfig = () => ({ + databaseUrl: process.env.DATABASE_URL, + dataDir: process.env.DATA_DIR || './data', + // Migration behavior + failOnMigrationError: process.env.DB_FAIL_ON_MIGRATION_ERROR !== 'false', + verboseLogging: process.env.DB_VERBOSE_LOGGING === 'true', + skipMigrations: process.env.SKIP_MIGRATIONS === 'true' +}); + +// Database type detection +const getDatabaseType = () => { + const url = process.env.DATABASE_URL; + return !!(url && (url.startsWith('postgres://') || url.startsWith('postgresql://'))); +}; + +// Export flags for external use +export const isPostgres = getDatabaseType(); +export const isSqlite = !isPostgres; + +// ============================================================================= +// LOGGING UTILITIES +// ============================================================================= + +const SEPARATOR = '='.repeat(60); +const WARNING_SEPARATOR = '!'.repeat(60); + +function logHeader(title: string): void { + console.log('\n' + SEPARATOR); + console.log(title); + console.log(SEPARATOR); +} + +function logSuccess(message: string): void { + console.log(`[OK] ${message}`); +} + +function logInfo(message: string): void { + console.log(` ${message}`); +} + +function logWarning(message: string): void { + console.log(`[!!] ${message}`); +} + +function logStep(step: string): void { + console.log(` -> ${step}`); +} + +function maskPassword(url: string): string { + try { + const parsed = new URL(url); + if (parsed.password) { + parsed.password = '***'; + } + return parsed.toString(); + } catch { + return url.replace(/:[^:@]+@/, ':***@'); + } +} + +// ============================================================================= +// VALIDATION +// ============================================================================= + +/** + * Validate PostgreSQL connection URL format. + */ +function validatePostgresUrl(url: string): void { + try { + const parsed = new URL(url); + + if (parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:') { + exitWithError(`Invalid protocol "${parsed.protocol}". Expected "postgres:" or "postgresql:"`, url); + } + + if (!parsed.hostname) { + exitWithError('Missing hostname in DATABASE_URL', url); + } + + if (!parsed.pathname || parsed.pathname === '/') { + exitWithError('Missing database name in DATABASE_URL', url); + } + } catch { + exitWithError('Invalid URL format', url); + } +} + +/** + * Print connection error and exit. + */ +function exitWithError(error: string, url?: string): never { + console.error('\n' + SEPARATOR); + console.error('DATABASE CONNECTION ERROR'); + console.error(SEPARATOR); + console.error(`\nError: ${error}`); + + if (url) { + console.error(`\nProvided URL: ${maskPassword(url)}`); + } + + console.error('\n' + '-'.repeat(60)); + console.error('DATABASE_URL format:'); + console.error('-'.repeat(60)); + console.error('\n postgres://USER:PASSWORD@HOST:PORT/DATABASE'); + console.error('\nExamples:'); + console.error(' postgres://dockhand:secret@localhost:5432/dockhand'); + console.error(' postgres://admin:p4ssw0rd@192.168.1.100:5432/dockhand'); + console.error(' postgresql://user:pass@db.example.com/mydb?sslmode=require'); + console.error('\n' + '-'.repeat(60)); + console.error('To use SQLite instead, remove the DATABASE_URL environment variable.'); + console.error(SEPARATOR + '\n'); + + process.exit(1); +} + +// ============================================================================= +// MIGRATION STATE +// ============================================================================= + +interface MigrationEntry { + idx: number; + version: string; + when: number; + tag: string; + breakpoints: boolean; +} + +interface MigrationJournal { + version: string; + dialect: string; + entries: MigrationEntry[]; +} + +interface AppliedMigration { + hash: string; + createdAt?: number; +} + +interface MigrationState { + journalExists: boolean; + allMigrations: string[]; + appliedMigrations: string[]; + pendingMigrations: string[]; + tableExists: boolean; +} + +/** + * Read the migration journal to get list of all migrations. + */ +function readMigrationJournal(migrationsFolder: string): MigrationJournal | null { + try { + const journalPath = join(migrationsFolder, 'meta', '_journal.json'); + if (!existsSync(journalPath)) { + return null; + } + const content = readFileSync(journalPath, 'utf-8'); + return JSON.parse(content); + } catch (error) { + const config = getConfig(); + if (config.verboseLogging) { + console.error('Failed to read migration journal:', error); + } + return null; + } +} + +/** + * Get applied migrations from the database. + * Note: Drizzle uses 'drizzle' schema for PostgreSQL. + */ +async function getAppliedMigrations(client: any, postgres: boolean): Promise { + try { + if (postgres) { + // PostgreSQL using Bun.sql - note the 'drizzle' schema + const result = await client`SELECT hash, created_at FROM drizzle.__drizzle_migrations ORDER BY id`; + return result.map((r: any) => ({ hash: r.hash, createdAt: r.created_at })); + } else { + // SQLite using bun:sqlite + const stmt = client.prepare('SELECT hash, created_at FROM __drizzle_migrations ORDER BY id'); + return stmt.all().map((r: any) => ({ hash: r.hash, createdAt: r.created_at })); + } + } catch { + // Table doesn't exist - fresh database + return []; + } +} + +/** + * Check if the migrations table exists. + * Note: Drizzle creates the migrations table in the 'drizzle' schema for PostgreSQL. + */ +async function checkMigrationsTableExists(client: any, postgres: boolean): Promise { + try { + if (postgres) { + // Drizzle uses 'drizzle' schema for PostgreSQL + const result = await client` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'drizzle' + AND table_name = '__drizzle_migrations' + ) as exists + `; + return result[0]?.exists === true; + } else { + const stmt = client.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'" + ); + const result = stmt.all(); + return result.length > 0; + } + } catch { + return false; + } +} + +/** + * Compute SHA-256 hash of a file (matching Drizzle's migration tracking). + */ +function computeFileHash(filePath: string): string { + const content = readFileSync(filePath); + return createHash('sha256').update(content).digest('hex'); +} + +/** + * Get migration files with their hashes. + */ +function getMigrationFiles(migrationsFolder: string): { tag: string; hash: string }[] { + const journal = readMigrationJournal(migrationsFolder); + if (!journal) return []; + + return journal.entries.map(entry => { + const sqlFile = join(migrationsFolder, `${entry.tag}.sql`); + if (!existsSync(sqlFile)) { + return { tag: entry.tag, hash: '' }; + } + const hash = computeFileHash(sqlFile); + return { tag: entry.tag, hash }; + }); +} + +/** + * Get full migration state including pending migrations. + */ +async function getMigrationState( + client: any, + postgres: boolean, + migrationsFolder: string +): Promise { + const journal = readMigrationJournal(migrationsFolder); + const migrations = getMigrationFiles(migrationsFolder); + const allMigrations = migrations.map(m => m.tag); + + const tableExists = await checkMigrationsTableExists(client, postgres); + const applied = await getAppliedMigrations(client, postgres); + const appliedHashes = new Set(applied.map(a => a.hash)); + + // Compare file hashes to determine pending migrations + const pendingMigrations = migrations + .filter(m => !appliedHashes.has(m.hash)) + .map(m => m.tag); + + return { + journalExists: journal !== null, + allMigrations, + appliedMigrations: applied.map(a => a.hash), + pendingMigrations, + tableExists + }; +} + +// ============================================================================= +// SCHEMA HEALTH CHECK +// ============================================================================= + +const REQUIRED_TABLES = [ + 'environments', + 'hawser_tokens', + 'registries', + 'settings', + 'stack_events', + 'host_metrics', + 'config_sets', + 'auto_update_settings', + 'notification_settings', + 'environment_notifications', + 'auth_settings', + 'users', + 'sessions', + 'ldap_config', + 'oidc_config', + 'roles', + 'user_roles', + 'git_credentials', + 'git_repositories', + 'git_stacks', + 'stack_sources', + 'vulnerability_scans', + 'audit_logs', + 'container_events', + 'schedule_executions', + 'user_preferences' +]; + +/** + * Check if a table exists in the database. + */ +async function tableExists(client: any, postgres: boolean, tableName: string): Promise { + try { + if (postgres) { + const result = await client` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = ${tableName} + ) as exists + `; + return result[0]?.exists === true; + } else { + const stmt = client.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?" + ); + const result = stmt.all(tableName); + return result.length > 0; + } + } catch { + return false; + } +} + +export interface SchemaHealthResult { + healthy: boolean; + database: 'sqlite' | 'postgresql'; + connection: string; + migrationsTable: boolean; + appliedMigrations: number; + pendingMigrations: number; + schemaVersion: string | null; + tables: { + expected: number; + found: number; + missing: string[]; + }; + timestamp: string; +} + +/** + * Check the health of the database schema. + * Exported for use by the health endpoint. + */ +export async function checkSchemaHealth(): Promise { + const config = getConfig(); + const migrationsFolder = isPostgres ? './drizzle-pg' : './drizzle'; + + // Get connection string for display + let connectionDisplay: string; + if (isPostgres) { + connectionDisplay = maskPassword(config.databaseUrl || ''); + } else { + connectionDisplay = join(config.dataDir, 'db', 'dockhand.db'); + } + + // Check migration state + const migrationState = await getMigrationState(rawClient, isPostgres, migrationsFolder); + + // Check table existence + const missingTables: string[] = []; + for (const table of REQUIRED_TABLES) { + const exists = await tableExists(rawClient, isPostgres, table); + if (!exists) { + missingTables.push(table); + } + } + + // Get schema version from journal + const journal = readMigrationJournal(migrationsFolder); + const lastMigration = journal?.entries[journal.entries.length - 1]; + const schemaVersion = lastMigration?.tag ?? null; + + return { + healthy: missingTables.length === 0 && migrationState.pendingMigrations.length === 0, + database: isPostgres ? 'postgresql' : 'sqlite', + connection: connectionDisplay, + migrationsTable: migrationState.tableExists, + appliedMigrations: migrationState.appliedMigrations.length, + pendingMigrations: migrationState.pendingMigrations.length, + schemaVersion, + tables: { + expected: REQUIRED_TABLES.length, + found: REQUIRED_TABLES.length - missingTables.length, + missing: missingTables + }, + timestamp: new Date().toISOString() + }; +} + +// ============================================================================= +// MIGRATION RUNNER +// ============================================================================= + +interface MigrationResult { + success: boolean; + error?: string; + applied: number; + skipped: boolean; +} + +/** + * Run database migrations with comprehensive logging and error handling. + */ +async function runMigrations( + database: any, + client: any, + postgres: boolean, + migrationsFolder: string +): Promise { + const config = getConfig(); + + if (config.skipMigrations) { + logInfo('Migrations skipped (SKIP_MIGRATIONS=true)'); + return { success: true, applied: 0, skipped: true }; + } + + // Get migration state + const state = await getMigrationState(client, postgres, migrationsFolder); + + if (!state.journalExists) { + logWarning('Migration journal not found - this may be a development setup'); + return { success: true, applied: 0, skipped: true }; + } + + logInfo(`Total migrations: ${state.allMigrations.length}`); + logInfo(`Applied: ${state.appliedMigrations.length}`); + logInfo(`Pending: ${state.pendingMigrations.length}`); + + if (state.pendingMigrations.length === 0) { + logSuccess('Database schema is up to date'); + return { success: true, applied: 0, skipped: false }; + } + + // Log pending migrations + console.log('\nPending migrations:'); + for (const migration of state.pendingMigrations) { + logStep(migration); + } + console.log(''); + + // Run migrations + try { + if (postgres) { + const { migrate } = await import('drizzle-orm/bun-sql/migrator'); + await migrate(database, { migrationsFolder }); + } else { + const { migrate } = await import('drizzle-orm/bun-sqlite/migrator'); + await migrate(database, { migrationsFolder }); + } + + logSuccess(`Applied ${state.pendingMigrations.length} migration(s)`); + return { success: true, applied: state.pendingMigrations.length, skipped: false }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: message, applied: 0, skipped: false }; + } +} + +/** + * Handle migration failure with detailed error messages and recovery instructions. + */ +function handleMigrationFailure(error: string, postgres: boolean): never { + const config = getConfig(); + + console.error('\n' + WARNING_SEPARATOR); + console.error('MIGRATION FAILED'); + console.error(WARNING_SEPARATOR); + console.error(`\nError: ${error}`); + + // Provide specific guidance based on error type + if (error.includes('already exists')) { + console.error('\n' + '-'.repeat(60)); + console.error('DIAGNOSIS: Table or column already exists'); + console.error('-'.repeat(60)); + console.error('\nThis usually happens when:'); + console.error(' 1. A previous migration was partially applied'); + console.error(' 2. The database was modified manually'); + console.error(' 3. Migrations were regenerated without resetting the database'); + console.error('\nRECOVERY OPTIONS:'); + console.error('\n Option 1: Reset the database (DELETES ALL DATA)'); + if (postgres) { + console.error(' docker exec postgres psql -U -d postgres -c "DROP DATABASE dockhand;"'); + console.error(' docker exec postgres psql -U -d postgres -c "CREATE DATABASE dockhand;"'); + } else { + console.error(' rm -f ./data/db/dockhand.db'); + } + console.error('\n Option 2: Mark migration as applied (if schema is correct)'); + if (postgres) { + console.error(' INSERT INTO __drizzle_migrations (hash, created_at)'); + console.error(" VALUES ('', NOW());"); + } else { + console.error(' INSERT INTO __drizzle_migrations (hash, created_at)'); + console.error(" VALUES ('', strftime('%s', 'now') * 1000);"); + } + console.error('\n Option 3: Use emergency scripts'); + console.error(' docker exec dockhand /app/scripts/emergency/reset-db.sh'); + } else if (error.includes('does not exist') || error.includes('no such table')) { + console.error('\n' + '-'.repeat(60)); + console.error('DIAGNOSIS: Missing table or column'); + console.error('-'.repeat(60)); + console.error('\nThis usually happens when:'); + console.error(' 1. The database was created but migrations never ran'); + console.error(' 2. The __drizzle_migrations table is out of sync'); + console.error('\nRECOVERY OPTIONS:'); + console.error('\n Option 1: Clear migration history and retry'); + if (postgres) { + console.error(' TRUNCATE TABLE __drizzle_migrations;'); + } else { + console.error(' DELETE FROM __drizzle_migrations;'); + } + console.error(' Then restart the application.'); + } else if (error.includes('connection') || error.includes('ECONNREFUSED')) { + console.error('\n' + '-'.repeat(60)); + console.error('DIAGNOSIS: Database connection failed'); + console.error('-'.repeat(60)); + console.error('\nPlease verify:'); + console.error(' 1. Database server is running'); + console.error(' 2. DATABASE_URL is correct'); + console.error(' 3. Network connectivity to database host'); + console.error(' 4. Database user has necessary permissions'); + } + + console.error('\n' + '-'.repeat(60)); + console.error('OVERRIDE OPTIONS'); + console.error('-'.repeat(60)); + console.error('\n DB_FAIL_ON_MIGRATION_ERROR=false'); + console.error(' Start anyway (DANGEROUS - may cause runtime errors)'); + console.error('\n SKIP_MIGRATIONS=true'); + console.error(' Skip migrations entirely (only for debugging)'); + + console.error('\n' + WARNING_SEPARATOR + '\n'); + + if (config.failOnMigrationError) { + process.exit(1); + } + + // This line is never reached if failOnMigrationError is true + throw new Error(`Migration failed: ${error}`); +} + +// ============================================================================= +// DATABASE INITIALIZATION +// ============================================================================= + +// Database connection state (initialized lazily) +let db: any; +let rawClient: any; +let schema: any; +let initialized = false; + +/** + * Initialize the database connection at runtime. + * This function is called on first access to ensure DATABASE_URL is read + * from the actual runtime environment, not the build environment. + */ +async function initializeDatabase() { + if (initialized) return; + + const config = getConfig(); + const verbose = config.verboseLogging; + + logHeader('DATABASE INITIALIZATION'); + + if (isPostgres) { + // PostgreSQL via Bun.sql + validatePostgresUrl(config.databaseUrl!); + + logInfo(`Database: PostgreSQL`); + logInfo(`Connection: ${maskPassword(config.databaseUrl!)}`); + + const { drizzle } = await import('drizzle-orm/bun-sql'); + const { SQL } = await import('bun'); + + // Import PostgreSQL schema + schema = await import('./schema/pg-schema.js'); + + if (verbose) logStep('Connecting to PostgreSQL...'); + try { + rawClient = new SQL(config.databaseUrl!); + db = drizzle({ client: rawClient, schema }); + logSuccess('PostgreSQL connection established'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + exitWithError(`Failed to connect to PostgreSQL: ${message}`, config.databaseUrl); + } + + // Run migrations + const migrationsFolder = './drizzle-pg'; + const result = await runMigrations(db, rawClient, true, migrationsFolder); + if (!result.success && result.error) { + handleMigrationFailure(result.error, true); + } + } else { + // SQLite via bun:sqlite + const dbDir = join(config.dataDir, 'db'); + if (!existsSync(dbDir)) { + mkdirSync(dbDir, { recursive: true }); + } + + const dbPath = join(dbDir, 'dockhand.db'); + + logInfo(`Database: SQLite`); + logInfo(`Path: ${dbPath}`); + + const { drizzle } = await import('drizzle-orm/bun-sqlite'); + const { Database } = await import('bun:sqlite'); + + // Import SQLite schema + schema = await import('./schema/index.js'); + + if (verbose) logStep('Opening SQLite database...'); + rawClient = new Database(dbPath); + + // Enable WAL mode for better performance and concurrency + rawClient.exec('PRAGMA journal_mode = WAL'); + // Synchronous NORMAL is a good balance between safety and speed + rawClient.exec('PRAGMA synchronous = NORMAL'); + // Increase busy timeout to handle concurrent access (5 seconds) + rawClient.exec('PRAGMA busy_timeout = 5000'); + + db = drizzle({ client: rawClient, schema }); + logSuccess('SQLite database opened'); + + // Run migrations + const migrationsFolder = './drizzle'; + const result = await runMigrations(db, rawClient, false, migrationsFolder); + if (!result.success && result.error) { + handleMigrationFailure(result.error, false); + } + } + + initialized = true; +} + +// ============================================================================= +// DATABASE SEEDING +// ============================================================================= + +/** + * Seed the database with initial data. + * This is idempotent - safe to call on every startup. + */ +async function seedDatabase(): Promise { + await initializeDatabase(); + + const config = getConfig(); + const verbose = config.verboseLogging; + + if (verbose) console.log('\nSeeding database...'); + + // Create Docker Hub registry if no registries exist + const existingRegistries = await db.select().from(schema.registries); + if (existingRegistries.length === 0) { + await db.insert(schema.registries).values({ + name: 'Docker Hub', + url: 'https://registry.hub.docker.com', + isDefault: true + }); + logStep('Created Docker Hub registry'); + } + + // Create default auth settings if none exist + const existingAuth = await db.select().from(schema.authSettings); + if (existingAuth.length === 0) { + await db.insert(schema.authSettings).values({ + authEnabled: false, + defaultProvider: 'local', + sessionTimeout: 86400 + }); + logStep('Created default auth settings'); + } + + // Create default cron settings for system schedules if not exist + const scheduleCleanupCron = await db.select().from(schema.settings).where(eq(schema.settings.key, 'schedule_cleanup_cron')); + if (scheduleCleanupCron.length === 0) { + await db.insert(schema.settings).values({ + key: 'schedule_cleanup_cron', + value: '0 3 * * *' // Daily at 3 AM + }); + if (verbose) logStep('Created default schedule cleanup cron setting'); + } + + const eventCleanupCron = await db.select().from(schema.settings).where(eq(schema.settings.key, 'event_cleanup_cron')); + if (eventCleanupCron.length === 0) { + await db.insert(schema.settings).values({ + key: 'event_cleanup_cron', + value: '30 3 * * *' // Daily at 3:30 AM + }); + if (verbose) logStep('Created default event cleanup cron setting'); + } + + // Create default enabled flags for cleanup jobs + const scheduleCleanupEnabled = await db.select().from(schema.settings).where(eq(schema.settings.key, 'schedule_cleanup_enabled')); + if (scheduleCleanupEnabled.length === 0) { + await db.insert(schema.settings).values({ + key: 'schedule_cleanup_enabled', + value: 'true' + }); + if (verbose) logStep('Created default schedule cleanup enabled setting'); + } + + const eventCleanupEnabled = await db.select().from(schema.settings).where(eq(schema.settings.key, 'event_cleanup_enabled')); + if (eventCleanupEnabled.length === 0) { + await db.insert(schema.settings).values({ + key: 'event_cleanup_enabled', + value: 'true' + }); + if (verbose) logStep('Created default event cleanup enabled setting'); + } + + // Create system roles if not exist + const adminPermissions = JSON.stringify({ + containers: ['view', 'create', 'start', 'stop', 'restart', 'remove', 'exec', 'logs', 'inspect'], + images: ['view', 'pull', 'push', 'remove', 'build', 'inspect'], + volumes: ['view', 'create', 'remove', 'inspect'], + networks: ['view', 'create', 'remove', 'inspect', 'connect', 'disconnect'], + stacks: ['view', 'create', 'start', 'stop', 'remove', 'edit'], + environments: ['view', 'create', 'edit', 'delete'], + registries: ['view', 'create', 'edit', 'delete'], + notifications: ['view', 'create', 'edit', 'delete', 'test'], + configsets: ['view', 'create', 'edit', 'delete'], + settings: ['view', 'edit'], + users: ['view', 'create', 'edit', 'delete'], + git: ['view', 'create', 'edit', 'delete'], + license: ['manage'], + audit_logs: ['view'], + activity: ['view'], + schedules: ['view'] + }); + + const operatorPermissions = JSON.stringify({ + containers: ['view', 'start', 'stop', 'restart', 'logs', 'exec'], + images: ['view', 'pull'], + volumes: ['view', 'inspect'], + networks: ['view', 'inspect'], + stacks: ['view', 'start', 'stop'], + environments: ['view'], + registries: ['view'], + notifications: ['view'], + configsets: ['view'], + settings: ['view'], + users: [], + git: ['view', 'create', 'edit'], + license: [], + audit_logs: [], + activity: ['view'], + schedules: ['view'] + }); + + const viewerPermissions = JSON.stringify({ + containers: ['view', 'logs', 'inspect'], + images: ['view', 'inspect'], + volumes: ['view', 'inspect'], + networks: ['view', 'inspect'], + stacks: ['view'], + environments: ['view'], + registries: ['view'], + notifications: ['view'], + configsets: ['view'], + settings: [], + users: [], + git: ['view'], + license: [], + audit_logs: [], + activity: ['view'], + schedules: ['view'] + }); + + const existingRoles = await db.select().from(schema.roles); + if (existingRoles.length === 0) { + await db.insert(schema.roles).values([ + { name: 'Admin', description: 'Full access to all resources', isSystem: true, permissions: adminPermissions }, + { name: 'Operator', description: 'Can manage containers and view resources', isSystem: true, permissions: operatorPermissions }, + { name: 'Viewer', description: 'Read-only access to all resources', isSystem: true, permissions: viewerPermissions } + ]); + logStep('Created system roles'); + } else { + // Update system roles permissions + const now = new Date().toISOString(); + await db.update(schema.roles) + .set({ permissions: adminPermissions, updatedAt: now }) + .where(eq(schema.roles.name, 'Admin')); + await db.update(schema.roles) + .set({ permissions: operatorPermissions, updatedAt: now }) + .where(eq(schema.roles.name, 'Operator')); + await db.update(schema.roles) + .set({ permissions: viewerPermissions, updatedAt: now }) + .where(eq(schema.roles.name, 'Viewer')); + } + + logSuccess(`Database initialized (${isPostgres ? 'PostgreSQL' : 'SQLite'})`); + console.log(SEPARATOR + '\n'); +} + +// ============================================================================= +// STARTUP +// ============================================================================= + +// Seed the database on startup +await seedDatabase(); + +// ============================================================================= +// EXPORTS +// ============================================================================= + +// Create proxy to ensure database is initialized before access +const dbProxy = new Proxy({} as any, { + get(_target, prop) { + if (!initialized) { + throw new Error('Database not initialized. This should not happen.'); + } + return db[prop]; + } +}); + +// Export the database instance +export { dbProxy as db, rawClient }; + +// Create lazy schema exports +const schemaProxy = new Proxy({} as any, { + get(_target, prop) { + if (!initialized || !schema) { + throw new Error('Database not initialized. This should not happen.'); + } + return schema[prop]; + } +}); + +// Export schema tables via proxy +export const environments = schemaProxy.environments; +export const hawserTokens = schemaProxy.hawserTokens; +export const registries = schemaProxy.registries; +export const settings = schemaProxy.settings; +export const stackEvents = schemaProxy.stackEvents; +export const hostMetrics = schemaProxy.hostMetrics; +export const configSets = schemaProxy.configSets; +export const autoUpdateSettings = schemaProxy.autoUpdateSettings; +export const notificationSettings = schemaProxy.notificationSettings; +export const environmentNotifications = schemaProxy.environmentNotifications; +export const authSettings = schemaProxy.authSettings; +export const users = schemaProxy.users; +export const sessions = schemaProxy.sessions; +export const ldapConfig = schemaProxy.ldapConfig; +export const oidcConfig = schemaProxy.oidcConfig; +export const roles = schemaProxy.roles; +export const userRoles = schemaProxy.userRoles; +export const gitCredentials = schemaProxy.gitCredentials; +export const gitRepositories = schemaProxy.gitRepositories; +export const gitStacks = schemaProxy.gitStacks; +export const stackSources = schemaProxy.stackSources; +export const vulnerabilityScans = schemaProxy.vulnerabilityScans; +export const auditLogs = schemaProxy.auditLogs; +export const containerEvents = schemaProxy.containerEvents; +export const userPreferences = schemaProxy.userPreferences; +export const scheduleExecutions = schemaProxy.scheduleExecutions; +export const stackEnvironmentVariables = schemaProxy.stackEnvironmentVariables; +export const pendingContainerUpdates = schemaProxy.pendingContainerUpdates; + +// Re-export types from SQLite schema (they're compatible with PostgreSQL) +export type { + Environment, + NewEnvironment, + Registry, + NewRegistry, + HawserToken, + NewHawserToken, + Setting, + NewSetting, + User, + NewUser, + Session, + NewSession, + Role, + NewRole, + UserRole, + NewUserRole, + OidcConfig, + NewOidcConfig, + LdapConfig, + NewLdapConfig, + AuthSetting, + NewAuthSetting, + ConfigSet, + NewConfigSet, + NotificationSetting, + NewNotificationSetting, + EnvironmentNotification, + NewEnvironmentNotification, + GitCredential, + NewGitCredential, + GitRepository, + NewGitRepository, + GitStack, + NewGitStack, + StackSource, + NewStackSource, + VulnerabilityScan, + NewVulnerabilityScan, + AuditLog, + NewAuditLog, + ContainerEvent, + NewContainerEvent, + HostMetric, + NewHostMetric, + StackEvent, + NewStackEvent, + AutoUpdateSetting, + NewAutoUpdateSetting, + UserPreference, + NewUserPreference, + ScheduleExecution, + NewScheduleExecution, + StackEnvironmentVariable, + NewStackEnvironmentVariable, + PendingContainerUpdate, + NewPendingContainerUpdate +} from './schema/index.js'; + +export { eq, and, or, desc, asc, like, sql, inArray, isNull, isNotNull } from 'drizzle-orm'; + +interface SchemaInfo { + version: string | null; + date: string | null; +} + +/** + * Get the current database schema version from migration journal. + * Returns the tag and date of the latest migration. + */ +export async function getDatabaseSchemaVersion(): Promise { + try { + const journalPath = isPostgres ? './drizzle-pg/meta/_journal.json' : './drizzle/meta/_journal.json'; + const journalContent = readFileSync(journalPath, 'utf-8'); + const journal = JSON.parse(journalContent); + + if (journal.entries && journal.entries.length > 0) { + // Get the last entry (most recent migration) + const lastEntry = journal.entries[journal.entries.length - 1]; + const date = lastEntry.when ? new Date(lastEntry.when).toISOString().split('T')[0] : null; + return { + version: lastEntry.tag ?? null, + date + }; + } + return { version: null, date: null }; + } catch (e) { + console.error('Error getting schema version:', e); + return { version: null, date: null }; + } +} + +/** + * Get PostgreSQL connection info (host and port). + * Returns null if not using PostgreSQL. + */ +export function getPostgresConnectionInfo(): { host: string; port: string } | null { + if (!isPostgres) return null; + + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) return null; + + try { + const url = new URL(databaseUrl); + return { + host: url.hostname, + port: url.port || '5432' + }; + } catch { + return null; + } +} diff --git a/lib/server/db/schema/index.ts b/lib/server/db/schema/index.ts new file mode 100644 index 0000000..8f78d85 --- /dev/null +++ b/lib/server/db/schema/index.ts @@ -0,0 +1,567 @@ +/** + * Drizzle ORM Schema for Dockhand + * + * This schema supports both SQLite and PostgreSQL through Drizzle's + * database-agnostic schema definitions. + */ + +import { + sqliteTable, + text, + integer, + real, + primaryKey, + unique, + index +} from 'drizzle-orm/sqlite-core'; +import { sql } from 'drizzle-orm'; + +// ============================================================================= +// CORE TABLES +// ============================================================================= + +export const environments = sqliteTable('environments', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull().unique(), + host: text('host'), + port: integer('port').default(2375), + protocol: text('protocol').default('http'), + tlsCa: text('tls_ca'), + tlsCert: text('tls_cert'), + tlsKey: text('tls_key'), + tlsSkipVerify: integer('tls_skip_verify', { mode: 'boolean' }).default(false), + icon: text('icon').default('globe'), + collectActivity: integer('collect_activity', { mode: 'boolean' }).default(true), + collectMetrics: integer('collect_metrics', { mode: 'boolean' }).default(true), + highlightChanges: integer('highlight_changes', { mode: 'boolean' }).default(true), + labels: text('labels'), // JSON array of label strings for categorization + // Connection settings + connectionType: text('connection_type').default('socket'), // 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge' + socketPath: text('socket_path').default('/var/run/docker.sock'), // Unix socket path for 'socket' connection type + hawserToken: text('hawser_token'), // Plain-text token for hawser-standard auth + hawserLastSeen: text('hawser_last_seen'), + hawserAgentId: text('hawser_agent_id'), + hawserAgentName: text('hawser_agent_name'), + hawserVersion: text('hawser_version'), + hawserCapabilities: text('hawser_capabilities'), // JSON array: ["compose", "exec", "metrics"] + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) +}); + +export const hawserTokens = sqliteTable('hawser_tokens', { + id: integer('id').primaryKey({ autoIncrement: true }), + token: text('token').notNull().unique(), // Hashed token + tokenPrefix: text('token_prefix').notNull(), // First 8 chars for identification + name: text('name').notNull(), + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), + isActive: integer('is_active', { mode: 'boolean' }).default(true), + lastUsed: text('last_used'), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + expiresAt: text('expires_at') +}); + +export const registries = sqliteTable('registries', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull().unique(), + url: text('url').notNull(), + username: text('username'), + password: text('password'), + isDefault: integer('is_default', { mode: 'boolean' }).default(false), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) +}); + +export const settings = sqliteTable('settings', { + key: text('key').primaryKey(), + value: text('value').notNull(), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) +}); + +// ============================================================================= +// EVENT TRACKING TABLES +// ============================================================================= + +export const stackEvents = sqliteTable('stack_events', { + id: integer('id').primaryKey({ autoIncrement: true }), + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), + stackName: text('stack_name').notNull(), + eventType: text('event_type').notNull(), + timestamp: text('timestamp').default(sql`CURRENT_TIMESTAMP`), + metadata: text('metadata') +}); + +export const hostMetrics = sqliteTable('host_metrics', { + id: integer('id').primaryKey({ autoIncrement: true }), + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), + cpuPercent: real('cpu_percent').notNull(), + memoryPercent: real('memory_percent').notNull(), + memoryUsed: integer('memory_used'), + memoryTotal: integer('memory_total'), + timestamp: text('timestamp').default(sql`CURRENT_TIMESTAMP`) +}, (table) => ({ + envTimestampIdx: index('host_metrics_env_timestamp_idx').on(table.environmentId, table.timestamp) +})); + +// ============================================================================= +// CONFIGURATION TABLES +// ============================================================================= + +export const configSets = sqliteTable('config_sets', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull().unique(), + description: text('description'), + envVars: text('env_vars'), + labels: text('labels'), + ports: text('ports'), + volumes: text('volumes'), + networkMode: text('network_mode').default('bridge'), + restartPolicy: text('restart_policy').default('no'), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) +}); + +export const autoUpdateSettings = sqliteTable('auto_update_settings', { + id: integer('id').primaryKey({ autoIncrement: true }), + environmentId: integer('environment_id').references(() => environments.id), + containerName: text('container_name').notNull(), + enabled: integer('enabled', { mode: 'boolean' }).default(false), + scheduleType: text('schedule_type').default('daily'), + cronExpression: text('cron_expression'), + vulnerabilityCriteria: text('vulnerability_criteria').default('never'), // 'never' | 'any' | 'critical_high' | 'critical' | 'more_than_current' + lastChecked: text('last_checked'), + lastUpdated: text('last_updated'), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) +}, (table) => ({ + envContainerUnique: unique().on(table.environmentId, table.containerName) +})); + +export const notificationSettings = sqliteTable('notification_settings', { + id: integer('id').primaryKey({ autoIncrement: true }), + type: text('type').notNull(), + name: text('name').notNull(), + enabled: integer('enabled', { mode: 'boolean' }).default(true), + config: text('config').notNull(), + eventTypes: text('event_types'), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) +}); + +export const environmentNotifications = sqliteTable('environment_notifications', { + id: integer('id').primaryKey({ autoIncrement: true }), + environmentId: integer('environment_id').notNull().references(() => environments.id, { onDelete: 'cascade' }), + notificationId: integer('notification_id').notNull().references(() => notificationSettings.id, { onDelete: 'cascade' }), + enabled: integer('enabled', { mode: 'boolean' }).default(true), + eventTypes: text('event_types'), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) +}, (table) => ({ + envNotifUnique: unique().on(table.environmentId, table.notificationId) +})); + +// ============================================================================= +// AUTHENTICATION TABLES +// ============================================================================= + +export const authSettings = sqliteTable('auth_settings', { + id: integer('id').primaryKey({ autoIncrement: true }), + authEnabled: integer('auth_enabled', { mode: 'boolean' }).default(false), + defaultProvider: text('default_provider').default('local'), + sessionTimeout: integer('session_timeout').default(86400), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) +}); + +export const users = sqliteTable('users', { + id: integer('id').primaryKey({ autoIncrement: true }), + username: text('username').notNull().unique(), + email: text('email'), + passwordHash: text('password_hash').notNull(), + displayName: text('display_name'), + avatar: text('avatar'), + authProvider: text('auth_provider').default('local'), // e.g., 'local', 'oidc:Keycloak', 'ldap:AD' + mfaEnabled: integer('mfa_enabled', { mode: 'boolean' }).default(false), + mfaSecret: text('mfa_secret'), + isActive: integer('is_active', { mode: 'boolean' }).default(true), + lastLogin: text('last_login'), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) +}); + +export const sessions = sqliteTable('sessions', { + id: text('id').primaryKey(), + userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + provider: text('provider').notNull(), + expiresAt: text('expires_at').notNull(), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`) +}, (table) => ({ + userIdIdx: index('sessions_user_id_idx').on(table.userId), + expiresAtIdx: index('sessions_expires_at_idx').on(table.expiresAt) +})); + +export const ldapConfig = sqliteTable('ldap_config', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + enabled: integer('enabled', { mode: 'boolean' }).default(false), + serverUrl: text('server_url').notNull(), + bindDn: text('bind_dn'), + bindPassword: text('bind_password'), + baseDn: text('base_dn').notNull(), + userFilter: text('user_filter').default('(uid={{username}})'), + usernameAttribute: text('username_attribute').default('uid'), + emailAttribute: text('email_attribute').default('mail'), + displayNameAttribute: text('display_name_attribute').default('cn'), + groupBaseDn: text('group_base_dn'), + groupFilter: text('group_filter'), + adminGroup: text('admin_group'), + roleMappings: text('role_mappings'), // JSON: [{ groupDn: string, roleId: number }] + tlsEnabled: integer('tls_enabled', { mode: 'boolean' }).default(false), + tlsCa: text('tls_ca'), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) +}); + +export const oidcConfig = sqliteTable('oidc_config', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull(), + enabled: integer('enabled', { mode: 'boolean' }).default(false), + issuerUrl: text('issuer_url').notNull(), + clientId: text('client_id').notNull(), + clientSecret: text('client_secret').notNull(), + redirectUri: text('redirect_uri').notNull(), + scopes: text('scopes').default('openid profile email'), + usernameClaim: text('username_claim').default('preferred_username'), + emailClaim: text('email_claim').default('email'), + displayNameClaim: text('display_name_claim').default('name'), + adminClaim: text('admin_claim'), + adminValue: text('admin_value'), + roleMappingsClaim: text('role_mappings_claim').default('groups'), + roleMappings: text('role_mappings'), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) +}); + +// ============================================================================= +// ROLE-BASED ACCESS CONTROL TABLES +// ============================================================================= + +export const roles = sqliteTable('roles', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull().unique(), + description: text('description'), + isSystem: integer('is_system', { mode: 'boolean' }).default(false), + permissions: text('permissions').notNull(), + environmentIds: text('environment_ids'), // JSON array of env IDs, null = all environments + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) +}); + +export const userRoles = sqliteTable('user_roles', { + id: integer('id').primaryKey({ autoIncrement: true }), + userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + roleId: integer('role_id').notNull().references(() => roles.id, { onDelete: 'cascade' }), + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`) +}, (table) => ({ + userRoleEnvUnique: unique().on(table.userId, table.roleId, table.environmentId) +})); + +// ============================================================================= +// GIT INTEGRATION TABLES +// ============================================================================= + +export const gitCredentials = sqliteTable('git_credentials', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull().unique(), + authType: text('auth_type').notNull().default('none'), + username: text('username'), + password: text('password'), + sshPrivateKey: text('ssh_private_key'), + sshPassphrase: text('ssh_passphrase'), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) +}); + +export const gitRepositories = sqliteTable('git_repositories', { + id: integer('id').primaryKey({ autoIncrement: true }), + name: text('name').notNull().unique(), + url: text('url').notNull(), + branch: text('branch').default('main'), + credentialId: integer('credential_id').references(() => gitCredentials.id, { onDelete: 'set null' }), + composePath: text('compose_path').default('docker-compose.yml'), + environmentId: integer('environment_id'), + autoUpdate: integer('auto_update', { mode: 'boolean' }).default(false), + autoUpdateSchedule: text('auto_update_schedule').default('daily'), + autoUpdateCron: text('auto_update_cron').default('0 3 * * *'), + webhookEnabled: integer('webhook_enabled', { mode: 'boolean' }).default(false), + webhookSecret: text('webhook_secret'), + lastSync: text('last_sync'), + lastCommit: text('last_commit'), + syncStatus: text('sync_status').default('pending'), + syncError: text('sync_error'), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) +}); + +export const gitStacks = sqliteTable('git_stacks', { + id: integer('id').primaryKey({ autoIncrement: true }), + stackName: text('stack_name').notNull(), + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), + repositoryId: integer('repository_id').notNull().references(() => gitRepositories.id, { onDelete: 'cascade' }), + composePath: text('compose_path').default('docker-compose.yml'), + envFilePath: text('env_file_path'), // Path to .env file in repository (e.g., ".env", "config/.env.prod") + autoUpdate: integer('auto_update', { mode: 'boolean' }).default(false), + autoUpdateSchedule: text('auto_update_schedule').default('daily'), + autoUpdateCron: text('auto_update_cron').default('0 3 * * *'), + webhookEnabled: integer('webhook_enabled', { mode: 'boolean' }).default(false), + webhookSecret: text('webhook_secret'), + lastSync: text('last_sync'), + lastCommit: text('last_commit'), + syncStatus: text('sync_status').default('pending'), + syncError: text('sync_error'), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) +}, (table) => ({ + stackEnvUnique: unique().on(table.stackName, table.environmentId) +})); + +export const stackSources = sqliteTable('stack_sources', { + id: integer('id').primaryKey({ autoIncrement: true }), + stackName: text('stack_name').notNull(), + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), + sourceType: text('source_type').notNull().default('internal'), + gitRepositoryId: integer('git_repository_id').references(() => gitRepositories.id, { onDelete: 'set null' }), + gitStackId: integer('git_stack_id').references(() => gitStacks.id, { onDelete: 'set null' }), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) +}, (table) => ({ + stackSourceEnvUnique: unique().on(table.stackName, table.environmentId) +})); + +export const stackEnvironmentVariables = sqliteTable('stack_environment_variables', { + id: integer('id').primaryKey({ autoIncrement: true }), + stackName: text('stack_name').notNull(), + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), + key: text('key').notNull(), + value: text('value').notNull(), + isSecret: integer('is_secret', { mode: 'boolean' }).default(false), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) +}, (table) => ({ + stackEnvVarUnique: unique().on(table.stackName, table.environmentId, table.key) +})); + +// ============================================================================= +// SECURITY TABLES +// ============================================================================= + +export const vulnerabilityScans = sqliteTable('vulnerability_scans', { + id: integer('id').primaryKey({ autoIncrement: true }), + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), + imageId: text('image_id').notNull(), + imageName: text('image_name').notNull(), + scanner: text('scanner').notNull(), + scannedAt: text('scanned_at').notNull(), + scanDuration: integer('scan_duration'), + criticalCount: integer('critical_count').default(0), + highCount: integer('high_count').default(0), + mediumCount: integer('medium_count').default(0), + lowCount: integer('low_count').default(0), + negligibleCount: integer('negligible_count').default(0), + unknownCount: integer('unknown_count').default(0), + vulnerabilities: text('vulnerabilities'), + error: text('error'), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`) +}, (table) => ({ + envImageIdx: index('vulnerability_scans_env_image_idx').on(table.environmentId, table.imageId) +})); + +// ============================================================================= +// AUDIT LOGGING TABLES +// ============================================================================= + +export const auditLogs = sqliteTable('audit_logs', { + id: integer('id').primaryKey({ autoIncrement: true }), + userId: integer('user_id').references(() => users.id, { onDelete: 'set null' }), + username: text('username').notNull(), + action: text('action').notNull(), + entityType: text('entity_type').notNull(), + entityId: text('entity_id'), + entityName: text('entity_name'), + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'set null' }), + description: text('description'), + details: text('details'), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`) +}, (table) => ({ + userIdIdx: index('audit_logs_user_id_idx').on(table.userId), + createdAtIdx: index('audit_logs_created_at_idx').on(table.createdAt) +})); + +// ============================================================================= +// CONTAINER ACTIVITY TABLES +// ============================================================================= + +export const containerEvents = sqliteTable('container_events', { + id: integer('id').primaryKey({ autoIncrement: true }), + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), + containerId: text('container_id').notNull(), + containerName: text('container_name'), + image: text('image'), + action: text('action').notNull(), + actorAttributes: text('actor_attributes'), + timestamp: text('timestamp').notNull(), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`) +}, (table) => ({ + envTimestampIdx: index('container_events_env_timestamp_idx').on(table.environmentId, table.timestamp) +})); + +// ============================================================================= +// SCHEDULE EXECUTION TABLES +// ============================================================================= + +export const scheduleExecutions = sqliteTable('schedule_executions', { + id: integer('id').primaryKey({ autoIncrement: true }), + // Link to the scheduled job + scheduleType: text('schedule_type').notNull(), // 'container_update' | 'git_stack_sync' | 'system_cleanup' + scheduleId: integer('schedule_id').notNull(), // ID in autoUpdateSettings or gitStacks, or 0 for system jobs + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), + // What ran + entityName: text('entity_name').notNull(), // container name or stack name + // When and how + triggeredBy: text('triggered_by').notNull(), // 'cron' | 'webhook' | 'manual' + triggeredAt: text('triggered_at').notNull(), + startedAt: text('started_at'), + completedAt: text('completed_at'), + duration: integer('duration'), // milliseconds + // Result + status: text('status').notNull(), // 'queued' | 'running' | 'success' | 'failed' | 'skipped' + errorMessage: text('error_message'), + // Details + details: text('details'), // JSON with execution details + logs: text('logs'), // Execution logs/output + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`) +}, (table) => ({ + typeIdIdx: index('schedule_executions_type_id_idx').on(table.scheduleType, table.scheduleId) +})); + +// ============================================================================= +// PENDING CONTAINER UPDATES TABLE +// ============================================================================= + +export const pendingContainerUpdates = sqliteTable('pending_container_updates', { + id: integer('id').primaryKey({ autoIncrement: true }), + environmentId: integer('environment_id').notNull().references(() => environments.id, { onDelete: 'cascade' }), + containerId: text('container_id').notNull(), + containerName: text('container_name').notNull(), + currentImage: text('current_image').notNull(), + checkedAt: text('checked_at').default(sql`CURRENT_TIMESTAMP`), + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`) +}, (table) => ({ + envContainerUnique: unique().on(table.environmentId, table.containerId) +})); + +// ============================================================================= +// USER PREFERENCES TABLE (unified key-value store) +// ============================================================================= + +export const userPreferences = sqliteTable('user_preferences', { + id: integer('id').primaryKey({ autoIncrement: true }), + userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }), // NULL = shared (free edition), set = per-user (enterprise) + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), // NULL for global prefs + key: text('key').notNull(), // e.g., 'dashboard_layout', 'logs_favorites' + value: text('value').notNull(), // JSON value + createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) +}, (table) => [ + unique().on(table.userId, table.environmentId, table.key) +]); + +// ============================================================================= +// TYPE EXPORTS +// ============================================================================= + +export type Environment = typeof environments.$inferSelect; +export type NewEnvironment = typeof environments.$inferInsert; + +export type Registry = typeof registries.$inferSelect; +export type NewRegistry = typeof registries.$inferInsert; + +export type HawserToken = typeof hawserTokens.$inferSelect; +export type NewHawserToken = typeof hawserTokens.$inferInsert; + +export type Setting = typeof settings.$inferSelect; +export type NewSetting = typeof settings.$inferInsert; + +export type User = typeof users.$inferSelect; +export type NewUser = typeof users.$inferInsert; + +export type Session = typeof sessions.$inferSelect; +export type NewSession = typeof sessions.$inferInsert; + +export type Role = typeof roles.$inferSelect; +export type NewRole = typeof roles.$inferInsert; + +export type UserRole = typeof userRoles.$inferSelect; +export type NewUserRole = typeof userRoles.$inferInsert; + +export type OidcConfig = typeof oidcConfig.$inferSelect; +export type NewOidcConfig = typeof oidcConfig.$inferInsert; + +export type LdapConfig = typeof ldapConfig.$inferSelect; +export type NewLdapConfig = typeof ldapConfig.$inferInsert; + +export type AuthSetting = typeof authSettings.$inferSelect; +export type NewAuthSetting = typeof authSettings.$inferInsert; + +export type ConfigSet = typeof configSets.$inferSelect; +export type NewConfigSet = typeof configSets.$inferInsert; + +export type NotificationSetting = typeof notificationSettings.$inferSelect; +export type NewNotificationSetting = typeof notificationSettings.$inferInsert; + +export type EnvironmentNotification = typeof environmentNotifications.$inferSelect; +export type NewEnvironmentNotification = typeof environmentNotifications.$inferInsert; + +export type GitCredential = typeof gitCredentials.$inferSelect; +export type NewGitCredential = typeof gitCredentials.$inferInsert; + +export type GitRepository = typeof gitRepositories.$inferSelect; +export type NewGitRepository = typeof gitRepositories.$inferInsert; + +export type GitStack = typeof gitStacks.$inferSelect; +export type NewGitStack = typeof gitStacks.$inferInsert; + +export type StackSource = typeof stackSources.$inferSelect; +export type NewStackSource = typeof stackSources.$inferInsert; + +export type VulnerabilityScan = typeof vulnerabilityScans.$inferSelect; +export type NewVulnerabilityScan = typeof vulnerabilityScans.$inferInsert; + +export type AuditLog = typeof auditLogs.$inferSelect; +export type NewAuditLog = typeof auditLogs.$inferInsert; + +export type ContainerEvent = typeof containerEvents.$inferSelect; +export type NewContainerEvent = typeof containerEvents.$inferInsert; + +export type HostMetric = typeof hostMetrics.$inferSelect; +export type NewHostMetric = typeof hostMetrics.$inferInsert; + +export type StackEvent = typeof stackEvents.$inferSelect; +export type NewStackEvent = typeof stackEvents.$inferInsert; + +export type AutoUpdateSetting = typeof autoUpdateSettings.$inferSelect; +export type NewAutoUpdateSetting = typeof autoUpdateSettings.$inferInsert; + +export type UserPreference = typeof userPreferences.$inferSelect; +export type NewUserPreference = typeof userPreferences.$inferInsert; + +export type ScheduleExecution = typeof scheduleExecutions.$inferSelect; +export type NewScheduleExecution = typeof scheduleExecutions.$inferInsert; + +export type StackEnvironmentVariable = typeof stackEnvironmentVariables.$inferSelect; +export type NewStackEnvironmentVariable = typeof stackEnvironmentVariables.$inferInsert; + +export type PendingContainerUpdate = typeof pendingContainerUpdates.$inferSelect; +export type NewPendingContainerUpdate = typeof pendingContainerUpdates.$inferInsert; diff --git a/lib/server/db/schema/pg-schema.ts b/lib/server/db/schema/pg-schema.ts new file mode 100644 index 0000000..8533357 --- /dev/null +++ b/lib/server/db/schema/pg-schema.ts @@ -0,0 +1,482 @@ +/** + * Drizzle ORM Schema for Dockhand - PostgreSQL Version + * + * This schema is used for PostgreSQL migrations and is a mirror of the SQLite schema + * with PostgreSQL-specific types and syntax. + */ + +import { + pgTable, + text, + integer, + serial, + boolean, + doublePrecision, + bigint, + timestamp, + unique, + index +} from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; + +// ============================================================================= +// CORE TABLES +// ============================================================================= + +export const environments = pgTable('environments', { + id: serial('id').primaryKey(), + name: text('name').notNull().unique(), + host: text('host'), + port: integer('port').default(2375), + protocol: text('protocol').default('http'), + tlsCa: text('tls_ca'), + tlsCert: text('tls_cert'), + tlsKey: text('tls_key'), + tlsSkipVerify: boolean('tls_skip_verify').default(false), + icon: text('icon').default('globe'), + collectActivity: boolean('collect_activity').default(true), + collectMetrics: boolean('collect_metrics').default(true), + highlightChanges: boolean('highlight_changes').default(true), + labels: text('labels'), // JSON array of label strings for categorization + // Connection settings + connectionType: text('connection_type').default('socket'), // 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge' + socketPath: text('socket_path').default('/var/run/docker.sock'), // Unix socket path for 'socket' connection type + hawserToken: text('hawser_token'), // Plain-text token for hawser-standard auth + hawserLastSeen: timestamp('hawser_last_seen', { mode: 'string' }), + hawserAgentId: text('hawser_agent_id'), + hawserAgentName: text('hawser_agent_name'), + hawserVersion: text('hawser_version'), + hawserCapabilities: text('hawser_capabilities'), // JSON array: ["compose", "exec", "metrics"] + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() +}); + +export const hawserTokens = pgTable('hawser_tokens', { + id: serial('id').primaryKey(), + token: text('token').notNull().unique(), // Hashed token + tokenPrefix: text('token_prefix').notNull(), // First 8 chars for identification + name: text('name').notNull(), + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), + isActive: boolean('is_active').default(true), + lastUsed: timestamp('last_used', { mode: 'string' }), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), + expiresAt: timestamp('expires_at', { mode: 'string' }) +}); + +export const registries = pgTable('registries', { + id: serial('id').primaryKey(), + name: text('name').notNull().unique(), + url: text('url').notNull(), + username: text('username'), + password: text('password'), + isDefault: boolean('is_default').default(false), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() +}); + +export const settings = pgTable('settings', { + key: text('key').primaryKey(), + value: text('value').notNull(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() +}); + +// ============================================================================= +// EVENT TRACKING TABLES +// ============================================================================= + +export const stackEvents = pgTable('stack_events', { + id: serial('id').primaryKey(), + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), + stackName: text('stack_name').notNull(), + eventType: text('event_type').notNull(), + timestamp: timestamp('timestamp', { mode: 'string' }).defaultNow(), + metadata: text('metadata') +}); + +export const hostMetrics = pgTable('host_metrics', { + id: serial('id').primaryKey(), + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), + cpuPercent: doublePrecision('cpu_percent').notNull(), + memoryPercent: doublePrecision('memory_percent').notNull(), + memoryUsed: bigint('memory_used', { mode: 'number' }), + memoryTotal: bigint('memory_total', { mode: 'number' }), + timestamp: timestamp('timestamp', { mode: 'string' }).defaultNow() +}, (table) => ({ + envTimestampIdx: index('host_metrics_env_timestamp_idx').on(table.environmentId, table.timestamp) +})); + +// ============================================================================= +// CONFIGURATION TABLES +// ============================================================================= + +export const configSets = pgTable('config_sets', { + id: serial('id').primaryKey(), + name: text('name').notNull().unique(), + description: text('description'), + envVars: text('env_vars'), + labels: text('labels'), + ports: text('ports'), + volumes: text('volumes'), + networkMode: text('network_mode').default('bridge'), + restartPolicy: text('restart_policy').default('no'), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() +}); + +export const autoUpdateSettings = pgTable('auto_update_settings', { + id: serial('id').primaryKey(), + environmentId: integer('environment_id').references(() => environments.id), + containerName: text('container_name').notNull(), + enabled: boolean('enabled').default(false), + scheduleType: text('schedule_type').default('daily'), + cronExpression: text('cron_expression'), + vulnerabilityCriteria: text('vulnerability_criteria').default('never'), // 'never' | 'any' | 'critical_high' | 'critical' | 'more_than_current' + lastChecked: timestamp('last_checked', { mode: 'string' }), + lastUpdated: timestamp('last_updated', { mode: 'string' }), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() +}, (table) => ({ + envContainerUnique: unique().on(table.environmentId, table.containerName) +})); + +export const notificationSettings = pgTable('notification_settings', { + id: serial('id').primaryKey(), + type: text('type').notNull(), + name: text('name').notNull(), + enabled: boolean('enabled').default(true), + config: text('config').notNull(), + eventTypes: text('event_types'), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() +}); + +export const environmentNotifications = pgTable('environment_notifications', { + id: serial('id').primaryKey(), + environmentId: integer('environment_id').notNull().references(() => environments.id, { onDelete: 'cascade' }), + notificationId: integer('notification_id').notNull().references(() => notificationSettings.id, { onDelete: 'cascade' }), + enabled: boolean('enabled').default(true), + eventTypes: text('event_types'), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() +}, (table) => ({ + envNotifUnique: unique().on(table.environmentId, table.notificationId) +})); + +// ============================================================================= +// AUTHENTICATION TABLES +// ============================================================================= + +export const authSettings = pgTable('auth_settings', { + id: serial('id').primaryKey(), + authEnabled: boolean('auth_enabled').default(false), + defaultProvider: text('default_provider').default('local'), + sessionTimeout: integer('session_timeout').default(86400), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() +}); + +export const users = pgTable('users', { + id: serial('id').primaryKey(), + username: text('username').notNull().unique(), + email: text('email'), + passwordHash: text('password_hash').notNull(), + displayName: text('display_name'), + avatar: text('avatar'), + authProvider: text('auth_provider').default('local'), // e.g., 'local', 'oidc:Keycloak', 'ldap:AD' + mfaEnabled: boolean('mfa_enabled').default(false), + mfaSecret: text('mfa_secret'), + isActive: boolean('is_active').default(true), + lastLogin: timestamp('last_login', { mode: 'string' }), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() +}); + +export const sessions = pgTable('sessions', { + id: text('id').primaryKey(), + userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + provider: text('provider').notNull(), + expiresAt: timestamp('expires_at', { mode: 'string' }).notNull(), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow() +}, (table) => ({ + userIdIdx: index('sessions_user_id_idx').on(table.userId), + expiresAtIdx: index('sessions_expires_at_idx').on(table.expiresAt) +})); + +export const ldapConfig = pgTable('ldap_config', { + id: serial('id').primaryKey(), + name: text('name').notNull(), + enabled: boolean('enabled').default(false), + serverUrl: text('server_url').notNull(), + bindDn: text('bind_dn'), + bindPassword: text('bind_password'), + baseDn: text('base_dn').notNull(), + userFilter: text('user_filter').default('(uid={{username}})'), + usernameAttribute: text('username_attribute').default('uid'), + emailAttribute: text('email_attribute').default('mail'), + displayNameAttribute: text('display_name_attribute').default('cn'), + groupBaseDn: text('group_base_dn'), + groupFilter: text('group_filter'), + adminGroup: text('admin_group'), + roleMappings: text('role_mappings'), // JSON: [{ groupDn: string, roleId: number }] + tlsEnabled: boolean('tls_enabled').default(false), + tlsCa: text('tls_ca'), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() +}); + +export const oidcConfig = pgTable('oidc_config', { + id: serial('id').primaryKey(), + name: text('name').notNull(), + enabled: boolean('enabled').default(false), + issuerUrl: text('issuer_url').notNull(), + clientId: text('client_id').notNull(), + clientSecret: text('client_secret').notNull(), + redirectUri: text('redirect_uri').notNull(), + scopes: text('scopes').default('openid profile email'), + usernameClaim: text('username_claim').default('preferred_username'), + emailClaim: text('email_claim').default('email'), + displayNameClaim: text('display_name_claim').default('name'), + adminClaim: text('admin_claim'), + adminValue: text('admin_value'), + roleMappingsClaim: text('role_mappings_claim').default('groups'), + roleMappings: text('role_mappings'), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() +}); + +// ============================================================================= +// ROLE-BASED ACCESS CONTROL TABLES +// ============================================================================= + +export const roles = pgTable('roles', { + id: serial('id').primaryKey(), + name: text('name').notNull().unique(), + description: text('description'), + isSystem: boolean('is_system').default(false), + permissions: text('permissions').notNull(), + environmentIds: text('environment_ids'), // JSON array of env IDs, null = all environments + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() +}); + +export const userRoles = pgTable('user_roles', { + id: serial('id').primaryKey(), + userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + roleId: integer('role_id').notNull().references(() => roles.id, { onDelete: 'cascade' }), + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow() +}, (table) => ({ + userRoleEnvUnique: unique().on(table.userId, table.roleId, table.environmentId) +})); + +// ============================================================================= +// GIT INTEGRATION TABLES +// ============================================================================= + +export const gitCredentials = pgTable('git_credentials', { + id: serial('id').primaryKey(), + name: text('name').notNull().unique(), + authType: text('auth_type').notNull().default('none'), + username: text('username'), + password: text('password'), + sshPrivateKey: text('ssh_private_key'), + sshPassphrase: text('ssh_passphrase'), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() +}); + +export const gitRepositories = pgTable('git_repositories', { + id: serial('id').primaryKey(), + name: text('name').notNull().unique(), + url: text('url').notNull(), + branch: text('branch').default('main'), + credentialId: integer('credential_id').references(() => gitCredentials.id, { onDelete: 'set null' }), + composePath: text('compose_path').default('docker-compose.yml'), + environmentId: integer('environment_id'), + autoUpdate: boolean('auto_update').default(false), + autoUpdateSchedule: text('auto_update_schedule').default('daily'), + autoUpdateCron: text('auto_update_cron').default('0 3 * * *'), + webhookEnabled: boolean('webhook_enabled').default(false), + webhookSecret: text('webhook_secret'), + lastSync: timestamp('last_sync', { mode: 'string' }), + lastCommit: text('last_commit'), + syncStatus: text('sync_status').default('pending'), + syncError: text('sync_error'), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() +}); + +export const gitStacks = pgTable('git_stacks', { + id: serial('id').primaryKey(), + stackName: text('stack_name').notNull(), + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), + repositoryId: integer('repository_id').notNull().references(() => gitRepositories.id, { onDelete: 'cascade' }), + composePath: text('compose_path').default('docker-compose.yml'), + envFilePath: text('env_file_path'), // Path to .env file in repository (e.g., ".env", "config/.env.prod") + autoUpdate: boolean('auto_update').default(false), + autoUpdateSchedule: text('auto_update_schedule').default('daily'), + autoUpdateCron: text('auto_update_cron').default('0 3 * * *'), + webhookEnabled: boolean('webhook_enabled').default(false), + webhookSecret: text('webhook_secret'), + lastSync: timestamp('last_sync', { mode: 'string' }), + lastCommit: text('last_commit'), + syncStatus: text('sync_status').default('pending'), + syncError: text('sync_error'), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() +}, (table) => ({ + stackEnvUnique: unique().on(table.stackName, table.environmentId) +})); + +export const stackSources = pgTable('stack_sources', { + id: serial('id').primaryKey(), + stackName: text('stack_name').notNull(), + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), + sourceType: text('source_type').notNull().default('internal'), + gitRepositoryId: integer('git_repository_id').references(() => gitRepositories.id, { onDelete: 'set null' }), + gitStackId: integer('git_stack_id').references(() => gitStacks.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() +}, (table) => ({ + stackSourceEnvUnique: unique().on(table.stackName, table.environmentId) +})); + +export const stackEnvironmentVariables = pgTable('stack_environment_variables', { + id: serial('id').primaryKey(), + stackName: text('stack_name').notNull(), + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), + key: text('key').notNull(), + value: text('value').notNull(), + isSecret: boolean('is_secret').default(false), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() +}, (table) => ({ + stackEnvVarUnique: unique().on(table.stackName, table.environmentId, table.key) +})); + +// ============================================================================= +// SECURITY TABLES +// ============================================================================= + +export const vulnerabilityScans = pgTable('vulnerability_scans', { + id: serial('id').primaryKey(), + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), + imageId: text('image_id').notNull(), + imageName: text('image_name').notNull(), + scanner: text('scanner').notNull(), + scannedAt: timestamp('scanned_at', { mode: 'string' }).notNull(), + scanDuration: integer('scan_duration'), + criticalCount: integer('critical_count').default(0), + highCount: integer('high_count').default(0), + mediumCount: integer('medium_count').default(0), + lowCount: integer('low_count').default(0), + negligibleCount: integer('negligible_count').default(0), + unknownCount: integer('unknown_count').default(0), + vulnerabilities: text('vulnerabilities'), + error: text('error'), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow() +}, (table) => ({ + envImageIdx: index('vulnerability_scans_env_image_idx').on(table.environmentId, table.imageId) +})); + +// ============================================================================= +// AUDIT LOGGING TABLES +// ============================================================================= + +export const auditLogs = pgTable('audit_logs', { + id: serial('id').primaryKey(), + userId: integer('user_id').references(() => users.id, { onDelete: 'set null' }), + username: text('username').notNull(), + action: text('action').notNull(), + entityType: text('entity_type').notNull(), + entityId: text('entity_id'), + entityName: text('entity_name'), + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'set null' }), + description: text('description'), + details: text('details'), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow() +}, (table) => ({ + userIdIdx: index('audit_logs_user_id_idx').on(table.userId), + createdAtIdx: index('audit_logs_created_at_idx').on(table.createdAt) +})); + +// ============================================================================= +// CONTAINER ACTIVITY TABLES +// ============================================================================= + +export const containerEvents = pgTable('container_events', { + id: serial('id').primaryKey(), + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), + containerId: text('container_id').notNull(), + containerName: text('container_name'), + image: text('image'), + action: text('action').notNull(), + actorAttributes: text('actor_attributes'), + timestamp: timestamp('timestamp', { mode: 'string' }).notNull(), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow() +}, (table) => ({ + envTimestampIdx: index('container_events_env_timestamp_idx').on(table.environmentId, table.timestamp) +})); + +// ============================================================================= +// SCHEDULE EXECUTION TABLES +// ============================================================================= + +export const scheduleExecutions = pgTable('schedule_executions', { + id: serial('id').primaryKey(), + // Link to the scheduled job + scheduleType: text('schedule_type').notNull(), // 'container_update' | 'git_stack_sync' | 'system_cleanup' + scheduleId: integer('schedule_id').notNull(), // ID in autoUpdateSettings or gitStacks, or 0 for system jobs + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), + // What ran + entityName: text('entity_name').notNull(), // container name or stack name + // When and how + triggeredBy: text('triggered_by').notNull(), // 'cron' | 'webhook' | 'manual' + triggeredAt: timestamp('triggered_at', { mode: 'string' }).notNull(), + startedAt: timestamp('started_at', { mode: 'string' }), + completedAt: timestamp('completed_at', { mode: 'string' }), + duration: integer('duration'), // milliseconds + // Result + status: text('status').notNull(), // 'queued' | 'running' | 'success' | 'failed' | 'skipped' + errorMessage: text('error_message'), + // Details + details: text('details'), // JSON with execution details + logs: text('logs'), // Execution logs/output + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow() +}, (table) => ({ + typeIdIdx: index('schedule_executions_type_id_idx').on(table.scheduleType, table.scheduleId) +})); + +// ============================================================================= +// PENDING CONTAINER UPDATES TABLE +// ============================================================================= + +export const pendingContainerUpdates = pgTable('pending_container_updates', { + id: serial('id').primaryKey(), + environmentId: integer('environment_id').notNull().references(() => environments.id, { onDelete: 'cascade' }), + containerId: text('container_id').notNull(), + containerName: text('container_name').notNull(), + currentImage: text('current_image').notNull(), + checkedAt: timestamp('checked_at', { mode: 'string' }).defaultNow(), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow() +}, (table) => ({ + envContainerUnique: unique().on(table.environmentId, table.containerId) +})); + +// ============================================================================= +// USER PREFERENCES TABLE (unified key-value store) +// ============================================================================= + +export const userPreferences = pgTable('user_preferences', { + id: serial('id').primaryKey(), + userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }), // NULL = shared (free edition), set = per-user (enterprise) + environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), // NULL for global prefs + key: text('key').notNull(), // e.g., 'dashboard_layout', 'logs_favorites' + value: text('value').notNull(), // JSON value + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() +}, (table) => [ + unique().on(table.userId, table.environmentId, table.key) +]); diff --git a/lib/server/docker.ts b/lib/server/docker.ts new file mode 100644 index 0000000..f16d1d5 --- /dev/null +++ b/lib/server/docker.ts @@ -0,0 +1,3236 @@ +/** + * Docker Operations Module + * + * Uses direct Docker API calls over Unix socket or HTTP/HTTPS. + * No external dependencies like dockerode - uses native Bun fetch. + */ + +import { homedir } from 'node:os'; +import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import type { Environment } from './db'; +import { getStackEnvVarsAsRecord } from './db'; + +/** + * Custom error for when an environment is not found. + * API endpoints should catch this and return 404. + */ +export class EnvironmentNotFoundError extends Error { + public readonly envId: number; + + constructor(envId: number) { + super(`Environment ${envId} not found`); + this.name = 'EnvironmentNotFoundError'; + this.envId = envId; + } +} + +/** + * Custom error for Docker connection failures with user-friendly messages. + * Wraps raw Bun fetch errors to hide technical details from users. + */ +export class DockerConnectionError extends Error { + public readonly originalError: unknown; + + constructor(message: string, originalError: unknown) { + super(message); + this.name = 'DockerConnectionError'; + this.originalError = originalError; + } + + /** + * Create a DockerConnectionError from any error, sanitizing technical messages + */ + static fromError(error: unknown, context?: string): DockerConnectionError { + const errorStr = String(error); + let friendlyMessage: string; + + if (errorStr.includes('FailedToOpenSocket') || errorStr.includes('ECONNREFUSED')) { + friendlyMessage = 'Docker socket not accessible'; + } else if (errorStr.includes('ECONNRESET') || errorStr.includes('connection was closed')) { + friendlyMessage = 'Connection lost'; + } else if (errorStr.includes('verbose') || errorStr.includes('typo')) { + friendlyMessage = 'Connection failed'; + } else if (errorStr.includes('timeout') || errorStr.includes('Timeout') || errorStr.includes('ETIMEDOUT')) { + friendlyMessage = 'Connection timeout'; + } else if (errorStr.includes('ENOTFOUND') || errorStr.includes('getaddrinfo')) { + friendlyMessage = 'Host not found'; + } else if (errorStr.includes('EHOSTUNREACH')) { + friendlyMessage = 'Host unreachable'; + } else { + friendlyMessage = 'Connection error'; + } + + if (context) { + friendlyMessage = `${context}: ${friendlyMessage}`; + } + + return new DockerConnectionError(friendlyMessage, error); + } +} + +/** + * Container inspect result from Docker API + */ +export interface ContainerInspectResult { + Id: string; + Name: string; + RestartCount: number; + State: { + Status: string; + Running: boolean; + Paused: boolean; + Restarting: boolean; + OOMKilled: boolean; + Dead: boolean; + Pid: number; + ExitCode: number; + Error: string; + StartedAt: string; + FinishedAt: string; + Health?: { + Status: string; + FailingStreak: number; + Log: Array<{ + Start: string; + End: string; + ExitCode: number; + Output: string; + }>; + }; + }; + Config: { + Hostname: string; + User: string; + Tty: boolean; + Env: string[]; + Cmd: string[]; + Image: string; + Labels: Record; + WorkingDir: string; + Entrypoint: string[] | null; + }; + NetworkSettings: { + Networks: Record; + Ports: Record | null>; + }; + Mounts: Array<{ + Type: string; + Source: string; + Destination: string; + Mode: string; + RW: boolean; + }>; + HostConfig: { + Binds: string[] | null; + NetworkMode: string; + PortBindings: Record> | null; + RestartPolicy: { + Name: string; + MaximumRetryCount: number; + }; + Privileged: boolean; + Memory: number; + MemorySwap: number; + NanoCpus: number; + CpuShares: number; + }; +} + +// Detect Docker socket path for local connections +function detectDockerSocket(): string { + // Check environment variable first + if (process.env.DOCKER_SOCKET && existsSync(process.env.DOCKER_SOCKET)) { + console.log(`Using Docker socket from DOCKER_SOCKET env: ${process.env.DOCKER_SOCKET}`); + return process.env.DOCKER_SOCKET; + } + + // Check DOCKER_HOST environment variable + if (process.env.DOCKER_HOST) { + const dockerHost = process.env.DOCKER_HOST; + if (dockerHost.startsWith('unix://')) { + const socketPath = dockerHost.replace('unix://', ''); + if (existsSync(socketPath)) { + console.log(`Using Docker socket from DOCKER_HOST: ${socketPath}`); + return socketPath; + } + } + } + + // List of possible socket locations in order of preference + const possibleSockets = [ + '/var/run/docker.sock', // Standard Linux/Docker Desktop + `${homedir()}/.docker/run/docker.sock`, // Docker Desktop for Mac (new location) + `${homedir()}/.orbstack/run/docker.sock`, // OrbStack + '/run/docker.sock', // Alternative Linux location + ]; + + for (const socket of possibleSockets) { + if (existsSync(socket)) { + console.log(`Detected Docker socket at: ${socket}`); + return socket; + } + } + + // Fallback to default + console.warn('No Docker socket found, using default /var/run/docker.sock'); + return '/var/run/docker.sock'; +} + +const socketPath = detectDockerSocket(); + +/** + * Demultiplex Docker stream output (strip 8-byte headers) + * Docker streams have: 1 byte type, 3 bytes padding, 4 bytes size BE, then payload + */ +function demuxDockerStream(buffer: Buffer, options?: { separateStreams?: boolean }): string | { stdout: string; stderr: string } { + const stdout: string[] = []; + const stderr: string[] = []; + let offset = 0; + + while (offset < buffer.length) { + if (offset + 8 > buffer.length) break; + + const streamType = buffer.readUInt8(offset); + const frameSize = buffer.readUInt32BE(offset + 4); + + if (frameSize === 0 || frameSize > buffer.length - offset - 8) { + // Invalid frame, return raw content with control chars stripped + const raw = buffer.toString('utf-8').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ''); + return options?.separateStreams ? { stdout: raw, stderr: '' } : raw; + } + + const payload = buffer.slice(offset + 8, offset + 8 + frameSize).toString('utf-8'); + + if (streamType === 1) { + stdout.push(payload); + } else if (streamType === 2) { + stderr.push(payload); + } else { + stdout.push(payload); // Default to stdout for unknown types + } + + offset += 8 + frameSize; + } + + if (options?.separateStreams) { + return { stdout: stdout.join(''), stderr: stderr.join('') }; + } + return [...stdout, ...stderr].join(''); +} + +/** + * Process Docker stream frames incrementally from a buffer + * Returns processed frames and remaining buffer + */ +function processStreamFrames( + buffer: Buffer, + onStdout?: (data: string) => void, + onStderr?: (data: string) => void +): { stdout: string; remaining: Buffer } { + let stdout = ''; + let offset = 0; + + while (buffer.length >= offset + 8) { + const streamType = buffer.readUInt8(offset); + const frameSize = buffer.readUInt32BE(offset + 4); + + if (buffer.length < offset + 8 + frameSize) break; + + const payload = buffer.slice(offset + 8, offset + 8 + frameSize).toString('utf-8'); + + if (streamType === 1) { + stdout += payload; + onStdout?.(payload); + } else if (streamType === 2) { + onStderr?.(payload); + } + + offset += 8 + frameSize; + } + + return { stdout, remaining: buffer.slice(offset) }; +} + +// Cache for environment configurations with timestamps +interface CachedEnv { + env: Environment; + lastUsed: number; +} +const envCache = new Map(); + +// Cache TTL: 30 minutes (in milliseconds) +const CACHE_TTL = 30 * 60 * 1000; + +// Cleanup stale cache entries periodically +function cleanupEnvCache() { + const now = Date.now(); + const entries = Array.from(envCache.entries()); + for (const [envId, cached] of entries) { + if (now - cached.lastUsed > CACHE_TTL) { + envCache.delete(envId); + } + } +} + +// Guard against multiple intervals during HMR +declare global { + var __dockerEnvCacheCleanupInterval: ReturnType | undefined; +} + +// Run cleanup every 10 minutes (guarded to prevent HMR leaks) +if (!globalThis.__dockerEnvCacheCleanupInterval) { + globalThis.__dockerEnvCacheCleanupInterval = setInterval(cleanupEnvCache, 10 * 60 * 1000); +} + +// Import db functions for environment lookup +import { getEnvironment } from './db'; + +// Import hawser edge connection manager for edge mode routing +import { sendEdgeRequest, sendEdgeStreamRequest, isEdgeConnected, type EdgeResponse } from './hawser'; + +/** + * Docker API client configuration + */ +interface DockerClientConfig { + type: 'socket' | 'http' | 'https'; + socketPath?: string; + host?: string; + port?: number; + ca?: string; + cert?: string; + key?: string; + skipVerify?: boolean; + // Hawser connection settings + connectionType?: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge'; + hawserToken?: string; + // Environment ID for edge mode routing + environmentId?: number; +} + +/** + * Build Docker client config from an environment + */ +function buildConfigFromEnv(env: Environment): DockerClientConfig { + // Socket connection type - use Unix socket + if (env.connectionType === 'socket' || !env.connectionType) { + return { + type: 'socket', + socketPath: env.socketPath || '/var/run/docker.sock', + connectionType: 'socket', + environmentId: env.id + }; + } + + // Direct or Hawser connection types - use HTTP/HTTPS + const protocol = (env.protocol as 'http' | 'https') || 'http'; + return { + type: protocol, + host: env.host || 'localhost', + port: env.port || 2375, + ca: env.tlsCa || undefined, + cert: env.tlsCert || undefined, + key: env.tlsKey || undefined, + skipVerify: env.tlsSkipVerify || undefined, + connectionType: env.connectionType as 'direct' | 'hawser-standard' | 'hawser-edge', + hawserToken: env.hawserToken || undefined, + environmentId: env.id + }; +} + +/** + * Get Docker client configuration for an environment + */ +async function getDockerConfig(envId?: number | null): Promise { + if (!envId) { + throw new Error('No environment specified'); + } + + // Check cache first + const cached = envCache.get(envId); + if (cached) { + cached.lastUsed = Date.now(); + return buildConfigFromEnv(cached.env); + } + + // Fetch and cache + const env = await getEnvironment(envId); + if (env) { + envCache.set(envId, { env, lastUsed: Date.now() }); + return buildConfigFromEnv(env); + } + + throw new EnvironmentNotFoundError(envId); +} + +interface DockerFetchOptions extends RequestInit { + /** Set to true for long-lived streaming connections (disables Bun's idle timeout) */ + streaming?: boolean; +} + +/** + * Check if a string is valid base64 + */ +function isBase64(str: string): boolean { + if (!str || str.length === 0) return false; + // Base64 strings have length divisible by 4 and contain only valid chars + if (str.length % 4 !== 0) return false; + return /^[A-Za-z0-9+/]*={0,2}$/.test(str); +} + +/** + * Convert EdgeResponse from hawser WebSocket to a standard Response object + * Handles base64-encoded binary data from Go agent + */ +function edgeResponseToResponse(edgeResponse: EdgeResponse): Response { + let body: string | Uint8Array = edgeResponse.body; + + // The Go agent sends isBinary flag to indicate if body is base64-encoded + if (edgeResponse.isBinary && typeof body === 'string' && body.length > 0) { + // Decode base64 to binary + body = Uint8Array.from(atob(body), c => c.charCodeAt(0)); + } + + return new Response(body as BodyInit, { + status: edgeResponse.statusCode, + headers: edgeResponse.headers + }); +} + +/** + * Make a request to the Docker API + * Exported for use by stacks.ts module + */ +export async function dockerFetch( + path: string, + options: DockerFetchOptions = {}, + envId?: number | null +): Promise { + const startTime = Date.now(); + const config = await getDockerConfig(envId); + const { streaming, ...fetchOptions } = options; + const method = (options.method || 'GET').toUpperCase(); + + // For streaming connections, disable Bun's idle timeout + // This prevents long-lived streams (like Docker events) from being terminated + const bunOptions = streaming ? { timeout: false } : {}; + + // Hawser Edge mode - route through WebSocket connection + if (config.connectionType === 'hawser-edge' && config.environmentId) { + // Check if agent is connected + if (!isEdgeConnected(config.environmentId)) { + const error = new Error('Hawser Edge agent is not connected'); + // Log without stack trace for cleaner output + console.warn(`[Docker] Edge env ${config.environmentId}: agent not connected for ${method} ${path}`); + throw error; + } + + // Extract request details + const headers: Record = {}; + + // Convert Headers object to plain object + if (fetchOptions.headers) { + if (fetchOptions.headers instanceof Headers) { + fetchOptions.headers.forEach((value, key) => { + headers[key] = value; + }); + } else if (typeof fetchOptions.headers === 'object') { + Object.assign(headers, fetchOptions.headers); + } + } + + // Parse body if present + let body: unknown; + if (fetchOptions.body) { + if (typeof fetchOptions.body === 'string') { + try { + body = JSON.parse(fetchOptions.body); + } catch { + body = fetchOptions.body; + } + } else { + body = fetchOptions.body; + } + } + + // Send request through edge connection + try { + const edgeResponse = await sendEdgeRequest( + config.environmentId, + method, + path, + body, + headers, + streaming || false, + streaming ? 300000 : 30000 // 5 min for streaming, 30s for normal requests + ); + const elapsed = Date.now() - startTime; + if (elapsed > 5000) { + console.warn(`[Docker] Edge env ${config.environmentId}: ${method} ${path} took ${elapsed}ms`); + } + return edgeResponseToResponse(edgeResponse); + } catch (error) { + const elapsed = Date.now() - startTime; + console.error(`[Docker] Edge env ${config.environmentId}: ${method} ${path} failed after ${elapsed}ms:`, error); + throw DockerConnectionError.fromError(error); + } + } + + if (config.type === 'socket') { + // Use Bun's native Unix socket support + const url = `http://localhost${path}`; + try { + const response = await fetch(url, { + ...fetchOptions, + // @ts-ignore - Bun supports unix socket and timeout options + unix: config.socketPath, + ...bunOptions + }); + const elapsed = Date.now() - startTime; + if (elapsed > 5000) { + console.warn(`[Docker] Socket: ${method} ${path} took ${elapsed}ms`); + } + return response; + } catch (error) { + const elapsed = Date.now() - startTime; + console.error(`[Docker] Socket: ${method} ${path} failed after ${elapsed}ms:`, error); + throw DockerConnectionError.fromError(error); + } + } else { + // HTTP/HTTPS remote connection + const protocol = config.type; + const url = `${protocol}://${config.host}:${config.port}${path}`; + + const finalOptions: RequestInit = { ...fetchOptions }; + + // For Hawser Standard mode with token authentication + if (config.connectionType === 'hawser-standard' && config.hawserToken) { + finalOptions.headers = { + ...finalOptions.headers, + 'X-Hawser-Token': config.hawserToken + }; + } + + // For HTTPS with TLS certificates, we need to configure TLS + // IMPORTANT: Bun requires certificates as Buffer objects, not strings + if (config.type === 'https') { + const tlsOptions: Record = {}; + + // CA certificate - must be array of Buffers for Bun + if (config.ca) { + tlsOptions.ca = [Buffer.from(config.ca)]; + } + + // Client certificate and key for mTLS - must be Buffers + if (config.cert) { + tlsOptions.cert = Buffer.from(config.cert); + } + if (config.key) { + tlsOptions.key = Buffer.from(config.key); + } + + // Skip verification (self-signed without CA) + if (config.skipVerify) { + tlsOptions.rejectUnauthorized = false; + } else { + tlsOptions.rejectUnauthorized = true; + } + + if (Object.keys(tlsOptions).length > 0) { + // @ts-ignore - Bun supports tls options with Buffer certs + finalOptions.tls = tlsOptions; + } + } + + // @ts-ignore - Bun supports timeout option + try { + const response = await fetch(url, { ...finalOptions, ...bunOptions }); + const elapsed = Date.now() - startTime; + if (elapsed > 5000) { + console.warn(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} took ${elapsed}ms`); + } + return response; + } catch (error) { + const elapsed = Date.now() - startTime; + console.error(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} failed after ${elapsed}ms:`, error); + throw DockerConnectionError.fromError(error); + } + } +} + +/** + * Make a JSON request to Docker API + */ +async function dockerJsonRequest( + path: string, + options: RequestInit = {}, + envId?: number | null +): Promise { + const response = await dockerFetch(path, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }, envId); + + if (!response.ok) { + const errorText = await response.text(); + let errorJson: any = {}; + try { + errorJson = JSON.parse(errorText); + } catch { + // Not JSON, use text as message + errorJson = { message: errorText }; + } + const error: any = new Error(errorJson.message || `Docker API error: ${response.status}`); + error.statusCode = response.status; + error.json = errorJson; + throw error; + } + + return response.json(); +} + +// Clear cached client for an environment (e.g., when settings change) +export function clearDockerClientCache(envId?: number) { + if (envId !== undefined) { + envCache.delete(envId); + } else { + envCache.clear(); + } +} + +export interface ContainerInfo { + id: string; + name: string; + image: string; + state: string; + status: string; + created: number; + ports: Array<{ + IP?: string; + PrivatePort: number; + PublicPort?: number; + Type: string; + }>; + networks: { [networkName: string]: { ipAddress: string } }; + health?: string; + restartCount: number; + mounts: Array<{ type: string; source: string; destination: string; mode: string; rw: boolean }>; + labels: { [key: string]: string }; + command: string; +} + +export interface ImageInfo { + id: string; + tags: string[]; + size: number; + created: number; +} + +// Container operations +export async function listContainers(all = true, envId?: number | null): Promise { + const containers = await dockerJsonRequest( + `/containers/json?all=${all}`, + {}, + envId + ); + + // Fetch restart counts only for restarting containers + const restartCounts = new Map(); + const restartingContainers = containers.filter(c => c.State === 'restarting'); + + await Promise.all( + restartingContainers.map(async (container) => { + try { + const inspect = await inspectContainer(container.Id, envId); + restartCounts.set(container.Id, inspect.RestartCount || 0); + } catch { + // Ignore errors + } + }) + ); + + return containers.map((container) => { + // Extract network info with IP addresses + const networks: { [networkName: string]: { ipAddress: string } } = {}; + if (container.NetworkSettings?.Networks) { + for (const [networkName, networkData] of Object.entries(container.NetworkSettings.Networks)) { + networks[networkName] = { + ipAddress: (networkData as any).IPAddress || '' + }; + } + } + + // Extract mount info + const mounts = (container.Mounts || []).map((m: any) => ({ + type: m.Type || 'unknown', + source: m.Source || m.Name || '', + destination: m.Destination || '', + mode: m.Mode || '', + rw: m.RW ?? true + })); + + // Extract health status from Status string + let health: string | undefined; + const healthMatch = container.Status?.match(/\((healthy|unhealthy|starting)\)/i); + if (healthMatch) { + health = healthMatch[1].toLowerCase(); + } + + return { + id: container.Id, + name: container.Names[0]?.replace(/^\//, '') || 'unnamed', + image: container.Image, + state: container.State, + status: container.Status, + created: container.Created, + ports: container.Ports || [], + networks, + health, + restartCount: restartCounts.get(container.Id) || 0, + mounts, + labels: container.Labels || {}, + command: container.Command || '' + }; + }); +} + +export async function getContainerStats(id: string, envId?: number | null) { + return dockerJsonRequest(`/containers/${id}/stats?stream=false`, {}, envId); +} + +export async function startContainer(id: string, envId?: number | null) { + await dockerFetch(`/containers/${id}/start`, { method: 'POST' }, envId); +} + +export async function stopContainer(id: string, envId?: number | null) { + await dockerFetch(`/containers/${id}/stop`, { method: 'POST' }, envId); +} + +export async function restartContainer(id: string, envId?: number | null) { + await dockerFetch(`/containers/${id}/restart`, { method: 'POST' }, envId); +} + +export async function pauseContainer(id: string, envId?: number | null) { + await dockerFetch(`/containers/${id}/pause`, { method: 'POST' }, envId); +} + +export async function unpauseContainer(id: string, envId?: number | null) { + await dockerFetch(`/containers/${id}/unpause`, { method: 'POST' }, envId); +} + +export async function removeContainer(id: string, force = false, envId?: number | null) { + const response = await dockerFetch(`/containers/${id}?force=${force}`, { method: 'DELETE' }, envId); + if (!response.ok) { + const errorBody = await response.text(); + let errorMessage = `Failed to remove container ${id}`; + try { + const parsed = JSON.parse(errorBody); + if (parsed.message) { + errorMessage = parsed.message; + } + } catch { + if (errorBody) { + errorMessage = errorBody; + } + } + throw new Error(errorMessage); + } +} + +export async function renameContainer(id: string, newName: string, envId?: number | null) { + await dockerFetch(`/containers/${id}/rename?name=${encodeURIComponent(newName)}`, { method: 'POST' }, envId); +} + +export async function getContainerLogs(id: string, tail = 100, envId?: number | null): Promise { + // Check if container has TTY enabled + const info = await inspectContainer(id, envId); + const hasTty = info.Config?.Tty ?? false; + + const response = await dockerFetch( + `/containers/${id}/logs?stdout=true&stderr=true&tail=${tail}×tamps=true`, + {}, + envId + ); + + const buffer = Buffer.from(await response.arrayBuffer()); + + // If TTY is enabled, logs are raw text (no demux needed) + if (hasTty) { + return buffer.toString('utf-8'); + } + + return demuxDockerStream(buffer) as string; +} + +export async function inspectContainer(id: string, envId?: number | null): Promise { + return dockerJsonRequest(`/containers/${id}/json`, {}, envId); +} + +export interface HealthcheckConfig { + test?: string[]; + interval?: number; + timeout?: number; + retries?: number; + startPeriod?: number; +} + +export interface UlimitConfig { + name: string; + soft: number; + hard: number; +} + +export interface DeviceMapping { + hostPath: string; + containerPath: string; + permissions?: string; +} + +export interface CreateContainerOptions { + name: string; + image: string; + ports?: { [key: string]: { HostPort: string } }; + volumes?: { [key: string]: {} }; + volumeBinds?: string[]; + env?: string[]; + labels?: { [key: string]: string }; + cmd?: string[]; + restartPolicy?: string; + networkMode?: string; + networks?: string[]; + user?: string; + privileged?: boolean; + healthcheck?: HealthcheckConfig; + memory?: number; + memoryReservation?: number; + cpuShares?: number; + cpuQuota?: number; + cpuPeriod?: number; + nanoCpus?: number; + capAdd?: string[]; + capDrop?: string[]; + devices?: DeviceMapping[]; + dns?: string[]; + dnsSearch?: string[]; + dnsOptions?: string[]; + securityOpt?: string[]; + ulimits?: UlimitConfig[]; +} + +export async function createContainer(options: CreateContainerOptions, envId?: number | null) { + const containerConfig: any = { + Image: options.image, + Env: options.env || [], + Labels: options.labels || {}, + HostConfig: { + RestartPolicy: { + Name: options.restartPolicy || 'no' + } + } + }; + + if (options.cmd && options.cmd.length > 0) { + containerConfig.Cmd = options.cmd; + } + + if (options.user) { + containerConfig.User = options.user; + } + + if (options.healthcheck) { + containerConfig.Healthcheck = {}; + if (options.healthcheck.test && options.healthcheck.test.length > 0) { + containerConfig.Healthcheck.Test = options.healthcheck.test; + } + if (options.healthcheck.interval !== undefined) { + containerConfig.Healthcheck.Interval = options.healthcheck.interval; + } + if (options.healthcheck.timeout !== undefined) { + containerConfig.Healthcheck.Timeout = options.healthcheck.timeout; + } + if (options.healthcheck.retries !== undefined) { + containerConfig.Healthcheck.Retries = options.healthcheck.retries; + } + if (options.healthcheck.startPeriod !== undefined) { + containerConfig.Healthcheck.StartPeriod = options.healthcheck.startPeriod; + } + } + + if (options.ports) { + containerConfig.ExposedPorts = {}; + containerConfig.HostConfig.PortBindings = {}; + + for (const [containerPort, hostConfig] of Object.entries(options.ports)) { + containerConfig.ExposedPorts[containerPort] = {}; + containerConfig.HostConfig.PortBindings[containerPort] = [hostConfig]; + } + } + + if (options.volumeBinds && options.volumeBinds.length > 0) { + containerConfig.HostConfig.Binds = options.volumeBinds; + } + + if (options.volumes) { + containerConfig.Volumes = options.volumes; + } + + if (options.networkMode) { + containerConfig.HostConfig.NetworkMode = options.networkMode; + } + + if (options.networks && options.networks.length > 0) { + containerConfig.HostConfig.NetworkMode = options.networks[0]; + containerConfig.NetworkingConfig = { + EndpointsConfig: { + [options.networks[0]]: {} + } + }; + } + + if (options.privileged) { + containerConfig.HostConfig.Privileged = options.privileged; + } + + if (options.memory) { + containerConfig.HostConfig.Memory = options.memory; + } + if (options.memoryReservation) { + containerConfig.HostConfig.MemoryReservation = options.memoryReservation; + } + if (options.cpuShares) { + containerConfig.HostConfig.CpuShares = options.cpuShares; + } + if (options.cpuQuota) { + containerConfig.HostConfig.CpuQuota = options.cpuQuota; + } + if (options.cpuPeriod) { + containerConfig.HostConfig.CpuPeriod = options.cpuPeriod; + } + if (options.nanoCpus) { + containerConfig.HostConfig.NanoCpus = options.nanoCpus; + } + + if (options.capAdd && options.capAdd.length > 0) { + containerConfig.HostConfig.CapAdd = options.capAdd; + } + if (options.capDrop && options.capDrop.length > 0) { + containerConfig.HostConfig.CapDrop = options.capDrop; + } + + if (options.devices && options.devices.length > 0) { + containerConfig.HostConfig.Devices = options.devices.map(d => ({ + PathOnHost: d.hostPath, + PathInContainer: d.containerPath, + CgroupPermissions: d.permissions || 'rwm' + })); + } + + if (options.dns && options.dns.length > 0) { + containerConfig.HostConfig.Dns = options.dns; + } + if (options.dnsSearch && options.dnsSearch.length > 0) { + containerConfig.HostConfig.DnsSearch = options.dnsSearch; + } + if (options.dnsOptions && options.dnsOptions.length > 0) { + containerConfig.HostConfig.DnsOptions = options.dnsOptions; + } + + if (options.securityOpt && options.securityOpt.length > 0) { + containerConfig.HostConfig.SecurityOpt = options.securityOpt; + } + + if (options.ulimits && options.ulimits.length > 0) { + containerConfig.HostConfig.Ulimits = options.ulimits.map(u => ({ + Name: u.name, + Soft: u.soft, + Hard: u.hard + })); + } + + const result = await dockerJsonRequest<{ Id: string }>( + `/containers/create?name=${encodeURIComponent(options.name)}`, + { + method: 'POST', + body: JSON.stringify(containerConfig) + }, + envId + ); + + // Connect to additional networks after container creation + if (options.networks && options.networks.length > 1) { + for (let i = 1; i < options.networks.length; i++) { + await dockerFetch( + `/networks/${options.networks[i]}/connect`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ Container: result.Id }) + }, + envId + ); + } + } + + return { id: result.Id, start: () => startContainer(result.Id, envId) }; +} + +export async function updateContainer(id: string, options: CreateContainerOptions, startAfterUpdate = false, envId?: number | null) { + const oldContainerInfo = await inspectContainer(id, envId); + const wasRunning = oldContainerInfo.State.Running; + + if (wasRunning) { + await stopContainer(id, envId); + } + + await removeContainer(id, true, envId); + + const newContainer = await createContainer(options, envId); + + if (startAfterUpdate || wasRunning) { + await newContainer.start(); + } + + return newContainer; +} + +// Image operations +export async function listImages(envId?: number | null): Promise { + const images = await dockerJsonRequest('/images/json', {}, envId); + return images.map((image) => ({ + id: image.Id, + tags: image.RepoTags || [], + size: image.Size, + created: image.Created + })); +} + +export async function pullImage(imageName: string, onProgress?: (data: any) => void, envId?: number | null) { + // Parse image name and tag to avoid pulling all tags + // Docker API: if tag is empty, it pulls ALL tags for the image + // Format can be: repo:tag, repo@digest, or just repo (defaults to :latest) + let fromImage = imageName; + let tag = 'latest'; + + if (imageName.includes('@')) { + // Image with digest: repo@sha256:abc123 + // Don't split, pass as-is (digest is part of fromImage) + fromImage = imageName; + tag = ''; // Empty tag when using digest + } else if (imageName.includes(':')) { + // Image with tag: repo:tag or registry.example.com/repo:tag + const lastColonIndex = imageName.lastIndexOf(':'); + const potentialTag = imageName.substring(lastColonIndex + 1); + // Make sure we're not splitting on a port number (e.g., registry.example.com:5000/repo) + // Tags don't contain slashes, but registry ports are followed by a path + if (!potentialTag.includes('/')) { + fromImage = imageName.substring(0, lastColonIndex); + tag = potentialTag; + } + } + + // Build URL with explicit tag parameter to prevent pulling all tags + const url = tag + ? `/images/create?fromImage=${encodeURIComponent(fromImage)}&tag=${encodeURIComponent(tag)}` + : `/images/create?fromImage=${encodeURIComponent(fromImage)}`; + + // Look up registry credentials for authenticated pulls + const headers: Record = {}; + try { + const { registry } = parseImageReference(imageName); + const creds = await findRegistryCredentials(registry); + if (creds) { + console.log(`[Pull] Using credentials for ${registry} (user: ${creds.username})`); + // Docker API expects X-Registry-Auth header with base64-encoded JSON + const authConfig = { + username: creds.username, + password: creds.password, + serveraddress: registry + }; + headers['X-Registry-Auth'] = Buffer.from(JSON.stringify(authConfig)).toString('base64'); + } else { + console.log(`[Pull] No credentials found for ${registry}`); + } + } catch (e) { + console.error(`[Pull] Failed to lookup credentials:`, e); + } + + // Use streaming: true for longer timeout on edge environments + const response = await dockerFetch(url, { method: 'POST', streaming: true, headers }, envId); + + if (!response.ok) { + throw new Error(`Failed to pull image: ${await response.text()}`); + } + + // Stream the response for progress updates + const reader = response.body?.getReader(); + if (!reader) return; + + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.trim()) { + try { + const data = JSON.parse(line); + if (onProgress) onProgress(data); + } catch { + // Ignore parse errors + } + } + } + } +} + +export async function removeImage(id: string, force = false, envId?: number | null) { + const response = await dockerFetch(`/images/${encodeURIComponent(id)}?force=${force}`, { method: 'DELETE' }, envId); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + const error: any = new Error(data.message || 'Failed to remove image'); + error.statusCode = response.status; + error.json = data; + throw error; + } +} + +export async function getImageHistory(id: string, envId?: number | null) { + return dockerJsonRequest(`/images/${encodeURIComponent(id)}/history`, {}, envId); +} + +export async function inspectImage(id: string, envId?: number | null) { + return dockerJsonRequest(`/images/${encodeURIComponent(id)}/json`, {}, envId); +} + +/** + * Parse an image reference into registry, repository, and tag components. + * Follows Docker's reference parsing rules. + * Examples: + * nginx:latest -> { registry: 'index.docker.io', repo: 'library/nginx', tag: 'latest' } + * ghcr.io/user/image:v1 -> { registry: 'ghcr.io', repo: 'user/image', tag: 'v1' } + * registry.example.com:5000/repo:tag -> { registry: 'registry.example.com:5000', repo: 'repo', tag: 'tag' } + */ +function parseImageReference(imageName: string): { registry: string; repo: string; tag: string } { + let registry = 'index.docker.io'; // Docker Hub's actual host + let repo = imageName; + let tag = 'latest'; + + // Handle digest references (remove digest part for manifest lookup) + if (repo.includes('@')) { + const [repoWithoutDigest] = repo.split('@'); + repo = repoWithoutDigest; + } + + // Extract tag + const lastColon = repo.lastIndexOf(':'); + if (lastColon > -1) { + const potentialTag = repo.substring(lastColon + 1); + // Make sure it's not a port number (no slashes in tags) + if (!potentialTag.includes('/')) { + tag = potentialTag; + repo = repo.substring(0, lastColon); + } + } + + // Extract registry if present + const firstSlash = repo.indexOf('/'); + if (firstSlash > -1) { + const firstPart = repo.substring(0, firstSlash); + // If the first part contains a dot, colon, or is "localhost", it's a registry + if (firstPart.includes('.') || firstPart.includes(':') || firstPart === 'localhost') { + registry = firstPart; + repo = repo.substring(firstSlash + 1); + } + } + + // Docker Hub requires library/ prefix for official images + if (registry === 'index.docker.io' && !repo.includes('/')) { + repo = `library/${repo}`; + } + + return { registry, repo, tag }; +} + +/** + * Find registry credentials from Dockhand's stored registries. + * Matches by registry host (url field). + */ +async function findRegistryCredentials(registryHost: string): Promise<{ username: string; password: string } | null> { + try { + // Import here to avoid circular dependency + const { getRegistries } = await import('./db.js'); + const registries = await getRegistries(); + + for (const reg of registries) { + // Match by URL - extract host from stored URL + const storedHost = reg.url.replace(/^https?:\/\//, '').replace(/\/.*$/, ''); + if (storedHost === registryHost || reg.url.includes(registryHost)) { + if (reg.username && reg.password) { + return { username: reg.username, password: reg.password }; + } + } + } + + // Also check for Docker Hub variations + if (registryHost === 'index.docker.io' || registryHost === 'registry-1.docker.io') { + for (const reg of registries) { + const storedHost = reg.url.replace(/^https?:\/\//, '').replace(/\/.*$/, ''); + // Match all Docker Hub URL variations + if (storedHost === 'docker.io' || storedHost === 'hub.docker.com' || + storedHost === 'registry.hub.docker.com' || storedHost === 'index.docker.io' || + storedHost === 'registry-1.docker.io') { + if (reg.username && reg.password) { + return { username: reg.username, password: reg.password }; + } + } + } + } + + return null; + } catch (e) { + console.error('Failed to lookup registry credentials:', e); + return null; + } +} + +/** + * Get bearer token from registry using challenge-response flow. + * This follows the Docker Registry v2 authentication spec: + * 1. Make request to /v2/ to get WWW-Authenticate challenge + * 2. Parse realm, service, scope from challenge + * 3. Request token from realm URL (with credentials if available) + */ +async function getRegistryBearerToken(registry: string, repo: string): Promise { + try { + const registryUrl = `https://${registry}`; + + // Look up stored credentials for this registry + const credentials = await findRegistryCredentials(registry); + + // Step 1: Challenge request to /v2/ + const challengeResponse = await fetch(`${registryUrl}/v2/`, { + method: 'GET', + headers: { 'User-Agent': 'Dockhand/1.0' } + }); + + // If 200, no auth needed + if (challengeResponse.ok) { + return null; + } + + // If not 401, something else is wrong + if (challengeResponse.status !== 401) { + console.error(`Registry challenge failed: ${challengeResponse.status}`); + return null; + } + + // Step 2: Parse WWW-Authenticate header + const wwwAuth = challengeResponse.headers.get('WWW-Authenticate') || ''; + const challenge = wwwAuth.toLowerCase(); + + if (challenge.startsWith('basic')) { + // Basic auth - use credentials if we have them + if (credentials) { + const basicAuth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'); + return `Basic ${basicAuth}`; + } + return null; + } + + if (!challenge.startsWith('bearer')) { + console.error(`Unsupported auth type: ${wwwAuth}`); + return null; + } + + // Parse bearer challenge: Bearer realm="...",service="...",scope="..." + const realmMatch = wwwAuth.match(/realm="([^"]+)"/i); + const serviceMatch = wwwAuth.match(/service="([^"]+)"/i); + + if (!realmMatch) { + console.error('No realm in WWW-Authenticate header'); + return null; + } + + const realm = realmMatch[1]; + const service = serviceMatch ? serviceMatch[1] : ''; + const scope = `repository:${repo}:pull`; + + // Step 3: Request token from realm (with credentials if available) + const tokenUrl = new URL(realm); + if (service) tokenUrl.searchParams.set('service', service); + tokenUrl.searchParams.set('scope', scope); + + const tokenHeaders: Record = { 'User-Agent': 'Dockhand/1.0' }; + + // Add Basic auth header if we have credentials + if (credentials) { + const basicAuth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'); + tokenHeaders['Authorization'] = `Basic ${basicAuth}`; + } + + const tokenResponse = await fetch(tokenUrl.toString(), { + headers: tokenHeaders + }); + + if (!tokenResponse.ok) { + console.error(`Token request failed: ${tokenResponse.status}`); + return null; + } + + const tokenData = await tokenResponse.json() as { token?: string; access_token?: string }; + const token = tokenData.token || tokenData.access_token || null; + + return token ? `Bearer ${token}` : null; + + } catch (e) { + console.error('Failed to get registry bearer token:', e); + return null; + } +} + +/** + * Check the registry for the current manifest digest of an image. + * Simple HEAD request to get Docker-Content-Digest header. + * Docker stores the manifest list digest in RepoDigests, so we compare that directly. + */ +export async function getRegistryManifestDigest(imageName: string): Promise { + try { + const { registry, repo, tag } = parseImageReference(imageName); + const token = await getRegistryBearerToken(registry, repo); + const manifestUrl = `https://${registry}/v2/${repo}/manifests/${tag}`; + + const headers: Record = { + 'User-Agent': 'Dockhand/1.0', + 'Accept': [ + 'application/vnd.docker.distribution.manifest.list.v2+json', + 'application/vnd.oci.image.index.v1+json', + 'application/vnd.docker.distribution.manifest.v2+json', + 'application/vnd.oci.image.manifest.v1+json' + ].join(', ') + }; + if (token) headers['Authorization'] = token; + + const response = await fetch(manifestUrl, { method: 'HEAD', headers }); + + if (!response.ok) { + if (response.status !== 429) { + console.error(`[Registry] ${imageName}: ${response.status}`); + } + return null; + } + + return response.headers.get('Docker-Content-Digest'); + } catch (e) { + console.error(`[Registry] ${imageName}: ${e}`); + return null; + } +} + +export interface ImageUpdateCheckResult { + hasUpdate: boolean; + currentDigest?: string; + registryDigest?: string; + /** True if this is a local-only image (no registry) */ + isLocalImage?: boolean; + /** Error message if check failed */ + error?: string; +} + +/** + * Check if an image has an update available by comparing local digests against registry. + * This is a lightweight check that doesn't pull the image. + * + * @param imageName - The image name with optional tag (e.g., "nginx:latest") + * @param currentImageId - The sha256 ID of the current local image + * @param envId - Optional environment ID for multi-environment support + * @returns Update check result with hasUpdate flag and digest info + */ +export async function checkImageUpdateAvailable( + imageName: string, + currentImageId: string, + envId?: number +): Promise { + try { + // Get current image info to get RepoDigests + let currentImageInfo: any; + try { + currentImageInfo = await inspectImage(currentImageId, envId); + } catch { + return { hasUpdate: false, error: 'Could not inspect current image' }; + } + + const currentRepoDigests: string[] = currentImageInfo?.RepoDigests || []; + + // Extract digest part from RepoDigest (format: repo@sha256:...) + const extractDigest = (rd: string): string | null => { + const atIndex = rd.lastIndexOf('@'); + return atIndex > -1 ? rd.substring(atIndex + 1) : null; + }; + + // Get ALL local digests - an image can have multiple RepoDigests + // (e.g., when a tag is updated but the content for your architecture is the same) + const localDigests = currentRepoDigests + .map(extractDigest) + .filter((d): d is string => d !== null); + + // If no local digests, this is likely a local-only image + if (localDigests.length === 0) { + return { + hasUpdate: false, + isLocalImage: true, + currentDigest: currentImageId + }; + } + + // Query registry for current manifest digest + const registryDigest = await getRegistryManifestDigest(imageName); + + if (!registryDigest) { + // Registry unreachable or image not found - can't determine update status + return { + hasUpdate: false, + currentDigest: currentRepoDigests[0], + error: 'Could not query registry' + }; + } + + // Check if registry digest matches ANY of the local digests + const matchesLocal = localDigests.includes(registryDigest); + const hasUpdate = !matchesLocal; + + return { + hasUpdate, + currentDigest: currentRepoDigests[0], + registryDigest: hasUpdate ? registryDigest : undefined + }; + } catch (e: any) { + return { hasUpdate: false, error: e.message }; + } +} + +export async function tagImage(id: string, repo: string, tag: string, envId?: number | null) { + await dockerFetch( + `/images/${encodeURIComponent(id)}/tag?repo=${encodeURIComponent(repo)}&tag=${encodeURIComponent(tag)}`, + { method: 'POST' }, + envId + ); +} + +/** + * Generate a temporary tag name for safe pulling during auto-updates. + * This allows scanning the new image before committing to the update. + * @param imageName - The original image name (e.g., "nginx:latest" or "nginx") + * @returns Temporary tag name (e.g., "nginx:latest-dockhand-pending") + */ +export function getTempImageTag(imageName: string): string { + // Handle images with digest (e.g., nginx@sha256:abc123) + if (imageName.includes('@')) { + // For digest-based images, we can't use temp tags - return as-is + return imageName; + } + + // Find the last colon + const lastColon = imageName.lastIndexOf(':'); + + // No colon at all - simple image like "nginx" + if (lastColon === -1) { + return `${imageName}:latest-dockhand-pending`; + } + + const afterColon = imageName.substring(lastColon + 1); + + // If the part after the last colon contains a slash, it's a port number + // e.g., "registry:5000/nginx" -> afterColon = "5000/nginx" + // In this case, there's no tag, so we append :latest-dockhand-pending + if (afterColon.includes('/')) { + return `${imageName}:latest-dockhand-pending`; + } + + // Otherwise, the last colon separates repo from tag + // e.g., "registry.bor6.pl/test:latest" -> repo="registry.bor6.pl/test", tag="latest" + const repo = imageName.substring(0, lastColon); + const tag = afterColon; + + return `${repo}:${tag}-dockhand-pending`; +} + +/** + * Check if an image name is using a digest (sha256) instead of a tag. + * Digest-based images don't need temp tag handling. + */ +export function isDigestBasedImage(imageName: string): boolean { + return imageName.includes('@sha256:'); +} + +/** + * Normalize an image tag for comparison. + * Docker Hub images can be represented as: + * - n8nio/n8n:latest + * - docker.io/n8nio/n8n:latest + * - docker.io/library/nginx:latest (for official images) + * - library/nginx:latest + * - nginx:latest + * Custom registries: + * - docker.n8n.io/n8nio/n8n (n8n's custom registry) + */ +function normalizeImageTag(tag: string): string { + let normalized = tag; + // Remove docker.io/ prefix + normalized = normalized.replace(/^docker\.io\//, ''); + // Remove library/ prefix for official images + normalized = normalized.replace(/^library\//, ''); + // Add :latest if no tag specified (and not a digest) + if (!normalized.includes(':') && !normalized.includes('@')) { + normalized = `${normalized}:latest`; + } + return normalized.toLowerCase(); +} + +/** + * Get image ID by tag name. + * Uses Docker's image inspect API which correctly resolves any image reference + * (docker.io, ghcr.io, custom registries, etc.) + * @returns Image ID (sha256:...) or null if not found + */ +export async function getImageIdByTag(tagName: string, envId?: number | null): Promise { + try { + // First try: Use Docker's image inspect API - this is the most reliable + // as Docker knows exactly how to resolve the image name + const imageInfo = await inspectImage(tagName, envId) as { Id?: string } | null; + if (imageInfo?.Id) { + return imageInfo.Id; + } + } catch { + // Image inspect failed - fall back to listing images + } + + try { + // Fallback: Search through listed images with normalization + const images = await listImages(envId); + const normalizedSearch = normalizeImageTag(tagName); + + for (const image of images) { + if (image.tags) { + for (const tag of image.tags) { + if (normalizeImageTag(tag) === normalizedSearch) { + return image.id; + } + } + } + } + return null; + } catch { + return null; + } +} + +/** + * Remove a temporary image by its tag. + * Used to clean up after a blocked auto-update. + * @param imageIdOrTag - Image ID or tag to remove + * @param force - Force removal even if image is in use + */ +export async function removeTempImage(imageIdOrTag: string, envId?: number | null, force = true): Promise { + try { + await removeImage(imageIdOrTag, force, envId); + } catch (error: any) { + // Log but don't throw - cleanup failure shouldn't break the flow + console.warn(`[Docker] Failed to remove temp image ${imageIdOrTag}: ${error.message}`); + } +} + +/** + * Export (save) an image as a tar archive stream. + * Uses Docker's GET /images/{name}/get endpoint. + * @returns Response object with tar stream body + */ +export async function exportImage(id: string, envId?: number | null): Promise { + const response = await dockerFetch( + `/images/${encodeURIComponent(id)}/get`, + { method: 'GET', streaming: true }, + envId + ); + + if (!response.ok) { + const error = await response.text().catch(() => 'Unknown error'); + throw new Error(`Failed to export image: ${response.status} - ${error}`); + } + + return response; +} + +// System information +export async function getDockerInfo(envId?: number | null) { + return dockerJsonRequest('/info', {}, envId); +} + +export async function getDockerVersion(envId?: number | null) { + return dockerJsonRequest('/version', {}, envId); +} + +/** + * Get Hawser agent info (for hawser-standard mode) + * Returns agent info including uptime + */ +export async function getHawserInfo(envId: number): Promise<{ + agentId: string; + agentName: string; + dockerVersion: string; + hawserVersion: string; + mode: string; + uptime: number; +} | null> { + try { + const response = await dockerFetch('/_hawser/info', {}, envId); + if (response.ok) { + return await response.json(); + } + } catch { + // Hawser info not available + } + return null; +} + +// Volume operations +export interface VolumeInfo { + name: string; + driver: string; + mountpoint: string; + scope: string; + created: string; + labels: { [key: string]: string }; +} + +export async function listVolumes(envId?: number | null): Promise { + // Fetch volumes and containers in parallel + const [volumeResult, containers] = await Promise.all([ + dockerJsonRequest<{ Volumes: any[] }>('/volumes', {}, envId), + dockerJsonRequest('/containers/json?all=true', {}, envId) + ]); + + // Build a map of volume name -> containers using it + const volumeUsageMap = new Map(); + + for (const container of containers) { + const containerName = container.Names?.[0]?.replace(/^\//, '') || 'unnamed'; + const containerId = container.Id; + + for (const mount of container.Mounts || []) { + // Check for volume-type mounts (not bind mounts) + if (mount.Type === 'volume' && mount.Name) { + const volumeName = mount.Name; + if (!volumeUsageMap.has(volumeName)) { + volumeUsageMap.set(volumeName, []); + } + volumeUsageMap.get(volumeName)!.push({ containerId, containerName }); + } + } + } + + return (volumeResult.Volumes || []).map((volume: any) => ({ + name: volume.Name, + driver: volume.Driver, + mountpoint: volume.Mountpoint, + scope: volume.Scope, + created: volume.CreatedAt, + labels: volume.Labels || {}, + usedBy: volumeUsageMap.get(volume.Name) || [] + })); +} + +/** + * Check if a volume is in use by any containers + * Returns list of containers using the volume + */ +export async function getVolumeUsage( + volumeName: string, + envId?: number | null +): Promise<{ containerId: string; containerName: string; state: string }[]> { + const containers = await dockerJsonRequest('/containers/json?all=true', {}, envId); + const usage: { containerId: string; containerName: string; state: string }[] = []; + + for (const container of containers) { + // Skip our own helper containers + if (container.Labels?.['dockhand.volume.helper'] === 'true') { + continue; + } + + const containerName = container.Names?.[0]?.replace(/^\//, '') || 'unnamed'; + const containerId = container.Id; + const state = container.State || 'unknown'; + + for (const mount of container.Mounts || []) { + if (mount.Type === 'volume' && mount.Name === volumeName) { + usage.push({ containerId, containerName, state }); + break; + } + } + } + + return usage; +} + +export async function removeVolume(name: string, force = false, envId?: number | null) { + const response = await dockerFetch(`/volumes/${encodeURIComponent(name)}?force=${force}`, { method: 'DELETE' }, envId); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + const error: any = new Error(data.message || 'Failed to remove volume'); + error.statusCode = response.status; + error.json = data; + throw error; + } +} + +export async function inspectVolume(name: string, envId?: number | null) { + return dockerJsonRequest(`/volumes/${encodeURIComponent(name)}`, {}, envId); +} + +export interface CreateVolumeOptions { + name: string; + driver?: string; + driverOpts?: { [key: string]: string }; + labels?: { [key: string]: string }; +} + +export async function createVolume(options: CreateVolumeOptions, envId?: number | null) { + const volumeConfig = { + Name: options.name, + Driver: options.driver || 'local', + DriverOpts: options.driverOpts || {}, + Labels: options.labels || {} + }; + return dockerJsonRequest('/volumes/create', { + method: 'POST', + body: JSON.stringify(volumeConfig) + }, envId); +} + +// Network operations +export interface NetworkInfo { + id: string; + name: string; + driver: string; + scope: string; + internal: boolean; + ipam: { + driver: string; + config: Array<{ subnet?: string; gateway?: string }>; + }; + containers: { [key: string]: { name: string; ipv4Address: string } }; +} + +export async function listNetworks(envId?: number | null): Promise { + const networks = await dockerJsonRequest('/networks', {}, envId); + + // Docker's /networks endpoint returns empty Containers - we need to inspect each network + // to get the actual connected containers. Run inspections in parallel for performance. + const networkDetails = await Promise.all( + networks.map(async (network: any) => { + try { + const details = await dockerJsonRequest(`/networks/${network.Id}`, {}, envId); + return { + ...network, + Containers: details.Containers || {} + }; + } catch { + // If inspection fails, return network with empty containers + return network; + } + }) + ); + + return networkDetails.map((network: any) => ({ + id: network.Id, + name: network.Name, + driver: network.Driver, + scope: network.Scope, + internal: network.Internal || false, + ipam: { + driver: network.IPAM?.Driver || 'default', + // Normalize IPAM config field names to lowercase for consistency + config: (network.IPAM?.Config || []).map((cfg: any) => ({ + subnet: cfg.Subnet || cfg.subnet, + gateway: cfg.Gateway || cfg.gateway, + ipRange: cfg.IPRange || cfg.ipRange, + auxAddress: cfg.AuxAddress || cfg.auxAddress + })) + }, + containers: Object.entries(network.Containers || {}).reduce((acc: any, [id, data]: [string, any]) => { + acc[id] = { + name: data.Name, + ipv4Address: data.IPv4Address + }; + return acc; + }, {}) + })); +} + +export async function removeNetwork(id: string, envId?: number | null) { + const response = await dockerFetch(`/networks/${id}`, { method: 'DELETE' }, envId); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + const error: any = new Error(data.message || 'Failed to remove network'); + error.statusCode = response.status; + error.json = data; + throw error; + } +} + +export async function inspectNetwork(id: string, envId?: number | null) { + return dockerJsonRequest(`/networks/${id}`, {}, envId); +} + +export interface CreateNetworkOptions { + name: string; + driver?: string; + internal?: boolean; + attachable?: boolean; + ingress?: boolean; + enableIPv6?: boolean; + ipam?: { + driver?: string; + config?: Array<{ + subnet?: string; + ipRange?: string; + gateway?: string; + auxAddress?: { [key: string]: string }; + }>; + options?: { [key: string]: string }; + }; + options?: { [key: string]: string }; + labels?: { [key: string]: string }; +} + +export async function createNetwork(options: CreateNetworkOptions, envId?: number | null) { + const networkConfig: any = { + Name: options.name, + Driver: options.driver || 'bridge', + Internal: options.internal || false, + Attachable: options.attachable || false, + Ingress: options.ingress || false, + EnableIPv6: options.enableIPv6 || false, + Options: options.options || {}, + Labels: options.labels || {} + }; + + if (options.ipam) { + networkConfig.IPAM = { + Driver: options.ipam.driver || 'default', + Config: options.ipam.config?.map(cfg => ({ + Subnet: cfg.subnet, + IPRange: cfg.ipRange, + Gateway: cfg.gateway, + AuxiliaryAddresses: cfg.auxAddress + })).filter(cfg => cfg.Subnet || cfg.Gateway) || [], + Options: options.ipam.options || {} + }; + } + + return dockerJsonRequest('/networks/create', { + method: 'POST', + body: JSON.stringify(networkConfig) + }, envId); +} + +// Network connect/disconnect operations +export async function connectContainerToNetwork( + networkId: string, + containerId: string, + envId?: number | null +): Promise { + const response = await dockerFetch( + `/networks/${networkId}/connect`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ Container: containerId }) + }, + envId + ); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.message || 'Failed to connect container to network'); + } +} + +export async function disconnectContainerFromNetwork( + networkId: string, + containerId: string, + force = false, + envId?: number | null +): Promise { + const response = await dockerFetch( + `/networks/${networkId}/disconnect`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ Container: containerId, Force: force }) + }, + envId + ); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.message || 'Failed to disconnect container from network'); + } +} + +// Container exec operations +export interface ExecOptions { + containerId: string; + cmd: string[]; + user?: string; + workingDir?: string; + envId?: number | null; +} + +export async function createExec(options: ExecOptions): Promise<{ Id: string }> { + const execConfig = { + Cmd: options.cmd, + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Tty: true, + User: options.user || 'root', + WorkingDir: options.workingDir + }; + + return dockerJsonRequest(`/containers/${options.containerId}/exec`, { + method: 'POST', + body: JSON.stringify(execConfig) + }, options.envId); +} + +export async function resizeExec(execId: string, cols: number, rows: number, envId?: number | null) { + try { + await dockerFetch(`/exec/${execId}/resize?h=${rows}&w=${cols}`, { method: 'POST' }, envId); + } catch { + // Resize may fail if exec is not running, ignore + } +} + +/** + * Get Docker connection info for direct WebSocket connections from the client + * This is used by the terminal to connect directly to the Docker API + */ +export async function getDockerConnectionInfo(envId?: number | null): Promise<{ + type: 'socket' | 'http' | 'https'; + socketPath?: string; + host?: string; + port?: number; +}> { + const config = await getDockerConfig(envId); + return { + type: config.type, + socketPath: config.socketPath, + host: config.host, + port: config.port + }; +} + +// System disk usage +export async function getDiskUsage(envId?: number | null) { + return dockerJsonRequest('/system/df', {}, envId); +} + +// Prune operations +export async function pruneContainers(envId?: number | null) { + return dockerJsonRequest('/containers/prune', { method: 'POST' }, envId); +} + +export async function pruneImages(dangling = true, envId?: number | null) { + const filters = dangling ? '{"dangling":["true"]}' : '{}'; + return dockerJsonRequest(`/images/prune?filters=${encodeURIComponent(filters)}`, { method: 'POST' }, envId); +} + +export async function pruneVolumes(envId?: number | null) { + return dockerJsonRequest('/volumes/prune', { method: 'POST' }, envId); +} + +export async function pruneNetworks(envId?: number | null) { + return dockerJsonRequest('/networks/prune', { method: 'POST' }, envId); +} + +export async function pruneAll(envId?: number | null) { + const containers = await pruneContainers(envId); + const images = await pruneImages(false, envId); + const volumes = await pruneVolumes(envId); + const networks = await pruneNetworks(envId); + return { containers, images, volumes, networks }; +} + +// Registry operations +export async function searchImages(term: string, limit = 25, envId?: number | null) { + return dockerJsonRequest(`/images/search?term=${encodeURIComponent(term)}&limit=${limit}`, {}, envId); +} + +// List containers with size info (slower operation) +export async function listContainersWithSize(all = true, envId?: number | null): Promise> { + const containers = await dockerJsonRequest( + `/containers/json?all=${all}&size=true`, + {}, + envId + ); + + const sizes: Record = {}; + for (const container of containers) { + sizes[container.Id] = { + sizeRw: container.SizeRw || 0, + sizeRootFs: container.SizeRootFs || 0 + }; + } + return sizes; +} + +// Get container top (process list) +export async function getContainerTop(id: string, envId?: number | null): Promise<{ Titles: string[]; Processes: string[][] }> { + return dockerJsonRequest(`/containers/${id}/top`, {}, envId); +} + +// Execute a command in a container and return the output +export async function execInContainer( + containerId: string, + cmd: string[], + envId?: number | null +): Promise { + // Create exec instance + const execCreate = await dockerJsonRequest<{ Id: string }>( + `/containers/${containerId}/exec`, + { + method: 'POST', + body: JSON.stringify({ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + Tty: false + }) + }, + envId + ); + + // Start exec and get output + const response = await dockerFetch( + `/exec/${execCreate.Id}/start`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ Detach: false, Tty: false }) + }, + envId + ); + + const buffer = Buffer.from(await response.arrayBuffer()); + const output = demuxDockerStream(buffer) as string; + + // Check exit code by inspecting the exec instance + const execInfo = await dockerJsonRequest<{ ExitCode: number }>( + `/exec/${execCreate.Id}/json`, + {}, + envId + ); + + if (execInfo.ExitCode !== 0) { + const errorMsg = output.trim() || `Command failed with exit code ${execInfo.ExitCode}`; + throw new Error(errorMsg); + } + + return output; +} + +// Get Docker events as a stream (for SSE) +export async function getDockerEvents( + filters: Record, + envId?: number | null +): Promise | null> { + const filterJson = JSON.stringify(filters); + + try { + // Note: We use streaming: true to disable Bun's idle timeout for this long-lived connection. + // The Docker events API keeps the connection open indefinitely, sending events as they occur. + // Without streaming: true, Bun would terminate the connection after ~5 seconds of inactivity. + const response = await dockerFetch( + `/events?filters=${encodeURIComponent(filterJson)}`, + { streaming: true }, + envId + ); + + if (!response.ok) { + throw new Error(`Docker events API returned ${response.status}`); + } + + return response.body; + } catch (error: any) { + throw error; + } +} + +// Check if volume exists +export async function volumeExists(volumeName: string, envId?: number | null): Promise { + try { + const volumes = await listVolumes(envId); + return volumes.some(v => v.name === volumeName); + } catch { + return false; + } +} + +// Generate a random suffix for container names (avoids conflicts) +function randomSuffix(): string { + return Math.random().toString(36).substring(2, 8); +} + +// Run a short-lived container and return stdout +export async function runContainer(options: { + image: string; + cmd: string[]; + binds?: string[]; + env?: string[]; + name?: string; + autoRemove?: boolean; + envId?: number | null; +}): Promise<{ stdout: string; stderr: string }> { + // Add random suffix to avoid naming conflicts + const baseName = options.name || `dockhand-temp-${Date.now()}`; + const containerName = `${baseName}-${randomSuffix()}`; + + // Create container + const containerConfig: any = { + Image: options.image, + Cmd: options.cmd, + Env: options.env || [], + Tty: false, + HostConfig: { + Binds: options.binds || [], + AutoRemove: options.autoRemove !== false + } + }; + + const createResult = await dockerJsonRequest<{ Id: string }>( + `/containers/create?name=${encodeURIComponent(containerName)}`, + { + method: 'POST', + body: JSON.stringify(containerConfig) + }, + options.envId + ); + + const containerId = createResult.Id; + + try { + // Start container + await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, options.envId); + + // Wait for container to finish + await dockerFetch(`/containers/${containerId}/wait`, { method: 'POST' }, options.envId); + + // Get logs + const logsResponse = await dockerFetch( + `/containers/${containerId}/logs?stdout=true&stderr=true`, + {}, + options.envId + ); + + const buffer = Buffer.from(await logsResponse.arrayBuffer()); + return demuxDockerStream(buffer, { separateStreams: true }) as { stdout: string; stderr: string }; + } finally { + // Cleanup container if not auto-removed + if (options.autoRemove === false) { + try { + await dockerFetch(`/containers/${containerId}?force=true`, { method: 'DELETE' }, options.envId); + } catch { + // Ignore cleanup errors + } + } + } +} + +// Run a container with attached streams (for scanners that need real-time output) +export async function runContainerWithStreaming(options: { + image: string; + cmd: string[]; + binds?: string[]; + env?: string[]; + name?: string; + envId?: number | null; + onStdout?: (data: string) => void; + onStderr?: (data: string) => void; +}): Promise { + // Add random suffix to avoid naming conflicts + const baseName = options.name || `dockhand-stream-${Date.now()}`; + const containerName = `${baseName}-${randomSuffix()}`; + + // Create container + const containerConfig: any = { + Image: options.image, + Cmd: options.cmd, + Env: options.env || [], + Tty: false, + HostConfig: { + Binds: options.binds || [], + AutoRemove: true + } + }; + + // Try to create container, handle 409 conflict by removing stale container + let createResult: { Id: string }; + try { + createResult = await dockerJsonRequest<{ Id: string }>( + `/containers/create?name=${encodeURIComponent(containerName)}`, + { + method: 'POST', + body: JSON.stringify(containerConfig) + }, + options.envId + ); + } catch (error: any) { + // Check for 409 conflict (container name already in use) + if (error?.message?.includes('409') || error?.status === 409) { + console.log(`[Docker] Container name conflict for ${containerName}, attempting cleanup...`); + // Try to force remove the conflicting container + try { + await dockerFetch(`/containers/${containerName}?force=true`, { method: 'DELETE' }, options.envId); + console.log(`[Docker] Removed stale container ${containerName}`); + } catch (removeError) { + console.error(`[Docker] Failed to remove stale container:`, removeError); + } + // Retry with a new random suffix + const retryName = `${baseName}-${randomSuffix()}`; + createResult = await dockerJsonRequest<{ Id: string }>( + `/containers/create?name=${encodeURIComponent(retryName)}`, + { + method: 'POST', + body: JSON.stringify(containerConfig) + }, + options.envId + ); + } else { + throw error; + } + } + + const containerId = createResult.Id; + + // Start container + await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, options.envId); + + // Check if this is an edge environment for streaming approach + const config = await getDockerConfig(options.envId ?? undefined); + + // Stream logs while container is running + if (config.connectionType === 'hawser-edge' && config.environmentId) { + // Edge mode: use sendEdgeStreamRequest for real-time streaming + return new Promise((resolve, reject) => { + let stdout = ''; + let buffer: Buffer = Buffer.alloc(0); + + const { cancel } = sendEdgeStreamRequest( + config.environmentId!, + 'GET', + `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true`, + { + onData: (data: string) => { + try { + // Data is base64 encoded from edge agent + const decoded = Buffer.from(data, 'base64'); + buffer = Buffer.concat([buffer, decoded]); + + // Process Docker stream frames + const result = processStreamFrames(buffer, options.onStdout, options.onStderr); + stdout += result.stdout; + buffer = result.remaining; + } catch { + // If not base64, try as raw data + const result = processStreamFrames(Buffer.from(data), options.onStdout, options.onStderr); + stdout += result.stdout; + } + }, + onEnd: () => { + resolve(stdout); + }, + onError: (error: string) => { + // If container finished, treat as success + if (error.includes('container') && (error.includes('exited') || error.includes('not running'))) { + resolve(stdout); + } else { + reject(new Error(error)); + } + } + } + ); + }); + } + + // Non-edge mode: use regular streaming + const logsResponse = await dockerFetch( + `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true`, + { streaming: true }, + options.envId + ); + + let stdout = ''; + const reader = logsResponse.body?.getReader(); + if (reader) { + let buffer: Buffer = Buffer.alloc(0); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer = Buffer.concat([buffer, Buffer.from(value)]); + const result = processStreamFrames(buffer, options.onStdout, options.onStderr); + stdout += result.stdout; + buffer = result.remaining; + } + } + + return stdout; +} + +// Push image to registry +export async function pushImage( + imageTag: string, + authConfig: { username?: string; password?: string; serveraddress: string }, + onProgress?: (data: any) => void, + envId?: number | null +): Promise { + // Parse tag to get registry info + const [repo, tag = 'latest'] = imageTag.split(':'); + + // Create X-Registry-Auth header + const authHeader = Buffer.from(JSON.stringify(authConfig)).toString('base64'); + + const response = await dockerFetch( + `/images/${encodeURIComponent(imageTag)}/push`, + { + method: 'POST', + headers: { + 'X-Registry-Auth': authHeader + } + }, + envId + ); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to push image: ${error}`); + } + + // Stream the response for progress updates + const reader = response.body?.getReader(); + if (!reader) return; + + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.trim()) { + try { + const data = JSON.parse(line); + if (data.error) { + throw new Error(data.error); + } + if (onProgress) onProgress(data); + } catch (e: any) { + if (e.message && !e.message.includes('JSON')) { + throw e; + } + } + } + } + } +} + +// Container filesystem operations +export interface FileEntry { + name: string; + type: 'file' | 'directory' | 'symlink' | 'other'; + size: number; + permissions: string; + owner: string; + group: string; + modified: string; + linkTarget?: string; + readonly?: boolean; +} + +/** + * Parse ls -la output into FileEntry array + * Handles multiple formats: + * - GNU ls with --time-style=iso: drwxr-xr-x 2 root root 4096 2024-12-08 10:30 dirname + * - Standard GNU ls: drwxr-xr-x 2 root root 4096 Dec 8 10:30 dirname + * - Busybox ls: drwxr-xr-x 2 root root 4096 Dec 8 10:30 dirname + */ +function parseLsOutput(output: string): FileEntry[] { + const lines = output.trim().split('\n'); + const entries: FileEntry[] = []; + const currentYear = new Date().getFullYear(); + + // Month name to number mapping + const monthMap: Record = { + Jan: '01', + Feb: '02', + Mar: '03', + Apr: '04', + May: '05', + Jun: '06', + Jul: '07', + Aug: '08', + Sep: '09', + Oct: '10', + Nov: '11', + Dec: '12' + }; + + for (const line of lines) { + // Skip total line, empty lines, and error messages + if (!line || line.startsWith('total ') || line.includes('cannot access') || line.includes('Permission denied')) continue; + + let typeChar: string; + let perms: string; + let owner: string; + let group: string; + let sizeStr: string; + let date: string; + let time: string; + let nameAndLink: string; + + // Try ISO format first (GNU ls with --time-style=iso) + // Format: drwxr-xr-x 2 root root 4096 2024-12-08 10:30 dirname + // With ACL: drwxr-xr-x+ 2 root root 4096 2024-12-08 10:30 dirname + // With extended attrs: drwxr-xr-x@ 2 root root 4096 2024-12-08 10:30 dirname + const isoMatch = line.match( + /^([dlcbps-])([rwxsStT-]{9})[+@.]?\s+\d+\s+(\S+)\s+(\S+)\s+(\d+)\s+(\d{2,4}-\d{2}(?:-\d{2})?)\s+(\d{2}:\d{2})\s+(.+)$/ + ); + + if (isoMatch) { + [, typeChar, perms, owner, group, sizeStr, date, time, nameAndLink] = isoMatch; + // Normalize date to YYYY-MM-DD format + if (date.length <= 5) { + // Format: MM-DD (no year) + date = `${currentYear}-${date}`; + } else if (!date.includes('-', 4)) { + // Format: YYYY-MM (no day) + date = `${date}-01`; + } + } else { + // Try standard format (GNU/busybox without --time-style) + // Format: drwxr-xr-x 2 root root 4096 Dec 8 10:30 dirname + // Or: drwxr-xr-x 2 root root 4096 Dec 8 10:30 dirname + // Or with year: drwxr-xr-x 2 root root 4096 Dec 8 2023 dirname + // With ACL/attrs: drwxr-xr-x+ or drwxr-xr-x@ or drwxr-xr-x. + const stdMatch = line.match( + /^([dlcbps-])([rwxsStT-]{9})[+@.]?\s+\d+\s+(\S+)\s+(\S+)\s+(\d+)\s+(\w{3})\s+(\d{1,2})\s+(\d{1,2}:\d{2}|\d{4})\s+(.+)$/ + ); + + if (!stdMatch) { + // Try device file format (block/char devices have major,minor instead of size) + // Format: crw-rw-rw- 1 root root 1, 3 Dec 8 10:30 null + const deviceMatch = line.match( + /^([cb])([rwxsStT-]{9})[+@.]?\s+\d+\s+(\S+)\s+(\S+)\s+(\d+),\s*(\d+)\s+(\w{3})\s+(\d{1,2})\s+(\d{1,2}:\d{2}|\d{4})\s+(.+)$/ + ); + + if (deviceMatch) { + let monthStr: string; + let dayStr: string; + let timeOrYear: string; + [, typeChar, perms, owner, group, , , monthStr, dayStr, timeOrYear, nameAndLink] = deviceMatch; + sizeStr = '0'; // Device files don't have a traditional size + + const month = monthMap[monthStr] || '01'; + const day = dayStr.padStart(2, '0'); + + if (timeOrYear.includes(':')) { + time = timeOrYear; + date = `${currentYear}-${month}-${day}`; + } else { + time = '00:00'; + date = `${timeOrYear}-${month}-${day}`; + } + } else { + continue; + } + } else { + let monthStr: string; + let dayStr: string; + let timeOrYear: string; + [, typeChar, perms, owner, group, sizeStr, monthStr, dayStr, timeOrYear, nameAndLink] = + stdMatch; + + const month = monthMap[monthStr] || '01'; + const day = dayStr.padStart(2, '0'); + + // timeOrYear is either "HH:MM" or "YYYY" + if (timeOrYear.includes(':')) { + time = timeOrYear; + date = `${currentYear}-${month}-${day}`; + } else { + time = '00:00'; + date = `${timeOrYear}-${month}-${day}`; + } + } + } + + let type: FileEntry['type']; + switch (typeChar) { + case 'd': + type = 'directory'; + break; + case 'l': + type = 'symlink'; + break; + case '-': + type = 'file'; + break; + default: + type = 'other'; + } + + let name = nameAndLink; + let linkTarget: string | undefined; + + // Handle symlinks: "name -> target" + if (type === 'symlink' && nameAndLink.includes(' -> ')) { + const parts = nameAndLink.split(' -> '); + name = parts[0]; + linkTarget = parts.slice(1).join(' -> '); + } + + // Skip . and .. entries + if (name === '.' || name === '..') continue; + + // Check if file is read-only (owner doesn't have write permission) + // perms format: rwxrwxrwx - index 1 is owner write + const isReadonly = perms.charAt(1) !== 'w'; + + entries.push({ + name, + type, + size: parseInt(sizeStr, 10), + permissions: perms, + owner, + group, + modified: `${date}T${time}:00`, + linkTarget, + readonly: isReadonly + }); + } + + return entries; +} + +/** + * List files in a container directory + * Tries multiple ls command variants for compatibility with different containers. + */ +export async function listContainerDirectory( + containerId: string, + path: string, + envId?: number | null, + useSimpleLs?: boolean +): Promise<{ path: string; entries: FileEntry[] }> { + // Sanitize path to prevent command injection + const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); + + // Commands to try in order of preference + const commands = useSimpleLs + ? [ + ['ls', '-la', safePath], + ['/bin/ls', '-la', safePath], + ['/usr/bin/ls', '-la', safePath], + ] + : [ + ['ls', '-la', '--time-style=iso', safePath], + ['ls', '-la', safePath], + ['/bin/ls', '-la', safePath], + ['/usr/bin/ls', '-la', safePath], + ]; + + let lastError: Error | null = null; + + for (const cmd of commands) { + try { + const output = await execInContainer(containerId, cmd, envId); + const entries = parseLsOutput(output); + return { path: safePath, entries }; + } catch (err: any) { + lastError = err; + continue; + } + } + + throw lastError || new Error('Failed to list directory: no working ls command found'); +} + +/** + * Get file/directory archive from container (for download) + * Returns the raw Docker API response for streaming + */ +export async function getContainerArchive( + containerId: string, + path: string, + envId?: number | null +): Promise { + // Sanitize path + const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); + + const response = await dockerFetch( + `/containers/${containerId}/archive?path=${encodeURIComponent(safePath)}`, + {}, + envId + ); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get archive: ${error}`); + } + + return response; +} + +/** + * Upload files to container (tar archive) + */ +export async function putContainerArchive( + containerId: string, + path: string, + tarData: ArrayBuffer | Uint8Array, + envId?: number | null +): Promise { + // Sanitize path + const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); + + const response = await dockerFetch( + `/containers/${containerId}/archive?path=${encodeURIComponent(safePath)}`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/x-tar' + }, + body: tarData as BodyInit + }, + envId + ); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to upload archive: ${error}`); + } +} + +/** + * Get stat info for a file/directory in container + */ +export async function statContainerPath( + containerId: string, + path: string, + envId?: number | null +): Promise<{ name: string; size: number; mode: number; mtime: string; linkTarget?: string }> { + // Sanitize path + const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); + + const response = await dockerFetch( + `/containers/${containerId}/archive?path=${encodeURIComponent(safePath)}`, + { method: 'HEAD' }, + envId + ); + + if (!response.ok) { + throw new Error(`Path not found: ${safePath}`); + } + + // Docker returns stat info in X-Docker-Container-Path-Stat header as base64 JSON + const statHeader = response.headers.get('X-Docker-Container-Path-Stat'); + if (!statHeader) { + throw new Error('No stat info returned'); + } + + const statJson = Buffer.from(statHeader, 'base64').toString('utf-8'); + return JSON.parse(statJson); +} + +/** + * Read file content from container + * Uses cat command via exec to read file contents + */ +export async function readContainerFile( + containerId: string, + path: string, + envId?: number | null +): Promise { + // Sanitize path to prevent command injection + const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); + + // Use cat to read file content + const output = await execInContainer(containerId, ['cat', safePath], envId); + return output; +} + +/** + * Write file content to container + * Uses Docker archive API to write file + */ +export async function writeContainerFile( + containerId: string, + path: string, + content: string, + envId?: number | null +): Promise { + // Sanitize path + const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); + + // Get directory and filename + const parts = safePath.split('/'); + const filename = parts.pop() || 'file'; + const directory = parts.join('/') || '/'; + + // Create a minimal tar archive with the file + // Tar format: 512-byte header + file content + padding to 512-byte boundary + const contentBytes = new TextEncoder().encode(content); + const fileSize = contentBytes.length; + + // Calculate total tar size (header + content + padding + two 512-byte end blocks) + const paddedContentSize = Math.ceil(fileSize / 512) * 512; + const tarSize = 512 + paddedContentSize + 1024; // header + padded content + end blocks + + const tarData = new Uint8Array(tarSize); + + // Write tar header (512 bytes) + // File name (100 bytes) + const filenameBytes = new TextEncoder().encode(filename); + tarData.set(filenameBytes.slice(0, 100), 0); + + // File mode (8 bytes octal) - 0644 + tarData.set(new TextEncoder().encode('0000644\0'), 100); + + // UID (8 bytes octal) - 0 + tarData.set(new TextEncoder().encode('0000000\0'), 108); + + // GID (8 bytes octal) - 0 + tarData.set(new TextEncoder().encode('0000000\0'), 116); + + // File size (12 bytes octal) + const sizeOctal = fileSize.toString(8).padStart(11, '0') + '\0'; + tarData.set(new TextEncoder().encode(sizeOctal), 124); + + // Mtime (12 bytes octal) - current time + const mtime = Math.floor(Date.now() / 1000).toString(8).padStart(11, '0') + '\0'; + tarData.set(new TextEncoder().encode(mtime), 136); + + // Checksum placeholder (8 bytes) - filled with spaces initially + tarData.set(new TextEncoder().encode(' '), 148); + + // Type flag (1 byte) - '0' for regular file + tarData[156] = 48; // ASCII '0' + + // Link name (100 bytes) - empty for regular files + // Already zeros + + // USTAR magic (6 bytes) + version (2 bytes) + tarData.set(new TextEncoder().encode('ustar\0'), 257); + tarData.set(new TextEncoder().encode('00'), 263); + + // Owner name (32 bytes) - root + tarData.set(new TextEncoder().encode('root'), 265); + + // Group name (32 bytes) - root + tarData.set(new TextEncoder().encode('root'), 297); + + // Calculate and write checksum + let checksum = 0; + for (let i = 0; i < 512; i++) { + checksum += tarData[i]; + } + const checksumOctal = checksum.toString(8).padStart(6, '0') + '\0 '; + tarData.set(new TextEncoder().encode(checksumOctal), 148); + + // Write file content after header + tarData.set(contentBytes, 512); + + // Upload to container + await putContainerArchive(containerId, directory, tarData, envId); +} + +/** + * Create an empty file in container + */ +export async function createContainerFile( + containerId: string, + path: string, + envId?: number | null +): Promise { + // Sanitize path to prevent command injection + const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); + + // Use touch to create empty file + await execInContainer(containerId, ['touch', safePath], envId); +} + +/** + * Create a directory in container + */ +export async function createContainerDirectory( + containerId: string, + path: string, + envId?: number | null +): Promise { + // Sanitize path to prevent command injection + const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); + + // Use mkdir -p to create directory (and parents if needed) + await execInContainer(containerId, ['mkdir', '-p', safePath], envId); +} + +/** + * Delete a file or directory in container + */ +export async function deleteContainerPath( + containerId: string, + path: string, + envId?: number | null +): Promise { + // Sanitize path to prevent command injection + const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); + + // Safety check: don't allow deleting root or critical paths + const dangerousPaths = ['/', '/bin', '/sbin', '/usr', '/lib', '/lib64', '/etc', '/var', '/root', '/home']; + if (dangerousPaths.includes(safePath) || safePath === '') { + throw new Error('Cannot delete critical system path'); + } + + // Use rm -rf to delete file or directory + await execInContainer(containerId, ['rm', '-rf', safePath], envId); +} + +/** + * Rename/move a file or directory in container + */ +export async function renameContainerPath( + containerId: string, + oldPath: string, + newPath: string, + envId?: number | null +): Promise { + // Sanitize paths to prevent command injection + const safeOldPath = oldPath.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); + const safeNewPath = newPath.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); + + // Use mv to rename + await execInContainer(containerId, ['mv', safeOldPath, safeNewPath], envId); +} + +/** + * Change permissions of a file or directory in container + */ +export async function chmodContainerPath( + containerId: string, + path: string, + mode: string, + recursive: boolean = false, + envId?: number | null +): Promise { + // Sanitize path to prevent command injection + const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); + + // Validate mode (should be octal like 755 or symbolic like u+x) + if (!/^[0-7]{3,4}$/.test(mode) && !/^[ugoa]*[+-=][rwxXst]+$/.test(mode)) { + throw new Error('Invalid chmod mode'); + } + + // Build command + const cmd = recursive ? ['chmod', '-R', mode, safePath] : ['chmod', mode, safePath]; + await execInContainer(containerId, cmd, envId); +} + +// Volume browsing and export helpers + +const VOLUME_HELPER_IMAGE = 'busybox:latest'; +const VOLUME_MOUNT_PATH = '/volume'; +const VOLUME_HELPER_TTL_SECONDS = 300; // 5 minutes TTL for helper containers + +// Cache for volume helper containers: key = `${volumeName}:${envId ?? 'local'}` -> containerId +const volumeHelperCache = new Map(); + +/** + * Get cache key for a volume helper container + */ +function getVolumeCacheKey(volumeName: string, envId?: number | null): string { + return `${volumeName}:${envId ?? 'local'}`; +} + +/** + * Ensure the volume helper image (busybox) is available, pulling if necessary + */ +async function ensureVolumeHelperImage(envId?: number | null): Promise { + // Check if image exists + const response = await dockerFetch(`/images/${encodeURIComponent(VOLUME_HELPER_IMAGE)}/json`, {}, envId); + + if (response.ok) { + return; // Image exists + } + + // Image not found, pull it + console.log(`Pulling ${VOLUME_HELPER_IMAGE} for volume browsing...`); + const pullResponse = await dockerFetch( + `/images/create?fromImage=${encodeURIComponent(VOLUME_HELPER_IMAGE)}`, + { method: 'POST' }, + envId + ); + + if (!pullResponse.ok) { + const error = await pullResponse.text(); + throw new Error(`Failed to pull ${VOLUME_HELPER_IMAGE}: ${error}`); + } + + // Wait for pull to complete by consuming the stream + const reader = pullResponse.body?.getReader(); + if (reader) { + while (true) { + const { done } = await reader.read(); + if (done) break; + } + } + + console.log(`Successfully pulled ${VOLUME_HELPER_IMAGE}`); +} + +/** + * Check if a container exists and is running + */ +async function isContainerRunning(containerId: string, envId?: number | null): Promise { + try { + const response = await dockerFetch(`/containers/${containerId}/json`, {}, envId); + if (!response.ok) return false; + const info = await response.json(); + return info.State?.Running === true; + } catch { + return false; + } +} + +/** + * Get or create a helper container for volume browsing. + * Reuses existing containers from cache for better performance. + * Returns the container ID. + * @param readOnly - If true, mount volume read-only (default). If false, mount writable. + */ +export async function getOrCreateVolumeHelperContainer( + volumeName: string, + envId?: number | null, + readOnly: boolean = true +): Promise { + // Include readOnly in cache key since we need different containers for ro/rw + const cacheKey = `${getVolumeCacheKey(volumeName, envId)}:${readOnly ? 'ro' : 'rw'}`; + const now = Date.now(); + + // Check cache for existing container + const cached = volumeHelperCache.get(cacheKey); + if (cached && cached.expiresAt > now) { + // Verify container is still running + if (await isContainerRunning(cached.containerId, envId)) { + // Refresh expiry time on access + cached.expiresAt = now + VOLUME_HELPER_TTL_SECONDS * 1000; + return cached.containerId; + } + // Container no longer running, remove from cache + volumeHelperCache.delete(cacheKey); + } + + // Ensure helper image is available (auto-pull if missing) + await ensureVolumeHelperImage(envId); + + // Generate a unique container name based on volume name + const safeVolumeName = volumeName.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 50); + const rwSuffix = readOnly ? 'ro' : 'rw'; + const containerName = `dockhand-browse-${safeVolumeName}-${rwSuffix}-${Date.now().toString(36)}`; + + // Create a temporary container with the volume mounted + const bindMount = readOnly + ? `${volumeName}:${VOLUME_MOUNT_PATH}:ro` + : `${volumeName}:${VOLUME_MOUNT_PATH}`; + + const containerConfig = { + Image: VOLUME_HELPER_IMAGE, + Cmd: ['sleep', 'infinity'], // Keep alive indefinitely (managed by cache TTL) + HostConfig: { + Binds: [bindMount], + AutoRemove: false + }, + Labels: { + 'dockhand.volume.helper': 'true', + 'dockhand.volume.name': volumeName, + 'dockhand.volume.readonly': String(readOnly) + } + }; + + const response = await dockerJsonRequest<{ Id: string }>( + `/containers/create?name=${encodeURIComponent(containerName)}`, + { + method: 'POST', + body: JSON.stringify(containerConfig) + }, + envId + ); + + const containerId = response.Id; + + // Start the container + await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, envId); + + // Cache the container + volumeHelperCache.set(cacheKey, { + containerId, + expiresAt: now + VOLUME_HELPER_TTL_SECONDS * 1000 + }); + + return containerId; +} + +/** + * @deprecated Use getOrCreateVolumeHelperContainer instead + * Create a temporary container with a volume mounted for browsing/export + * Returns the container ID. Caller is responsible for removing the container. + */ +export async function createVolumeHelperContainer( + volumeName: string, + envId?: number | null +): Promise { + return getOrCreateVolumeHelperContainer(volumeName, envId); +} + +/** + * Release a cached volume helper container when done browsing. + * This removes the container from cache and stops/removes it from Docker. + * Cleans up both ro and rw variants if they exist. + */ +export async function releaseVolumeHelperContainer( + volumeName: string, + envId?: number | null +): Promise { + const baseCacheKey = getVolumeCacheKey(volumeName, envId); + + // Clean up both read-only and read-write variants + for (const suffix of [':ro', ':rw']) { + const cacheKey = baseCacheKey + suffix; + const cached = volumeHelperCache.get(cacheKey); + + if (cached) { + volumeHelperCache.delete(cacheKey); + await removeVolumeHelperContainer(cached.containerId, envId).catch(err => { + console.warn('Failed to cleanup volume helper container:', err); + }); + } + } +} + +/** + * Cleanup expired volume helper containers. + * Called periodically to remove containers that have exceeded their TTL. + */ +export async function cleanupExpiredVolumeHelpers(): Promise { + const now = Date.now(); + const expiredEntries: Array<{ key: string; containerId: string; envId?: number | null }> = []; + + for (const [key, cached] of volumeHelperCache.entries()) { + if (cached.expiresAt <= now) { + // Parse envId from key: "volumeName:envId" or "volumeName:local" + const [, envIdStr] = key.split(':'); + const envId = envIdStr === 'local' ? null : parseInt(envIdStr); + expiredEntries.push({ key, containerId: cached.containerId, envId }); + } + } + + // Remove from cache and cleanup containers + for (const { key, containerId, envId } of expiredEntries) { + volumeHelperCache.delete(key); + removeVolumeHelperContainer(containerId, envId ?? undefined).catch(err => { + console.warn('Failed to cleanup expired volume helper container:', err); + }); + } + + if (expiredEntries.length > 0) { + console.log(`Cleaned up ${expiredEntries.length} expired volume helper container(s)`); + } +} + +/** + * Remove a volume helper container + */ +export async function removeVolumeHelperContainer( + containerId: string, + envId?: number | null +): Promise { + try { + // Stop the container first (force) + await dockerFetch(`/containers/${containerId}/stop?t=1`, { method: 'POST' }, envId); + } catch { + // Ignore stop errors + } + + // Remove the container + await dockerFetch(`/containers/${containerId}?force=true`, { method: 'DELETE' }, envId); +} + +/** + * Cleanup all stale volume helper containers on a specific environment. + * Finds containers with label dockhand.volume.helper=true and removes them. + * Called on startup to clean up containers from previous process runs. + */ +async function cleanupStaleVolumeHelpersForEnv(envId?: number | null): Promise { + try { + // Query containers with our helper label + const filters = JSON.stringify({ label: ['dockhand.volume.helper=true'] }); + const response = await dockerFetch( + `/containers/json?all=true&filters=${encodeURIComponent(filters)}`, + {}, + envId + ); + + if (!response.ok) { + return 0; + } + + const containers: Array<{ Id: string; Names: string[] }> = await response.json(); + let removed = 0; + + for (const container of containers) { + try { + await removeVolumeHelperContainer(container.Id, envId); + removed++; + } catch (err) { + console.warn(`Failed to remove stale helper container ${container.Names?.[0] || container.Id}:`, err); + } + } + + return removed; + } catch (err) { + console.warn('Failed to query stale volume helpers:', err); + return 0; + } +} + +/** + * Cleanup stale volume helper containers across all environments. + * Should be called on startup to clean up orphaned containers. + * @param environments - Optional pre-fetched environments (avoids dynamic import in production) + */ +export async function cleanupStaleVolumeHelpers(environments: Array<{ id: number }>): Promise { + console.log('Cleaning up stale volume helper containers...'); + + if (!environments || environments.length === 0) { + console.log('No environments to clean up'); + return; + } + + let totalRemoved = 0; + + // Clean up all configured environments + for (const env of environments) { + totalRemoved += await cleanupStaleVolumeHelpersForEnv(env.id); + } + + if (totalRemoved > 0) { + console.log(`Removed ${totalRemoved} stale volume helper container(s)`); + } +} + +/** + * List directory contents in a volume + * Uses cached helper containers for better performance. + */ +export async function listVolumeDirectory( + volumeName: string, + path: string, + envId?: number | null, + readOnly: boolean = true +): Promise<{ path: string; entries: FileEntry[]; containerId: string }> { + const containerId = await getOrCreateVolumeHelperContainer(volumeName, envId, readOnly); + + // Sanitize path + const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); + const fullPath = `${VOLUME_MOUNT_PATH}${safePath.startsWith('/') ? safePath : '/' + safePath}`; + + // Use simple ls since busybox doesn't support --time-style + const output = await execInContainer(containerId, ['ls', '-la', fullPath], envId); + const entries = parseLsOutput(output); + + return { + path: safePath || '/', + entries, + containerId + }; + // Note: Container is kept alive for reuse. It will be cleaned up + // when the cache TTL expires or when the volume browser modal closes. +} + +/** + * Get archive of volume contents for download + * Uses cached helper containers for better performance. + */ +export async function getVolumeArchive( + volumeName: string, + path: string, + envId?: number | null, + readOnly: boolean = true +): Promise<{ response: Response; containerId: string }> { + const containerId = await getOrCreateVolumeHelperContainer(volumeName, envId, readOnly); + + // Sanitize path + const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); + const fullPath = `${VOLUME_MOUNT_PATH}${safePath.startsWith('/') ? safePath : '/' + safePath}`; + + const response = await dockerFetch( + `/containers/${containerId}/archive?path=${encodeURIComponent(fullPath)}`, + {}, + envId + ); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get archive: ${error}`); + } + + return { response, containerId }; + // Note: Container is kept alive for reuse. Cache TTL will handle cleanup. +} + +/** + * Read file content from volume + * Uses cached helper containers for better performance. + */ +export async function readVolumeFile( + volumeName: string, + path: string, + envId?: number | null, + readOnly: boolean = true +): Promise { + const containerId = await getOrCreateVolumeHelperContainer(volumeName, envId, readOnly); + + // Sanitize path + const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, ''); + const fullPath = `${VOLUME_MOUNT_PATH}${safePath.startsWith('/') ? safePath : '/' + safePath}`; + + // Use cat to read file content + const output = await execInContainer(containerId, ['cat', fullPath], envId); + return output; + // Note: Container is kept alive for reuse. Cache TTL will handle cleanup. +} diff --git a/lib/server/event-collector.ts b/lib/server/event-collector.ts new file mode 100644 index 0000000..8cc496a --- /dev/null +++ b/lib/server/event-collector.ts @@ -0,0 +1,18 @@ +/** + * Container Event Emitter + * + * Shared EventEmitter for broadcasting container events to SSE clients. + * Events are emitted by the subprocess-manager when it receives them from the event-subprocess. + */ + +import { EventEmitter } from 'node:events'; + +// Event emitter for broadcasting new events to SSE clients +// Used by: +// - subprocess-manager.ts: emits events received from event-subprocess via IPC +// - api/activity/events/+server.ts: listens for events to broadcast via SSE +export const containerEventEmitter = new EventEmitter(); + +// Allow up to 100 concurrent SSE listeners (default is 10) +// This prevents MaxListenersExceededWarning with many dashboard clients +containerEventEmitter.setMaxListeners(100); diff --git a/lib/server/git.ts b/lib/server/git.ts new file mode 100644 index 0000000..00d8d55 --- /dev/null +++ b/lib/server/git.ts @@ -0,0 +1,1135 @@ +import { existsSync, mkdirSync, rmSync, chmodSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { + getGitRepository, + getGitCredential, + updateGitRepository, + getGitStack, + updateGitStack, + upsertStackSource, + type GitRepository, + type GitCredential, + type GitStackWithRepo +} from './db'; +import { deployStack } from './stacks'; + +// Directory for storing cloned repositories +const GIT_REPOS_DIR = process.env.GIT_REPOS_DIR || './data/git-repos'; + +// Ensure git repos directory exists +if (!existsSync(GIT_REPOS_DIR)) { + mkdirSync(GIT_REPOS_DIR, { recursive: true }); +} + +/** + * Mask sensitive values in environment variables for safe logging. + */ +function maskSecrets(vars: Record): Record { + const masked: Record = {}; + const secretPatterns = /password|secret|token|key|api_key|apikey|auth|credential|private/i; + for (const [key, value] of Object.entries(vars)) { + if (secretPatterns.test(key)) { + masked[key] = '***'; + } else if (value.length > 50) { + masked[key] = value.substring(0, 10) + '...(truncated)'; + } else { + masked[key] = value; + } + } + return masked; +} + +function getRepoPath(repoId: number): string { + return join(GIT_REPOS_DIR, `repo-${repoId}`); +} + +interface GitEnv { + [key: string]: string; +} + +async function buildGitEnv(credential: GitCredential | null): Promise { + const env: GitEnv = { + ...process.env as GitEnv, + GIT_TERMINAL_PROMPT: '0', + // Prevent SSH agent from providing keys automatically + SSH_AUTH_SOCK: '' + }; + + if (credential?.authType === 'ssh' && credential.sshPrivateKey) { + // Create a temporary SSH key file (use absolute path so SSH can find it) + const sshKeyPath = resolve(join(GIT_REPOS_DIR, `.ssh-key-${credential.id}`)); + await Bun.write(sshKeyPath, credential.sshPrivateKey); + // Ensure SSH key has correct permissions (0600 = owner read/write only) + // Bun.write's mode option doesn't always work reliably, so use chmodSync + chmodSync(sshKeyPath, 0o600); + + // Configure SSH to use ONLY this key (no agent, no default keys) + const sshCommand = `ssh -i "${sshKeyPath}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes`; + env.GIT_SSH_COMMAND = sshCommand; + } else { + // No SSH credential - prevent using any keys (IdentitiesOnly=yes with no -i means no keys) + env.GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes -o PasswordAuthentication=no -o PubkeyAuthentication=no'; + } + + return env; +} + +function cleanupSshKey(credential: GitCredential | null): void { + if (credential?.authType === 'ssh') { + const sshKeyPath = resolve(join(GIT_REPOS_DIR, `.ssh-key-${credential.id}`)); + try { + if (existsSync(sshKeyPath)) { + rmSync(sshKeyPath); + } + } catch { + // Ignore cleanup errors + } + } +} + +function buildRepoUrl(url: string, credential: GitCredential | null): string { + // For SSH URLs or no auth, return as-is + if (!credential || credential.authType !== 'password' || url.startsWith('git@')) { + return url; + } + + // For HTTPS with password auth, embed credentials + try { + const parsed = new URL(url); + if (credential.username) { + parsed.username = credential.username; + } + if (credential.password) { + parsed.password = credential.password; + } + return parsed.toString(); + } catch { + return url; + } +} + +async function execGit(args: string[], cwd: string, env: GitEnv): Promise<{ stdout: string; stderr: string; code: number }> { + try { + const proc = Bun.spawn(['git', ...args], { + cwd, + env, + stdout: 'pipe', + stderr: 'pipe' + }); + + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text() + ]); + + const code = await proc.exited; + + return { stdout: stdout.trim(), stderr: stderr.trim(), code }; + } catch (err: any) { + return { stdout: '', stderr: err.message, code: 1 }; + } +} + +export interface SyncResult { + success: boolean; + commit?: string; + composeContent?: string; + envFileVars?: Record; // Variables from .env file in repo + error?: string; + updated?: boolean; +} + +export interface TestResult { + success: boolean; + branch?: string; + lastCommit?: string; + composeFileExists?: boolean; + error?: string; +} + +/** + * Clean up git/SSH error messages for user display + */ +function cleanGitError(stderr: string): string { + // Remove SSH warnings and noise + const lines = stderr.split('\n').filter(line => { + const l = line.trim().toLowerCase(); + // Skip SSH warnings + if (l.startsWith('warning:')) return false; + if (l.includes('added') && l.includes('to the list of known hosts')) return false; + // Skip empty lines + if (!l) return false; + return true; + }); + + // Find the most relevant error + const fatalLine = lines.find(l => l.toLowerCase().includes('fatal:')); + const permissionLine = lines.find(l => l.toLowerCase().includes('permission denied')); + const errorLine = lines.find(l => l.toLowerCase().includes('error:')); + + // Return cleaner message + if (permissionLine) { + return 'Permission denied. Check your SSH credentials.'; + } + if (fatalLine) { + // Clean up common fatal messages + const msg = fatalLine.replace(/^fatal:\s*/i, '').trim(); + if (msg.includes('Could not read from remote repository')) { + return 'Could not access repository. Check URL and credentials.'; + } + return msg; + } + if (errorLine) { + return errorLine.replace(/^error:\s*/i, '').trim(); + } + + // Fallback to original (joined and trimmed) + return lines.join(' ').trim() || 'Failed to connect to repository'; +} + +/** + * Core function to test a git repository connection. + * Tests the URL, branch, and credentials passed directly (not from DB). + */ +async function testRepositoryConnection(options: { + url: string; + branch: string; + credential: GitCredential | null; +}): Promise { + const { url, branch, credential } = options; + + const env = await buildGitEnv(credential); + const repoUrl = buildRepoUrl(url, credential); + + try { + // Use git ls-remote to test connection and verify branch + const result = await execGit( + ['ls-remote', '--heads', '--refs', repoUrl, branch || 'HEAD'], + process.cwd(), + env + ); + + cleanupSshKey(credential); + + if (result.code !== 0) { + console.error('[Git] Connection test failed:', result.stderr); + return { success: false, error: cleanGitError(result.stderr) }; + } + + // Parse the output to get commit hash + const lines = result.stdout.split('\n').filter(l => l.trim()); + if (lines.length === 0) { + // Branch not found, but connection worked - check if repo has any branches + const allBranchesResult = await execGit( + ['ls-remote', '--heads', '--refs', repoUrl], + process.cwd(), + env + ); + cleanupSshKey(credential); + + if (allBranchesResult.code !== 0) { + return { success: false, error: cleanGitError(allBranchesResult.stderr) }; + } + + const allBranches = allBranchesResult.stdout.split('\n') + .filter(l => l.trim()) + .map(l => { + const m = l.match(/refs\/heads\/(.+)$/); + return m ? m[1] : null; + }) + .filter(Boolean); + + if (allBranches.length === 0) { + return { success: true, branch: '(empty repository)' }; + } + + return { + success: false, + error: `Branch '${branch}' not found. Available branches: ${allBranches.slice(0, 5).join(', ')}${allBranches.length > 5 ? '...' : ''}` + }; + } + + const match = lines[0].match(/^([a-f0-9]+)\s+refs\/heads\/(.+)$/); + const lastCommit = match ? match[1].substring(0, 7) : undefined; + const foundBranch = match ? match[2] : branch; + + return { + success: true, + branch: foundBranch, + lastCommit + }; + } catch (error: any) { + cleanupSshKey(credential); + return { success: false, error: error.message }; + } +} + +/** + * Test a saved repository from the database (used by grid test button). + */ +export async function testRepository(repoId: number): Promise { + const repo = await getGitRepository(repoId); + if (!repo) { + return { success: false, error: 'Repository not found' }; + } + + const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null; + + return testRepositoryConnection({ + url: repo.url, + branch: repo.branch, + credential + }); +} + +/** + * Test a repository configuration before saving (used by modal test button). + * Uses credentialId to fetch stored credentials from the database. + */ +export async function testRepositoryConfig(options: { + url: string; + branch: string; + credentialId?: number | null; +}): Promise { + const { url, branch, credentialId } = options; + + if (!url) { + return { success: false, error: 'Repository URL is required' }; + } + + // Fetch credential from database if credentialId is provided + const credential = credentialId ? await getGitCredential(credentialId) : null; + if (credentialId && !credential) { + return { success: false, error: 'Credential not found' }; + } + + return testRepositoryConnection({ + url, + branch: branch || 'main', + credential + }); +} + +export async function syncRepository(repoId: number): Promise { + const repo = await getGitRepository(repoId); + if (!repo) { + return { success: false, error: 'Repository not found' }; + } + + // Check if sync is already in progress + if (repo.syncStatus === 'syncing') { + return { success: false, error: 'Sync already in progress' }; + } + + const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null; + const repoPath = getRepoPath(repoId); + const env = await buildGitEnv(credential); + + try { + // Update sync status + await updateGitRepository(repoId, { syncStatus: 'syncing', syncError: null }); + + let updated = false; + let currentCommit = ''; + + if (!existsSync(repoPath)) { + // Clone the repository (shallow clone) + const repoUrl = buildRepoUrl(repo.url, credential); + + const result = await execGit( + ['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath], + process.cwd(), + env + ); + if (result.code !== 0) { + // Clean up partial clone directory on failure + if (existsSync(repoPath)) { + rmSync(repoPath, { recursive: true, force: true }); + } + throw new Error(`Git clone failed: ${result.stderr}`); + } + + updated = true; + } else { + // Get current commit before pull + const beforeResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); + const beforeCommit = beforeResult.stdout; + + // Pull latest changes + const result = await execGit(['pull', 'origin', repo.branch], repoPath, env); + if (result.code !== 0) { + throw new Error(`Git pull failed: ${result.stderr}`); + } + + // Get commit after pull + const afterResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); + const afterCommit = afterResult.stdout; + + updated = beforeCommit !== afterCommit; + } + + // Get current commit hash + const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); + currentCommit = commitResult.stdout.substring(0, 7); + + // Read the compose file + const composePath = join(repoPath, repo.composePath); + if (!existsSync(composePath)) { + throw new Error(`Compose file not found: ${repo.composePath}`); + } + + const composeContent = await Bun.file(composePath).text(); + + // Update repository status + await updateGitRepository(repoId, { + syncStatus: 'synced', + lastSync: new Date().toISOString(), + lastCommit: currentCommit, + syncError: null + }); + + cleanupSshKey(credential); + + return { + success: true, + commit: currentCommit, + composeContent, + updated + }; + } catch (error: any) { + cleanupSshKey(credential); + await updateGitRepository(repoId, { + syncStatus: 'error', + syncError: error.message + }); + return { success: false, error: error.message }; + } +} + +export async function deployFromRepository(repoId: number): Promise<{ success: boolean; output?: string; error?: string }> { + const repo = await getGitRepository(repoId); + if (!repo) { + return { success: false, error: 'Repository not found' }; + } + + // Sync first + const syncResult = await syncRepository(repoId); + if (!syncResult.success) { + return { success: false, error: syncResult.error }; + } + + const stackName = repo.name; + + // Deploy using unified function - handles both new and existing stacks + const result = await deployStack({ + name: stackName, + compose: syncResult.composeContent!, + envId: repo.environmentId + }); + + if (result.success) { + // Record the stack source + await upsertStackSource({ + stackName: stackName, + environmentId: repo.environmentId, + sourceType: 'git', + gitRepositoryId: repoId + }); + } + + return result; +} + +export async function checkForUpdates(repoId: number): Promise<{ hasUpdates: boolean; currentCommit?: string; latestCommit?: string; error?: string }> { + const repo = await getGitRepository(repoId); + if (!repo) { + return { hasUpdates: false, error: 'Repository not found' }; + } + + const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null; + const repoPath = getRepoPath(repoId); + const env = await buildGitEnv(credential); + + try { + if (!existsSync(repoPath)) { + return { hasUpdates: true, currentCommit: 'none', latestCommit: 'unknown' }; + } + + // Get current commit + const currentResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); + const currentCommit = currentResult.stdout.substring(0, 7); + + // Fetch latest without merging + await execGit(['fetch', 'origin', repo.branch], repoPath, env); + + // Get remote commit + const latestResult = await execGit(['rev-parse', `origin/${repo.branch}`], repoPath, env); + const latestCommit = latestResult.stdout.substring(0, 7); + + cleanupSshKey(credential); + + return { + hasUpdates: currentCommit !== latestCommit, + currentCommit, + latestCommit + }; + } catch (error: any) { + cleanupSshKey(credential); + return { hasUpdates: false, error: error.message }; + } +} + +export function deleteRepositoryFiles(repoId: number): void { + const repoPath = getRepoPath(repoId); + try { + if (existsSync(repoPath)) { + rmSync(repoPath, { recursive: true, force: true }); + } + } catch (error) { + console.error('Failed to delete repository files:', error); + } +} + +// === Git Stack Functions === + +function getStackRepoPath(stackId: number): string { + return join(GIT_REPOS_DIR, `stack-${stackId}`); +} + +export async function syncGitStack(stackId: number): Promise { + const gitStack = await getGitStack(stackId); + if (!gitStack) { + return { success: false, error: 'Git stack not found' }; + } + + const logPrefix = `[Stack:${gitStack.stackName}]`; + console.log(`${logPrefix} ========================================`); + console.log(`${logPrefix} SYNC GIT STACK START`); + console.log(`${logPrefix} ========================================`); + console.log(`${logPrefix} Stack ID:`, stackId); + console.log(`${logPrefix} Stack name:`, gitStack.stackName); + console.log(`${logPrefix} Repository ID:`, gitStack.repositoryId); + console.log(`${logPrefix} Compose path:`, gitStack.composePath); + console.log(`${logPrefix} Env file path:`, gitStack.envFilePath || '(none)'); + console.log(`${logPrefix} Environment ID:`, gitStack.environmentId); + + // Check if sync is already in progress + if (gitStack.syncStatus === 'syncing') { + console.log(`${logPrefix} ERROR: Sync already in progress`); + return { success: false, error: 'Sync already in progress' }; + } + + const repo = await getGitRepository(gitStack.repositoryId); + if (!repo) { + console.log(`${logPrefix} ERROR: Repository not found`); + return { success: false, error: 'Repository not found' }; + } + + console.log(`${logPrefix} Repository URL:`, repo.url); + console.log(`${logPrefix} Repository branch:`, repo.branch); + + const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null; + const repoPath = getStackRepoPath(stackId); + const env = await buildGitEnv(credential); + + console.log(`${logPrefix} Local repo path:`, repoPath); + console.log(`${logPrefix} Has credential:`, !!credential); + + try { + // Update sync status + await updateGitStack(stackId, { syncStatus: 'syncing', syncError: null }); + + let updated = false; + let currentCommit = ''; + + if (!existsSync(repoPath)) { + console.log(`${logPrefix} Repo doesn't exist locally, cloning...`); + // Clone the repository (shallow clone) + const repoUrl = buildRepoUrl(repo.url, credential); + + const result = await execGit( + ['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath], + process.cwd(), + env + ); + console.log(`${logPrefix} Clone exit code:`, result.code); + if (result.stdout) console.log(`${logPrefix} Clone stdout:`, result.stdout); + if (result.stderr) console.log(`${logPrefix} Clone stderr:`, result.stderr); + + if (result.code !== 0) { + // Clean up partial clone directory on failure + if (existsSync(repoPath)) { + rmSync(repoPath, { recursive: true, force: true }); + } + throw new Error(`Git clone failed: ${result.stderr}`); + } + + updated = true; + } else { + console.log(`${logPrefix} Repo exists, pulling latest...`); + // Get current commit before pull + const beforeResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); + const beforeCommit = beforeResult.stdout; + console.log(`${logPrefix} Commit before pull:`, beforeCommit.substring(0, 7)); + + // Pull latest changes + const result = await execGit(['pull', 'origin', repo.branch], repoPath, env); + console.log(`${logPrefix} Pull exit code:`, result.code); + if (result.stdout) console.log(`${logPrefix} Pull stdout:`, result.stdout); + if (result.stderr) console.log(`${logPrefix} Pull stderr:`, result.stderr); + + if (result.code !== 0) { + throw new Error(`Git pull failed: ${result.stderr}`); + } + + // Get commit after pull + const afterResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); + const afterCommit = afterResult.stdout; + console.log(`${logPrefix} Commit after pull:`, afterCommit.substring(0, 7)); + + updated = beforeCommit !== afterCommit; + console.log(`${logPrefix} Repo updated:`, updated); + } + + // Get current commit hash + const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); + currentCommit = commitResult.stdout.substring(0, 7); + console.log(`${logPrefix} Current commit:`, currentCommit); + + // Read the compose file + const composePath = join(repoPath, gitStack.composePath); + console.log(`${logPrefix} Reading compose file from:`, composePath); + if (!existsSync(composePath)) { + console.log(`${logPrefix} ERROR: Compose file not found at:`, composePath); + throw new Error(`Compose file not found: ${gitStack.composePath}`); + } + + const composeContent = await Bun.file(composePath).text(); + console.log(`${logPrefix} Compose content length:`, composeContent.length, 'chars'); + console.log(`${logPrefix} Compose content:`); + console.log(composeContent); + + // Read env file if configured (optional - don't fail if missing) + let envFileVars: Record | undefined; + if (gitStack.envFilePath) { + const envFilePath = join(repoPath, gitStack.envFilePath); + console.log(`${logPrefix} Looking for env file at:`, envFilePath); + if (existsSync(envFilePath)) { + try { + console.log(`${logPrefix} Reading env file...`); + const envContent = await Bun.file(envFilePath).text(); + envFileVars = parseEnvFileContent(envContent, gitStack.stackName); + console.log(`${logPrefix} Env file parsed, vars count:`, Object.keys(envFileVars).length); + } catch (err) { + // Log but don't fail - env file is optional + console.warn(`${logPrefix} Failed to read env file ${gitStack.envFilePath}:`, err); + } + } else { + console.warn(`${logPrefix} Configured env file not found:`, gitStack.envFilePath); + } + } else { + console.log(`${logPrefix} No env file path configured`); + } + + // Update git stack status + await updateGitStack(stackId, { + syncStatus: 'synced', + lastSync: new Date().toISOString(), + lastCommit: currentCommit, + syncError: null + }); + + cleanupSshKey(credential); + + console.log(`${logPrefix} ----------------------------------------`); + console.log(`${logPrefix} SYNC GIT STACK COMPLETE`); + console.log(`${logPrefix} ----------------------------------------`); + console.log(`${logPrefix} Success: true`); + console.log(`${logPrefix} Updated:`, updated); + console.log(`${logPrefix} Commit:`, currentCommit); + console.log(`${logPrefix} Env file vars count:`, envFileVars ? Object.keys(envFileVars).length : 0); + + return { + success: true, + commit: currentCommit, + composeContent, + envFileVars, + updated + }; + } catch (error: any) { + cleanupSshKey(credential); + await updateGitStack(stackId, { + syncStatus: 'error', + syncError: error.message + }); + console.log(`${logPrefix} SYNC ERROR:`, error.message); + return { success: false, error: error.message }; + } +} + +export async function deployGitStack(stackId: number, options?: { force?: boolean }): Promise<{ success: boolean; output?: string; error?: string; skipped?: boolean }> { + const force = options?.force ?? true; // Default to force for backward compatibility + + const gitStack = await getGitStack(stackId); + if (!gitStack) { + return { success: false, error: 'Git stack not found' }; + } + + const logPrefix = `[Stack:${gitStack.stackName}]`; + console.log(`${logPrefix} ========================================`); + console.log(`${logPrefix} DEPLOY GIT STACK START`); + console.log(`${logPrefix} ========================================`); + console.log(`${logPrefix} Stack ID:`, stackId); + console.log(`${logPrefix} Force deploy:`, force); + + // Sync first + console.log(`${logPrefix} Syncing git repository...`); + const syncResult = await syncGitStack(stackId); + if (!syncResult.success) { + console.log(`${logPrefix} Sync failed:`, syncResult.error); + return { success: false, error: syncResult.error }; + } + + console.log(`${logPrefix} Sync successful`); + console.log(`${logPrefix} Sync result - updated:`, syncResult.updated); + console.log(`${logPrefix} Sync result - commit:`, syncResult.commit); + console.log(`${logPrefix} Sync result - env file vars:`, syncResult.envFileVars ? Object.keys(syncResult.envFileVars).length : 0); + if (syncResult.envFileVars && Object.keys(syncResult.envFileVars).length > 0) { + console.log(`${logPrefix} Env file var keys:`, Object.keys(syncResult.envFileVars).join(', ')); + console.log(`${logPrefix} Env file vars (masked):`, JSON.stringify(maskSecrets(syncResult.envFileVars), null, 2)); + } + + // Check if there are changes - skip redeploy if no changes and not forced + // Note: For new stacks (first deploy), syncResult.updated will be true + if (!force && !syncResult.updated) { + console.log(`${logPrefix} No changes detected and force=false, skipping redeploy`); + return { + success: true, + output: 'No changes detected, skipping redeploy', + skipped: true + }; + } + + const forceRecreate = syncResult.updated && !!gitStack.envFilePath; + console.log(`${logPrefix} Will force recreate:`, forceRecreate, `(updated=${syncResult.updated}, hasEnvFile=${!!gitStack.envFilePath})`); + + // Deploy using unified function - handles both new and existing stacks + // Uses `docker compose up -d --remove-orphans` which only recreates changed services + // Force recreate when git detected changes AND stack has .env file configured + // This ensures containers pick up new env var values even if compose file didn't change + // Note: Without this, docker compose only detects compose file changes, not env var changes + console.log(`${logPrefix} Calling deployStack...`); + const result = await deployStack({ + name: gitStack.stackName, + compose: syncResult.composeContent!, + envId: gitStack.environmentId, + envFileVars: syncResult.envFileVars, + forceRecreate + }); + + console.log(`${logPrefix} ----------------------------------------`); + console.log(`${logPrefix} DEPLOY GIT STACK RESULT`); + console.log(`${logPrefix} ----------------------------------------`); + console.log(`${logPrefix} Success:`, result.success); + if (result.output) console.log(`${logPrefix} Output:`, result.output); + if (result.error) console.log(`${logPrefix} Error:`, result.error); + + if (result.success) { + // Record the stack source + await upsertStackSource({ + stackName: gitStack.stackName, + environmentId: gitStack.environmentId, + sourceType: 'git', + gitRepositoryId: gitStack.repositoryId, + gitStackId: stackId + }); + } + + return result; +} + +export async function testGitStack(stackId: number): Promise { + const gitStack = await getGitStack(stackId); + if (!gitStack) { + return { success: false, error: 'Git stack not found' }; + } + + const repo = await getGitRepository(gitStack.repositoryId); + if (!repo) { + return { success: false, error: 'Repository not found' }; + } + + const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null; + const env = await buildGitEnv(credential); + const repoUrl = buildRepoUrl(repo.url, credential); + + try { + // Use git ls-remote to test connection and get branch info + const result = await execGit( + ['ls-remote', '--heads', '--refs', repoUrl, repo.branch], + process.cwd(), + env + ); + + cleanupSshKey(credential); + + if (result.code !== 0) { + return { success: false, error: result.stderr || 'Failed to connect to repository' }; + } + + // Parse the output to get commit hash + const lines = result.stdout.split('\n').filter(l => l.trim()); + if (lines.length === 0) { + return { success: false, error: `Branch '${repo.branch}' not found in repository` }; + } + + const match = lines[0].match(/^([a-f0-9]+)\s+refs\/heads\/(.+)$/); + const lastCommit = match ? match[1].substring(0, 7) : undefined; + const branch = match ? match[2] : repo.branch; + + cleanupSshKey(credential); + + return { + success: true, + branch, + lastCommit + }; + } catch (error: any) { + cleanupSshKey(credential); + return { success: false, error: error.message }; + } +} + +export function deleteGitStackFiles(stackId: number): void { + const repoPath = getStackRepoPath(stackId); + try { + if (existsSync(repoPath)) { + rmSync(repoPath, { recursive: true, force: true }); + } + } catch (error) { + console.error('Failed to delete git stack files:', error); + } +} + +// Progress callback type +type ProgressCallback = (data: { + status: 'connecting' | 'cloning' | 'fetching' | 'reading' | 'deploying' | 'complete' | 'error'; + message?: string; + step?: number; + totalSteps?: number; + error?: string; +}) => void; + +export async function deployGitStackWithProgress( + stackId: number, + onProgress: ProgressCallback +): Promise<{ success: boolean; output?: string; error?: string }> { + const gitStack = await getGitStack(stackId); + if (!gitStack) { + onProgress({ status: 'error', error: 'Git stack not found' }); + return { success: false, error: 'Git stack not found' }; + } + + // Check if sync is already in progress + if (gitStack.syncStatus === 'syncing') { + onProgress({ status: 'error', error: 'Sync already in progress' }); + return { success: false, error: 'Sync already in progress' }; + } + + const repo = await getGitRepository(gitStack.repositoryId); + if (!repo) { + onProgress({ status: 'error', error: 'Repository not found' }); + return { success: false, error: 'Repository not found' }; + } + + const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null; + const repoPath = getStackRepoPath(stackId); + const env = await buildGitEnv(credential); + + const totalSteps = 5; + + try { + // Step 1: Connecting + onProgress({ status: 'connecting', message: 'Connecting to repository...', step: 1, totalSteps }); + await updateGitStack(stackId, { syncStatus: 'syncing', syncError: null }); + + let updated = false; + let currentCommit = ''; + + if (!existsSync(repoPath)) { + // Step 2: Cloning + onProgress({ status: 'cloning', message: 'Cloning repository...', step: 2, totalSteps }); + + const repoUrl = buildRepoUrl(repo.url, credential); + + // Step 3: Fetching + onProgress({ status: 'fetching', message: `Fetching branch ${repo.branch}...`, step: 3, totalSteps }); + const result = await execGit( + ['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath], + process.cwd(), + env + ); + if (result.code !== 0) { + // Clean up partial clone directory on failure + if (existsSync(repoPath)) { + rmSync(repoPath, { recursive: true, force: true }); + } + throw new Error(`Git clone failed: ${result.stderr}`); + } + + updated = true; + } else { + // Step 2-3: Fetching and resetting to latest (works with shallow clones) + onProgress({ status: 'fetching', message: 'Fetching latest changes...', step: 2, totalSteps }); + + const beforeResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); + const beforeCommit = beforeResult.stdout; + + // Fetch the latest from origin (shallow fetch) + const fetchResult = await execGit(['fetch', '--depth=1', 'origin', repo.branch], repoPath, env); + if (fetchResult.code !== 0) { + throw new Error(`Git fetch failed: ${fetchResult.stderr}`); + } + + // Reset to the fetched commit (this works reliably with shallow clones) + onProgress({ status: 'fetching', message: 'Updating to latest...', step: 3, totalSteps }); + const resetResult = await execGit(['reset', '--hard', `origin/${repo.branch}`], repoPath, env); + if (resetResult.code !== 0) { + throw new Error(`Git reset failed: ${resetResult.stderr}`); + } + + const afterResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); + const afterCommit = afterResult.stdout; + + updated = beforeCommit !== afterCommit; + } + + // Get current commit hash + const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); + currentCommit = commitResult.stdout.substring(0, 7); + + // Step 4: Reading compose file + onProgress({ status: 'reading', message: `Reading ${gitStack.composePath}...`, step: 4, totalSteps }); + const composePath = join(repoPath, gitStack.composePath); + if (!existsSync(composePath)) { + throw new Error(`Compose file not found: ${gitStack.composePath}`); + } + + const composeContent = await Bun.file(composePath).text(); + + // Read env file if configured (optional - don't fail if missing) + let envFileVars: Record | undefined; + if (gitStack.envFilePath) { + const envFilePath = join(repoPath, gitStack.envFilePath); + if (existsSync(envFilePath)) { + try { + const envContent = await Bun.file(envFilePath).text(); + envFileVars = parseEnvFileContent(envContent, gitStack.stackName); + } catch (err) { + // Log but don't fail - env file is optional + console.warn(`Failed to read env file ${gitStack.envFilePath}:`, err); + } + } else { + console.warn(`Configured env file not found: ${gitStack.envFilePath}`); + } + } + + // Update git stack status + await updateGitStack(stackId, { + syncStatus: 'synced', + lastSync: new Date().toISOString(), + lastCommit: currentCommit, + syncError: null + }); + + cleanupSshKey(credential); + + // Step 5: Deploying stack + // Uses `docker compose up -d --remove-orphans` which only recreates changed services + onProgress({ status: 'deploying', message: `Deploying ${gitStack.stackName}...`, step: 5, totalSteps }); + const result = await deployStack({ + name: gitStack.stackName, + compose: composeContent, + envId: gitStack.environmentId, + envFileVars + }); + + if (result.success) { + // Record the stack source + await upsertStackSource({ + stackName: gitStack.stackName, + environmentId: gitStack.environmentId, + sourceType: 'git', + gitRepositoryId: gitStack.repositoryId, + gitStackId: stackId + }); + + onProgress({ status: 'complete', message: `Successfully deployed ${gitStack.stackName}` }); + } else { + throw new Error(result.error || 'Failed to deploy stack'); + } + + return result; + } catch (error: any) { + cleanupSshKey(credential); + await updateGitStack(stackId, { + syncStatus: 'error', + syncError: error.message + }); + onProgress({ status: 'error', error: error.message }); + return { success: false, error: error.message }; + } +} + +// ============================================================================= +// ENV FILE OPERATIONS +// ============================================================================= + +/** + * List all .env* files in a git stack's repository. + * Returns relative paths from the repository root. + */ +export async function listGitStackEnvFiles(stackId: number): Promise<{ files: string[]; error?: string }> { + const gitStack = await getGitStack(stackId); + if (!gitStack) { + return { files: [], error: 'Git stack not found' }; + } + + const repoPath = getStackRepoPath(stackId); + if (!existsSync(repoPath)) { + return { files: [], error: 'Repository not synced - deploy the stack first' }; + } + + try { + // Find all .env* files recursively (but not too deep) + const maxDepth = 3; + + // Use find to locate all .env* files + const proc = Bun.spawn(['find', repoPath, '-maxdepth', String(maxDepth), '-type', 'f', '-name', '.env*'], { + stdout: 'pipe', + stderr: 'pipe' + }); + const output = await new Response(proc.stdout).text(); + await proc.exited; + + const files = output.trim().split('\n').filter(f => f); + const envFiles: string[] = []; + + for (const file of files) { + // Convert absolute path to relative from repo root + const relativePath = file.replace(repoPath + '/', ''); + // Skip files in node_modules or .git directories + if (!relativePath.includes('node_modules/') && !relativePath.includes('.git/')) { + envFiles.push(relativePath); + } + } + + return { files: envFiles.sort() }; + } catch (error: any) { + return { files: [], error: error.message }; + } +} + +/** + * Parse a .env file content into key-value pairs. + * Handles comments, empty lines, and quoted values. + */ +export function parseEnvFileContent(content: string, stackName?: string): Record { + const logPrefix = stackName ? `[Stack:${stackName}]` : '[Git]'; + const result: Record = {}; + const skippedLines: string[] = []; + const invalidKeys: string[] = []; + + console.log(`${logPrefix} ----------------------------------------`); + console.log(`${logPrefix} PARSE ENV FILE CONTENT`); + console.log(`${logPrefix} ----------------------------------------`); + console.log(`${logPrefix} Raw content length:`, content.length, 'chars'); + console.log(`${logPrefix} Raw content:`); + console.log(content); + + const lines = content.split('\n'); + console.log(`${logPrefix} Total lines:`, lines.length); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + // Skip empty lines and comments + if (!trimmed || trimmed.startsWith('#')) { + if (trimmed) skippedLines.push(`Line ${i + 1}: ${trimmed.substring(0, 50)}...`); + continue; + } + + // Find the first = sign + const eqIndex = trimmed.indexOf('='); + if (eqIndex === -1) { + skippedLines.push(`Line ${i + 1} (no =): ${trimmed.substring(0, 50)}`); + continue; + } + + const key = trimmed.substring(0, eqIndex).trim(); + let value = trimmed.substring(eqIndex + 1).trim(); + + // Handle quoted values + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + + // Only add if key is valid env var name + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + result[key] = value; + } else { + invalidKeys.push(`Line ${i + 1}: "${key}" (invalid key format)`); + } + } + + console.log(`${logPrefix} Parsed env vars count:`, Object.keys(result).length); + console.log(`${logPrefix} Parsed env var keys:`, Object.keys(result).join(', ')); + console.log(`${logPrefix} Parsed env vars (masked):`, JSON.stringify(maskSecrets(result), null, 2)); + if (skippedLines.length > 0) { + console.log(`${logPrefix} Skipped lines (${skippedLines.length}):`, skippedLines.slice(0, 10).join('; ')); + } + if (invalidKeys.length > 0) { + console.log(`${logPrefix} Invalid keys (${invalidKeys.length}):`, invalidKeys.join('; ')); + } + + return result; +} + +/** + * Read and parse a .env file from a git stack's repository. + */ +export async function readGitStackEnvFile( + stackId: number, + envFilePath: string +): Promise<{ vars: Record; error?: string }> { + const gitStack = await getGitStack(stackId); + if (!gitStack) { + return { vars: {}, error: 'Git stack not found' }; + } + + const repoPath = getStackRepoPath(stackId); + if (!existsSync(repoPath)) { + return { vars: {}, error: 'Repository not synced - deploy the stack first' }; + } + + // Security check: ensure the path doesn't escape the repo + const normalizedPath = envFilePath.replace(/\.\./g, '').replace(/^\//, ''); + const fullPath = join(repoPath, normalizedPath); + + if (!fullPath.startsWith(repoPath)) { + return { vars: {}, error: 'Invalid file path' }; + } + + if (!existsSync(fullPath)) { + return { vars: {}, error: `File not found: ${envFilePath}` }; + } + + try { + const content = await Bun.file(fullPath).text(); + const vars = parseEnvFileContent(content); + return { vars }; + } catch (error: any) { + return { vars: {}, error: error.message }; + } +} diff --git a/lib/server/hawser.ts b/lib/server/hawser.ts new file mode 100644 index 0000000..c3a361a --- /dev/null +++ b/lib/server/hawser.ts @@ -0,0 +1,945 @@ +/** + * Hawser Edge Connection Manager + * + * Manages WebSocket connections from Hawser agents running in Edge mode. + * Handles request/response correlation, heartbeat tracking, and metrics collection. + */ + +import { db, hawserTokens, environments, eq } from './db/drizzle.js'; +import { logContainerEvent, saveHostMetric, type ContainerEventAction } from './db.js'; +import { containerEventEmitter } from './event-collector.js'; +import { sendEnvironmentNotification } from './notifications.js'; + +// Protocol constants +export const HAWSER_PROTOCOL_VERSION = '1.0'; + +// Message types (matching Hawser agent protocol) +export const MessageType = { + HELLO: 'hello', + WELCOME: 'welcome', + REQUEST: 'request', + RESPONSE: 'response', + STREAM: 'stream', + STREAM_END: 'stream_end', + METRICS: 'metrics', + PING: 'ping', + PONG: 'pong', + ERROR: 'error' +} as const; + +// Active edge connections mapped by environment ID +export interface EdgeConnection { + ws: WebSocket; + environmentId: number; + agentId: string; + agentName: string; + agentVersion: string; + dockerVersion: string; + hostname: string; + capabilities: string[]; + connectedAt: Date; + lastHeartbeat: Date; + pendingRequests: Map; + pendingStreamRequests: Map; + lastMetrics?: { + uptime?: number; + cpuUsage?: number; + memoryTotal?: number; + memoryUsed?: number; + }; +} + +interface PendingRequest { + resolve: (response: EdgeResponse) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; +} + +interface PendingStreamRequest { + onData: (data: string, stream?: 'stdout' | 'stderr') => void; + onEnd: (reason?: string) => void; + onError: (error: string) => void; +} + +export interface EdgeResponse { + statusCode: number; + headers: Record; + body: string | Uint8Array; + isBinary?: boolean; +} + +// Global map of active connections (stored in globalThis for dev mode sharing with vite.config.ts) +declare global { + var __hawserEdgeConnections: Map | undefined; + var __hawserSendMessage: ((envId: number, message: string) => boolean) | undefined; + var __hawserHandleContainerEvent: ((envId: number, event: ContainerEventMessage['event']) => Promise) | undefined; + var __hawserHandleMetrics: ((envId: number, metrics: MetricsMessage['metrics']) => Promise) | undefined; +} +export const edgeConnections: Map = + globalThis.__hawserEdgeConnections ?? (globalThis.__hawserEdgeConnections = new Map()); + +// Cleanup interval for stale connections (check every 30 seconds) +let cleanupInterval: NodeJS.Timeout | null = null; + +/** + * Initialize the edge connection manager + */ +export function initializeEdgeManager(): void { + if (cleanupInterval) return; + + cleanupInterval = setInterval(() => { + const now = Date.now(); + const timeout = 90 * 1000; // 90 seconds (3 missed heartbeats) + + for (const [envId, conn] of edgeConnections) { + if (now - conn.lastHeartbeat.getTime() > timeout) { + const pendingCount = conn.pendingRequests.size; + const streamCount = conn.pendingStreamRequests.size; + console.log( + `[Hawser] Connection timeout for environment ${envId}. ` + + `Rejecting ${pendingCount} pending requests and ${streamCount} stream requests.` + ); + + // Reject all pending requests before closing + for (const [requestId, pending] of conn.pendingRequests) { + console.log(`[Hawser] Rejecting pending request ${requestId} due to connection timeout`); + clearTimeout(pending.timeout); + pending.reject(new Error('Connection timeout')); + } + for (const [requestId, pending] of conn.pendingStreamRequests) { + console.log(`[Hawser] Ending stream request ${requestId} due to connection timeout`); + pending.onEnd?.('Connection timeout'); + } + conn.pendingRequests.clear(); + conn.pendingStreamRequests.clear(); + + conn.ws.close(1001, 'Connection timeout'); + edgeConnections.delete(envId); + updateEnvironmentStatus(envId, null); + } + } + }, 30000); +} + +/** + * Stop the edge connection manager + */ +export function stopEdgeManager(): void { + if (cleanupInterval) { + clearInterval(cleanupInterval); + cleanupInterval = null; + } + + // Close all connections + for (const [, conn] of edgeConnections) { + conn.ws.close(1001, 'Server shutdown'); + } + edgeConnections.clear(); +} + +/** + * Handle container event from Edge agent + * Saves to database, emits to SSE clients, and sends notifications + */ +export async function handleEdgeContainerEvent( + environmentId: number, + event: ContainerEventMessage['event'] +): Promise { + try { + // Log the event + console.log(`[Hawser] Container event from env ${environmentId}: ${event.action} ${event.containerName || event.containerId}`); + + // Save to database + const savedEvent = await logContainerEvent({ + environmentId, + containerId: event.containerId, + containerName: event.containerName || null, + image: event.image || null, + action: event.action as ContainerEventAction, + actorAttributes: event.actorAttributes || null, + timestamp: event.timestamp + }); + + // Broadcast to SSE clients + containerEventEmitter.emit('event', savedEvent); + + // Prepare notification + const actionLabel = event.action.charAt(0).toUpperCase() + event.action.slice(1); + const containerLabel = event.containerName || event.containerId.substring(0, 12); + const notificationType = + event.action === 'die' || event.action === 'kill' || event.action === 'oom' + ? 'error' + : event.action === 'stop' + ? 'warning' + : event.action === 'start' + ? 'success' + : 'info'; + + // Send notification + await sendEnvironmentNotification(environmentId, event.action as ContainerEventAction, { + title: `Container ${actionLabel}`, + message: `Container "${containerLabel}" ${event.action}${event.image ? ` (${event.image})` : ''}`, + type: notificationType as 'success' | 'error' | 'warning' | 'info' + }, event.image); + } catch (error) { + console.error('[Hawser] Error handling container event:', error); + } +} + +// Register global handler for patch-build.ts to use +globalThis.__hawserHandleContainerEvent = handleEdgeContainerEvent; + +/** + * Handle metrics from Edge agent + * Saves to database for dashboard graphs and stores latest metrics in connection + */ +export async function handleEdgeMetrics( + environmentId: number, + metrics: MetricsMessage['metrics'] +): Promise { + try { + // Store latest metrics in the edge connection for quick access (e.g., uptime) + const connection = edgeConnections.get(environmentId); + if (connection) { + connection.lastMetrics = { + uptime: metrics.uptime, + cpuUsage: metrics.cpuUsage, + memoryTotal: metrics.memoryTotal, + memoryUsed: metrics.memoryUsed + }; + } + + // Normalize CPU by core count (agent sends raw percentage across all cores) + const cpuPercent = metrics.cpuCores > 0 ? metrics.cpuUsage / metrics.cpuCores : metrics.cpuUsage; + const memoryPercent = metrics.memoryTotal > 0 + ? (metrics.memoryUsed / metrics.memoryTotal) * 100 + : 0; + + // Save to database using the existing function + await saveHostMetric( + cpuPercent, + memoryPercent, + metrics.memoryUsed, + metrics.memoryTotal, + environmentId + ); + } catch (error) { + console.error('[Hawser] Error saving metrics:', error); + } +} + +// Register global handler for metrics +globalThis.__hawserHandleMetrics = handleEdgeMetrics; + +/** + * Validate a Hawser token + */ +export async function validateHawserToken( + token: string +): Promise<{ valid: boolean; environmentId?: number; tokenId?: number }> { + // Get all active tokens + const tokens = await db.select().from(hawserTokens).where(eq(hawserTokens.isActive, true)); + + // Check each token (tokens are hashed) + for (const t of tokens) { + try { + const isValid = await Bun.password.verify(token, t.token); + if (isValid) { + // Update last used timestamp + await db + .update(hawserTokens) + .set({ lastUsed: new Date().toISOString() }) + .where(eq(hawserTokens.id, t.id)); + + return { + valid: true, + environmentId: t.environmentId ?? undefined, + tokenId: t.id + }; + } + } catch { + // Invalid hash, continue checking + } + } + + return { valid: false }; +} + +/** + * Generate a new Hawser token for an environment + * @param rawToken - Optional pre-generated token (base64url string). If not provided, generates a new one. + */ +export async function generateHawserToken( + name: string, + environmentId: number, + expiresAt?: string, + rawToken?: string +): Promise<{ token: string; tokenId: number }> { + // Close any existing edge connection for this environment + // This forces the agent to reconnect with the new token + const existingConnection = edgeConnections.get(environmentId); + if (existingConnection) { + console.log(`[Hawser] Closing existing connection for env ${environmentId} due to new token generation`); + existingConnection.ws.close(1000, 'Token regenerated'); + edgeConnections.delete(environmentId); + } + + // Use provided token or generate a new one + let token: string; + if (rawToken) { + // Use the pre-generated token directly (already in base64url format) + token = rawToken; + } else { + // Generate a secure random token (32 bytes = 256 bits) + const tokenBytes = new Uint8Array(32); + crypto.getRandomValues(tokenBytes); + token = Buffer.from(tokenBytes).toString('base64url'); + } + + // Hash the token for storage (using Bun's built-in Argon2id) + const hashedToken = await Bun.password.hash(token, { + algorithm: 'argon2id', + memoryCost: 19456, + timeCost: 2 + }); + + // Get prefix for identification + const tokenPrefix = token.substring(0, 8); + + // Store in database + const result = await db + .insert(hawserTokens) + .values({ + token: hashedToken, + tokenPrefix, + name, + environmentId, + isActive: true, + expiresAt + }) + .returning({ id: hawserTokens.id }); + + return { + token, // Return unhashed token (only shown once) + tokenId: result[0].id + }; +} + +/** + * Revoke a Hawser token + */ +export async function revokeHawserToken(tokenId: number): Promise { + await db.update(hawserTokens).set({ isActive: false }).where(eq(hawserTokens.id, tokenId)); +} + +/** + * Close an Edge connection and clean up pending requests. + * Called when an environment is deleted. + */ +export function closeEdgeConnection(environmentId: number): void { + const connection = edgeConnections.get(environmentId); + if (!connection) { + console.log(`[Hawser] No Edge connection to close for environment ${environmentId}`); + return; + } + + const pendingCount = connection.pendingRequests.size; + const streamCount = connection.pendingStreamRequests.size; + console.log( + `[Hawser] Closing Edge connection for deleted environment ${environmentId}. ` + + `Rejecting ${pendingCount} pending requests and ${streamCount} stream requests.` + ); + + // Reject all pending requests + for (const [requestId, pending] of connection.pendingRequests) { + console.log(`[Hawser] Rejecting pending request ${requestId} due to environment deletion`); + clearTimeout(pending.timeout); + pending.reject(new Error('Environment deleted')); + } + for (const [requestId, pending] of connection.pendingStreamRequests) { + console.log(`[Hawser] Ending stream request ${requestId} due to environment deletion`); + pending.onEnd?.('Environment deleted'); + } + connection.pendingRequests.clear(); + connection.pendingStreamRequests.clear(); + + // Close the WebSocket + try { + connection.ws.close(1000, 'Environment deleted'); + } catch (e) { + console.error(`[Hawser] Error closing WebSocket for environment ${environmentId}:`, e); + } + + edgeConnections.delete(environmentId); + console.log(`[Hawser] Edge connection closed for environment ${environmentId}`); +} + +/** + * Handle a new edge connection from a Hawser agent + */ +export function handleEdgeConnection( + ws: WebSocket, + environmentId: number, + hello: HelloMessage +): EdgeConnection { + // Check if there's already a connection for this environment + const existing = edgeConnections.get(environmentId); + if (existing) { + const pendingCount = existing.pendingRequests.size; + const streamCount = existing.pendingStreamRequests.size; + console.log( + `[Hawser] Replacing existing connection for environment ${environmentId}. ` + + `Rejecting ${pendingCount} pending requests and ${streamCount} stream requests.` + ); + + // Reject all pending requests before closing + for (const [requestId, pending] of existing.pendingRequests) { + console.log(`[Hawser] Rejecting pending request ${requestId} due to connection replacement`); + pending.reject(new Error('Connection replaced by new agent')); + } + for (const [requestId, pending] of existing.pendingStreamRequests) { + console.log(`[Hawser] Ending stream request ${requestId} due to connection replacement`); + pending.onEnd?.('Connection replaced by new agent'); + } + existing.pendingRequests.clear(); + existing.pendingStreamRequests.clear(); + + existing.ws.close(1000, 'Replaced by new connection'); + } + + const connection: EdgeConnection = { + ws, + environmentId, + agentId: hello.agentId, + agentName: hello.agentName, + agentVersion: hello.version, + dockerVersion: hello.dockerVersion, + hostname: hello.hostname, + capabilities: hello.capabilities, + connectedAt: new Date(), + lastHeartbeat: new Date(), + pendingRequests: new Map(), + pendingStreamRequests: new Map() + }; + + edgeConnections.set(environmentId, connection); + + // Update environment record + updateEnvironmentStatus(environmentId, connection); + + return connection; +} + +/** + * Update environment status in database + */ +async function updateEnvironmentStatus( + environmentId: number, + connection: EdgeConnection | null +): Promise { + if (connection) { + await db + .update(environments) + .set({ + hawserLastSeen: new Date().toISOString(), + hawserAgentId: connection.agentId, + hawserAgentName: connection.agentName, + hawserVersion: connection.agentVersion, + hawserCapabilities: JSON.stringify(connection.capabilities), + updatedAt: new Date().toISOString() + }) + .where(eq(environments.id, environmentId)); + } else { + await db + .update(environments) + .set({ + hawserLastSeen: new Date().toISOString(), + updatedAt: new Date().toISOString() + }) + .where(eq(environments.id, environmentId)); + } +} + +/** + * Send a request to a Hawser agent and wait for response + */ +export async function sendEdgeRequest( + environmentId: number, + method: string, + path: string, + body?: unknown, + headers?: Record, + streaming = false, + timeout = 30000 +): Promise { + const connection = edgeConnections.get(environmentId); + if (!connection) { + throw new Error('Edge agent not connected'); + } + + const requestId = crypto.randomUUID(); + + return new Promise((resolve, reject) => { + const timeoutHandle = setTimeout(() => { + connection.pendingRequests.delete(requestId); + if (streaming) { + connection.pendingStreamRequests.delete(requestId); + } + reject(new Error('Request timeout')); + }, timeout); + + // For streaming requests, the Go agent sends 'stream' messages instead of a single 'response'. + // We need to register a stream handler that collects all data and resolves when complete. + if (streaming) { + // Initialize pendingStreamRequests if not present (dev mode HMR safety) + if (!connection.pendingStreamRequests) { + connection.pendingStreamRequests = new Map(); + } + + const chunks: Buffer[] = []; + + connection.pendingStreamRequests.set(requestId, { + onData: (data: string, stream?: 'stdout' | 'stderr') => { + // Data is base64 encoded from Go agent + try { + const decoded = Buffer.from(data, 'base64'); + chunks.push(decoded); + } catch { + // If not base64, use as-is + chunks.push(Buffer.from(data)); + } + }, + onEnd: (reason?: string) => { + clearTimeout(timeoutHandle); + connection.pendingRequests.delete(requestId); + connection.pendingStreamRequests.delete(requestId); + + // Combine all chunks and return as response + const combined = Buffer.concat(chunks); + resolve({ + statusCode: 200, + headers: {}, + body: combined, + isBinary: true + }); + }, + onError: (error: string) => { + clearTimeout(timeoutHandle); + connection.pendingRequests.delete(requestId); + connection.pendingStreamRequests.delete(requestId); + reject(new Error(error)); + } + }); + } + + // Also register in pendingRequests in case the agent sends a 'response' instead of 'stream' + // (e.g., for error responses or non-streaming paths) + connection.pendingRequests.set(requestId, { + resolve: (response: EdgeResponse) => { + clearTimeout(timeoutHandle); + if (streaming) { + connection.pendingStreamRequests.delete(requestId); + } + resolve(response); + }, + reject: (error: Error) => { + clearTimeout(timeoutHandle); + if (streaming) { + connection.pendingStreamRequests.delete(requestId); + } + reject(error); + }, + timeout: timeoutHandle + }); + + const message: RequestMessage = { + type: MessageType.REQUEST, + requestId, + method, + path, + headers: headers || {}, + body: body, // Body is already an object, will be serialized by JSON.stringify(message) + streaming + }; + + const messageStr = JSON.stringify(message); + + // In dev mode, use the global send function from vite.config.ts + // In production, use the WebSocket directly + if (globalThis.__hawserSendMessage) { + const sent = globalThis.__hawserSendMessage(environmentId, messageStr); + if (!sent) { + connection.pendingRequests.delete(requestId); + if (streaming) { + connection.pendingStreamRequests.delete(requestId); + } + clearTimeout(timeoutHandle); + reject(new Error('Failed to send message')); + } + } else { + try { + connection.ws.send(messageStr); + } catch (sendError) { + console.error(`[Hawser Edge] Error sending message:`, sendError); + connection.pendingRequests.delete(requestId); + if (streaming) { + connection.pendingStreamRequests.delete(requestId); + } + clearTimeout(timeoutHandle); + reject(sendError as Error); + } + } + }); +} + +/** + * Send a streaming request to a Hawser agent + * Returns a cancel function to stop the stream + */ +export function sendEdgeStreamRequest( + environmentId: number, + method: string, + path: string, + callbacks: { + onData: (data: string, stream?: 'stdout' | 'stderr') => void; + onEnd: (reason?: string) => void; + onError: (error: string) => void; + }, + body?: unknown, + headers?: Record +): { requestId: string; cancel: () => void } { + const connection = edgeConnections.get(environmentId); + if (!connection) { + callbacks.onError('Edge agent not connected'); + return { requestId: '', cancel: () => {} }; + } + + const requestId = crypto.randomUUID(); + + // Initialize pendingStreamRequests if not present (can happen in dev mode due to HMR) + if (!connection.pendingStreamRequests) { + connection.pendingStreamRequests = new Map(); + } + + connection.pendingStreamRequests.set(requestId, { + onData: callbacks.onData, + onEnd: callbacks.onEnd, + onError: callbacks.onError + }); + + const message: RequestMessage = { + type: MessageType.REQUEST, + requestId, + method, + path, + headers: headers || {}, + body: body, // Body is already an object, will be serialized by JSON.stringify(message) + streaming: true + }; + + const messageStr = JSON.stringify(message); + + // In dev mode, use the global send function from vite.config.ts + // In production, use the WebSocket directly + if (globalThis.__hawserSendMessage) { + const sent = globalThis.__hawserSendMessage(environmentId, messageStr); + if (!sent) { + connection.pendingStreamRequests.delete(requestId); + callbacks.onError('Failed to send message'); + return { requestId: '', cancel: () => {} }; + } + } else { + try { + connection.ws.send(messageStr); + } catch (sendError) { + console.error(`[Hawser Edge] Error sending streaming message:`, sendError); + connection.pendingStreamRequests.delete(requestId); + callbacks.onError(sendError instanceof Error ? sendError.message : String(sendError)); + return { requestId: '', cancel: () => {} }; + } + } + + return { + requestId, + cancel: () => { + connection.pendingStreamRequests.delete(requestId); + // Send stream_end message to agent to stop the stream + const cancelMessage: StreamEndMessage = { + type: 'stream_end', + requestId, + reason: 'cancelled' + }; + try { + connection.ws.send(JSON.stringify(cancelMessage)); + } catch { + // Connection may already be closed, ignore + } + } + }; +} + +/** + * Handle incoming stream data from Hawser agent + */ +export function handleEdgeStreamData(environmentId: number, message: StreamMessage): void { + const connection = edgeConnections.get(environmentId); + if (!connection) { + console.warn(`[Hawser] Stream data for unknown environment ${environmentId}, requestId=${message.requestId}`); + return; + } + + const pending = connection.pendingStreamRequests.get(message.requestId); + if (!pending) { + console.warn(`[Hawser] Stream data for unknown request ${message.requestId} on env ${environmentId}`); + return; + } + + pending.onData(message.data, message.stream); +} + +/** + * Handle stream end from Hawser agent + */ +export function handleEdgeStreamEnd(environmentId: number, message: StreamEndMessage): void { + const connection = edgeConnections.get(environmentId); + if (!connection) { + console.warn(`[Hawser] Stream end for unknown environment ${environmentId}, requestId=${message.requestId}`); + return; + } + + const pending = connection.pendingStreamRequests.get(message.requestId); + if (!pending) { + console.warn(`[Hawser] Stream end for unknown request ${message.requestId} on env ${environmentId}`); + return; + } + + connection.pendingStreamRequests.delete(message.requestId); + pending.onEnd(message.reason); +} + +/** + * Handle incoming response from Hawser agent + */ +export function handleEdgeResponse(environmentId: number, response: ResponseMessage): void { + const connection = edgeConnections.get(environmentId); + if (!connection) { + console.warn(`[Hawser] Response for unknown environment ${environmentId}, requestId=${response.requestId}`); + return; + } + + const pending = connection.pendingRequests.get(response.requestId); + if (!pending) { + console.warn(`[Hawser] Response for unknown request ${response.requestId} on env ${environmentId}`); + return; + } + + clearTimeout(pending.timeout); + connection.pendingRequests.delete(response.requestId); + + pending.resolve({ + statusCode: response.statusCode, + headers: response.headers || {}, + body: response.body || '', + isBinary: response.isBinary || false + }); +} + +/** + * Handle heartbeat from agent + */ +export function handleHeartbeat(environmentId: number): void { + const connection = edgeConnections.get(environmentId); + if (connection) { + connection.lastHeartbeat = new Date(); + } +} + +/** + * Handle connection close + */ +export function handleDisconnect(environmentId: number): void { + const connection = edgeConnections.get(environmentId); + if (connection) { + // Reject all pending requests + for (const [, pending] of connection.pendingRequests) { + clearTimeout(pending.timeout); + pending.reject(new Error('Connection closed')); + } + + // End all pending stream requests + for (const [, pending] of connection.pendingStreamRequests) { + pending.onEnd('Connection closed'); + } + + edgeConnections.delete(environmentId); + updateEnvironmentStatus(environmentId, null); + } +} + +/** + * Check if an environment has an active edge connection + */ +export function isEdgeConnected(environmentId: number): boolean { + return edgeConnections.has(environmentId); +} + +/** + * Get connection info for an environment + */ +export function getEdgeConnectionInfo(environmentId: number): EdgeConnection | undefined { + return edgeConnections.get(environmentId); +} + +/** + * Get all active connections + */ +export function getAllEdgeConnections(): Map { + return edgeConnections; +} + +// Message type definitions +export interface HelloMessage { + type: 'hello'; + version: string; + agentId: string; + agentName: string; + token: string; + dockerVersion: string; + hostname: string; + capabilities: string[]; +} + +export interface WelcomeMessage { + type: 'welcome'; + environmentId: number; + message?: string; +} + +export interface RequestMessage { + type: 'request'; + requestId: string; + method: string; + path: string; + headers?: Record; + body?: unknown; // JSON-serializable object, will be serialized when message is stringified + streaming?: boolean; +} + +export interface ResponseMessage { + type: 'response'; + requestId: string; + statusCode: number; + headers?: Record; + body?: string; + isBinary?: boolean; +} + +export interface StreamMessage { + type: 'stream'; + requestId: string; + data: string; + stream?: 'stdout' | 'stderr'; +} + +export interface StreamEndMessage { + type: 'stream_end'; + requestId: string; + reason?: string; +} + +export interface MetricsMessage { + type: 'metrics'; + timestamp: number; + metrics: { + cpuUsage: number; + cpuCores: number; + memoryTotal: number; + memoryUsed: number; + memoryFree: number; + diskTotal: number; + diskUsed: number; + diskFree: number; + networkRxBytes: number; + networkTxBytes: number; + uptime: number; + }; +} + +export interface ErrorMessage { + type: 'error'; + requestId?: string; + error: string; + code?: string; +} + +// Exec message types for bidirectional terminal +export interface ExecStartMessage { + type: 'exec_start'; + execId: string; + containerId: string; + cmd: string; + user: string; + cols: number; + rows: number; +} + +export interface ExecReadyMessage { + type: 'exec_ready'; + execId: string; +} + +export interface ExecInputMessage { + type: 'exec_input'; + execId: string; + data: string; // Base64-encoded +} + +export interface ExecOutputMessage { + type: 'exec_output'; + execId: string; + data: string; // Base64-encoded +} + +export interface ExecResizeMessage { + type: 'exec_resize'; + execId: string; + cols: number; + rows: number; +} + +export interface ExecEndMessage { + type: 'exec_end'; + execId: string; + reason?: string; +} + +export interface ContainerEventMessage { + type: 'container_event'; + event: { + containerId: string; + containerName?: string; + image?: string; + action: string; + actorAttributes?: Record; + timestamp: string; + }; +} + +export type HawserMessage = + | HelloMessage + | WelcomeMessage + | RequestMessage + | ResponseMessage + | StreamMessage + | StreamEndMessage + | MetricsMessage + | ErrorMessage + | ExecStartMessage + | ExecReadyMessage + | ExecInputMessage + | ExecOutputMessage + | ExecResizeMessage + | ExecEndMessage + | ContainerEventMessage + | { type: 'ping'; timestamp: number } + | { type: 'pong'; timestamp: number }; diff --git a/lib/server/license.ts b/lib/server/license.ts new file mode 100644 index 0000000..4ba3b67 --- /dev/null +++ b/lib/server/license.ts @@ -0,0 +1,253 @@ +import crypto from 'node:crypto'; +import os from 'node:os'; +import { getSetting, setSetting } from './db'; +import { sendEventNotification } from './notifications'; + +// RSA Public Key for license verification +// This key can only VERIFY signatures, not create them +// The private key is kept secret and used only for license generation +const LICENSE_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoGJOObrKQyOPrDC+xSVh +Cq5WeUQqwvAl2xEoI5iOhJtHIvnlxayc2UKt9D5WVWS0dgzi41L7VD2OjTayrbL8 +RxPXYh0EfMtnKoJZyFwN1XdlYk8yUjs2TRXnrw8Y+riuMjFWgUHmWUQTA7yBnJG6 +9efCMUDREHwGglPIKhTstQfSqi2fNO1GCgY1W7JCMnE8CCpwLGvLodbWFUe1CwT0 +OgRZRNWPljc/cX5DLSaB1RXFUnBM4O9YalNCNOR3HvEV/8HULFtDpZT0ZwRbC3K3 +R8GFY97lrqADuWVaEdRRYdr402eAcd4DnRT62OjpEllNbRI3U5Wyj6EmYm3Cmc9Q +GwIDAQAB +-----END PUBLIC KEY-----`; + +export type LicenseType = 'enterprise' | 'smb'; + +export interface LicensePayload { + name: string; + host: string; + issued: string; + expires: string | null; + type: LicenseType; + v?: number; // Version: 2 = RSA signed +} + +export interface LicenseStatus { + valid: boolean; + active: boolean; + payload?: LicensePayload; + error?: string; +} + +export interface StoredLicense { + name: string; + key: string; + activated_at: string; +} + +/** + * Validates a license key using RSA-SHA256 signature verification + */ +export function validateLicense(licenseKey: string, currentHost?: string): LicenseStatus { + try { + // Clean the license key - remove whitespace, newlines, etc. + const cleanKey = licenseKey.replace(/\s+/g, ''); + + const parts = cleanKey.split('.'); + if (parts.length !== 2) { + return { valid: false, active: false, error: 'Invalid license format' }; + } + + const [payloadBase64, signature] = parts; + + // Verify RSA-SHA256 signature + const verify = crypto.createVerify('RSA-SHA256'); + verify.update(payloadBase64); + const isValid = verify.verify(LICENSE_PUBLIC_KEY, signature, 'base64url'); + + if (!isValid) { + return { valid: false, active: false, error: 'Invalid license signature' }; + } + + // Decode payload + const payload: LicensePayload = JSON.parse( + Buffer.from(payloadBase64, 'base64url').toString() + ); + + // Check expiration + if (payload.expires && new Date(payload.expires) < new Date()) { + return { valid: false, active: false, error: 'License has expired', payload }; + } + + // Check host (allow wildcard matching) + const hostToCheck = currentHost || os.hostname(); + if (payload.host !== '*') { + const hostMatches = + payload.host === hostToCheck || + (payload.host.startsWith('*.') && hostToCheck.endsWith(payload.host.slice(1))); + + if (!hostMatches) { + return { + valid: false, + active: false, + error: `License is not valid for this host (${hostToCheck})`, + payload + }; + } + } + + return { valid: true, active: true, payload }; + } catch (error) { + return { + valid: false, + active: false, + error: `License validation failed: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + } +} + +/** + * Gets the currently stored license + */ +export async function getStoredLicense(): Promise { + return getSetting('enterprise_license'); +} + +/** + * Stores and activates a license + */ +export async function activateLicense( + name: string, + key: string +): Promise<{ success: boolean; error?: string; license?: StoredLicense }> { + // Clean the key - remove whitespace, newlines, etc. + const cleanKey = key.replace(/\s+/g, ''); + + // Validate the license first (use getHostname() for Docker-aware hostname detection) + const validation = validateLicense(cleanKey, getHostname()); + + if (!validation.valid) { + return { success: false, error: validation.error }; + } + + // Check if the name matches + if (validation.payload && validation.payload.name !== name.trim()) { + return { + success: false, + error: `License name mismatch. Expected "${validation.payload.name}", got "${name.trim()}"` + }; + } + + // Store the license (with cleaned key) + const license: StoredLicense = { + name: name.trim(), + key: cleanKey, + activated_at: new Date().toISOString() + }; + + await setSetting('enterprise_license', license); + + return { success: true, license }; +} + +/** + * Removes the current license + */ +export async function deactivateLicense(): Promise { + await setSetting('enterprise_license', null); + return true; +} + +/** + * Checks if the current installation has an active enterprise license + */ +export async function isEnterprise(): Promise { + const stored = await getStoredLicense(); + if (!stored || !stored.key) { + return false; + } + + const validation = validateLicense(stored.key, getHostname()); + // Only true for enterprise licenses (SMB does not unlock enterprise features) + return validation.valid && validation.active && validation.payload?.type === 'enterprise'; +} + +/** + * Gets the license type if a valid license is active + */ +export async function getLicenseType(): Promise { + const stored = await getStoredLicense(); + if (!stored || !stored.key) { + return null; + } + + const validation = validateLicense(stored.key, getHostname()); + if (validation.valid && validation.active && validation.payload) { + return validation.payload.type; + } + return null; +} + +/** + * Gets the full license status including validation + */ +export async function getLicenseStatus(): Promise { + const stored = await getStoredLicense(); + + if (!stored || !stored.key) { + return { valid: false, active: false }; + } + + const validation = validateLicense(stored.key, getHostname()); + return { ...validation, stored }; +} + +/** + * Gets the current hostname for license validation. + * + * In Docker: DOCKHAND_HOSTNAME is set by the entrypoint script from Docker API. + * Outside Docker: Falls back to os.hostname(). + */ +export function getHostname(): string { + return process.env.DOCKHAND_HOSTNAME || os.hostname(); +} + +// Track when we last sent a license expiring notification +let lastLicenseExpiryNotification: number | null = null; +const LICENSE_EXPIRY_NOTIFICATION_COOLDOWN = 86400000; // 24 hours between notifications +const LICENSE_EXPIRY_WARNING_DAYS = 30; // Warn when license expires within 30 days + +/** + * Check if the enterprise license is expiring soon and send notification + * Call this periodically (e.g., on startup and daily) + */ +export async function checkLicenseExpiry(): Promise { + try { + const status = await getLicenseStatus(); + + // Only check if we have an active license with an expiry date + if (!status.valid || !status.active || !status.payload?.expires) { + return; + } + + const expiryDate = new Date(status.payload.expires); + const now = new Date(); + const daysUntilExpiry = Math.ceil((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + + // Check if expiring within warning threshold + if (daysUntilExpiry > 0 && daysUntilExpiry <= LICENSE_EXPIRY_WARNING_DAYS) { + // Check cooldown to avoid spamming + if (lastLicenseExpiryNotification && Date.now() - lastLicenseExpiryNotification < LICENSE_EXPIRY_NOTIFICATION_COOLDOWN) { + return; + } + + const licenseTypeName = status.payload.type === 'enterprise' ? 'Enterprise' : 'SMB'; + console.log(`[License] ${licenseTypeName} license expiring in ${daysUntilExpiry} days`); + + await sendEventNotification('license_expiring', { + title: 'License expiring soon', + message: `Your ${licenseTypeName} license expires in ${daysUntilExpiry} day${daysUntilExpiry === 1 ? '' : 's'} (${expiryDate.toLocaleDateString()}). Contact support to renew.`, + type: 'warning' + }); + + lastLicenseExpiryNotification = Date.now(); + } + } catch (error) { + console.error('[License] Failed to check license expiry:', error); + } +} diff --git a/lib/server/metrics-collector.ts b/lib/server/metrics-collector.ts new file mode 100644 index 0000000..dbccbff --- /dev/null +++ b/lib/server/metrics-collector.ts @@ -0,0 +1,271 @@ +import { saveHostMetric, getEnvironments, getEnvSetting } from './db'; +import { listContainers, getContainerStats, getDockerInfo, getDiskUsage } from './docker'; +import { sendEventNotification } from './notifications'; +import os from 'node:os'; + +const COLLECT_INTERVAL = 10000; // 10 seconds +const DISK_CHECK_INTERVAL = 300000; // 5 minutes +const DEFAULT_DISK_THRESHOLD = 80; // 80% threshold for disk warnings + +let collectorInterval: ReturnType | null = null; +let diskCheckInterval: ReturnType | null = null; + +// Track last disk warning sent per environment to avoid spamming +const lastDiskWarning: Map = new Map(); +const DISK_WARNING_COOLDOWN = 3600000; // 1 hour between warnings + +/** + * Collect metrics for a single environment + */ +async function collectEnvMetrics(env: { id: number; name: string; collectMetrics?: boolean }) { + try { + // Skip environments where metrics collection is disabled + if (env.collectMetrics === false) { + return; + } + + // Get running containers + const containers = await listContainers(false, env.id); // Only running + let totalCpuPercent = 0; + let totalMemUsed = 0; + + // Get stats for each running container + const statsPromises = containers.map(async (container) => { + try { + const stats = await getContainerStats(container.id, env.id) as any; + + // Calculate CPU percentage + const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage; + const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage; + const cpuCount = stats.cpu_stats.online_cpus || os.cpus().length; + + let cpuPercent = 0; + if (systemDelta > 0 && cpuDelta > 0) { + cpuPercent = (cpuDelta / systemDelta) * cpuCount * 100; + } + + // Get container memory usage + const memUsage = stats.memory_stats?.usage || 0; + const memCache = stats.memory_stats?.stats?.cache || 0; + // Subtract cache from usage to get actual memory used by the container + const actualMemUsed = memUsage - memCache; + + return { cpu: cpuPercent, mem: actualMemUsed > 0 ? actualMemUsed : memUsage }; + } catch { + return { cpu: 0, mem: 0 }; + } + }); + + const statsResults = await Promise.all(statsPromises); + totalCpuPercent = statsResults.reduce((sum, v) => sum + v.cpu, 0); + totalMemUsed = statsResults.reduce((sum, v) => sum + v.mem, 0); + + // Get host total memory from Docker info (this is the remote host's memory) + const info = await getDockerInfo(env.id) as any; + const memTotal = info.MemTotal || os.totalmem(); + + // Calculate memory percentage based on container usage vs host total + const memPercent = memTotal > 0 ? (totalMemUsed / memTotal) * 100 : 0; + + // Normalize CPU by number of cores from the remote host + const cpuCount = info.NCPU || os.cpus().length; + const normalizedCpu = totalCpuPercent / cpuCount; + + // Save to database + await saveHostMetric( + normalizedCpu, + memPercent, + totalMemUsed, + memTotal, + env.id + ); + } catch (error) { + // Skip this environment if it fails (might be offline) + console.error(`Failed to collect metrics for ${env.name}:`, error); + } +} + +async function collectMetrics() { + try { + const environments = await getEnvironments(); + + // Filter enabled environments and collect metrics in parallel + const enabledEnvs = environments.filter(env => env.collectMetrics !== false); + + // Process all environments in parallel for better performance + await Promise.all(enabledEnvs.map(env => collectEnvMetrics(env))); + } catch (error) { + console.error('Metrics collection error:', error); + } +} + +/** + * Check disk space for a single environment + */ +async function checkEnvDiskSpace(env: { id: number; name: string; collectMetrics?: boolean }) { + try { + // Skip environments where metrics collection is disabled + if (env.collectMetrics === false) { + return; + } + + // Check if we're in cooldown for this environment + const lastWarningTime = lastDiskWarning.get(env.id); + if (lastWarningTime && Date.now() - lastWarningTime < DISK_WARNING_COOLDOWN) { + return; // Skip this environment, still in cooldown + } + + // Get Docker disk usage data + const diskData = await getDiskUsage(env.id) as any; + if (!diskData) return; + + // Calculate total Docker disk usage using reduce for cleaner code + let totalUsed = 0; + if (diskData.Images) { + totalUsed += diskData.Images.reduce((sum: number, img: any) => sum + (img.Size || 0), 0); + } + if (diskData.Containers) { + totalUsed += diskData.Containers.reduce((sum: number, c: any) => sum + (c.SizeRw || 0), 0); + } + if (diskData.Volumes) { + totalUsed += diskData.Volumes.reduce((sum: number, v: any) => sum + (v.UsageData?.Size || 0), 0); + } + if (diskData.BuildCache) { + totalUsed += diskData.BuildCache.reduce((sum: number, bc: any) => sum + (bc.Size || 0), 0); + } + + // Get Docker root filesystem info from Docker info + const info = await getDockerInfo(env.id) as any; + const driverStatus = info?.DriverStatus; + + // Try to find "Data Space Total" from driver status + let dataSpaceTotal = 0; + let diskPercentUsed = 0; + + if (driverStatus) { + for (const [key, value] of driverStatus) { + if (key === 'Data Space Total' && typeof value === 'string') { + dataSpaceTotal = parseSize(value); + break; + } + } + } + + // If we found total disk space, calculate percentage + if (dataSpaceTotal > 0) { + diskPercentUsed = (totalUsed / dataSpaceTotal) * 100; + } else { + // Fallback: just report absolute usage if we can't determine percentage + const GB = 1024 * 1024 * 1024; + if (totalUsed > 50 * GB) { + await sendEventNotification('disk_space_warning', { + title: 'High Docker disk usage', + message: `Environment "${env.name}" is using ${formatSize(totalUsed)} of Docker disk space`, + type: 'warning' + }, env.id); + lastDiskWarning.set(env.id, Date.now()); + } + return; + } + + // Check against threshold + const threshold = await getEnvSetting('disk_warning_threshold', env.id) || DEFAULT_DISK_THRESHOLD; + if (diskPercentUsed >= threshold) { + console.log(`[Metrics] Docker disk usage for ${env.name}: ${diskPercentUsed.toFixed(1)}% (threshold: ${threshold}%)`); + + await sendEventNotification('disk_space_warning', { + title: 'Disk space warning', + message: `Environment "${env.name}" Docker disk usage is at ${diskPercentUsed.toFixed(1)}% (${formatSize(totalUsed)} used)`, + type: 'warning' + }, env.id); + + lastDiskWarning.set(env.id, Date.now()); + } + } catch (error) { + // Skip this environment if it fails + console.error(`Failed to check disk space for ${env.name}:`, error); + } +} + +/** + * Check Docker disk usage and send warnings if above threshold + */ +async function checkDiskSpace() { + try { + const environments = await getEnvironments(); + + // Filter enabled environments and check disk space in parallel + const enabledEnvs = environments.filter(env => env.collectMetrics !== false); + + // Process all environments in parallel for better performance + await Promise.all(enabledEnvs.map(env => checkEnvDiskSpace(env))); + } catch (error) { + console.error('Disk space check error:', error); + } +} + +/** + * Parse size string like "107.4GB" to bytes + */ +function parseSize(sizeStr: string): number { + const units: Record = { + 'B': 1, + 'KB': 1024, + 'MB': 1024 * 1024, + 'GB': 1024 * 1024 * 1024, + 'TB': 1024 * 1024 * 1024 * 1024 + }; + + const match = sizeStr.match(/^([\d.]+)\s*([KMGT]?B)$/i); + if (!match) return 0; + + const value = parseFloat(match[1]); + const unit = match[2].toUpperCase(); + return value * (units[unit] || 1); +} + +/** + * Format bytes to human readable string + */ +function formatSize(bytes: number): string { + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let unitIndex = 0; + let size = bytes; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(1)} ${units[unitIndex]}`; +} + +export function startMetricsCollector() { + if (collectorInterval) return; // Already running + + console.log('Starting server-side metrics collector (every 10s)'); + + // Initial collection + collectMetrics(); + + // Schedule regular collection + collectorInterval = setInterval(collectMetrics, COLLECT_INTERVAL); + + // Start disk space checking (every 5 minutes) + console.log('Starting disk space monitoring (every 5 minutes)'); + checkDiskSpace(); // Initial check + diskCheckInterval = setInterval(checkDiskSpace, DISK_CHECK_INTERVAL); +} + +export function stopMetricsCollector() { + if (collectorInterval) { + clearInterval(collectorInterval); + collectorInterval = null; + } + if (diskCheckInterval) { + clearInterval(diskCheckInterval); + diskCheckInterval = null; + } + lastDiskWarning.clear(); + console.log('Metrics collector stopped'); +} diff --git a/lib/server/notifications.ts b/lib/server/notifications.ts new file mode 100644 index 0000000..555e239 --- /dev/null +++ b/lib/server/notifications.ts @@ -0,0 +1,499 @@ +import nodemailer from 'nodemailer'; +import { + getEnabledNotificationSettings, + getEnabledEnvironmentNotifications, + getEnvironment, + type NotificationSettingData, + type SmtpConfig, + type AppriseConfig, + type NotificationEventType +} from './db'; + +export interface NotificationPayload { + title: string; + message: string; + type?: 'info' | 'success' | 'warning' | 'error'; + environmentId?: number; + environmentName?: string; +} + +// Send notification via SMTP +async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise { + try { + const transporter = nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.secure, + auth: config.username ? { + user: config.username, + pass: config.password + } : undefined + }); + + const envBadge = payload.environmentName + ? `${payload.environmentName}` + : ''; + const envText = payload.environmentName ? ` [${payload.environmentName}]` : ''; + + const html = ` +
    +

    ${payload.title}${envBadge}

    +

    ${payload.message}

    +
    +

    Sent by Dockhand

    +
    + `; + + await transporter.sendMail({ + from: config.from_name ? `"${config.from_name}" <${config.from_email}>` : config.from_email, + to: config.to_emails.join(', '), + subject: `[Dockhand]${envText} ${payload.title}`, + text: `${payload.title}${envText}\n\n${payload.message}`, + html + }); + + return true; + } catch (error) { + console.error('[Notifications] SMTP send failed:', error); + return false; + } +} + +// Parse Apprise URL and send notification +async function sendAppriseNotification(config: AppriseConfig, payload: NotificationPayload): Promise { + let success = true; + + for (const url of config.urls) { + try { + const sent = await sendToAppriseUrl(url, payload); + if (!sent) success = false; + } catch (error) { + console.error(`[Notifications] Failed to send to ${url}:`, error); + success = false; + } + } + + return success; +} + +// Send to a single Apprise URL +async function sendToAppriseUrl(url: string, payload: NotificationPayload): Promise { + try { + // Extract protocol from Apprise URL format (protocol://...) + // Note: Can't use new URL() because custom schemes like 'tgram://' are not valid URLs + const protocolMatch = url.match(/^([a-z]+):\/\//i); + if (!protocolMatch) { + console.error('[Notifications] Invalid Apprise URL format - missing protocol:', url); + return false; + } + const protocol = protocolMatch[1].toLowerCase(); + + // Handle different notification services + switch (protocol) { + case 'discord': + case 'discords': + return await sendDiscord(url, payload); + case 'slack': + case 'slacks': + return await sendSlack(url, payload); + case 'tgram': + return await sendTelegram(url, payload); + case 'gotify': + case 'gotifys': + return await sendGotify(url, payload); + case 'ntfy': + case 'ntfys': + return await sendNtfy(url, payload); + case 'pushover': + return await sendPushover(url, payload); + case 'json': + case 'jsons': + return await sendGenericWebhook(url, payload); + default: + console.warn(`[Notifications] Unsupported Apprise protocol: ${protocol}`); + return false; + } + } catch (error) { + console.error('[Notifications] Failed to parse Apprise URL:', error); + return false; + } +} + +// Discord webhook +async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Promise { + // discord://webhook_id/webhook_token or discords://... + const url = appriseUrl.replace(/^discords?:\/\//, 'https://discord.com/api/webhooks/'); + const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title; + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + embeds: [{ + title: titleWithEnv, + description: payload.message, + color: payload.type === 'error' ? 0xff0000 : payload.type === 'warning' ? 0xffaa00 : payload.type === 'success' ? 0x00ff00 : 0x0099ff, + ...(payload.environmentName && { + footer: { text: `Environment: ${payload.environmentName}` } + }) + }] + }) + }); + + return response.ok; +} + +// Slack webhook +async function sendSlack(appriseUrl: string, payload: NotificationPayload): Promise { + // slack://token_a/token_b/token_c or webhook URL + let url: string; + if (appriseUrl.includes('hooks.slack.com')) { + url = appriseUrl.replace(/^slacks?:\/\//, 'https://'); + } else { + const parts = appriseUrl.replace(/^slacks?:\/\//, '').split('/'); + url = `https://hooks.slack.com/services/${parts.join('/')}`; + } + + const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : ''; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: `*${payload.title}*${envTag}\n${payload.message}` + }) + }); + + return response.ok; +} + +// Telegram +async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise { + // tgram://bot_token/chat_id + const match = appriseUrl.match(/^tgram:\/\/([^/]+)\/(.+)/); + if (!match) { + console.error('[Notifications] Invalid Telegram URL format. Expected: tgram://bot_token/chat_id'); + return false; + } + + const [, botToken, chatId] = match; + const url = `https://api.telegram.org/bot${botToken}/sendMessage`; + + const envTag = payload.environmentName ? ` \\[${payload.environmentName}\\]` : ''; + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + text: `*${payload.title}*${envTag}\n${payload.message}`, + parse_mode: 'Markdown' + }) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + console.error('[Notifications] Telegram API error:', response.status, errorData); + } + + return response.ok; + } catch (error) { + console.error('[Notifications] Telegram send failed:', error); + return false; + } +} + +// Gotify +async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise { + // gotify://hostname/token or gotifys://hostname/token + const match = appriseUrl.match(/^gotifys?:\/\/([^/]+)\/(.+)/); + if (!match) return false; + + const [, hostname, token] = match; + const protocol = appriseUrl.startsWith('gotifys') ? 'https' : 'http'; + const url = `${protocol}://${hostname}/message?token=${token}`; + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: payload.title, + message: payload.message, + priority: payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2 + }) + }); + + return response.ok; +} + +// ntfy +async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise { + // ntfy://topic or ntfys://hostname/topic + let url: string; + const isSecure = appriseUrl.startsWith('ntfys'); + const path = appriseUrl.replace(/^ntfys?:\/\//, ''); + + if (path.includes('/')) { + // Custom server + url = `${isSecure ? 'https' : 'http'}://${path}`; + } else { + // Default ntfy.sh + url = `https://ntfy.sh/${path}`; + } + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Title': payload.title, + 'Priority': payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3', + 'Tags': payload.type || 'info' + }, + body: payload.message + }); + + return response.ok; +} + +// Pushover +async function sendPushover(appriseUrl: string, payload: NotificationPayload): Promise { + // pushover://user_key/api_token + const match = appriseUrl.match(/^pushover:\/\/([^/]+)\/(.+)/); + if (!match) return false; + + const [, userKey, apiToken] = match; + const url = 'https://api.pushover.net/1/messages.json'; + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: apiToken, + user: userKey, + title: payload.title, + message: payload.message, + priority: payload.type === 'error' ? 1 : 0 + }) + }); + + return response.ok; +} + +// Generic JSON webhook +async function sendGenericWebhook(appriseUrl: string, payload: NotificationPayload): Promise { + // json://hostname/path or jsons://hostname/path + const url = appriseUrl.replace(/^jsons?:\/\//, appriseUrl.startsWith('jsons') ? 'https://' : 'http://'); + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: payload.title, + message: payload.message, + type: payload.type || 'info', + timestamp: new Date().toISOString() + }) + }); + + return response.ok; +} + +// Send notification to all enabled channels +export async function sendNotification(payload: NotificationPayload): Promise<{ success: boolean; results: { name: string; success: boolean }[] }> { + const settings = await getEnabledNotificationSettings(); + const results: { name: string; success: boolean }[] = []; + + for (const setting of settings) { + let success = false; + + if (setting.type === 'smtp') { + success = await sendSmtpNotification(setting.config as SmtpConfig, payload); + } else if (setting.type === 'apprise') { + success = await sendAppriseNotification(setting.config as AppriseConfig, payload); + } + + results.push({ name: setting.name, success }); + } + + return { + success: results.every(r => r.success), + results + }; +} + +// Test a specific notification setting +export async function testNotification(setting: NotificationSettingData): Promise { + const payload: NotificationPayload = { + title: 'Dockhand Test Notification', + message: 'This is a test notification from Dockhand. If you receive this, your notification settings are configured correctly.', + type: 'info' + }; + + if (setting.type === 'smtp') { + return await sendSmtpNotification(setting.config as SmtpConfig, payload); + } else if (setting.type === 'apprise') { + return await sendAppriseNotification(setting.config as AppriseConfig, payload); + } + + return false; +} + +// Map Docker action to notification event type +function mapActionToEventType(action: string): NotificationEventType | null { + const mapping: Record = { + 'start': 'container_started', + 'stop': 'container_stopped', + 'restart': 'container_restarted', + 'die': 'container_exited', + 'kill': 'container_exited', + 'oom': 'container_oom', + 'health_status: unhealthy': 'container_unhealthy', + 'pull': 'image_pulled' + }; + return mapping[action] || null; +} + +// Scanner image patterns to exclude from notifications +const SCANNER_IMAGE_PATTERNS = [ + 'anchore/grype', + 'aquasec/trivy', + 'ghcr.io/anchore/grype', + 'ghcr.io/aquasecurity/trivy' +]; + +function isScannerContainer(image: string | null | undefined): boolean { + if (!image) return false; + const lowerImage = image.toLowerCase(); + return SCANNER_IMAGE_PATTERNS.some(pattern => lowerImage.includes(pattern.toLowerCase())); +} + +// Send notification for an environment-specific event +export async function sendEnvironmentNotification( + environmentId: number, + action: string, + payload: Omit, + image?: string | null +): Promise<{ success: boolean; sent: number }> { + const eventType = mapActionToEventType(action); + if (!eventType) { + // Not a notifiable event type + return { success: true, sent: 0 }; + } + + // Get environment name + const env = await getEnvironment(environmentId); + if (!env) { + return { success: false, sent: 0 }; + } + + // Get enabled notification channels for this environment and event type + const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType); + if (envNotifications.length === 0) { + return { success: true, sent: 0 }; + } + + const enrichedPayload: NotificationPayload = { + ...payload, + environmentId, + environmentName: env.name + }; + + // Check if this is a scanner container + const isScanner = isScannerContainer(image); + + let sent = 0; + let allSuccess = true; + + // Skip all notifications for scanner containers (Trivy, Grype) + if (isScanner) { + return { success: true, sent: 0 }; + } + + for (const notif of envNotifications) { + try { + let success = false; + if (notif.channelType === 'smtp') { + success = await sendSmtpNotification(notif.config as SmtpConfig, enrichedPayload); + } else if (notif.channelType === 'apprise') { + success = await sendAppriseNotification(notif.config as AppriseConfig, enrichedPayload); + } + if (success) sent++; + else allSuccess = false; + } catch (error) { + console.error(`[Notifications] Failed to send to channel ${notif.channelName}:`, error); + allSuccess = false; + } + } + + return { success: allSuccess, sent }; +} + +// Send notification for a specific event type (not mapped from Docker action) +// Used for auto-update, git sync, vulnerability, and system events +export async function sendEventNotification( + eventType: NotificationEventType, + payload: NotificationPayload, + environmentId?: number +): Promise<{ success: boolean; sent: number }> { + // Get environment name if provided + let enrichedPayload = { ...payload }; + if (environmentId) { + const env = await getEnvironment(environmentId); + if (env) { + enrichedPayload.environmentId = environmentId; + enrichedPayload.environmentName = env.name; + } + } + + // Get enabled notification channels for this event type + let channels: Array<{ + channel_type: 'smtp' | 'apprise'; + channel_name: string; + config: SmtpConfig | AppriseConfig; + }> = []; + + if (environmentId) { + // Environment-specific: get channels subscribed to this env and event type + const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType); + channels = envNotifications + .filter(n => n.channelType && n.channelName) + .map(n => ({ + channel_type: n.channelType!, + channel_name: n.channelName!, + config: n.config + })); + } else { + // System-wide: get all globally enabled channels that subscribe to this event type + const globalSettings = await getEnabledNotificationSettings(); + channels = globalSettings + .filter(s => s.eventTypes?.includes(eventType)) + .map(s => ({ + channel_type: s.type, + channel_name: s.name, + config: s.config + })); + } + + if (channels.length === 0) { + return { success: true, sent: 0 }; + } + + let sent = 0; + let allSuccess = true; + + for (const channel of channels) { + try { + let success = false; + if (channel.channel_type === 'smtp') { + success = await sendSmtpNotification(channel.config as SmtpConfig, enrichedPayload); + } else if (channel.channel_type === 'apprise') { + success = await sendAppriseNotification(channel.config as AppriseConfig, enrichedPayload); + } + if (success) sent++; + else allSuccess = false; + } catch (error) { + console.error(`[Notifications] Failed to send to channel ${channel.channel_name}:`, error); + allSuccess = false; + } + } + + return { success: allSuccess, sent }; +} diff --git a/lib/server/scanner.ts b/lib/server/scanner.ts new file mode 100644 index 0000000..14d4212 --- /dev/null +++ b/lib/server/scanner.ts @@ -0,0 +1,829 @@ +// Vulnerability Scanner Service +// Supports Grype and Trivy scanners +// Uses long-running containers for faster subsequent scans (cached vulnerability databases) + +import { + listImages, + pullImage, + createVolume, + listVolumes, + removeVolume, + runContainer, + runContainerWithStreaming, + inspectImage +} from './docker'; +import { getEnvironment, getEnvSetting, getSetting } from './db'; +import { sendEventNotification } from './notifications'; + +export type ScannerType = 'none' | 'grype' | 'trivy' | 'both'; + +/** + * Send vulnerability notifications based on scan results. + * Sends the most severe notification type based on found vulnerabilities. + */ +export async function sendVulnerabilityNotifications( + imageName: string, + summary: VulnerabilitySeverity, + envId?: number +): Promise { + const totalVulns = summary.critical + summary.high + summary.medium + summary.low + summary.negligible + summary.unknown; + + if (totalVulns === 0) { + // No vulnerabilities found, no notification needed + return; + } + + // Send notifications based on severity (most severe first) + // Note: Users can subscribe to specific severity levels, so we send all applicable + if (summary.critical > 0) { + await sendEventNotification('vulnerability_critical', { + title: 'Critical vulnerabilities found', + message: `Image "${imageName}" has ${summary.critical} critical vulnerabilities (${totalVulns} total)`, + type: 'error' + }, envId); + } + + if (summary.high > 0) { + await sendEventNotification('vulnerability_high', { + title: 'High severity vulnerabilities found', + message: `Image "${imageName}" has ${summary.high} high severity vulnerabilities (${totalVulns} total)`, + type: 'warning' + }, envId); + } + + // Only send 'any' notification if there are medium/low/negligible but no critical/high + // This prevents notification spam for users who only want to know about lesser severities + if (summary.critical === 0 && summary.high === 0 && totalVulns > 0) { + await sendEventNotification('vulnerability_any', { + title: 'Vulnerabilities found', + message: `Image "${imageName}" has ${totalVulns} vulnerabilities (medium: ${summary.medium}, low: ${summary.low})`, + type: 'info' + }, envId); + } +} + +// Volume names for scanner database caching +const GRYPE_VOLUME_NAME = 'dockhand-grype-db'; +const TRIVY_VOLUME_NAME = 'dockhand-trivy-db'; + +// Track running scanner instances to detect concurrent scans +const runningScanners = new Map(); // key: "grype" or "trivy", value: count + +// Default CLI arguments for scanners (image name is substituted for {image}) +export const DEFAULT_GRYPE_ARGS = '-o json -v {image}'; +export const DEFAULT_TRIVY_ARGS = 'image --format json {image}'; + +export interface VulnerabilitySeverity { + critical: number; + high: number; + medium: number; + low: number; + negligible: number; + unknown: number; +} + +export interface Vulnerability { + id: string; + severity: string; + package: string; + version: string; + fixedVersion?: string; + description?: string; + link?: string; + scanner: 'grype' | 'trivy'; +} + +export interface ScanResult { + imageId: string; + imageName: string; + scanner: 'grype' | 'trivy'; + scannedAt: string; + vulnerabilities: Vulnerability[]; + summary: VulnerabilitySeverity; + scanDuration: number; + error?: string; +} + +export interface ScanProgress { + stage: 'checking' | 'pulling-scanner' | 'scanning' | 'parsing' | 'complete' | 'error'; + message: string; + scanner?: 'grype' | 'trivy'; + progress?: number; + result?: ScanResult; + results?: ScanResult[]; // All scanner results when using 'both' + error?: string; + output?: string; // Line of scanner output +} + +// Get global default scanner CLI args from general settings (or fallback to hardcoded defaults) +export async function getGlobalScannerDefaults(): Promise<{ + grypeArgs: string; + trivyArgs: string; +}> { + const [grypeArgs, trivyArgs] = await Promise.all([ + getSetting('default_grype_args'), + getSetting('default_trivy_args') + ]); + return { + grypeArgs: grypeArgs ?? DEFAULT_GRYPE_ARGS, + trivyArgs: trivyArgs ?? DEFAULT_TRIVY_ARGS + }; +} + +// Get scanner settings (scanner type is per-environment, CLI args are global) +export async function getScannerSettings(envId?: number): Promise<{ + scanner: ScannerType; + grypeArgs: string; + trivyArgs: string; +}> { + // CLI args are always global - no need for per-env settings + const [globalDefaults, scanner] = await Promise.all([ + getGlobalScannerDefaults(), + getEnvSetting('vulnerability_scanner', envId) + ]); + + return { + scanner: scanner || 'none', + grypeArgs: globalDefaults.grypeArgs, + trivyArgs: globalDefaults.trivyArgs + }; +} + +// Optimized version that accepts pre-cached global defaults (avoids redundant DB calls) +// Only looks up scanner type per-environment since CLI args are global +export async function getScannerSettingsWithDefaults( + envId: number | undefined, + globalDefaults: { grypeArgs: string; trivyArgs: string } +): Promise<{ + scanner: ScannerType; + grypeArgs: string; + trivyArgs: string; +}> { + const scanner = await getEnvSetting('vulnerability_scanner', envId) || 'none'; + return { + scanner, + grypeArgs: globalDefaults.grypeArgs, + trivyArgs: globalDefaults.trivyArgs + }; +} + +// Parse CLI args string into array, substituting {image} placeholder +function parseCliArgs(argsString: string, imageName: string): string[] { + // Replace {image} placeholder with actual image name + const withImage = argsString.replace(/\{image\}/g, imageName); + // Split by whitespace, respecting quoted strings + const args: string[] = []; + let current = ''; + let inQuote = false; + let quoteChar = ''; + + for (const char of withImage) { + if ((char === '"' || char === "'") && !inQuote) { + inQuote = true; + quoteChar = char; + } else if (char === quoteChar && inQuote) { + inQuote = false; + quoteChar = ''; + } else if (char === ' ' && !inQuote) { + if (current) { + args.push(current); + current = ''; + } + } else { + current += char; + } + } + if (current) { + args.push(current); + } + return args; +} + +// Check if a scanner image is available locally +async function isScannerImageAvailable(scannerImage: string, envId?: number): Promise { + try { + const images = await listImages(envId); + return images.some((img) => + img.tags?.some((tag: string) => tag.includes(scannerImage.split(':')[0])) + ); + } catch { + return false; + } +} + +// Pull scanner image if not available +async function ensureScannerImage( + scannerImage: string, + envId?: number, + onProgress?: (progress: ScanProgress) => void +): Promise { + const isAvailable = await isScannerImageAvailable(scannerImage, envId); + + if (isAvailable) { + return true; + } + + onProgress?.({ + stage: 'pulling-scanner', + message: `Pulling scanner image ${scannerImage}...` + }); + + try { + await pullImage(scannerImage, undefined, envId); + return true; + } catch (error) { + console.error(`Failed to pull scanner image ${scannerImage}:`, error); + return false; + } +} + +// Parse Grype JSON output +function parseGrypeOutput(output: string): { vulnerabilities: Vulnerability[]; summary: VulnerabilitySeverity } { + const vulnerabilities: Vulnerability[] = []; + const summary: VulnerabilitySeverity = { + critical: 0, + high: 0, + medium: 0, + low: 0, + negligible: 0, + unknown: 0 + }; + + console.log('[Grype] Raw output length:', output.length); + console.log('[Grype] Output starts with:', output.slice(0, 200)); + + try { + const data = JSON.parse(output); + console.log('[Grype] Parsed JSON, matches count:', data.matches?.length || 0); + + if (data.matches) { + for (const match of data.matches) { + const severity = (match.vulnerability?.severity || 'Unknown').toLowerCase(); + const vuln: Vulnerability = { + id: match.vulnerability?.id || 'Unknown', + severity: severity, + package: match.artifact?.name || 'Unknown', + version: match.artifact?.version || 'Unknown', + fixedVersion: match.vulnerability?.fix?.versions?.[0], + description: match.vulnerability?.description, + link: match.vulnerability?.dataSource, + scanner: 'grype' + }; + vulnerabilities.push(vuln); + + // Count by severity + if (severity === 'critical') summary.critical++; + else if (severity === 'high') summary.high++; + else if (severity === 'medium') summary.medium++; + else if (severity === 'low') summary.low++; + else if (severity === 'negligible') summary.negligible++; + else summary.unknown++; + } + } + } catch (error) { + console.error('[Grype] Failed to parse output:', error); + console.error('[Grype] Output was:', output.slice(0, 500)); + // Check if output looks like an error message from grype + const firstLine = output.split('\n')[0].trim(); + if (firstLine && !firstLine.startsWith('{')) { + throw new Error(`Scanner output error: ${firstLine}`); + } + throw new Error('Failed to parse scanner output - ensure CLI args include "-o json"'); + } + + console.log('[Grype] Parsed vulnerabilities:', vulnerabilities.length); + return { vulnerabilities, summary }; +} + +// Parse Trivy JSON output +function parseTrivyOutput(output: string): { vulnerabilities: Vulnerability[]; summary: VulnerabilitySeverity } { + const vulnerabilities: Vulnerability[] = []; + const summary: VulnerabilitySeverity = { + critical: 0, + high: 0, + medium: 0, + low: 0, + negligible: 0, + unknown: 0 + }; + + try { + const data = JSON.parse(output); + + const results = data.Results || []; + for (const result of results) { + const vulns = result.Vulnerabilities || []; + for (const v of vulns) { + const severity = (v.Severity || 'Unknown').toLowerCase(); + const vuln: Vulnerability = { + id: v.VulnerabilityID || 'Unknown', + severity: severity, + package: v.PkgName || 'Unknown', + version: v.InstalledVersion || 'Unknown', + fixedVersion: v.FixedVersion, + description: v.Description, + link: v.PrimaryURL || v.References?.[0], + scanner: 'trivy' + }; + vulnerabilities.push(vuln); + + // Count by severity + if (severity === 'critical') summary.critical++; + else if (severity === 'high') summary.high++; + else if (severity === 'medium') summary.medium++; + else if (severity === 'low') summary.low++; + else if (severity === 'negligible') summary.negligible++; + else summary.unknown++; + } + } + } catch (error) { + console.error('[Trivy] Failed to parse output:', error); + console.error('[Trivy] Output was:', output.slice(0, 500)); + // Check if output looks like an error message from trivy + const firstLine = output.split('\n')[0].trim(); + if (firstLine && !firstLine.startsWith('{')) { + throw new Error(`Scanner output error: ${firstLine}`); + } + throw new Error('Failed to parse scanner output - ensure CLI args include "--format json"'); + } + + return { vulnerabilities, summary }; +} + +// Get the SHA256 image ID for a given image name/tag +async function getImageSha(imageName: string, envId?: number): Promise { + try { + const imageInfo = await inspectImage(imageName, envId) as any; + // The Id field contains the full sha256:... hash + return imageInfo.Id || imageName; + } catch { + // If we can't inspect the image, fall back to the name + return imageName; + } +} + +// Ensure a named volume exists for caching scanner databases +async function ensureVolume(volumeName: string, envId?: number): Promise { + const volumes = await listVolumes(envId); + const exists = volumes.some(v => v.name === volumeName); + if (!exists) { + console.log(`[Scanner] Creating database volume: ${volumeName}`); + await createVolume({ name: volumeName }, envId); + } else { + console.log(`[Scanner] Using existing database volume: ${volumeName}`); + } +} + +// Run scanner in a fresh container with volume-cached database +async function runScannerContainer( + scannerImage: string, + scannerType: 'grype' | 'trivy', + imageName: string, + cmd: string[], + envId?: number, + onOutput?: (line: string) => void +): Promise { + // Ensure database cache volume exists + const volumeName = scannerType === 'grype' ? GRYPE_VOLUME_NAME : TRIVY_VOLUME_NAME; + await ensureVolume(volumeName, envId); + + // Check if another scanner of the same type is already running + // If so, use a unique cache subdirectory to avoid lock conflicts + const currentCount = runningScanners.get(scannerType) || 0; + const scanId = currentCount > 0 ? `-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` : ''; + + // Increment running counter + runningScanners.set(scannerType, currentCount + 1); + + // Configure volume mount based on scanner type + // Use a unique subdirectory if another scan is in progress + const basePath = scannerType === 'grype' ? '/cache/grype' : '/cache/trivy'; + const dbPath = scanId ? `${basePath}${scanId}` : basePath; + + const binds = [ + '/var/run/docker.sock:/var/run/docker.sock:ro', + `${volumeName}:${basePath}` // Always mount to base path + ]; + + // Environment variables to ensure scanners use the correct cache path + // For concurrent scans, use a unique subdirectory + const envVars = scannerType === 'grype' + ? [`GRYPE_DB_CACHE_DIR=${dbPath}`] + : [`TRIVY_CACHE_DIR=${dbPath}`]; + + if (scanId) { + console.log(`[Scanner] Concurrent scan detected - using unique cache dir: ${dbPath}`); + } + console.log(`[Scanner] Running ${scannerType} with volume ${volumeName} mounted at ${basePath}`); + + try { + // Run the scanner container + const output = await runContainerWithStreaming({ + image: scannerImage, + cmd, + binds, + env: envVars, + name: `dockhand-${scannerType}-${Date.now()}`, + envId, + onStderr: (data) => { + // Stream stderr lines for real-time progress output + const lines = data.split('\n'); + for (const line of lines) { + if (line.trim()) { + onOutput?.(line); + } + } + } + }); + + return output; + } finally { + // Decrement running counter + const newCount = (runningScanners.get(scannerType) || 1) - 1; + if (newCount <= 0) { + runningScanners.delete(scannerType); + } else { + runningScanners.set(scannerType, newCount); + } + } +} + +// Scan image with Grype +export async function scanWithGrype( + imageName: string, + envId?: number, + onProgress?: (progress: ScanProgress) => void +): Promise { + const startTime = Date.now(); + const scannerImage = 'anchore/grype:latest'; + const { grypeArgs } = await getScannerSettings(envId); + + onProgress?.({ + stage: 'checking', + message: 'Checking Grype scanner availability...', + scanner: 'grype' + }); + + // Ensure scanner image is available + const available = await ensureScannerImage(scannerImage, envId, onProgress); + if (!available) { + throw new Error('Failed to get Grype scanner image. Please ensure Docker can pull images.'); + } + + onProgress?.({ + stage: 'scanning', + message: `Scanning ${imageName} with Grype...`, + scanner: 'grype', + progress: 30 + }); + + try { + // Parse CLI args from settings + const cmd = parseCliArgs(grypeArgs, imageName); + const output = await runScannerContainer( + scannerImage, + 'grype', + imageName, + cmd, + envId, + (line) => { + onProgress?.({ + stage: 'scanning', + message: `Scanning ${imageName} with Grype...`, + scanner: 'grype', + progress: 50, + output: line + }); + } + ); + + onProgress?.({ + stage: 'parsing', + message: 'Parsing scan results...', + scanner: 'grype', + progress: 80 + }); + + const { vulnerabilities, summary } = parseGrypeOutput(output); + + // Get the actual SHA256 image ID for reliable caching + const imageId = await getImageSha(imageName, envId); + + const result: ScanResult = { + imageId, + imageName, + scanner: 'grype', + scannedAt: new Date().toISOString(), + vulnerabilities, + summary, + scanDuration: Date.now() - startTime + }; + + onProgress?.({ + stage: 'complete', + message: 'Grype scan complete', + scanner: 'grype', + progress: 100, + result + }); + + return result; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + onProgress?.({ + stage: 'error', + message: `Grype scan failed: ${errorMsg}`, + scanner: 'grype', + error: errorMsg + }); + throw error; + } +} + +// Scan image with Trivy +export async function scanWithTrivy( + imageName: string, + envId?: number, + onProgress?: (progress: ScanProgress) => void +): Promise { + const startTime = Date.now(); + const scannerImage = 'aquasec/trivy:latest'; + const { trivyArgs } = await getScannerSettings(envId); + + onProgress?.({ + stage: 'checking', + message: 'Checking Trivy scanner availability...', + scanner: 'trivy' + }); + + // Ensure scanner image is available + const available = await ensureScannerImage(scannerImage, envId, onProgress); + if (!available) { + throw new Error('Failed to get Trivy scanner image. Please ensure Docker can pull images.'); + } + + onProgress?.({ + stage: 'scanning', + message: `Scanning ${imageName} with Trivy...`, + scanner: 'trivy', + progress: 30 + }); + + try { + // Parse CLI args from settings + const cmd = parseCliArgs(trivyArgs, imageName); + const output = await runScannerContainer( + scannerImage, + 'trivy', + imageName, + cmd, + envId, + (line) => { + onProgress?.({ + stage: 'scanning', + message: `Scanning ${imageName} with Trivy...`, + scanner: 'trivy', + progress: 50, + output: line + }); + } + ); + + onProgress?.({ + stage: 'parsing', + message: 'Parsing scan results...', + scanner: 'trivy', + progress: 80 + }); + + const { vulnerabilities, summary } = parseTrivyOutput(output); + + // Get the actual SHA256 image ID for reliable caching + const imageId = await getImageSha(imageName, envId); + + const result: ScanResult = { + imageId, + imageName, + scanner: 'trivy', + scannedAt: new Date().toISOString(), + vulnerabilities, + summary, + scanDuration: Date.now() - startTime + }; + + onProgress?.({ + stage: 'complete', + message: 'Trivy scan complete', + scanner: 'trivy', + progress: 100, + result + }); + + return result; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + onProgress?.({ + stage: 'error', + message: `Trivy scan failed: ${errorMsg}`, + scanner: 'trivy', + error: errorMsg + }); + throw error; + } +} + +// Scan image with configured scanner(s) +export async function scanImage( + imageName: string, + envId?: number, + onProgress?: (progress: ScanProgress) => void, + forceScannerType?: ScannerType +): Promise { + const { scanner } = await getScannerSettings(envId); + const scannerType = forceScannerType || scanner; + + if (scannerType === 'none') { + return []; + } + + const results: ScanResult[] = []; + + const errors: Error[] = []; + + if (scannerType === 'grype' || scannerType === 'both') { + try { + const result = await scanWithGrype(imageName, envId, onProgress); + results.push(result); + } catch (error) { + console.error('Grype scan failed:', error); + errors.push(error instanceof Error ? error : new Error(String(error))); + if (scannerType === 'grype') throw error; + } + } + + if (scannerType === 'trivy' || scannerType === 'both') { + try { + const result = await scanWithTrivy(imageName, envId, onProgress); + results.push(result); + } catch (error) { + console.error('Trivy scan failed:', error); + errors.push(error instanceof Error ? error : new Error(String(error))); + if (scannerType === 'trivy') throw error; + } + } + + // If using 'both' and all scanners failed, throw an error + if (scannerType === 'both' && results.length === 0 && errors.length > 0) { + throw new Error(`All scanners failed: ${errors.map(e => e.message).join('; ')}`); + } + + // Send vulnerability notifications based on combined results + // When using 'both' scanners, take the MAX of each severity across all results + if (results.length > 0) { + const combinedSummary: VulnerabilitySeverity = { + critical: Math.max(...results.map(r => r.summary.critical)), + high: Math.max(...results.map(r => r.summary.high)), + medium: Math.max(...results.map(r => r.summary.medium)), + low: Math.max(...results.map(r => r.summary.low)), + negligible: Math.max(...results.map(r => r.summary.negligible)), + unknown: Math.max(...results.map(r => r.summary.unknown)) + }; + + // Send notifications (async, don't block return) + sendVulnerabilityNotifications(imageName, combinedSummary, envId).catch(err => { + console.error('[Scanner] Failed to send vulnerability notifications:', err); + }); + } + + return results; +} + +// Check if scanner images are available +export async function checkScannerAvailability(envId?: number): Promise<{ + grype: boolean; + trivy: boolean; +}> { + const [grypeAvailable, trivyAvailable] = await Promise.all([ + isScannerImageAvailable('anchore/grype', envId), + isScannerImageAvailable('aquasec/trivy', envId) + ]); + + return { + grype: grypeAvailable, + trivy: trivyAvailable + }; +} + +// Get scanner version by running a temporary container +async function getScannerVersion( + scannerType: 'grype' | 'trivy', + envId?: number +): Promise { + try { + const scannerImage = scannerType === 'grype' ? 'anchore/grype:latest' : 'aquasec/trivy:latest'; + + // Check if image exists first + const images = await listImages(envId); + const hasImage = images.some((img) => + img.tags?.some((tag: string) => tag.includes(scannerImage.split(':')[0])) + ); + if (!hasImage) return null; + + // Create temporary container to get version + const versionCmd = scannerType === 'grype' ? ['version'] : ['--version']; + const { stdout, stderr } = await runContainer({ + image: scannerImage, + cmd: versionCmd, + name: `dockhand-${scannerType}-version-${Date.now()}`, + envId + }); + + const output = stdout || stderr; + + // Parse version from output + // Grype: "grype 0.74.0" or "Application: grype\nVersion: 0.86.1" + // Trivy: "Version: 0.48.0" or just "0.48.0" + const versionMatch = output.match(/(?:grype|trivy|Version:?\s*)?([\d]+\.[\d]+\.[\d]+)/i); + const version = versionMatch ? versionMatch[1] : null; + + if (!version) { + console.error(`Could not parse ${scannerType} version from output:`, output.substring(0, 200)); + } + + return version; + } catch (error) { + console.error(`Failed to get ${scannerType} version:`, error); + return null; + } +} + +// Get versions of available scanners +export async function getScannerVersions(envId?: number): Promise<{ + grype: string | null; + trivy: string | null; +}> { + const [grypeVersion, trivyVersion] = await Promise.all([ + getScannerVersion('grype', envId), + getScannerVersion('trivy', envId) + ]); + + return { + grype: grypeVersion, + trivy: trivyVersion + }; +} + +// Check if scanner images have updates available by comparing local digest with remote +export async function checkScannerUpdates(envId?: number): Promise<{ + grype: { hasUpdate: boolean; localDigest?: string; remoteDigest?: string }; + trivy: { hasUpdate: boolean; localDigest?: string; remoteDigest?: string }; +}> { + const result = { + grype: { hasUpdate: false, localDigest: undefined as string | undefined, remoteDigest: undefined as string | undefined }, + trivy: { hasUpdate: false, localDigest: undefined as string | undefined, remoteDigest: undefined as string | undefined } + }; + + try { + const images = await listImages(envId); + + // Check both scanners + for (const [scanner, imageName] of [['grype', 'anchore/grype:latest'], ['trivy', 'aquasec/trivy:latest']] as const) { + try { + // Find local image + const localImage = images.find((img) => + img.tags?.includes(imageName) + ); + + if (localImage) { + result[scanner].localDigest = localImage.id?.substring(7, 19); // Short digest + // Note: Remote digest checking would require pulling or using registry API + // For simplicity, we just note that checking for updates requires a pull + result[scanner].hasUpdate = false; + } + } catch (error) { + console.error(`Failed to check updates for ${scanner}:`, error); + } + } + } catch (error) { + console.error('Failed to check scanner updates:', error); + } + + return result; +} + +// Clean up scanner database volumes (removes cached vulnerability databases) +export async function cleanupScannerVolumes(envId?: number): Promise { + try { + // Remove scanner database volumes + for (const volumeName of [GRYPE_VOLUME_NAME, TRIVY_VOLUME_NAME]) { + try { + await removeVolume(volumeName, true, envId); + console.log(`[Scanner] Removed volume: ${volumeName}`); + } catch { + // Volume might not exist, ignore + } + } + } catch (error) { + console.error('Failed to cleanup scanner volumes:', error); + } +} diff --git a/lib/server/scheduler/index.ts b/lib/server/scheduler/index.ts new file mode 100644 index 0000000..0fda3e5 --- /dev/null +++ b/lib/server/scheduler/index.ts @@ -0,0 +1,632 @@ +/** + * Unified Scheduler Service + * + * Manages all scheduled tasks using croner with automatic job lifecycle: + * - System cleanup jobs (static cron schedules) + * - Container auto-updates (dynamic schedules from database) + * - Git stack auto-sync (dynamic schedules from database) + * + * All execution logic is in separate task files for clean architecture. + */ + +import { Cron } from 'croner'; +import { + getEnabledAutoUpdateSettings, + getEnabledAutoUpdateGitStacks, + getAutoUpdateSettingById, + getGitStack, + getScheduleCleanupCron, + getEventCleanupCron, + getScheduleRetentionDays, + getEventRetentionDays, + getScheduleCleanupEnabled, + getEventCleanupEnabled, + getEnvironments, + getEnvUpdateCheckSettings, + getAllEnvUpdateCheckSettings, + getEnvironment, + getEnvironmentTimezone, + getDefaultTimezone +} from '../db'; +import { + cleanupStaleVolumeHelpers, + cleanupExpiredVolumeHelpers +} from '../docker'; + +// Import task execution functions +import { runContainerUpdate } from './tasks/container-update'; +import { runGitStackSync } from './tasks/git-stack-sync'; +import { runEnvUpdateCheckJob } from './tasks/env-update-check'; +import { + runScheduleCleanupJob, + runEventCleanupJob, + runVolumeHelperCleanupJob, + SYSTEM_SCHEDULE_CLEANUP_ID, + SYSTEM_EVENT_CLEANUP_ID, + SYSTEM_VOLUME_HELPER_CLEANUP_ID +} from './tasks/system-cleanup'; + +// Store all active cron jobs +const activeJobs: Map = new Map(); + +// System cleanup jobs +let cleanupJob: Cron | null = null; +let eventCleanupJob: Cron | null = null; +let volumeHelperCleanupJob: Cron | null = null; + +// Scheduler state +let isRunning = false; + +/** + * Start the unified scheduler service. + * Registers all schedules with croner for automatic execution. + */ +export async function startScheduler(): Promise { + if (isRunning) { + console.log('[Scheduler] Already running'); + return; + } + + console.log('[Scheduler] Starting scheduler service...'); + isRunning = true; + + // Get cron expressions and default timezone from database + const scheduleCleanupCron = await getScheduleCleanupCron(); + const eventCleanupCron = await getEventCleanupCron(); + const defaultTimezone = await getDefaultTimezone(); + + // Start system cleanup jobs (static schedules with default timezone) + cleanupJob = new Cron(scheduleCleanupCron, { timezone: defaultTimezone }, async () => { + await runScheduleCleanupJob(); + }); + + eventCleanupJob = new Cron(eventCleanupCron, { timezone: defaultTimezone }, async () => { + await runEventCleanupJob(); + }); + + // Cleanup functions to pass to the job (avoids dynamic import issues in production) + // Wrap cleanupStaleVolumeHelpers to pre-fetch environments + const wrappedCleanupStale = async () => { + const envs = await getEnvironments(); + await cleanupStaleVolumeHelpers(envs); + }; + const volumeCleanupFns = { + cleanupStaleVolumeHelpers: wrappedCleanupStale, + cleanupExpiredVolumeHelpers + }; + + // Volume helper cleanup runs every 30 minutes to clean up expired browse containers + volumeHelperCleanupJob = new Cron('*/30 * * * *', { timezone: defaultTimezone }, async () => { + await runVolumeHelperCleanupJob('cron', volumeCleanupFns); + }); + + // Run volume helper cleanup immediately on startup to clean up stale containers + runVolumeHelperCleanupJob('startup', volumeCleanupFns).catch(err => { + console.error('[Scheduler] Error during startup volume helper cleanup:', err); + }); + + console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`); + console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`); + console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${defaultTimezone}]`); + + // Register all dynamic schedules from database + await refreshAllSchedules(); + + console.log('[Scheduler] Service started'); +} + +/** + * Stop the scheduler service and cleanup all jobs. + */ +export function stopScheduler(): void { + if (!isRunning) return; + + console.log('[Scheduler] Stopping scheduler...'); + isRunning = false; + + // Stop system jobs + if (cleanupJob) { + cleanupJob.stop(); + cleanupJob = null; + } + if (eventCleanupJob) { + eventCleanupJob.stop(); + eventCleanupJob = null; + } + if (volumeHelperCleanupJob) { + volumeHelperCleanupJob.stop(); + volumeHelperCleanupJob = null; + } + + // Stop all dynamic jobs + for (const [key, job] of activeJobs.entries()) { + job.stop(); + } + activeJobs.clear(); + + console.log('[Scheduler] Service stopped'); +} + +/** + * Refresh all dynamic schedules from database. + * Called on startup and optionally for recovery. + */ +export async function refreshAllSchedules(): Promise { + console.log('[Scheduler] Refreshing all schedules...'); + + // Clear existing dynamic jobs + for (const [key, job] of activeJobs.entries()) { + job.stop(); + } + activeJobs.clear(); + + let containerCount = 0; + let gitStackCount = 0; + + // Register container auto-update schedules + try { + const containerSettings = await getEnabledAutoUpdateSettings(); + for (const setting of containerSettings) { + if (setting.cronExpression) { + const registered = await registerSchedule( + setting.id, + 'container_update', + setting.environmentId + ); + if (registered) containerCount++; + } + } + } catch (error) { + console.error('[Scheduler] Error loading container schedules:', error); + } + + // Register git stack auto-sync schedules + try { + const gitStacks = await getEnabledAutoUpdateGitStacks(); + for (const stack of gitStacks) { + if (stack.autoUpdateCron) { + const registered = await registerSchedule( + stack.id, + 'git_stack_sync', + stack.environmentId + ); + if (registered) gitStackCount++; + } + } + } catch (error) { + console.error('[Scheduler] Error loading git stack schedules:', error); + } + + // Register environment update check schedules + let envUpdateCheckCount = 0; + try { + const envConfigs = await getAllEnvUpdateCheckSettings(); + for (const { envId, settings } of envConfigs) { + if (settings.enabled && settings.cron) { + const registered = await registerSchedule( + envId, + 'env_update_check', + envId + ); + if (registered) envUpdateCheckCount++; + } + } + } catch (error) { + console.error('[Scheduler] Error loading env update check schedules:', error); + } + + console.log(`[Scheduler] Registered ${containerCount} container schedules, ${gitStackCount} git stack schedules, ${envUpdateCheckCount} env update check schedules`); +} + +/** + * Register or update a schedule with automatic croner execution. + * Idempotent - can be called multiple times safely. + */ +export async function registerSchedule( + scheduleId: number, + type: 'container_update' | 'git_stack_sync' | 'env_update_check', + environmentId: number | null +): Promise { + const key = `${type}-${scheduleId}`; + + try { + // Unregister existing job if present + unregisterSchedule(scheduleId, type); + + // Fetch schedule data from database + let cronExpression: string | null = null; + let entityName: string | null = null; + let enabled = false; + + if (type === 'container_update') { + const setting = await getAutoUpdateSettingById(scheduleId); + if (!setting) return false; + cronExpression = setting.cronExpression; + entityName = setting.containerName; + enabled = setting.enabled; + } else if (type === 'git_stack_sync') { + const stack = await getGitStack(scheduleId); + if (!stack) return false; + cronExpression = stack.autoUpdateCron; + entityName = stack.stackName; + enabled = stack.autoUpdate; + } else if (type === 'env_update_check') { + const config = await getEnvUpdateCheckSettings(scheduleId); + if (!config) return false; + const env = await getEnvironment(scheduleId); + if (!env) return false; + cronExpression = config.cron; + entityName = `Update: ${env.name}`; + enabled = config.enabled; + } + + // Don't create job if disabled or no cron expression + if (!enabled || !cronExpression) { + return false; + } + + // Get timezone for this environment + const timezone = environmentId ? await getEnvironmentTimezone(environmentId) : 'UTC'; + + // Create new Cron instance with timezone + const job = new Cron(cronExpression, { timezone }, async () => { + // Defensive check: verify schedule still exists and is enabled + if (type === 'container_update') { + const setting = await getAutoUpdateSettingById(scheduleId); + if (!setting || !setting.enabled) return; + await runContainerUpdate(scheduleId, setting.containerName, environmentId, 'cron'); + } else if (type === 'git_stack_sync') { + const stack = await getGitStack(scheduleId); + if (!stack || !stack.autoUpdate) return; + await runGitStackSync(scheduleId, stack.stackName, environmentId, 'cron'); + } else if (type === 'env_update_check') { + const config = await getEnvUpdateCheckSettings(scheduleId); + if (!config || !config.enabled) return; + await runEnvUpdateCheckJob(scheduleId, 'cron'); + } + }); + + // Store in active jobs map + activeJobs.set(key, job); + console.log(`[Scheduler] Registered ${type} schedule ${scheduleId} (${entityName}): ${cronExpression} [${timezone}]`); + return true; + } catch (error: any) { + console.error(`[Scheduler] Failed to register ${type} schedule ${scheduleId}:`, error.message); + return false; + } +} + +/** + * Unregister a schedule and stop its croner job. + * Idempotent - safe to call even if not registered. + */ +export function unregisterSchedule( + scheduleId: number, + type: 'container_update' | 'git_stack_sync' | 'env_update_check' +): void { + const key = `${type}-${scheduleId}`; + const job = activeJobs.get(key); + + if (job) { + job.stop(); + activeJobs.delete(key); + console.log(`[Scheduler] Unregistered ${type} schedule ${scheduleId}`); + } +} + +/** + * Refresh all schedules for a specific environment. + * Called when an environment's timezone changes to re-register jobs with the new timezone. + */ +export async function refreshSchedulesForEnvironment(environmentId: number): Promise { + console.log(`[Scheduler] Refreshing schedules for environment ${environmentId} (timezone changed)`); + + let refreshedCount = 0; + + // Re-register container auto-update schedules for this environment + try { + const containerSettings = await getEnabledAutoUpdateSettings(); + for (const setting of containerSettings) { + if (setting.environmentId === environmentId && setting.cronExpression) { + const registered = await registerSchedule( + setting.id, + 'container_update', + setting.environmentId + ); + if (registered) refreshedCount++; + } + } + } catch (error) { + console.error('[Scheduler] Error refreshing container schedules:', error); + } + + // Re-register git stack auto-sync schedules for this environment + try { + const gitStacks = await getEnabledAutoUpdateGitStacks(); + for (const stack of gitStacks) { + if (stack.environmentId === environmentId && stack.autoUpdateCron) { + const registered = await registerSchedule( + stack.id, + 'git_stack_sync', + stack.environmentId + ); + if (registered) refreshedCount++; + } + } + } catch (error) { + console.error('[Scheduler] Error refreshing git stack schedules:', error); + } + + // Re-register environment update check schedule for this environment + try { + const config = await getEnvUpdateCheckSettings(environmentId); + if (config && config.enabled && config.cron) { + const registered = await registerSchedule( + environmentId, + 'env_update_check', + environmentId + ); + if (registered) refreshedCount++; + } + } catch (error) { + console.error('[Scheduler] Error refreshing env update check schedule:', error); + } + + console.log(`[Scheduler] Refreshed ${refreshedCount} schedules for environment ${environmentId}`); +} + +/** + * Refresh system cleanup jobs with the new default timezone. + * Called when the default timezone setting changes. + */ +export async function refreshSystemJobs(): Promise { + console.log('[Scheduler] Refreshing system jobs (default timezone changed)'); + + // Get current settings + const scheduleCleanupCron = await getScheduleCleanupCron(); + const eventCleanupCron = await getEventCleanupCron(); + const defaultTimezone = await getDefaultTimezone(); + + // Cleanup functions to pass to the job + const wrappedCleanupStale = async () => { + const envs = await getEnvironments(); + await cleanupStaleVolumeHelpers(envs); + }; + const volumeCleanupFns = { + cleanupStaleVolumeHelpers: wrappedCleanupStale, + cleanupExpiredVolumeHelpers + }; + + // Stop existing system jobs + if (cleanupJob) { + cleanupJob.stop(); + } + if (eventCleanupJob) { + eventCleanupJob.stop(); + } + if (volumeHelperCleanupJob) { + volumeHelperCleanupJob.stop(); + } + + // Re-create with new timezone + cleanupJob = new Cron(scheduleCleanupCron, { timezone: defaultTimezone }, async () => { + await runScheduleCleanupJob(); + }); + + eventCleanupJob = new Cron(eventCleanupCron, { timezone: defaultTimezone }, async () => { + await runEventCleanupJob(); + }); + + volumeHelperCleanupJob = new Cron('*/30 * * * *', { timezone: defaultTimezone }, async () => { + await runVolumeHelperCleanupJob('cron', volumeCleanupFns); + }); + + console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`); + console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`); + console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${defaultTimezone}]`); +} + +// ============================================================================= +// MANUAL TRIGGER FUNCTIONS (for API endpoints) +// ============================================================================= + +/** + * Manually trigger a container update. + */ +export async function triggerContainerUpdate(settingId: number): Promise<{ success: boolean; executionId?: number; error?: string }> { + try { + const setting = await getAutoUpdateSettingById(settingId); + if (!setting) { + return { success: false, error: 'Auto-update setting not found' }; + } + + // Run in background + runContainerUpdate(settingId, setting.containerName, setting.environmentId, 'manual'); + + return { success: true }; + } catch (error: any) { + return { success: false, error: error.message }; + } +} + +/** + * Manually trigger a git stack sync. + */ +export async function triggerGitStackSync(stackId: number): Promise<{ success: boolean; executionId?: number; error?: string }> { + try { + const stack = await getGitStack(stackId); + if (!stack) { + return { success: false, error: 'Git stack not found' }; + } + + // Run in background + runGitStackSync(stackId, stack.stackName, stack.environmentId, 'manual'); + + return { success: true }; + } catch (error: any) { + return { success: false, error: error.message }; + } +} + +/** + * Trigger git stack sync from webhook (called from webhook endpoint). + */ +export async function triggerGitStackSyncFromWebhook(stackId: number): Promise<{ success: boolean; executionId?: number; error?: string }> { + try { + const stack = await getGitStack(stackId); + if (!stack) { + return { success: false, error: 'Git stack not found' }; + } + + // Run in background + runGitStackSync(stackId, stack.stackName, stack.environmentId, 'webhook'); + + return { success: true }; + } catch (error: any) { + return { success: false, error: error.message }; + } +} + +/** + * Manually trigger an environment update check. + */ +export async function triggerEnvUpdateCheck(environmentId: number): Promise<{ success: boolean; executionId?: number; error?: string }> { + try { + const config = await getEnvUpdateCheckSettings(environmentId); + if (!config) { + return { success: false, error: 'Update check settings not found for this environment' }; + } + + const env = await getEnvironment(environmentId); + if (!env) { + return { success: false, error: 'Environment not found' }; + } + + // Run in background + runEnvUpdateCheckJob(environmentId, 'manual'); + + return { success: true }; + } catch (error: any) { + return { success: false, error: error.message }; + } +} + +/** + * Manually trigger a system job (schedule cleanup, event cleanup, etc.). + */ +export async function triggerSystemJob(jobId: string): Promise<{ success: boolean; executionId?: number; error?: string }> { + try { + if (jobId === String(SYSTEM_SCHEDULE_CLEANUP_ID) || jobId === 'schedule-cleanup') { + runScheduleCleanupJob('manual'); + return { success: true }; + } else if (jobId === String(SYSTEM_EVENT_CLEANUP_ID) || jobId === 'event-cleanup') { + runEventCleanupJob('manual'); + return { success: true }; + } else if (jobId === String(SYSTEM_VOLUME_HELPER_CLEANUP_ID) || jobId === 'volume-helper-cleanup') { + // Wrap to pre-fetch environments (avoids dynamic import in production) + const wrappedCleanupStale = async () => { + const envs = await getEnvironments(); + await cleanupStaleVolumeHelpers(envs); + }; + runVolumeHelperCleanupJob('manual', { + cleanupStaleVolumeHelpers: wrappedCleanupStale, + cleanupExpiredVolumeHelpers + }); + return { success: true }; + } else { + return { success: false, error: 'Unknown system job ID' }; + } + } catch (error: any) { + return { success: false, error: error.message }; + } +} + +// ============================================================================= +// UTILITY FUNCTIONS +// ============================================================================= + +/** + * Get the next run time for a cron expression. + * @param cronExpression - The cron expression + * @param timezone - Optional IANA timezone (e.g., 'Europe/Warsaw'). Defaults to local timezone. + */ +export function getNextRun(cronExpression: string, timezone?: string): Date | null { + try { + const options = timezone ? { timezone } : undefined; + const job = new Cron(cronExpression, options); + const next = job.nextRun(); + job.stop(); + return next; + } catch { + return null; + } +} + +/** + * Check if a cron expression is valid. + */ +export function isValidCron(cronExpression: string): boolean { + try { + const job = new Cron(cronExpression); + job.stop(); + return true; + } catch { + return false; + } +} + +/** + * Get system schedules info for the API. + */ +export async function getSystemSchedules(): Promise { + const scheduleRetention = await getScheduleRetentionDays(); + const eventRetention = await getEventRetentionDays(); + const scheduleCleanupCron = await getScheduleCleanupCron(); + const eventCleanupCron = await getEventCleanupCron(); + const scheduleCleanupEnabled = await getScheduleCleanupEnabled(); + const eventCleanupEnabled = await getEventCleanupEnabled(); + + return [ + { + id: SYSTEM_SCHEDULE_CLEANUP_ID, + type: 'system_cleanup' as const, + name: 'Schedule execution cleanup', + description: `Removes execution logs older than ${scheduleRetention} days`, + cronExpression: scheduleCleanupCron, + nextRun: scheduleCleanupEnabled ? getNextRun(scheduleCleanupCron)?.toISOString() ?? null : null, + isSystem: true, + enabled: scheduleCleanupEnabled + }, + { + id: SYSTEM_EVENT_CLEANUP_ID, + type: 'system_cleanup' as const, + name: 'Container event cleanup', + description: `Removes container events older than ${eventRetention} days`, + cronExpression: eventCleanupCron, + nextRun: eventCleanupEnabled ? getNextRun(eventCleanupCron)?.toISOString() ?? null : null, + isSystem: true, + enabled: eventCleanupEnabled + }, + { + id: SYSTEM_VOLUME_HELPER_CLEANUP_ID, + type: 'system_cleanup' as const, + name: 'Volume helper cleanup', + description: 'Cleans up temporary volume browser containers', + cronExpression: '*/30 * * * *', + nextRun: getNextRun('*/30 * * * *')?.toISOString() ?? null, + isSystem: true, + enabled: true + } + ]; +} + +export interface SystemScheduleInfo { + id: number; + type: 'system_cleanup'; + name: string; + description: string; + cronExpression: string; + nextRun: string | null; + isSystem: true; + enabled: boolean; +} diff --git a/lib/server/scheduler/tasks/container-update.ts b/lib/server/scheduler/tasks/container-update.ts new file mode 100644 index 0000000..c1b7b32 --- /dev/null +++ b/lib/server/scheduler/tasks/container-update.ts @@ -0,0 +1,575 @@ +/** + * Container Auto-Update Task + * + * Handles automatic container updates with vulnerability scanning. + */ + +import type { ScheduleTrigger, VulnerabilityCriteria } from '../../db'; +import { + getAutoUpdateSettingById, + updateAutoUpdateLastChecked, + updateAutoUpdateLastUpdated, + createScheduleExecution, + updateScheduleExecution, + appendScheduleExecutionLog, + saveVulnerabilityScan, + getCombinedScanForImage +} from '../../db'; +import { + pullImage, + listContainers, + inspectContainer, + createContainer, + stopContainer, + removeContainer, + checkImageUpdateAvailable, + getTempImageTag, + isDigestBasedImage, + getImageIdByTag, + removeTempImage, + tagImage +} from '../../docker'; +import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner'; +import { sendEventNotification } from '../../notifications'; +import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isDockhandContainer } from './update-utils'; + +/** + * Execute a container auto-update. + */ +export async function runContainerUpdate( + settingId: number, + containerName: string, + environmentId: number | null | undefined, + triggeredBy: ScheduleTrigger +): Promise { + const envId = environmentId ?? undefined; + const startTime = Date.now(); + + // Create execution record + const execution = await createScheduleExecution({ + scheduleType: 'container_update', + scheduleId: settingId, + environmentId: environmentId ?? null, + entityName: containerName, + triggeredBy, + status: 'running' + }); + + await updateScheduleExecution(execution.id, { + startedAt: new Date().toISOString() + }); + + const log = (message: string) => { + console.log(`[Auto-update] ${message}`); + appendScheduleExecutionLog(execution.id, `[${new Date().toISOString()}] ${message}`); + }; + + try { + log(`Checking container: ${containerName}`); + await updateAutoUpdateLastChecked(containerName, envId); + + // Find the container + const containers = await listContainers(true, envId); + const container = containers.find(c => c.name === containerName); + + if (!container) { + log(`Container not found: ${containerName}`); + await updateScheduleExecution(execution.id, { + status: 'failed', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + errorMessage: 'Container not found' + }); + return; + } + + // Get the full container config to extract the image name (tag) + const inspectData = await inspectContainer(container.id, envId) as any; + const imageNameFromConfig = inspectData.Config?.Image; + + if (!imageNameFromConfig) { + log(`Could not determine image name from container config`); + await updateScheduleExecution(execution.id, { + status: 'failed', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + errorMessage: 'Could not determine image name' + }); + return; + } + + // Prevent Dockhand from updating itself + if (isDockhandContainer(imageNameFromConfig)) { + log(`Skipping Dockhand container - cannot auto-update self`); + await updateScheduleExecution(execution.id, { + status: 'skipped', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + details: { reason: 'Cannot auto-update Dockhand itself' } + }); + return; + } + + // Get the actual image ID from inspect data + const currentImageId = inspectData.Image; + + log(`Container is using image: ${imageNameFromConfig}`); + log(`Current image ID: ${currentImageId?.substring(0, 19)}`); + + // Get scanner and schedule settings early to determine scan strategy + const [scannerSettings, updateSetting] = await Promise.all([ + getScannerSettings(envId), + getAutoUpdateSettingById(settingId) + ]); + + const vulnerabilityCriteria = (updateSetting?.vulnerabilityCriteria || 'never') as VulnerabilityCriteria; + // Scan if scanning is enabled (scanner !== 'none') + // The vulnerabilityCriteria only controls whether to BLOCK updates, not whether to SCAN + const shouldScan = scannerSettings.scanner !== 'none'; + + // ============================================================================= + // SAFE UPDATE FLOW + // ============================================================================= + // 1. Registry check (no pull) - determine if update is available + // 2. If scanning enabled: + // a. Pull new image (overwrites original tag temporarily) + // b. Get new image ID + // c. SAFETY: Restore original tag to point to OLD image + // d. Tag new image with temp suffix for scanning + // e. Scan temp image + // f. If blocked: remove temp image, original tag still safe + // g. If approved: re-tag to original and proceed + // 3. If no scanning: simple pull and update + // ============================================================================= + + // Step 1: Check for update using registry check (no pull) + log(`Checking registry for updates: ${imageNameFromConfig}`); + const registryCheck = await checkImageUpdateAvailable(imageNameFromConfig, currentImageId, envId); + + // Handle local images or registry errors + if (registryCheck.isLocalImage) { + log(`Local image detected - skipping (auto-update requires registry)`); + await updateScheduleExecution(execution.id, { + status: 'skipped', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + details: { reason: 'Local image - no registry available' } + }); + return; + } + + if (registryCheck.error) { + log(`Registry check error: ${registryCheck.error}`); + // Don't fail on transient errors, just skip this run + await updateScheduleExecution(execution.id, { + status: 'skipped', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + details: { reason: `Registry check failed: ${registryCheck.error}` } + }); + return; + } + + if (!registryCheck.hasUpdate) { + log(`Already up-to-date: ${containerName} is running the latest version`); + await updateScheduleExecution(execution.id, { + status: 'skipped', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + details: { reason: 'Already up-to-date' } + }); + return; + } + + log(`Update available! Registry digest: ${registryCheck.registryDigest?.substring(0, 19) || 'unknown'}`); + + // Variables for scan results + let scanResults: ScanResult[] | undefined; + let scanSummary: VulnerabilitySeverity | undefined; + let newImageId: string | null = null; + const newDigest = registryCheck.registryDigest; + + // Step 2: Safe pull with temp tag protection (if scanning enabled) + if (shouldScan) { + log(`Safe-pull enabled (scanner: ${scannerSettings.scanner}, criteria: ${vulnerabilityCriteria})`); + + // Check if this is a digest-based image (can't use temp tags) + if (isDigestBasedImage(imageNameFromConfig)) { + log(`Digest-based image detected - temp tag protection not available`); + // Fall through to simple flow + } else { + const tempTag = getTempImageTag(imageNameFromConfig); + log(`Using temp tag for safe pull: ${tempTag}`); + + try { + // Step 2a: Pull new image (overwrites original tag) + log(`Pulling new image: ${imageNameFromConfig}`); + await pullImage(imageNameFromConfig, undefined, envId); + + // Step 2b: Get new image ID + newImageId = await getImageIdByTag(imageNameFromConfig, envId); + if (!newImageId) { + throw new Error('Failed to get new image ID after pull'); + } + log(`New image pulled: ${newImageId.substring(0, 19)}`); + + // Step 2c: SAFETY - Restore original tag to OLD image + log(`Restoring original tag to current safe image...`); + const [oldRepo, oldTag] = parseImageNameAndTag(imageNameFromConfig); + await tagImage(currentImageId, oldRepo, oldTag, envId); + log(`Original tag ${imageNameFromConfig} restored to safe image`); + + // Step 2d: Tag new image with temp suffix + const [tempRepo, tempTagName] = parseImageNameAndTag(tempTag); + await tagImage(newImageId, tempRepo, tempTagName, envId); + log(`New image tagged as: ${tempTag}`); + + // Step 2e: Scan temp image + log(`Scanning new image for vulnerabilities...`); + try { + scanResults = await scanImage(tempTag, envId, (progress) => { + const scannerTag = progress.scanner ? `[${progress.scanner}]` : '[scan]'; + if (progress.message) { + log(`${scannerTag} ${progress.message}`); + } + if (progress.output) { + log(`${scannerTag} ${progress.output}`); + } + }); + + if (scanResults.length > 0) { + scanSummary = combineScanSummaries(scanResults); + log(`Scan result: ${scanSummary.critical} critical, ${scanSummary.high} high, ${scanSummary.medium} medium, ${scanSummary.low} low`); + + // Save scan results + for (const result of scanResults) { + try { + await saveVulnerabilityScan({ + environmentId: envId ?? null, + imageId: newImageId, + imageName: result.imageName, + scanner: result.scanner, + scannedAt: result.scannedAt, + scanDuration: result.scanDuration, + criticalCount: result.summary.critical, + highCount: result.summary.high, + mediumCount: result.summary.medium, + lowCount: result.summary.low, + negligibleCount: result.summary.negligible, + unknownCount: result.summary.unknown, + vulnerabilities: result.vulnerabilities, + error: result.error ?? null + }); + } catch (saveError: any) { + log(`Warning: Could not save scan results: ${saveError.message}`); + } + } + + // Handle 'more_than_current' criteria + let currentScanSummary: VulnerabilitySeverity | undefined; + if (vulnerabilityCriteria === 'more_than_current') { + log(`Looking up cached scan for current image...`); + try { + const cachedScan = await getCombinedScanForImage(currentImageId, envId ?? null); + if (cachedScan) { + currentScanSummary = cachedScan; + log(`Cached scan: ${currentScanSummary.critical} critical, ${currentScanSummary.high} high`); + } else { + log(`No cached scan found, scanning current image...`); + const currentScanResults = await scanImage(currentImageId, envId, (progress) => { + const tag = progress.scanner ? `[${progress.scanner}]` : '[scan]'; + if (progress.message) log(`${tag} ${progress.message}`); + }); + if (currentScanResults.length > 0) { + currentScanSummary = combineScanSummaries(currentScanResults); + log(`Current image: ${currentScanSummary.critical} critical, ${currentScanSummary.high} high`); + // Save for future use + for (const result of currentScanResults) { + try { + await saveVulnerabilityScan({ + environmentId: envId ?? null, + imageId: currentImageId, + imageName: result.imageName, + scanner: result.scanner, + scannedAt: result.scannedAt, + scanDuration: result.scanDuration, + criticalCount: result.summary.critical, + highCount: result.summary.high, + mediumCount: result.summary.medium, + lowCount: result.summary.low, + negligibleCount: result.summary.negligible, + unknownCount: result.summary.unknown, + vulnerabilities: result.vulnerabilities, + error: result.error ?? null + }); + } catch { /* ignore */ } + } + } + } + } catch (cacheError: any) { + log(`Warning: Could not get current scan: ${cacheError.message}`); + } + } + + // Check if update should be blocked + const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, currentScanSummary); + + if (blocked) { + // Step 2f: BLOCKED - Remove temp image, original tag is safe + log(`UPDATE BLOCKED: ${reason}`); + log(`Removing blocked image: ${tempTag}`); + await removeTempImage(newImageId, envId); + log(`Blocked image removed - container will continue using safe image`); + + await updateScheduleExecution(execution.id, { + status: 'skipped', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + details: { + mode: 'auto_update', + reason: 'vulnerabilities_found', + blockReason: reason, + vulnerabilityCriteria, + summary: { checked: 1, updated: 0, blocked: 1, failed: 0 }, + containers: [{ + name: containerName, + status: 'blocked', + blockReason: reason, + scannerResults: scanResults.map(r => ({ + scanner: r.scanner, + critical: r.summary.critical, + high: r.summary.high, + medium: r.summary.medium, + low: r.summary.low, + negligible: r.summary.negligible, + unknown: r.summary.unknown + })) + }], + scanResult: { + summary: scanSummary, + scanners: scanResults.map(r => r.scanner), + scannedAt: scanResults[0]?.scannedAt, + scannerResults: scanResults.map(r => ({ + scanner: r.scanner, + critical: r.summary.critical, + high: r.summary.high, + medium: r.summary.medium, + low: r.summary.low, + negligible: r.summary.negligible, + unknown: r.summary.unknown + })) + } + } + }); + + await sendEventNotification('auto_update_blocked', { + title: 'Auto-update blocked', + message: `Container "${containerName}" update blocked: ${reason}`, + type: 'warning' + }, envId); + + return; + } + + log(`Scan passed vulnerability criteria`); + } + } catch (scanError: any) { + // Scan failure - cleanup temp image and fail + log(`Scan failed: ${scanError.message}`); + log(`Removing temp image due to scan failure...`); + await removeTempImage(newImageId, envId); + + await updateScheduleExecution(execution.id, { + status: 'failed', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + errorMessage: `Vulnerability scan failed: ${scanError.message}` + }); + return; + } + + // Step 2g: APPROVED - Re-tag to original for update + log(`Re-tagging approved image to: ${imageNameFromConfig}`); + await tagImage(newImageId, oldRepo, oldTag, envId); + log(`Image ready for update`); + + // Clean up temp tag (optional, image will be removed when container is recreated) + try { + await removeTempImage(tempTag, envId); + } catch { /* ignore cleanup errors */ } + + } catch (pullError: any) { + log(`Safe-pull failed: ${pullError.message}`); + await updateScheduleExecution(execution.id, { + status: 'failed', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + errorMessage: `Failed to pull image: ${pullError.message}` + }); + return; + } + } + } else { + // No scanning - simple pull + log(`Pulling update (no vulnerability scan)...`); + try { + await pullImage(imageNameFromConfig, undefined, envId); + log(`Image pulled successfully`); + } catch (pullError: any) { + log(`Pull failed: ${pullError.message}`); + await updateScheduleExecution(execution.id, { + status: 'failed', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + errorMessage: `Failed to pull image: ${pullError.message}` + }); + return; + } + } + + log(`Proceeding with container recreation...`); + const success = await recreateContainer(containerName, envId, log); + + if (success) { + await updateAutoUpdateLastUpdated(containerName, envId); + log(`Successfully updated container: ${containerName}`); + await updateScheduleExecution(execution.id, { + status: 'success', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + details: { + mode: 'auto_update', + newDigest, + vulnerabilityCriteria, + summary: { checked: 1, updated: 1, blocked: 0, failed: 0 }, + containers: [{ + name: containerName, + status: 'updated', + scannerResults: scanResults?.map(r => ({ + scanner: r.scanner, + critical: r.summary.critical, + high: r.summary.high, + medium: r.summary.medium, + low: r.summary.low, + negligible: r.summary.negligible, + unknown: r.summary.unknown + })) + }], + scanResult: scanSummary ? { + summary: scanSummary, + scanners: scanResults?.map(r => r.scanner) || [], + scannedAt: scanResults?.[0]?.scannedAt, + scannerResults: scanResults?.map(r => ({ + scanner: r.scanner, + critical: r.summary.critical, + high: r.summary.high, + medium: r.summary.medium, + low: r.summary.low, + negligible: r.summary.negligible, + unknown: r.summary.unknown + })) || [] + } : undefined + } + }); + + // Send notification for successful update + await sendEventNotification('auto_update_success', { + title: 'Container auto-updated', + message: `Container "${containerName}" was updated to a new image version`, + type: 'success' + }, envId); + } else { + throw new Error('Failed to recreate container'); + } + } catch (error: any) { + log(`Error: ${error.message}`); + await updateScheduleExecution(execution.id, { + status: 'failed', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + errorMessage: error.message + }); + + // Send notification for failed update + await sendEventNotification('auto_update_failed', { + title: 'Auto-update failed', + message: `Container "${containerName}" auto-update failed: ${error.message}`, + type: 'error' + }, envId); + } +} + +// ============================================================================= +// HELPER FUNCTIONS +// ============================================================================= + +async function recreateContainer( + containerName: string, + envId?: number, + log?: (msg: string) => void +): Promise { + try { + // Find the container by name + const containers = await listContainers(true, envId); + const container = containers.find(c => c.name === containerName); + + if (!container) { + log?.(`Container not found: ${containerName}`); + return false; + } + + // Get full container config + const inspectData = await inspectContainer(container.id, envId) as any; + const wasRunning = inspectData.State.Running; + const config = inspectData.Config; + const hostConfig = inspectData.HostConfig; + + log?.(`Recreating container: ${containerName} (was running: ${wasRunning})`); + + // Stop container if running + if (wasRunning) { + log?.('Stopping container...'); + await stopContainer(container.id, envId); + } + + // Remove old container + log?.('Removing old container...'); + await removeContainer(container.id, true, envId); + + // Prepare port bindings + const ports: { [key: string]: { HostPort: string } } = {}; + if (hostConfig.PortBindings) { + for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) { + if (bindings && (bindings as any[]).length > 0) { + ports[containerPort] = { HostPort: (bindings as any[])[0].HostPort || '' }; + } + } + } + + // Create new container + log?.('Creating new container...'); + const newContainer = await createContainer({ + name: containerName, + image: config.Image, + ports, + volumeBinds: hostConfig.Binds || [], + env: config.Env || [], + labels: config.Labels || {}, + cmd: config.Cmd || undefined, + restartPolicy: hostConfig.RestartPolicy?.Name || 'no', + networkMode: hostConfig.NetworkMode || undefined + }, envId); + + // Start if was running + if (wasRunning) { + log?.('Starting new container...'); + await newContainer.start(); + } + + log?.('Container recreated successfully'); + return true; + } catch (error: any) { + log?.(`Failed to recreate container: ${error.message}`); + return false; + } +} diff --git a/lib/server/scheduler/tasks/env-update-check.ts b/lib/server/scheduler/tasks/env-update-check.ts new file mode 100644 index 0000000..87fe915 --- /dev/null +++ b/lib/server/scheduler/tasks/env-update-check.ts @@ -0,0 +1,509 @@ +/** + * Environment Update Check Task + * + * Checks all containers in an environment for available image updates. + * Can optionally auto-update containers when updates are found. + */ + +import type { ScheduleTrigger, VulnerabilityCriteria } from '../../db'; +import { + getEnvUpdateCheckSettings, + getEnvironment, + createScheduleExecution, + updateScheduleExecution, + appendScheduleExecutionLog, + saveVulnerabilityScan, + clearPendingContainerUpdates, + addPendingContainerUpdate, + removePendingContainerUpdate +} from '../../db'; +import { + listContainers, + inspectContainer, + checkImageUpdateAvailable, + pullImage, + stopContainer, + removeContainer, + createContainer, + getTempImageTag, + isDigestBasedImage, + getImageIdByTag, + removeTempImage, + tagImage +} from '../../docker'; +import { sendEventNotification } from '../../notifications'; +import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner'; +import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isDockhandContainer } from './update-utils'; + +interface UpdateInfo { + containerId: string; + containerName: string; + imageName: string; + currentImageId: string; + currentDigest?: string; + newDigest?: string; +} + +// Track running update checks to prevent concurrent execution +const runningUpdateChecks = new Set(); + +/** + * Execute environment update check job. + * @param environmentId - The environment ID to check + * @param triggeredBy - What triggered this execution + */ +export async function runEnvUpdateCheckJob( + environmentId: number, + triggeredBy: ScheduleTrigger = 'cron' +): Promise { + // Prevent concurrent execution for the same environment + if (runningUpdateChecks.has(environmentId)) { + console.log(`[EnvUpdateCheck] Environment ${environmentId} update check already running, skipping`); + return; + } + + runningUpdateChecks.add(environmentId); + const startTime = Date.now(); + + try { + // Get environment info + const env = await getEnvironment(environmentId); + if (!env) { + console.error(`[EnvUpdateCheck] Environment ${environmentId} not found`); + return; + } + + // Get settings + const config = await getEnvUpdateCheckSettings(environmentId); + if (!config) { + console.error(`[EnvUpdateCheck] No settings found for environment ${environmentId}`); + return; + } + + // Create execution record + const execution = await createScheduleExecution({ + scheduleType: 'env_update_check', + scheduleId: environmentId, + environmentId, + entityName: `Update: ${env.name}`, + triggeredBy, + status: 'running' + }); + + await updateScheduleExecution(execution.id, { + startedAt: new Date().toISOString() + }); + + const log = async (message: string) => { + console.log(`[EnvUpdateCheck] ${message}`); + await appendScheduleExecutionLog(execution.id, `[${new Date().toISOString()}] ${message}`); + }; + + try { + await log(`Starting update check for environment: ${env.name}`); + await log(`Auto-update mode: ${config.autoUpdate ? 'ON' : 'OFF'}`); + + // Clear pending updates at the start - we'll re-add as we discover updates + await clearPendingContainerUpdates(environmentId); + + // Get all containers in this environment + const containers = await listContainers(true, environmentId); + await log(`Found ${containers.length} containers`); + + const updatesAvailable: UpdateInfo[] = []; + let checkedCount = 0; + let errorCount = 0; + + // Check each container for updates + for (const container of containers) { + try { + const inspectData = await inspectContainer(container.id, environmentId) as any; + const imageName = inspectData.Config?.Image; + const currentImageId = inspectData.Image; + + if (!imageName) { + await log(` [${container.name}] Skipping - no image name found`); + continue; + } + + checkedCount++; + await log(` Checking: ${container.name} (${imageName})`); + + const result = await checkImageUpdateAvailable(imageName, currentImageId, environmentId); + + if (result.isLocalImage) { + await log(` Local image - skipping update check`); + continue; + } + + if (result.error) { + await log(` Error: ${result.error}`); + errorCount++; + continue; + } + + if (result.hasUpdate) { + updatesAvailable.push({ + containerId: container.id, + containerName: container.name, + imageName, + currentImageId, + currentDigest: result.currentDigest, + newDigest: result.registryDigest + }); + // Add to pending table immediately - will be removed on successful update + await addPendingContainerUpdate(environmentId, container.id, container.name, imageName); + await log(` UPDATE AVAILABLE`); + await log(` Current: ${result.currentDigest?.substring(0, 24) || 'unknown'}...`); + await log(` New: ${result.registryDigest?.substring(0, 24) || 'unknown'}...`); + } else { + await log(` Up to date`); + } + } catch (err: any) { + await log(` [${container.name}] Error: ${err.message}`); + errorCount++; + } + } + + // Summary + await log(''); + await log('=== SUMMARY ==='); + await log(`Total containers: ${containers.length}`); + await log(`Checked: ${checkedCount}`); + await log(`Updates available: ${updatesAvailable.length}`); + await log(`Errors: ${errorCount}`); + + if (updatesAvailable.length === 0) { + await log('All containers are up to date'); + // Pending updates already cleared at start, nothing to add + await updateScheduleExecution(execution.id, { + status: 'success', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + details: { + updatesFound: 0, + containersChecked: checkedCount, + errors: errorCount + } + }); + return; + } + + // Build notification message with details + const updateList = updatesAvailable + .map(u => { + const currentShort = u.currentDigest?.substring(0, 12) || 'unknown'; + const newShort = u.newDigest?.substring(0, 12) || 'unknown'; + return `- ${u.containerName} (${u.imageName})\n ${currentShort}... -> ${newShort}...`; + }) + .join('\n'); + + if (config.autoUpdate) { + // Auto-update mode: actually update the containers with safe-pull flow + await log(''); + await log('=== AUTO-UPDATE MODE ==='); + + // Get scanner settings and vulnerability criteria + const scannerSettings = await getScannerSettings(environmentId); + const vulnerabilityCriteria = (config.vulnerabilityCriteria || 'never') as VulnerabilityCriteria; + // Scan if scanning is enabled (scanner !== 'none') + // The vulnerabilityCriteria only controls whether to BLOCK updates, not whether to SCAN + const shouldScan = scannerSettings.scanner !== 'none'; + + await log(`Vulnerability criteria: ${vulnerabilityCriteria}`); + if (shouldScan) { + await log(`Scanner: ${scannerSettings.scanner} (scan enabled)`); + } + await log(`Updating ${updatesAvailable.length} containers...`); + + let successCount = 0; + let failCount = 0; + let blockedCount = 0; + const updatedContainers: string[] = []; + const failedContainers: string[] = []; + const blockedContainers: { name: string; reason: string; scannerResults?: { scanner: string; critical: number; high: number; medium: number; low: number }[] }[] = []; + + for (const update of updatesAvailable) { + // Skip Dockhand container - cannot update itself + if (isDockhandContainer(update.imageName)) { + await log(`\n[${update.containerName}] Skipping - cannot auto-update Dockhand itself`); + continue; + } + + try { + await log(`\nUpdating: ${update.containerName}`); + + // Get full container config + const inspectData = await inspectContainer(update.containerId, environmentId) as any; + const wasRunning = inspectData.State.Running; + const containerConfig = inspectData.Config; + const hostConfig = inspectData.HostConfig; + + // SAFE-PULL FLOW + if (shouldScan && !isDigestBasedImage(update.imageName)) { + const tempTag = getTempImageTag(update.imageName); + await log(` Safe-pull with temp tag: ${tempTag}`); + + // Step 1: Pull new image + await log(` Pulling ${update.imageName}...`); + await pullImage(update.imageName, () => {}, environmentId); + + // Step 2: Get new image ID + const newImageId = await getImageIdByTag(update.imageName, environmentId); + if (!newImageId) { + throw new Error('Failed to get new image ID after pull'); + } + await log(` New image: ${newImageId.substring(0, 19)}`); + + // Step 3: SAFETY - Restore original tag to old image + const [oldRepo, oldTag] = parseImageNameAndTag(update.imageName); + await tagImage(update.currentImageId, oldRepo, oldTag, environmentId); + await log(` Restored original tag to safe image`); + + // Step 4: Tag new image with temp suffix + const [tempRepo, tempTagName] = parseImageNameAndTag(tempTag); + await tagImage(newImageId, tempRepo, tempTagName, environmentId); + + // Step 5: Scan temp image + await log(` Scanning for vulnerabilities...`); + let scanBlocked = false; + let blockReason = ''; + let currentScannerResults: { scanner: string; critical: number; high: number; medium: number; low: number }[] = []; + + // Collect scan logs to log after scan completes + const scanLogs: string[] = []; + + try { + const scanResults = await scanImage(tempTag, environmentId, (progress) => { + if (progress.message) { + scanLogs.push(` [${progress.scanner || 'scan'}] ${progress.message}`); + } + }); + + // Log collected scan messages + for (const scanLog of scanLogs) { + await log(scanLog); + } + + if (scanResults.length > 0) { + const scanSummary = combineScanSummaries(scanResults); + await log(` Scan: ${scanSummary.critical} critical, ${scanSummary.high} high, ${scanSummary.medium} medium, ${scanSummary.low} low`); + + // Capture per-scanner results for blocking info + currentScannerResults = scanResults.map(r => ({ + scanner: r.scanner, + critical: r.summary.critical, + high: r.summary.high, + medium: r.summary.medium, + low: r.summary.low + })); + + // Save scan results + for (const result of scanResults) { + try { + await saveVulnerabilityScan({ + environmentId, + imageId: newImageId, + imageName: result.imageName, + scanner: result.scanner, + scannedAt: result.scannedAt, + scanDuration: result.scanDuration, + criticalCount: result.summary.critical, + highCount: result.summary.high, + mediumCount: result.summary.medium, + lowCount: result.summary.low, + negligibleCount: result.summary.negligible, + unknownCount: result.summary.unknown, + vulnerabilities: result.vulnerabilities, + error: result.error ?? null + }); + } catch { /* ignore save errors */ } + } + + // Check if blocked + const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, undefined); + if (blocked) { + scanBlocked = true; + blockReason = reason; + } + } + } catch (scanErr: any) { + await log(` Scan failed: ${scanErr.message}`); + scanBlocked = true; + blockReason = `Scan failed: ${scanErr.message}`; + } + + if (scanBlocked) { + // BLOCKED - Remove temp image + await log(` UPDATE BLOCKED: ${blockReason}`); + await removeTempImage(newImageId, environmentId); + await log(` Removed blocked image - container stays safe`); + blockedCount++; + blockedContainers.push({ + name: update.containerName, + reason: blockReason, + scannerResults: currentScannerResults.length > 0 ? currentScannerResults : undefined + }); + continue; + } + + // APPROVED - Re-tag to original + await log(` Scan passed, re-tagging...`); + await tagImage(newImageId, oldRepo, oldTag, environmentId); + try { + await removeTempImage(tempTag, environmentId); + } catch { /* ignore cleanup errors */ } + } else { + // Simple pull (no scanning or digest-based image) + await log(` Pulling ${update.imageName}...`); + await pullImage(update.imageName, () => {}, environmentId); + } + + // Stop container if running + if (wasRunning) { + await log(` Stopping...`); + await stopContainer(update.containerId, environmentId); + } + + // Remove old container + await log(` Removing old container...`); + await removeContainer(update.containerId, true, environmentId); + + // Prepare port bindings + const ports: { [key: string]: { HostPort: string } } = {}; + if (hostConfig.PortBindings) { + for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) { + if (bindings && (bindings as any[]).length > 0) { + ports[containerPort] = { HostPort: (bindings as any[])[0].HostPort || '' }; + } + } + } + + // Create new container + await log(` Creating new container...`); + const newContainer = await createContainer({ + name: update.containerName, + image: update.imageName, + ports, + volumeBinds: hostConfig.Binds || [], + env: containerConfig.Env || [], + labels: containerConfig.Labels || {}, + cmd: containerConfig.Cmd || undefined, + restartPolicy: hostConfig.RestartPolicy?.Name || 'no', + networkMode: hostConfig.NetworkMode || undefined + }, environmentId); + + // Start if was running + if (wasRunning) { + await log(` Starting...`); + await newContainer.start(); + } + + await log(` Updated successfully`); + successCount++; + updatedContainers.push(update.containerName); + // Remove from pending table - successfully updated + await removePendingContainerUpdate(environmentId, update.containerId); + } catch (err: any) { + await log(` FAILED: ${err.message}`); + failCount++; + failedContainers.push(update.containerName); + } + } + + await log(''); + await log(`=== UPDATE COMPLETE ===`); + await log(`Updated: ${successCount}`); + await log(`Blocked: ${blockedCount}`); + await log(`Failed: ${failCount}`); + + // Send notifications + if (blockedCount > 0) { + await sendEventNotification('auto_update_blocked', { + title: `${blockedCount} update(s) blocked in ${env.name}`, + message: blockedContainers.map(c => `- ${c.name}: ${c.reason}`).join('\n'), + type: 'warning' + }, environmentId); + } + + const notificationMessage = successCount > 0 + ? `Updated ${successCount} container(s) in ${env.name}:\n${updatedContainers.map(c => `- ${c}`).join('\n')}${blockedCount > 0 ? `\n\nBlocked (${blockedCount}):\n${blockedContainers.map(c => `- ${c.name}`).join('\n')}` : ''}${failCount > 0 ? `\n\nFailed (${failCount}):\n${failedContainers.map(c => `- ${c}`).join('\n')}` : ''}` + : blockedCount > 0 ? `All updates blocked in ${env.name}` : `Update failed for all containers in ${env.name}`; + + await sendEventNotification('batch_update_success', { + title: successCount > 0 ? `Containers updated in ${env.name}` : blockedCount > 0 ? `Updates blocked in ${env.name}` : `Container updates failed in ${env.name}`, + message: notificationMessage, + type: successCount > 0 && failCount === 0 && blockedCount === 0 ? 'success' : successCount > 0 ? 'warning' : 'error' + }, environmentId); + + // Blocked/failed containers stay in pending table (successfully updated ones were removed) + + await updateScheduleExecution(execution.id, { + status: failCount > 0 && successCount === 0 && blockedCount === 0 ? 'failed' : 'success', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + details: { + mode: 'auto_update', + updatesFound: updatesAvailable.length, + containersChecked: checkedCount, + errors: errorCount, + autoUpdate: true, + vulnerabilityCriteria, + summary: { checked: checkedCount, updated: successCount, blocked: blockedCount, failed: failCount }, + containers: [ + ...updatedContainers.map(name => ({ name, status: 'updated' as const })), + ...blockedContainers.map(c => ({ name: c.name, status: 'blocked' as const, blockReason: c.reason, scannerResults: c.scannerResults })), + ...failedContainers.map(name => ({ name, status: 'failed' as const })) + ], + updated: successCount, + blocked: blockedCount, + failed: failCount, + blockedContainers + } + }); + } else { + // Check-only mode: just send notification + await log(''); + await log('Check-only mode - sending notification about available updates'); + // Pending updates already added as we discovered them + + await sendEventNotification('updates_detected', { + title: `Container updates available in ${env.name}`, + message: `${updatesAvailable.length} update(s) available:\n${updateList}`, + type: 'info' + }, environmentId); + + await updateScheduleExecution(execution.id, { + status: 'success', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + details: { + mode: 'notify_only', + updatesFound: updatesAvailable.length, + containersChecked: checkedCount, + errors: errorCount, + autoUpdate: false, + summary: { checked: checkedCount, updated: 0, blocked: 0, failed: 0 }, + containers: updatesAvailable.map(u => ({ + name: u.containerName, + status: 'checked' as const, + imageName: u.imageName, + currentDigest: u.currentDigest, + newDigest: u.newDigest + })) + } + }); + } + } catch (error: any) { + await log(`Error: ${error.message}`); + await updateScheduleExecution(execution.id, { + status: 'failed', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + errorMessage: error.message + }); + } + } finally { + runningUpdateChecks.delete(environmentId); + } +} diff --git a/lib/server/scheduler/tasks/git-stack-sync.ts b/lib/server/scheduler/tasks/git-stack-sync.ts new file mode 100644 index 0000000..e4ede07 --- /dev/null +++ b/lib/server/scheduler/tasks/git-stack-sync.ts @@ -0,0 +1,102 @@ +/** + * Git Stack Auto-Sync Task + * + * Handles automatic syncing and deploying of git-based compose stacks. + */ + +import type { ScheduleTrigger } from '../../db'; +import { + createScheduleExecution, + updateScheduleExecution, + appendScheduleExecutionLog +} from '../../db'; +import { deployGitStack } from '../../git'; +import { sendEventNotification } from '../../notifications'; + +/** + * Execute a git stack sync. + */ +export async function runGitStackSync( + stackId: number, + stackName: string, + environmentId: number | null | undefined, + triggeredBy: ScheduleTrigger +): Promise { + const startTime = Date.now(); + + // Create execution record + const execution = await createScheduleExecution({ + scheduleType: 'git_stack_sync', + scheduleId: stackId, + environmentId: environmentId ?? null, + entityName: stackName, + triggeredBy, + status: 'running' + }); + + await updateScheduleExecution(execution.id, { + startedAt: new Date().toISOString() + }); + + const log = (message: string) => { + console.log(`[Git-sync] ${message}`); + appendScheduleExecutionLog(execution.id, `[${new Date().toISOString()}] ${message}`); + }; + + try { + log(`Starting sync for stack: ${stackName}`); + + // Deploy the git stack (only if there are changes) + const result = await deployGitStack(stackId, { force: false }); + + const envId = environmentId ?? undefined; + + if (result.success) { + if (result.skipped) { + log(`No changes detected for stack: ${stackName}, skipping redeploy`); + + // Send notification for skipped sync + await sendEventNotification('git_sync_skipped', { + title: 'Git sync skipped', + message: `Stack "${stackName}" sync skipped: no changes detected`, + type: 'info' + }, envId); + } else { + log(`Successfully deployed stack: ${stackName}`); + + // Send notification for successful sync + await sendEventNotification('git_sync_success', { + title: 'Git stack deployed', + message: `Stack "${stackName}" was synced and deployed successfully`, + type: 'success' + }, envId); + } + if (result.output) log(result.output); + + await updateScheduleExecution(execution.id, { + status: result.skipped ? 'skipped' : 'success', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + details: { output: result.output } + }); + } else { + throw new Error(result.error || 'Deployment failed'); + } + } catch (error: any) { + log(`Error: ${error.message}`); + await updateScheduleExecution(execution.id, { + status: 'failed', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + errorMessage: error.message + }); + + // Send notification for failed sync + const envId = environmentId ?? undefined; + await sendEventNotification('git_sync_failed', { + title: 'Git sync failed', + message: `Stack "${stackName}" sync failed: ${error.message}`, + type: 'error' + }, envId); + } +} diff --git a/lib/server/scheduler/tasks/system-cleanup.ts b/lib/server/scheduler/tasks/system-cleanup.ts new file mode 100644 index 0000000..5f6d6e0 --- /dev/null +++ b/lib/server/scheduler/tasks/system-cleanup.ts @@ -0,0 +1,202 @@ +/** + * System Cleanup Tasks + * + * Handles system cleanup jobs (schedule executions, container events). + */ + +import type { ScheduleTrigger } from '../../db'; +import { + getScheduleRetentionDays, + cleanupOldExecutions, + getEventRetentionDays, + getScheduleCleanupEnabled, + getEventCleanupEnabled, + createScheduleExecution, + updateScheduleExecution, + appendScheduleExecutionLog +} from '../../db'; + +// System job IDs +export const SYSTEM_SCHEDULE_CLEANUP_ID = 1; +export const SYSTEM_EVENT_CLEANUP_ID = 2; +export const SYSTEM_VOLUME_HELPER_CLEANUP_ID = 3; + +/** + * Execute schedule execution cleanup job. + */ +export async function runScheduleCleanupJob(triggeredBy: ScheduleTrigger = 'cron'): Promise { + // Check if cleanup is enabled (skip check if manually triggered) + if (triggeredBy === 'cron') { + const enabled = await getScheduleCleanupEnabled(); + if (!enabled) { + return; // Skip execution if disabled + } + } + + const startTime = Date.now(); + + // Create execution record + const execution = await createScheduleExecution({ + scheduleType: 'system_cleanup', + scheduleId: SYSTEM_SCHEDULE_CLEANUP_ID, + environmentId: null, + entityName: 'Schedule execution cleanup', + triggeredBy, + status: 'running' + }); + + await updateScheduleExecution(execution.id, { + startedAt: new Date().toISOString() + }); + + const log = async (message: string) => { + console.log(`[Schedule Cleanup] ${message}`); + await appendScheduleExecutionLog(execution.id, `[${new Date().toISOString()}] ${message}`); + }; + + try { + const retentionDays = await getScheduleRetentionDays(); + await log(`Starting cleanup with ${retentionDays} day retention`); + + await cleanupOldExecutions(retentionDays); + + await log('Cleanup completed successfully'); + await updateScheduleExecution(execution.id, { + status: 'success', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + details: { retentionDays } + }); + } catch (error: any) { + await log(`Error: ${error.message}`); + await updateScheduleExecution(execution.id, { + status: 'failed', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + errorMessage: error.message + }); + } +} + +/** + * Execute event cleanup job. + */ +export async function runEventCleanupJob(triggeredBy: ScheduleTrigger = 'cron'): Promise { + // Check if cleanup is enabled (skip check if manually triggered) + if (triggeredBy === 'cron') { + const enabled = await getEventCleanupEnabled(); + if (!enabled) { + return; // Skip execution if disabled + } + } + + const startTime = Date.now(); + + // Create execution record + const execution = await createScheduleExecution({ + scheduleType: 'system_cleanup', + scheduleId: SYSTEM_EVENT_CLEANUP_ID, + environmentId: null, + entityName: 'Container event cleanup', + triggeredBy, + status: 'running' + }); + + await updateScheduleExecution(execution.id, { + startedAt: new Date().toISOString() + }); + + const log = async (message: string) => { + console.log(`[Event Cleanup] ${message}`); + await appendScheduleExecutionLog(execution.id, `[${new Date().toISOString()}] ${message}`); + }; + + try { + const { deleteOldContainerEvents } = await import('../../db'); + const retentionDays = await getEventRetentionDays(); + + await log(`Starting cleanup of events older than ${retentionDays} days`); + + const deleted = await deleteOldContainerEvents(retentionDays); + + await log(`Removed ${deleted} old container events`); + await updateScheduleExecution(execution.id, { + status: 'success', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + details: { deletedCount: deleted, retentionDays } + }); + } catch (error: any) { + await log(`Error: ${error.message}`); + await updateScheduleExecution(execution.id, { + status: 'failed', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + errorMessage: error.message + }); + } +} + +/** + * Execute volume helper cleanup job. + * Cleans up stale dockhand-browse-* containers used for volume browsing. + * @param triggeredBy - What triggered this execution + * @param cleanupFns - Optional cleanup functions (passed from scheduler to avoid dynamic import issues) + */ +export async function runVolumeHelperCleanupJob( + triggeredBy: ScheduleTrigger = 'cron', + cleanupFns?: { + cleanupStaleVolumeHelpers: () => Promise; + cleanupExpiredVolumeHelpers: () => Promise; + } +): Promise { + const startTime = Date.now(); + + // Create execution record + const execution = await createScheduleExecution({ + scheduleType: 'system_cleanup', + scheduleId: SYSTEM_VOLUME_HELPER_CLEANUP_ID, + environmentId: null, + entityName: 'Volume helper cleanup', + triggeredBy, + status: 'running' + }); + + await updateScheduleExecution(execution.id, { + startedAt: new Date().toISOString() + }); + + const log = async (message: string) => { + console.log(`[Volume Helper Cleanup] ${message}`); + await appendScheduleExecutionLog(execution.id, `[${new Date().toISOString()}] ${message}`); + }; + + try { + await log('Starting cleanup of stale and expired volume helper containers'); + + if (cleanupFns) { + // Use provided functions (from scheduler static imports) + await cleanupFns.cleanupStaleVolumeHelpers(); + await cleanupFns.cleanupExpiredVolumeHelpers(); + } else { + // Fallback to dynamic import (may not work in production) + const { runVolumeHelperCleanup } = await import('../../db'); + await runVolumeHelperCleanup(); + } + + await log('Cleanup completed successfully'); + await updateScheduleExecution(execution.id, { + status: 'success', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime + }); + } catch (error: any) { + await log(`Error: ${error.message}`); + await updateScheduleExecution(execution.id, { + status: 'failed', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + errorMessage: error.message + }); + } +} diff --git a/lib/server/scheduler/tasks/update-utils.ts b/lib/server/scheduler/tasks/update-utils.ts new file mode 100644 index 0000000..b3ebe4f --- /dev/null +++ b/lib/server/scheduler/tasks/update-utils.ts @@ -0,0 +1,114 @@ +/** + * Shared utilities for container and environment auto-update tasks. + */ + +import type { VulnerabilityCriteria } from '../../db'; +import type { VulnerabilitySeverity } from '../../scanner'; + +/** + * Parse image name and tag from a full image reference. + * Handles various formats: + * - nginx → ["nginx", "latest"] + * - nginx:1.25 → ["nginx", "1.25"] + * - registry.example.com:5000/myimage:v1 → ["registry.example.com:5000/myimage", "v1"] + * - nginx:latest-dockhand-pending → ["nginx", "latest-dockhand-pending"] + */ +export function parseImageNameAndTag(imageName: string): [string, string] { + // Handle digest-based images (return as-is with empty tag) + if (imageName.includes('@sha256:')) { + return [imageName, '']; + } + + // Find the last colon that's part of the tag (not part of registry port) + const lastColon = imageName.lastIndexOf(':'); + if (lastColon === -1) { + return [imageName, 'latest']; + } + + // Check if this colon is part of a registry port + // Registry ports appear before a slash: registry:5000/image + const afterColon = imageName.substring(lastColon + 1); + if (afterColon.includes('/')) { + // The colon is part of the registry, not the tag + return [imageName, 'latest']; + } + + // The colon separates repo from tag + return [imageName.substring(0, lastColon), afterColon]; +} + +/** + * Determine if an update should be blocked based on vulnerability criteria. + */ +export function shouldBlockUpdate( + criteria: VulnerabilityCriteria, + newScanSummary: VulnerabilitySeverity, + currentScanSummary?: VulnerabilitySeverity +): { blocked: boolean; reason: string } { + const totalVulns = newScanSummary.critical + newScanSummary.high + newScanSummary.medium + newScanSummary.low; + + switch (criteria) { + case 'any': + if (totalVulns > 0) { + return { + blocked: true, + reason: `Found ${totalVulns} vulnerabilities (${newScanSummary.critical} critical, ${newScanSummary.high} high, ${newScanSummary.medium} medium, ${newScanSummary.low} low)` + }; + } + break; + case 'critical_high': + if (newScanSummary.critical > 0 || newScanSummary.high > 0) { + return { + blocked: true, + reason: `Found ${newScanSummary.critical} critical and ${newScanSummary.high} high severity vulnerabilities` + }; + } + break; + case 'critical': + if (newScanSummary.critical > 0) { + return { + blocked: true, + reason: `Found ${newScanSummary.critical} critical vulnerabilities` + }; + } + break; + case 'more_than_current': + if (currentScanSummary) { + const currentTotal = currentScanSummary.critical + currentScanSummary.high + currentScanSummary.medium + currentScanSummary.low; + if (totalVulns > currentTotal) { + return { + blocked: true, + reason: `New image has ${totalVulns} vulnerabilities vs ${currentTotal} in current image` + }; + } + } + break; + case 'never': + default: + break; + } + + return { blocked: false, reason: '' }; +} + +/** + * Check if a container is the Dockhand application itself. + * Used to prevent Dockhand from updating its own container. + */ +export function isDockhandContainer(imageName: string): boolean { + return imageName.toLowerCase().includes('fnsys/dockhand'); +} + +/** + * Combine multiple scan summaries by taking the maximum of each severity level. + */ +export function combineScanSummaries(results: { summary: VulnerabilitySeverity }[]): VulnerabilitySeverity { + return results.reduce((acc, result) => ({ + critical: Math.max(acc.critical, result.summary.critical), + high: Math.max(acc.high, result.summary.high), + medium: Math.max(acc.medium, result.summary.medium), + low: Math.max(acc.low, result.summary.low), + negligible: Math.max(acc.negligible, result.summary.negligible), + unknown: Math.max(acc.unknown, result.summary.unknown) + }), { critical: 0, high: 0, medium: 0, low: 0, negligible: 0, unknown: 0 }); +} diff --git a/lib/server/stacks.ts b/lib/server/stacks.ts new file mode 100644 index 0000000..d4c99a1 --- /dev/null +++ b/lib/server/stacks.ts @@ -0,0 +1,1109 @@ +/** + * Stack Management Module + * + * Provides compose-first stack operations for internal, git, and external stacks. + * All lifecycle operations use docker compose commands. + */ + +import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { + getEnvironment, + getStackEnvVarsAsRecord, + getStackSource, + upsertStackSource, + deleteStackSource, + getGitStackByName, + deleteGitStack, + getStackSources, + deleteStackEnvVars +} from './db'; +import { deleteGitStackFiles } from './git'; + +// ============================================================================= +// TYPES +// ============================================================================= + +/** + * Stack source types + */ +export type StackSourceType = 'internal' | 'git' | 'external'; + +/** + * Stack operation result + */ +export interface StackOperationResult { + success: boolean; + output?: string; + error?: string; +} + +/** + * Container detail within a stack + */ +export interface ContainerDetail { + id: string; + name: string; + service: string; + state: string; + status: string; + health?: string; + image: string; + ports: Array<{ publicPort: number; privatePort: number; type: string; display: string }>; + networks: Array<{ name: string; ipAddress: string }>; + volumeCount: number; + restartCount: number; + created: number; +} + +/** + * Compose stack information + */ +export interface ComposeStackInfo { + name: string; + containers: string[]; + containerDetails: ContainerDetail[]; + status: 'running' | 'stopped' | 'partial' | 'created'; + sourceType?: StackSourceType; + hasComposeFile?: boolean; +} + +/** + * Stack deployment options + */ +export interface DeployStackOptions { + name: string; + compose: string; + envId?: number | null; + envFileVars?: Record; + forceRecreate?: boolean; +} + +// ============================================================================= +// ERRORS +// ============================================================================= + +/** + * Error for operations on external stacks without compose files + */ +export class ExternalStackError extends Error { + public readonly stackName: string; + + constructor(stackName: string) { + super( + `Stack "${stackName}" was created outside of Dockhand. ` + + `To manage this stack, first import it by clicking the Import button in the stack menu.` + ); + this.name = 'ExternalStackError'; + this.stackName = stackName; + } +} + +/** + * Error when compose file is missing for a managed stack + */ +export class ComposeFileNotFoundError extends Error { + public readonly stackName: string; + + constructor(stackName: string) { + super( + `Compose file not found for stack "${stackName}". ` + + `The stack may have been deleted or was created outside of Dockhand.` + ); + this.name = 'ComposeFileNotFoundError'; + this.stackName = stackName; + } +} + +// ============================================================================= +// INTERNAL STATE +// ============================================================================= + +// Cache stacks directory +let _stacksDir: string | null = null; + +// Per-stack locking mechanism to prevent race conditions during concurrent operations +const stackLocks = new Map>(); + +/** + * Execute a function with exclusive lock on a stack. + * Prevents race conditions when multiple operations target the same stack. + */ +async function withStackLock(stackName: string, fn: () => Promise): Promise { + const lockKey = stackName; + + // Wait for any existing lock to release + while (stackLocks.has(lockKey)) { + await stackLocks.get(lockKey); + } + + // Create new lock + let releaseLock: () => void; + const lockPromise = new Promise((resolve) => { + releaseLock = resolve; + }); + stackLocks.set(lockKey, lockPromise); + + try { + return await fn(); + } finally { + stackLocks.delete(lockKey); + releaseLock!(); + } +} + +// Timeout configuration for compose operations +const COMPOSE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes +const COMPOSE_KILL_GRACE_MS = 5000; // 5 seconds grace period before SIGKILL + +// ============================================================================= +// DEBUG UTILITIES +// ============================================================================= + +/** + * Mask sensitive values in environment variables for safe logging. + * Masks values for keys containing common secret patterns and truncates long values. + */ +function maskSecrets(vars: Record): Record { + const masked: Record = {}; + const secretPatterns = /password|secret|token|key|api_key|apikey|auth|credential|private/i; + for (const [key, value] of Object.entries(vars)) { + if (secretPatterns.test(key)) { + masked[key] = '***'; + } else if (value.length > 50) { + // Truncate long values that might be secrets + masked[key] = value.substring(0, 10) + '...(truncated)'; + } else { + masked[key] = value; + } + } + return masked; +} + +// ============================================================================= +// UTILITIES +// ============================================================================= + +/** + * Get the compose stacks directory (always returns absolute path) + */ +export function getStacksDir(): string { + if (_stacksDir) return _stacksDir; + const dataDir = process.env.DATA_DIR || './data'; + // Resolve to absolute path to avoid issues with relative paths in docker compose + _stacksDir = resolve(join(dataDir, 'stacks')); + if (!existsSync(_stacksDir)) { + mkdirSync(_stacksDir, { recursive: true }); + } + return _stacksDir; +} + +/** + * List stacks that have compose files stored locally + */ +export function listManagedStacks(): string[] { + const stacksDir = getStacksDir(); + if (!existsSync(stacksDir)) { + return []; + } + + return readdirSync(stacksDir, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .filter((dirent) => { + const composeYml = join(stacksDir, dirent.name, 'docker-compose.yml'); + const composeYaml = join(stacksDir, dirent.name, 'docker-compose.yaml'); + return existsSync(composeYml) || existsSync(composeYaml); + }) + .map((dirent) => dirent.name); +} + +// ============================================================================= +// COMPOSE FILE MANAGEMENT +// ============================================================================= + +/** + * Get compose file content for a stack + */ +export async function getStackComposeFile( + stackName: string +): Promise<{ success: boolean; content?: string; error?: string }> { + const stacksDir = getStacksDir(); + const stackDir = join(stacksDir, stackName); + const composeFile = join(stackDir, 'docker-compose.yml'); + + const ymlFile = Bun.file(composeFile); + if (await ymlFile.exists()) { + return { + success: true, + content: await ymlFile.text() + }; + } + + const yamlFile = Bun.file(join(stackDir, 'docker-compose.yaml')); + if (await yamlFile.exists()) { + return { + success: true, + content: await yamlFile.text() + }; + } + + return { + success: false, + error: `Compose file not found for stack "${stackName}". The stack may have been created outside of Dockhand.` + }; +} + +/** + * Save or create a stack compose file without deploying. + * @param name - Stack name + * @param content - Compose file content + * @param create - If true, creates a new stack (fails if exists). If false, updates existing (fails if not exists). + */ +export async function saveStackComposeFile( + name: string, + content: string, + create = false +): Promise<{ success: boolean; error?: string }> { + // Validate stack name + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + return { + success: false, + error: 'Stack name can only contain letters, numbers, hyphens, and underscores' + }; + } + + const stacksDir = getStacksDir(); + const stackDir = join(stacksDir, name); + const composeFile = join(stackDir, 'docker-compose.yml'); + const exists = existsSync(stackDir); + + if (create) { + // Creating new stack - if directory exists, it's orphaned (clean it up) + if (exists) { + try { + console.log(`Cleaning up orphaned stack directory: ${stackDir}`); + rmSync(stackDir, { recursive: true, force: true }); + } catch (err: any) { + return { success: false, error: `Stack directory exists and cleanup failed: ${err.message}` }; + } + } + try { + mkdirSync(stackDir, { recursive: true }); + } catch (err: any) { + return { success: false, error: `Failed to create stack directory: ${err.message}` }; + } + } else { + // Updating existing stack - must exist + if (!exists) { + return { success: false, error: `Stack "${name}" not found` }; + } + } + + try { + await Bun.write(composeFile, content); + return { success: true }; + } catch (err: any) { + return { success: false, error: `Failed to ${create ? 'create' : 'save'} compose file: ${err.message}` }; + } +} + +// ============================================================================= +// COMPOSE COMMAND EXECUTION +// ============================================================================= + +interface ComposeCommandOptions { + stackName: string; + envId?: number | null; + forceRecreate?: boolean; + removeVolumes?: boolean; +} + +/** + * Execute a docker compose command locally via Bun.spawn + */ +async function executeLocalCompose( + operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull', + stackName: string, + composeContent: string, + dockerHost?: string, + envVars?: Record, + forceRecreate?: boolean, + removeVolumes?: boolean +): Promise { + const logPrefix = `[Stack:${stackName}]`; + const stacksDir = getStacksDir(); + const stackDir = join(stacksDir, stackName); + mkdirSync(stackDir, { recursive: true }); + + const composeFile = join(stackDir, 'docker-compose.yml'); + await Bun.write(composeFile, composeContent); + + const spawnEnv: Record = { ...(process.env as Record) }; + if (dockerHost) { + spawnEnv.DOCKER_HOST = dockerHost; + } + // Add stack-specific environment variables + if (envVars) { + Object.assign(spawnEnv, envVars); + } + + // Build command based on operation + const args = ['docker', 'compose', '-p', stackName, '-f', composeFile]; + + switch (operation) { + case 'up': + args.push('up', '-d', '--remove-orphans'); + if (forceRecreate) args.push('--force-recreate'); + break; + case 'down': + args.push('down'); + if (removeVolumes) args.push('--volumes'); + break; + case 'stop': + args.push('stop'); + break; + case 'start': + args.push('start'); + break; + case 'restart': + args.push('restart'); + break; + case 'pull': + args.push('pull'); + break; + } + + console.log(`${logPrefix} ----------------------------------------`); + console.log(`${logPrefix} EXECUTE LOCAL COMPOSE`); + console.log(`${logPrefix} ----------------------------------------`); + console.log(`${logPrefix} Operation:`, operation); + console.log(`${logPrefix} Command:`, args.join(' ')); + console.log(`${logPrefix} Working directory:`, stackDir); + console.log(`${logPrefix} Compose file:`, composeFile); + console.log(`${logPrefix} DOCKER_HOST:`, dockerHost || '(local socket)'); + console.log(`${logPrefix} Force recreate:`, forceRecreate ?? false); + console.log(`${logPrefix} Remove volumes:`, removeVolumes ?? false); + console.log(`${logPrefix} Env vars count:`, envVars ? Object.keys(envVars).length : 0); + if (envVars && Object.keys(envVars).length > 0) { + console.log(`${logPrefix} Env vars being injected (masked):`, JSON.stringify(maskSecrets(envVars), null, 2)); + } + + try { + console.log(`${logPrefix} Spawning docker compose process...`); + const proc = Bun.spawn(args, { + cwd: stackDir, + env: spawnEnv, + stdout: 'pipe', + stderr: 'pipe' + }); + + // Set up timeout with SIGTERM -> SIGKILL escalation + let timedOut = false; + const timeoutId = setTimeout(() => { + timedOut = true; + console.log(`${logPrefix} TIMEOUT: Process exceeded ${COMPOSE_TIMEOUT_MS / 1000} seconds, sending SIGTERM`); + proc.kill('SIGTERM'); + // Give process grace period to terminate cleanly before SIGKILL + setTimeout(() => { + try { + proc.kill('SIGKILL'); + console.log(`${logPrefix} TIMEOUT: Sent SIGKILL after grace period`); + } catch { + // Process may already be dead + } + }, COMPOSE_KILL_GRACE_MS); + }, COMPOSE_TIMEOUT_MS); + + try { + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text() + ]); + + const code = await proc.exited; + + console.log(`${logPrefix} ----------------------------------------`); + console.log(`${logPrefix} COMPOSE PROCESS COMPLETE`); + console.log(`${logPrefix} ----------------------------------------`); + console.log(`${logPrefix} Exit code:`, code); + console.log(`${logPrefix} Timed out:`, timedOut); + if (stdout) { + console.log(`${logPrefix} STDOUT:`); + console.log(stdout); + } + if (stderr) { + console.log(`${logPrefix} STDERR:`); + console.log(stderr); + } + + if (timedOut) { + return { + success: false, + output: stdout, + error: `docker compose ${operation} timed out after ${COMPOSE_TIMEOUT_MS / 1000} seconds` + }; + } + + if (code === 0) { + return { + success: true, + output: stdout || stderr || `Stack "${stackName}" ${operation} completed successfully` + }; + } else { + return { + success: false, + output: stdout, + error: stderr || `docker compose ${operation} exited with code ${code}` + }; + } + } finally { + clearTimeout(timeoutId); + } + } catch (err: any) { + console.log(`${logPrefix} EXCEPTION in executeLocalCompose:`, err.message); + return { + success: false, + output: '', + error: `Failed to run docker compose ${operation}: ${err.message}` + }; + } +} + +/** + * Execute a docker compose command via Hawser agent + */ +async function executeComposeViaHawser( + operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull', + stackName: string, + composeContent: string, + envId: number, + envVars?: Record, + forceRecreate?: boolean, + removeVolumes?: boolean +): Promise { + const logPrefix = `[Stack:${stackName}]`; + // Import dockerFetch dynamically to avoid circular dependency + const { dockerFetch } = await import('./docker.js'); + + console.log(`${logPrefix} ----------------------------------------`); + console.log(`${logPrefix} EXECUTE COMPOSE VIA HAWSER`); + console.log(`${logPrefix} ----------------------------------------`); + console.log(`${logPrefix} Operation:`, operation); + console.log(`${logPrefix} Environment ID:`, envId); + console.log(`${logPrefix} Force recreate:`, forceRecreate ?? false); + console.log(`${logPrefix} Remove volumes:`, removeVolumes ?? false); + console.log(`${logPrefix} Env vars count:`, envVars ? Object.keys(envVars).length : 0); + if (envVars && Object.keys(envVars).length > 0) { + console.log(`${logPrefix} Env vars being sent (masked):`, JSON.stringify(maskSecrets(envVars), null, 2)); + } + console.log(`${logPrefix} Compose content length:`, composeContent.length, 'chars'); + + try { + const body = JSON.stringify({ + operation, + projectName: stackName, + composeFile: composeContent, + envVars: envVars || {}, + forceRecreate: forceRecreate || false, + removeVolumes: removeVolumes || false + }); + + console.log(`${logPrefix} Sending request to Hawser agent...`); + const response = await dockerFetch( + '/_hawser/compose', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body + }, + envId + ); + + const result = (await response.json()) as { + success: boolean; + output?: string; + error?: string; + }; + + console.log(`${logPrefix} ----------------------------------------`); + console.log(`${logPrefix} HAWSER RESPONSE`); + console.log(`${logPrefix} ----------------------------------------`); + console.log(`${logPrefix} Success:`, result.success); + if (result.output) { + console.log(`${logPrefix} Output:`, result.output); + } + if (result.error) { + console.log(`${logPrefix} Error:`, result.error); + } + + if (result.success) { + return { + success: true, + output: result.output || `Stack "${stackName}" ${operation} completed via Hawser` + }; + } else { + return { + success: false, + output: result.output || '', + error: result.error || `Compose ${operation} failed` + }; + } + } catch (err: any) { + console.log(`${logPrefix} EXCEPTION in executeComposeViaHawser:`, err.message); + return { + success: false, + output: '', + error: `Failed to ${operation} via Hawser: ${err.message}` + }; + } +} + +/** + * Route compose command to appropriate executor based on connection type + */ +async function executeComposeCommand( + operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull', + options: ComposeCommandOptions, + composeContent: string, + envVars?: Record +): Promise { + const { stackName, envId, forceRecreate, removeVolumes } = options; + + // Get environment configuration + const env = envId ? await getEnvironment(envId) : null; + + if (!env) { + // Local socket connection (no environment specified) + return executeLocalCompose( + operation, + stackName, + composeContent, + undefined, + envVars, + forceRecreate, + removeVolumes + ); + } + + switch (env.connectionType) { + case 'hawser-standard': + case 'hawser-edge': + return executeComposeViaHawser( + operation, + stackName, + composeContent, + envId!, + envVars, + forceRecreate, + removeVolumes + ); + + case 'direct': { + const port = env.port || 2375; + const dockerHost = `tcp://${env.host}:${port}`; + return executeLocalCompose( + operation, + stackName, + composeContent, + dockerHost, + envVars, + forceRecreate, + removeVolumes + ); + } + + case 'socket': + default: + return executeLocalCompose( + operation, + stackName, + composeContent, + undefined, + envVars, + forceRecreate, + removeVolumes + ); + } +} + +// ============================================================================= +// STACK DISCOVERY +// ============================================================================= + +/** + * List all compose stacks from Docker containers + */ +export async function listComposeStacks(envId?: number | null): Promise { + // Import dynamically to avoid circular dependency + const { listContainers } = await import('./docker.js'); + + const containers = await listContainers(true, envId); + const stacks = new Map>(); + + containers.forEach((container) => { + const projectLabel = container.labels['com.docker.compose.project']; + if (projectLabel) { + if (!stacks.has(projectLabel)) { + stacks.set(projectLabel, new Set()); + } + stacks.get(projectLabel)?.add(container.id); + } + }); + + const result: ComposeStackInfo[] = Array.from(stacks.entries()).map(([name, containerIds]) => { + const stackContainers = containers.filter((c) => containerIds.has(c.id)); + const runningCount = stackContainers.filter((c) => c.state === 'running').length; + + const containerDetails: ContainerDetail[] = stackContainers + .map((c) => { + const service = c.labels['com.docker.compose.service'] || c.name; + + // Build ports with structured data for clickable links + const ports = (c.ports || []) + .filter((p) => p.PublicPort) + .map((p) => ({ + publicPort: p.PublicPort!, + privatePort: p.PrivatePort, + type: p.Type, + display: `${p.PublicPort}:${p.PrivatePort}/${p.Type}` + })); + + // Build networks with IP addresses + const networks = Object.entries(c.networks || {}).map(([name, data]) => ({ + name, + ipAddress: data?.ipAddress || '' + })); + + const volumeCount = c.mounts?.length || 0; + + return { + id: c.id, + name: c.name, + service, + state: c.state, + status: c.status, + health: c.health, + image: c.image, + ports, + networks, + volumeCount, + restartCount: c.restartCount || 0, + created: c.created + }; + }) + .sort((a, b) => a.service.localeCompare(b.service)); + + return { + name, + containers: Array.from(containerIds), + containerDetails, + status: + runningCount === stackContainers.length + ? 'running' + : runningCount === 0 + ? 'stopped' + : 'partial' + }; + }); + + return result; +} + +/** + * Get containers for a specific stack by label + */ +async function getStackContainers(stackName: string, envId?: number | null): Promise { + const { listContainers } = await import('./docker.js'); + const containers = await listContainers(true, envId); + return containers.filter((c) => c.labels['com.docker.compose.project'] === stackName); +} + +/** + * Helper to perform container-based operations for external stacks + * Used as fallback when no compose file exists. + * Uses Promise.allSettled for parallel execution. + */ +async function withContainerFallback( + stackName: string, + envId: number | null | undefined, + operation: 'start' | 'stop' | 'restart' | 'remove' +): Promise { + const { startContainer, stopContainer, restartContainer, removeContainer } = await import('./docker.js'); + + const containers = await getStackContainers(stackName, envId); + if (containers.length === 0) { + return { success: false, error: `No containers found for stack "${stackName}"` }; + } + + // Execute all container operations in parallel + // Note: listContainers returns containers with lowercase property names: id, name, labels + const operationResults = await Promise.allSettled( + containers.map(async (container) => { + const containerName = container.name || container.id; + switch (operation) { + case 'start': + await startContainer(container.id, envId); + break; + case 'stop': + await stopContainer(container.id, envId); + break; + case 'restart': + await restartContainer(container.id, envId); + break; + case 'remove': + await removeContainer(container.id, true, envId); + break; + } + return containerName; + }) + ); + + // Collect successes and failures + const successes: string[] = []; + const errors: string[] = []; + + operationResults.forEach((result, index) => { + const containerName = containers[index].name || containers[index].id; + if (result.status === 'fulfilled') { + successes.push(result.value); + } else { + errors.push(`${containerName}: ${result.reason?.message || 'Unknown error'}`); + } + }); + + if (errors.length > 0) { + return { + success: successes.length > 0, + error: errors.join('; '), + output: successes.length > 0 ? `Partial success: ${successes.join(', ')}` : undefined + }; + } + + return { + success: true, + output: `${operation} completed for ${successes.length} container(s): ${successes.join(', ')}` + }; +} + +// ============================================================================= +// STACK LIFECYCLE OPERATIONS +// ============================================================================= + +/** + * Ensure we have a compose file for operations, throw appropriate error if not + */ +async function requireComposeFile( + stackName: string, + envId?: number | null +): Promise<{ content: string; envVars: Record }> { + const composeResult = await getStackComposeFile(stackName); + + if (!composeResult.success) { + // Check if this is an external stack + const source = await getStackSource(stackName, envId); + if (!source || source.sourceType === 'external') { + throw new ExternalStackError(stackName); + } + throw new ComposeFileNotFoundError(stackName); + } + + // Get environment variables from database + const envVars = await getStackEnvVarsAsRecord(stackName, envId); + + return { content: composeResult.content!, envVars }; +} + +/** + * Start a stack using docker compose up + * Falls back to individual container start for external stacks + */ +export async function startStack( + stackName: string, + envId?: number | null +): Promise { + try { + const { content, envVars } = await requireComposeFile(stackName, envId); + return executeComposeCommand('up', { stackName, envId }, content, envVars); + } catch (err) { + if (err instanceof ExternalStackError) { + return withContainerFallback(stackName, envId, 'start'); + } + throw err; + } +} + +/** + * Stop a stack using docker compose stop + * Falls back to individual container stop for external stacks + */ +export async function stopStack( + stackName: string, + envId?: number | null +): Promise { + try { + const { content, envVars } = await requireComposeFile(stackName, envId); + return executeComposeCommand('stop', { stackName, envId }, content, envVars); + } catch (err) { + if (err instanceof ExternalStackError) { + return withContainerFallback(stackName, envId, 'stop'); + } + throw err; + } +} + +/** + * Restart a stack using docker compose restart + * Falls back to individual container restart for external stacks + */ +export async function restartStack( + stackName: string, + envId?: number | null +): Promise { + try { + const { content, envVars } = await requireComposeFile(stackName, envId); + return executeComposeCommand('restart', { stackName, envId }, content, envVars); + } catch (err) { + if (err instanceof ExternalStackError) { + return withContainerFallback(stackName, envId, 'restart'); + } + throw err; + } +} + +/** + * Down a stack using docker compose down (removes containers, keeps files) + * For external stacks, this is equivalent to stop (no compose file to "down") + */ +export async function downStack( + stackName: string, + envId?: number | null, + removeVolumes = false +): Promise { + try { + const { content, envVars } = await requireComposeFile(stackName, envId); + return executeComposeCommand('down', { stackName, envId, removeVolumes }, content, envVars); + } catch (err) { + if (err instanceof ExternalStackError) { + // For external stacks, down is the same as stop (no compose file to tear down) + return withContainerFallback(stackName, envId, 'stop'); + } + throw err; + } +} + +/** + * Remove a stack completely (compose down + delete files + cleanup database) + * Uses stack locking to prevent concurrent operations. + */ +export async function removeStack( + stackName: string, + envId?: number | null, + force = false +): Promise { + return withStackLock(stackName, async () => { + // Get compose file (may not exist for external stacks) + const composeResult = await getStackComposeFile(stackName); + + // If compose file exists, run docker compose down first + if (composeResult.success) { + const envVars = await getStackEnvVarsAsRecord(stackName, envId); + const downResult = await executeComposeCommand( + 'down', + { stackName, envId }, + composeResult.content!, + envVars + ); + if (!downResult.success && !force) { + return downResult; + } + } else { + // External stack - remove containers directly in parallel + const { removeContainer } = await import('./docker.js'); + const stackContainers = await getStackContainers(stackName, envId); + + const removalResults = await Promise.allSettled( + stackContainers.map((container) => + removeContainer(container.id, force, envId).then(() => container.name) + ) + ); + + const errors: string[] = []; + removalResults.forEach((result, index) => { + if (result.status === 'rejected') { + const containerName = stackContainers[index].name || stackContainers[index].id; + errors.push(`Failed to remove ${containerName}: ${result.reason?.message || 'Unknown error'}`); + } + }); + + if (errors.length > 0 && !force) { + return { + success: false, + error: errors.join('; ') + }; + } + } + + // Clean up database records - collect errors but don't stop + const cleanupErrors: string[] = []; + + // Delete compose file and directory + const stacksDir = getStacksDir(); + const stackDir = join(stacksDir, stackName); + if (existsSync(stackDir)) { + try { + rmSync(stackDir, { recursive: true, force: true }); + } catch (err: any) { + console.error(`Failed to delete stack directory: ${err.message}`); + cleanupErrors.push(`directory: ${err.message}`); + } + // Verify deletion succeeded (rmSync with force:true may not throw on some failures) + if (existsSync(stackDir)) { + const verifyErr = 'Directory still exists after deletion attempt'; + console.error(`Failed to delete stack directory: ${verifyErr}`); + cleanupErrors.push(`directory: ${verifyErr}`); + } + } + + try { + await deleteStackSource(stackName, envId); + } catch (err: any) { + cleanupErrors.push(`stack source: ${err.message}`); + } + + try { + await deleteStackEnvVars(stackName, envId); + } catch (err: any) { + cleanupErrors.push(`env vars: ${err.message}`); + } + + // If git stack, clean up git stack record + try { + const gitStack = await getGitStackByName(stackName, envId); + if (gitStack) { + await deleteGitStack(gitStack.id); + deleteGitStackFiles(gitStack.id); + } + // Also cleanup any orphaned git stacks with NULL environment_id for this stack name + if (envId !== undefined && envId !== null) { + const orphanedGitStack = await getGitStackByName(stackName, null); + if (orphanedGitStack) { + await deleteGitStack(orphanedGitStack.id); + deleteGitStackFiles(orphanedGitStack.id); + } + } + } catch (err: any) { + cleanupErrors.push(`git stack: ${err.message}`); + } + + // Check if directory deletion failed - this blocks stack recreation + const directoryError = cleanupErrors.find(e => e.startsWith('directory:')); + if (directoryError) { + return { + success: false, + error: `Stack containers stopped but directory cleanup failed (${directoryError}). Cannot recreate stack with same name until directory is manually removed.` + }; + } + + // Return success with optional cleanup warnings for non-critical errors + const output = cleanupErrors.length > 0 + ? `Stack "${stackName}" removed with cleanup warnings: ${cleanupErrors.join('; ')}` + : `Stack "${stackName}" removed successfully`; + + return { success: true, output }; + }); +} + +/** + * Deploy a stack (create or update) + * Uses stack locking to prevent concurrent deployments. + */ +export async function deployStack(options: DeployStackOptions): Promise { + const { name, compose, envId, envFileVars, forceRecreate } = options; + const logPrefix = `[Stack:${name}]`; + + console.log(`${logPrefix} ========================================`); + console.log(`${logPrefix} DEPLOY STACK START`); + console.log(`${logPrefix} ========================================`); + console.log(`${logPrefix} Environment ID:`, envId ?? '(none - local)'); + console.log(`${logPrefix} Force recreate:`, forceRecreate ?? false); + console.log(`${logPrefix} Env file vars provided:`, envFileVars ? Object.keys(envFileVars).length : 0); + if (envFileVars && Object.keys(envFileVars).length > 0) { + console.log(`${logPrefix} Env file var keys:`, Object.keys(envFileVars).join(', ')); + console.log(`${logPrefix} Env file vars (masked):`, JSON.stringify(maskSecrets(envFileVars), null, 2)); + } + + // Validate stack name + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + console.log(`${logPrefix} ERROR: Invalid stack name format`); + return { + success: false, + output: '', + error: 'Stack name can only contain letters, numbers, hyphens, and underscores' + }; + } + + return withStackLock(name, async () => { + // Ensure stack directory exists and write compose file (for local reference) + const stacksDir = getStacksDir(); + const stackDir = join(stacksDir, name); + mkdirSync(stackDir, { recursive: true }); + + const composeFile = join(stackDir, 'docker-compose.yml'); + await Bun.write(composeFile, compose); + console.log(`${logPrefix} Compose file written to:`, composeFile); + console.log(`${logPrefix} Compose content length:`, compose.length, 'chars'); + console.log(`${logPrefix} Compose content (full):`); + console.log(compose); + + // Fetch stack environment variables from database (these are user overrides) + const dbEnvVars = await getStackEnvVarsAsRecord(name, envId); + console.log(`${logPrefix} DB env vars count:`, Object.keys(dbEnvVars).length); + if (Object.keys(dbEnvVars).length > 0) { + console.log(`${logPrefix} DB env var keys:`, Object.keys(dbEnvVars).join(', ')); + console.log(`${logPrefix} DB env vars (masked):`, JSON.stringify(maskSecrets(dbEnvVars), null, 2)); + } + + // Merge: env file vars as base, database overrides take precedence + const envVars = { ...envFileVars, ...dbEnvVars }; + console.log(`${logPrefix} Merged env vars count:`, Object.keys(envVars).length); + if (Object.keys(envVars).length > 0) { + console.log(`${logPrefix} Merged env var keys:`, Object.keys(envVars).join(', ')); + console.log(`${logPrefix} Merged env vars (masked):`, JSON.stringify(maskSecrets(envVars), null, 2)); + } + + console.log(`${logPrefix} Calling executeComposeCommand...`); + const result = await executeComposeCommand('up', { stackName: name, envId, forceRecreate }, compose, envVars); + console.log(`${logPrefix} ========================================`); + console.log(`${logPrefix} DEPLOY STACK RESULT`); + console.log(`${logPrefix} ========================================`); + console.log(`${logPrefix} Success:`, result.success); + if (result.output) { + console.log(`${logPrefix} Output:`, result.output); + } + if (result.error) { + console.log(`${logPrefix} Error:`, result.error); + } + return result; + }); +} + +/** + * Pull images for a stack + */ +export async function pullStackImages( + stackName: string, + envId?: number | null +): Promise<{ success: boolean; output?: string; error?: string }> { + const { content, envVars } = await requireComposeFile(stackName, envId); + + return executeComposeCommand('pull', { stackName, envId }, content, envVars); +} + +// ============================================================================= +// RE-EXPORTS FOR BACKWARDS COMPATIBILITY +// ============================================================================= + +// These exports maintain API compatibility with code that imports from docker.ts +// They can be removed once all imports are updated + +export type { StackOperationResult as CreateStackResult }; diff --git a/lib/server/subprocess-manager.ts b/lib/server/subprocess-manager.ts new file mode 100644 index 0000000..6db4ec5 --- /dev/null +++ b/lib/server/subprocess-manager.ts @@ -0,0 +1,593 @@ +/** + * Subprocess Manager + * + * Manages background subprocesses for metrics and event collection using Bun.spawn. + * Provides crash recovery, graceful shutdown, and IPC message routing. + */ + +import { Subprocess } from 'bun'; +import { saveHostMetric, logContainerEvent, type ContainerEventAction } from './db'; +import { sendEventNotification, sendEnvironmentNotification } from './notifications'; +import { containerEventEmitter } from './event-collector'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { existsSync } from 'node:fs'; + +// Get the directory of this file (works in both Vite and Bun) +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Determine subprocess script paths +// In development: src/lib/server/subprocesses/*.ts (via __dirname) +// In production: /app/subprocesses/*.js (bundled by scripts/build-subprocesses.ts) +function getSubprocessPath(name: string): string { + // Production path (Docker container) - bundled JS files + const prodPath = `/app/subprocesses/${name}.js`; + if (existsSync(prodPath)) { + return prodPath; + } + // Development path (relative to this file) - raw TS files + return path.join(__dirname, 'subprocesses', `${name}.ts`); +} + +// IPC Message Types (Subprocess → Main) +export interface MetricMessage { + type: 'metric'; + envId: number; + cpu: number; + memPercent: number; + memUsed: number; + memTotal: number; +} + +export interface DiskWarningMessage { + type: 'disk_warning'; + envId: number; + envName: string; + message: string; + diskPercent?: number; +} + +export interface ContainerEventMessage { + type: 'container_event'; + event: { + environmentId: number; + containerId: string; + containerName: string | null; + image: string | null; + action: ContainerEventAction; + actorAttributes: Record | null; + timestamp: string; + }; + notification?: { + action: ContainerEventAction; + title: string; + message: string; + notificationType: 'success' | 'error' | 'warning' | 'info'; + image?: string; + }; +} + +export interface EnvStatusMessage { + type: 'env_status'; + envId: number; + envName: string; + online: boolean; + error?: string; +} + +export interface ReadyMessage { + type: 'ready'; +} + +export interface ErrorMessage { + type: 'error'; + message: string; +} + +export type SubprocessMessage = + | MetricMessage + | DiskWarningMessage + | ContainerEventMessage + | EnvStatusMessage + | ReadyMessage + | ErrorMessage; + +// IPC Message Types (Main → Subprocess) +export interface RefreshEnvironmentsCommand { + type: 'refresh_environments'; +} + +export interface ShutdownCommand { + type: 'shutdown'; +} + +export type MainProcessCommand = RefreshEnvironmentsCommand | ShutdownCommand; + +// Subprocess configuration +interface SubprocessConfig { + name: string; + scriptPath: string; + restartDelayMs: number; + maxRestarts: number; +} + +// Subprocess state +interface SubprocessState { + process: Subprocess<'ignore', 'inherit', 'inherit'> | null; + restartCount: number; + lastRestartTime: number; + isShuttingDown: boolean; +} + +class SubprocessManager { + private metricsState: SubprocessState = { + process: null, + restartCount: 0, + lastRestartTime: 0, + isShuttingDown: false + }; + + private eventsState: SubprocessState = { + process: null, + restartCount: 0, + lastRestartTime: 0, + isShuttingDown: false + }; + + private readonly metricsConfig: SubprocessConfig = { + name: 'metrics-subprocess', + scriptPath: getSubprocessPath('metrics-subprocess'), + restartDelayMs: 5000, + maxRestarts: 10 + }; + + private readonly eventsConfig: SubprocessConfig = { + name: 'event-subprocess', + scriptPath: getSubprocessPath('event-subprocess'), + restartDelayMs: 5000, + maxRestarts: 10 + }; + + /** + * Start all subprocesses + */ + async start(): Promise { + console.log('[SubprocessManager] Starting background subprocesses...'); + + await this.startMetricsSubprocess(); + await this.startEventsSubprocess(); + + console.log('[SubprocessManager] All subprocesses started'); + } + + /** + * Stop all subprocesses gracefully + */ + async stop(): Promise { + console.log('[SubprocessManager] Stopping background subprocesses...'); + + this.metricsState.isShuttingDown = true; + this.eventsState.isShuttingDown = true; + + // Send shutdown commands + this.sendToMetrics({ type: 'shutdown' }); + this.sendToEvents({ type: 'shutdown' }); + + // Wait a bit for graceful shutdown + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Force kill if still running + if (this.metricsState.process) { + this.metricsState.process.kill(); + this.metricsState.process = null; + } + if (this.eventsState.process) { + this.eventsState.process.kill(); + this.eventsState.process = null; + } + + console.log('[SubprocessManager] All subprocesses stopped'); + } + + /** + * Notify subprocesses to refresh their environment list + */ + refreshEnvironments(): void { + this.sendToMetrics({ type: 'refresh_environments' }); + this.sendToEvents({ type: 'refresh_environments' }); + } + + /** + * Start the metrics collection subprocess + */ + private async startMetricsSubprocess(): Promise { + if (this.metricsState.isShuttingDown) return; + + try { + console.log(`[SubprocessManager] Starting ${this.metricsConfig.name}...`); + + const proc = Bun.spawn(['bun', 'run', this.metricsConfig.scriptPath], { + stdio: ['inherit', 'inherit', 'inherit'], + env: { ...process.env, SKIP_MIGRATIONS: '1' }, + ipc: (message) => this.handleMetricsMessage(message as SubprocessMessage), + onExit: (proc, exitCode, signalCode) => { + this.handleMetricsExit(exitCode, signalCode); + } + }); + + this.metricsState.process = proc; + this.metricsState.restartCount = 0; + + console.log(`[SubprocessManager] ${this.metricsConfig.name} started (PID: ${proc.pid})`); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[SubprocessManager] Failed to start ${this.metricsConfig.name}: ${msg}`); + this.scheduleMetricsRestart(); + } + } + + /** + * Start the event collection subprocess + */ + private async startEventsSubprocess(): Promise { + if (this.eventsState.isShuttingDown) return; + + try { + console.log(`[SubprocessManager] Starting ${this.eventsConfig.name}...`); + + const proc = Bun.spawn(['bun', 'run', this.eventsConfig.scriptPath], { + stdio: ['inherit', 'inherit', 'inherit'], + env: { ...process.env, SKIP_MIGRATIONS: '1' }, + ipc: (message) => this.handleEventsMessage(message as SubprocessMessage), + onExit: (proc, exitCode, signalCode) => { + this.handleEventsExit(exitCode, signalCode); + } + }); + + this.eventsState.process = proc; + this.eventsState.restartCount = 0; + + console.log(`[SubprocessManager] ${this.eventsConfig.name} started (PID: ${proc.pid})`); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[SubprocessManager] Failed to start ${this.eventsConfig.name}: ${msg}`); + this.scheduleEventsRestart(); + } + } + + /** + * Handle IPC messages from metrics subprocess + */ + private async handleMetricsMessage(message: SubprocessMessage): Promise { + try { + switch (message.type) { + case 'ready': + console.log(`[SubprocessManager] ${this.metricsConfig.name} is ready`); + break; + + case 'metric': + // Save metric to database + await saveHostMetric( + message.cpu, + message.memPercent, + message.memUsed, + message.memTotal, + message.envId + ); + break; + + case 'disk_warning': + // Send disk warning notification + await sendEventNotification( + 'disk_space_warning', + { + title: message.diskPercent ? 'Disk space warning' : 'High Docker disk usage', + message: message.message, + type: 'warning' + }, + message.envId + ); + break; + + case 'error': + console.error(`[SubprocessManager] ${this.metricsConfig.name} error:`, message.message); + break; + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[SubprocessManager] Error handling metrics message: ${msg}`); + } + } + + /** + * Handle IPC messages from events subprocess + */ + private async handleEventsMessage(message: SubprocessMessage): Promise { + try { + switch (message.type) { + case 'ready': + console.log(`[SubprocessManager] ${this.eventsConfig.name} is ready`); + break; + + case 'container_event': + // Save event to database + const savedEvent = await logContainerEvent(message.event); + + // Broadcast to SSE clients + containerEventEmitter.emit('event', savedEvent); + + // Send notification if provided + if (message.notification) { + const { action, title, message: notifMessage, notificationType, image } = message.notification; + sendEnvironmentNotification(message.event.environmentId, action, { + title, + message: notifMessage, + type: notificationType + }, image).catch((err) => { + console.error('[SubprocessManager] Failed to send notification:', err); + }); + } + break; + + case 'env_status': + // Broadcast to dashboard via containerEventEmitter + containerEventEmitter.emit('env_status', { + envId: message.envId, + envName: message.envName, + online: message.online, + error: message.error + }); + + // Send environment status notification + if (message.online) { + await sendEventNotification( + 'environment_online', + { + title: 'Environment online', + message: `Environment "${message.envName}" is now reachable`, + type: 'success' + }, + message.envId + ).catch((err) => { + console.error('[SubprocessManager] Failed to send online notification:', err); + }); + } else { + await sendEventNotification( + 'environment_offline', + { + title: 'Environment offline', + message: `Environment "${message.envName}" is unreachable${message.error ? `: ${message.error}` : ''}`, + type: 'error' + }, + message.envId + ).catch((err) => { + console.error('[SubprocessManager] Failed to send offline notification:', err); + }); + } + break; + + case 'error': + console.error(`[SubprocessManager] ${this.eventsConfig.name} error:`, message.message); + break; + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[SubprocessManager] Error handling events message: ${msg}`); + } + } + + /** + * Handle metrics subprocess exit + */ + private handleMetricsExit(exitCode: number | null, signalCode: string | null): void { + if (this.metricsState.isShuttingDown) { + console.log(`[SubprocessManager] ${this.metricsConfig.name} stopped`); + return; + } + + console.error( + `[SubprocessManager] ${this.metricsConfig.name} exited unexpectedly (code: ${exitCode}, signal: ${signalCode})` + ); + + this.metricsState.process = null; + this.scheduleMetricsRestart(); + } + + /** + * Handle events subprocess exit + */ + private handleEventsExit(exitCode: number | null, signalCode: string | null): void { + if (this.eventsState.isShuttingDown) { + console.log(`[SubprocessManager] ${this.eventsConfig.name} stopped`); + return; + } + + console.error( + `[SubprocessManager] ${this.eventsConfig.name} exited unexpectedly (code: ${exitCode}, signal: ${signalCode})` + ); + + this.eventsState.process = null; + this.scheduleEventsRestart(); + } + + /** + * Schedule metrics subprocess restart with backoff + */ + private scheduleMetricsRestart(): void { + if (this.metricsState.isShuttingDown) return; + + if (this.metricsState.restartCount >= this.metricsConfig.maxRestarts) { + console.error( + `[SubprocessManager] ${this.metricsConfig.name} exceeded max restarts (${this.metricsConfig.maxRestarts}), giving up` + ); + return; + } + + const delay = this.metricsConfig.restartDelayMs * Math.pow(2, this.metricsState.restartCount); + this.metricsState.restartCount++; + + console.log( + `[SubprocessManager] Restarting ${this.metricsConfig.name} in ${delay}ms (attempt ${this.metricsState.restartCount}/${this.metricsConfig.maxRestarts})` + ); + + setTimeout(() => { + this.startMetricsSubprocess(); + }, delay); + } + + /** + * Schedule events subprocess restart with backoff + */ + private scheduleEventsRestart(): void { + if (this.eventsState.isShuttingDown) return; + + if (this.eventsState.restartCount >= this.eventsConfig.maxRestarts) { + console.error( + `[SubprocessManager] ${this.eventsConfig.name} exceeded max restarts (${this.eventsConfig.maxRestarts}), giving up` + ); + return; + } + + const delay = this.eventsConfig.restartDelayMs * Math.pow(2, this.eventsState.restartCount); + this.eventsState.restartCount++; + + console.log( + `[SubprocessManager] Restarting ${this.eventsConfig.name} in ${delay}ms (attempt ${this.eventsState.restartCount}/${this.eventsConfig.maxRestarts})` + ); + + setTimeout(() => { + this.startEventsSubprocess(); + }, delay); + } + + /** + * Send command to metrics subprocess + */ + private sendToMetrics(command: MainProcessCommand): void { + if (this.metricsState.process) { + try { + this.metricsState.process.send(command); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[SubprocessManager] Failed to send to metrics subprocess: ${msg}`); + } + } + } + + /** + * Send command to events subprocess + */ + private sendToEvents(command: MainProcessCommand): void { + if (this.eventsState.process) { + try { + this.eventsState.process.send(command); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[SubprocessManager] Failed to send to events subprocess: ${msg}`); + } + } + } + + /** + * Get metrics subprocess PID (for HMR cleanup) + */ + getMetricsPid(): number | null { + return this.metricsState.process?.pid ?? null; + } + + /** + * Get events subprocess PID (for HMR cleanup) + */ + getEventsPid(): number | null { + return this.eventsState.process?.pid ?? null; + } +} + +// Singleton instance +let manager: SubprocessManager | null = null; + +// Store PIDs globally to survive HMR reloads +// Using globalThis to persist across module reloads in dev mode +const GLOBAL_KEY = '__dockhand_subprocess_pids__'; +interface SubprocessPids { + metrics: number | null; + events: number | null; +} + +function getStoredPids(): SubprocessPids { + return (globalThis as any)[GLOBAL_KEY] || { metrics: null, events: null }; +} + +function setStoredPids(pids: SubprocessPids): void { + (globalThis as any)[GLOBAL_KEY] = pids; +} + +/** + * Kill any orphaned processes from previous HMR reloads + */ +function killOrphanedProcesses(): void { + const pids = getStoredPids(); + + if (pids.metrics) { + try { + process.kill(pids.metrics, 'SIGTERM'); + console.log(`[SubprocessManager] Killed orphaned metrics process (PID: ${pids.metrics})`); + } catch { + // Process already dead, ignore + } + } + + if (pids.events) { + try { + process.kill(pids.events, 'SIGTERM'); + console.log(`[SubprocessManager] Killed orphaned events process (PID: ${pids.events})`); + } catch { + // Process already dead, ignore + } + } + + setStoredPids({ metrics: null, events: null }); +} + +/** + * Start background subprocesses + */ +export async function startSubprocesses(): Promise { + // Kill any orphaned processes from HMR reloads + killOrphanedProcesses(); + + if (manager) { + console.warn('[SubprocessManager] Subprocesses already started'); + return; + } + + manager = new SubprocessManager(); + await manager.start(); + + // Store PIDs for HMR cleanup + setStoredPids({ + metrics: manager.getMetricsPid(), + events: manager.getEventsPid() + }); +} + +/** + * Stop background subprocesses + */ +export async function stopSubprocesses(): Promise { + if (manager) { + await manager.stop(); + manager = null; + } + setStoredPids({ metrics: null, events: null }); +} + +/** + * Notify subprocesses to refresh environments + */ +export function refreshSubprocessEnvironments(): void { + if (manager) { + manager.refreshEnvironments(); + } +} diff --git a/lib/server/subprocesses/event-subprocess.ts b/lib/server/subprocesses/event-subprocess.ts new file mode 100644 index 0000000..5b6a6c4 --- /dev/null +++ b/lib/server/subprocesses/event-subprocess.ts @@ -0,0 +1,446 @@ +/** + * Event Collection Subprocess + * + * Runs as a separate process via Bun.spawn to collect Docker container events + * without blocking the main HTTP thread. + * + * Communication with main process via IPC (process.send). + */ + +import { getEnvironments, type ContainerEventAction } from '../db'; +import { getDockerEvents } from '../docker'; +import type { MainProcessCommand } from '../subprocess-manager'; + +// Reconnection settings +const RECONNECT_DELAY = 5000; // 5 seconds +const MAX_RECONNECT_DELAY = 60000; // 1 minute max + +// Track environment online status for notifications +// Only send notifications on status CHANGES, not on every reconnect attempt +const environmentOnlineStatus: Map = new Map(); + +// Active collectors per environment +const collectors: Map = new Map(); + +// Recent event cache for deduplication (key: timeNano-containerId-action) +const recentEvents: Map = new Map(); +const DEDUP_WINDOW_MS = 5000; // 5 second window for deduplication +const CACHE_CLEANUP_INTERVAL_MS = 30000; // Clean up cache every 30 seconds + +let cacheCleanupInterval: ReturnType | null = null; +let isShuttingDown = false; + +// Actions we care about for container activity +const CONTAINER_ACTIONS: ContainerEventAction[] = [ + 'create', + 'start', + 'stop', + 'die', + 'kill', + 'restart', + 'pause', + 'unpause', + 'destroy', + 'rename', + 'update', + 'oom', + 'health_status' +]; + +// Scanner image patterns to exclude from events +const SCANNER_IMAGE_PATTERNS = [ + 'anchore/grype', + 'aquasec/trivy', + 'ghcr.io/anchore/grype', + 'ghcr.io/aquasecurity/trivy' +]; + +// Container name patterns to exclude from events +const EXCLUDED_CONTAINER_PREFIXES = ['dockhand-browse-']; + +/** + * Send message to main process + */ +function send(message: any): void { + if (process.send) { + process.send(message); + } +} + +function isScannerContainer(image: string | null | undefined): boolean { + if (!image) return false; + const lowerImage = image.toLowerCase(); + return SCANNER_IMAGE_PATTERNS.some((pattern) => lowerImage.includes(pattern.toLowerCase())); +} + +function isExcludedContainer(containerName: string | null | undefined): boolean { + if (!containerName) return false; + return EXCLUDED_CONTAINER_PREFIXES.some((prefix) => containerName.startsWith(prefix)); +} + +/** + * Update environment online status and notify main process on change + */ +function updateEnvironmentStatus( + envId: number, + envName: string, + isOnline: boolean, + errorMessage?: string +) { + const previousStatus = environmentOnlineStatus.get(envId); + + // Only send notification on status CHANGE (not on first connection or repeated failures) + if (previousStatus !== undefined && previousStatus !== isOnline) { + send({ + type: 'env_status', + envId, + envName, + online: isOnline, + error: errorMessage + }); + } + + environmentOnlineStatus.set(envId, isOnline); +} + +interface DockerEvent { + Type: string; + Action: string; + Actor: { + ID: string; + Attributes: Record; + }; + time: number; + timeNano: number; +} + +/** + * Clean up old entries from the deduplication cache + */ +function cleanupRecentEvents() { + const now = Date.now(); + for (const [key, timestamp] of recentEvents.entries()) { + if (now - timestamp > DEDUP_WINDOW_MS) { + recentEvents.delete(key); + } + } +} + +/** + * Process a Docker event + */ +function processEvent(event: DockerEvent, envId: number) { + // Only process container events + if (event.Type !== 'container') return; + + // Map Docker action to our action type + const action = event.Action.split(':')[0] as ContainerEventAction; + + // Skip actions we don't care about + if (!CONTAINER_ACTIONS.includes(action)) return; + + const containerId = event.Actor?.ID; + const containerName = event.Actor?.Attributes?.name; + const image = event.Actor?.Attributes?.image; + + if (!containerId) return; + + // Skip scanner containers (Trivy, Grype) + if (isScannerContainer(image)) return; + + // Skip internal Dockhand containers (volume browser helpers) + if (isExcludedContainer(containerName)) return; + + // Deduplicate events + const dedupKey = `${envId}-${event.timeNano}-${containerId}-${action}`; + if (recentEvents.has(dedupKey)) { + return; + } + + // Mark as processed + recentEvents.set(dedupKey, Date.now()); + + // Clean up if cache gets too large + if (recentEvents.size > 200) { + cleanupRecentEvents(); + } + + // Convert Unix nanosecond timestamp to ISO string + const timestamp = new Date(Math.floor(event.timeNano / 1000000)).toISOString(); + + // Prepare notification data + const actionLabel = action.charAt(0).toUpperCase() + action.slice(1); + const containerLabel = containerName || containerId.substring(0, 12); + const notificationType = + action === 'die' || action === 'kill' || action === 'oom' + ? 'error' + : action === 'stop' + ? 'warning' + : action === 'start' + ? 'success' + : 'info'; + + // Send event to main process for DB save and SSE broadcast + send({ + type: 'container_event', + event: { + environmentId: envId, + containerId: containerId, + containerName: containerName || null, + image: image || null, + action, + actorAttributes: event.Actor?.Attributes || null, + timestamp + }, + notification: { + action, + title: `Container ${actionLabel}`, + message: `Container "${containerLabel}" ${action}${image ? ` (${image})` : ''}`, + notificationType, + image + } + }); +} + +/** + * Start collecting events for a specific environment + */ +async function startEnvironmentCollector(envId: number, envName: string) { + // Stop existing collector if any + stopEnvironmentCollector(envId); + + const controller = new AbortController(); + collectors.set(envId, controller); + + let reconnectDelay = RECONNECT_DELAY; + + const connect = async () => { + if (controller.signal.aborted || isShuttingDown) return; + + let reader: ReadableStreamDefaultReader | null = null; + + try { + console.log( + `[EventSubprocess] Connecting to Docker events for ${envName} (env ${envId})...` + ); + + const eventStream = await getDockerEvents({ type: ['container'] }, envId); + + if (!eventStream) { + console.error(`[EventSubprocess] Failed to get event stream for ${envName}`); + updateEnvironmentStatus(envId, envName, false, 'Failed to connect to Docker'); + scheduleReconnect(); + return; + } + + // Reset reconnect delay on successful connection + reconnectDelay = RECONNECT_DELAY; + console.log(`[EventSubprocess] Connected to Docker events for ${envName}`); + + updateEnvironmentStatus(envId, envName, true); + + reader = eventStream.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (!controller.signal.aborted && !isShuttingDown) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.trim()) { + try { + const event = JSON.parse(line) as DockerEvent; + processEvent(event, envId); + } catch { + // Ignore parse errors for partial chunks + } + } + } + } + } catch (error: any) { + if (!controller.signal.aborted && !isShuttingDown) { + if (error.name !== 'AbortError') { + console.error(`[EventSubprocess] Stream error for ${envName}:`, error.message); + updateEnvironmentStatus(envId, envName, false, error.message); + } + } + } finally { + if (reader) { + try { + reader.releaseLock(); + } catch { + // Reader already released or stream closed - ignore + } + } + } + + // Connection closed, reconnect + if (!controller.signal.aborted && !isShuttingDown) { + scheduleReconnect(); + } + } catch (error: any) { + if (reader) { + try { + reader.releaseLock(); + } catch { + // Reader already released or stream closed - ignore + } + } + + if (!controller.signal.aborted && !isShuttingDown && error.name !== 'AbortError') { + console.error(`[EventSubprocess] Connection error for ${envName}:`, error.message); + updateEnvironmentStatus(envId, envName, false, error.message); + } + + if (!controller.signal.aborted && !isShuttingDown) { + scheduleReconnect(); + } + } + }; + + const scheduleReconnect = () => { + if (controller.signal.aborted || isShuttingDown) return; + + console.log(`[EventSubprocess] Reconnecting to ${envName} in ${reconnectDelay / 1000}s...`); + setTimeout(() => { + if (!controller.signal.aborted && !isShuttingDown) { + connect(); + } + }, reconnectDelay); + + // Exponential backoff + reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY); + }; + + // Start the connection + connect(); +} + +/** + * Stop collecting events for a specific environment + */ +function stopEnvironmentCollector(envId: number) { + const controller = collectors.get(envId); + if (controller) { + controller.abort(); + collectors.delete(envId); + environmentOnlineStatus.delete(envId); + } +} + +/** + * Refresh collectors when environments change + */ +async function refreshEventCollectors() { + if (isShuttingDown) return; + + try { + const environments = await getEnvironments(); + + // Filter: only collect for environments with activity enabled AND not Hawser Edge + const activeEnvIds = new Set( + environments + .filter((e) => e.collectActivity && e.connectionType !== 'hawser-edge') + .map((e) => e.id) + ); + + // Stop collectors for removed environments or those with collection disabled + for (const envId of collectors.keys()) { + if (!activeEnvIds.has(envId)) { + console.log(`[EventSubprocess] Stopping collector for environment ${envId}`); + stopEnvironmentCollector(envId); + } + } + + // Start collectors for environments with collection enabled + for (const env of environments) { + // Skip Hawser Edge (handled by main process) + if (env.connectionType === 'hawser-edge') continue; + + if (env.collectActivity && !collectors.has(env.id)) { + startEnvironmentCollector(env.id, env.name); + } + } + } catch (error) { + console.error('[EventSubprocess] Failed to refresh collectors:', error); + send({ type: 'error', message: `Failed to refresh collectors: ${error}` }); + } +} + +/** + * Handle commands from main process + */ +function handleCommand(command: MainProcessCommand): void { + switch (command.type) { + case 'refresh_environments': + console.log('[EventSubprocess] Refreshing environments...'); + refreshEventCollectors(); + break; + + case 'shutdown': + console.log('[EventSubprocess] Shutdown requested'); + shutdown(); + break; + } +} + +/** + * Graceful shutdown + */ +function shutdown(): void { + isShuttingDown = true; + + // Stop periodic cache cleanup + if (cacheCleanupInterval) { + clearInterval(cacheCleanupInterval); + cacheCleanupInterval = null; + } + + // Stop all environment collectors + for (const envId of collectors.keys()) { + stopEnvironmentCollector(envId); + } + + // Clear the deduplication cache + recentEvents.clear(); + + console.log('[EventSubprocess] Stopped'); + process.exit(0); +} + +/** + * Start the event collector + */ +async function start(): Promise { + console.log('[EventSubprocess] Starting container event collection...'); + + // Start collectors for all environments + await refreshEventCollectors(); + + // Start periodic cache cleanup + cacheCleanupInterval = setInterval(cleanupRecentEvents, CACHE_CLEANUP_INTERVAL_MS); + console.log('[EventSubprocess] Started deduplication cache cleanup (every 30s)'); + + // Listen for commands from main process + process.on('message', (message: MainProcessCommand) => { + handleCommand(message); + }); + + // Handle termination signals + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); + + // Signal ready + send({ type: 'ready' }); + + console.log('[EventSubprocess] Started successfully'); +} + +// Start the subprocess +start(); diff --git a/lib/server/subprocesses/metrics-subprocess.ts b/lib/server/subprocesses/metrics-subprocess.ts new file mode 100644 index 0000000..139e6fa --- /dev/null +++ b/lib/server/subprocesses/metrics-subprocess.ts @@ -0,0 +1,419 @@ +/** + * Metrics Collection Subprocess + * + * Runs as a separate process via Bun.spawn to collect CPU/memory metrics + * and check disk space without blocking the main HTTP thread. + * + * Communication with main process via IPC (process.send). + */ + +import { getEnvironments, getEnvSetting } from '../db'; +import { listContainers, getContainerStats, getDockerInfo, getDiskUsage } from '../docker'; +import os from 'node:os'; +import type { MainProcessCommand } from '../subprocess-manager'; + +const COLLECT_INTERVAL = 10000; // 10 seconds +const DISK_CHECK_INTERVAL = 300000; // 5 minutes +const DEFAULT_DISK_THRESHOLD = 80; // 80% threshold for disk warnings +const ENV_METRICS_TIMEOUT = 15000; // 15 seconds timeout per environment for metrics +const ENV_DISK_TIMEOUT = 20000; // 20 seconds timeout per environment for disk checks + +/** + * Timeout wrapper - returns fallback if promise takes too long + */ +function withTimeout(promise: Promise, ms: number, fallback: T): Promise { + return Promise.race([ + promise, + new Promise(resolve => setTimeout(() => resolve(fallback), ms)) + ]); +} + +// Track last disk warning sent per environment to avoid spamming +const lastDiskWarning: Map = new Map(); +const DISK_WARNING_COOLDOWN = 3600000; // 1 hour between warnings + +let collectInterval: ReturnType | null = null; +let diskCheckInterval: ReturnType | null = null; +let isShuttingDown = false; + +/** + * Send message to main process + */ +function send(message: any): void { + if (process.send) { + process.send(message); + } +} + +/** + * Collect metrics for a single environment + */ +async function collectEnvMetrics(env: { id: number; name: string; host?: string; socketPath?: string; collectMetrics?: boolean; connectionType?: string }) { + try { + // Skip environments where metrics collection is disabled + if (env.collectMetrics === false) { + return; + } + + // Skip Hawser Edge environments (handled by main process) + if (env.connectionType === 'hawser-edge') { + return; + } + + // Get running containers + const containers = await listContainers(false, env.id); // Only running + let totalCpuPercent = 0; + let totalContainerMemUsed = 0; + + // Get stats for each running container + const statsPromises = containers.map(async (container) => { + try { + const stats = (await getContainerStats(container.id, env.id)) as any; + + // Calculate CPU percentage + const cpuDelta = + stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage; + const systemDelta = + stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage; + const cpuCount = stats.cpu_stats.online_cpus || os.cpus().length; + + let cpuPercent = 0; + if (systemDelta > 0 && cpuDelta > 0) { + cpuPercent = (cpuDelta / systemDelta) * cpuCount * 100; + } + + // Get container memory usage (subtract cache for actual usage) + const memUsage = stats.memory_stats?.usage || 0; + const memCache = stats.memory_stats?.stats?.cache || 0; + const actualMemUsed = memUsage - memCache; + + return { cpuPercent, memUsage: actualMemUsed > 0 ? actualMemUsed : memUsage }; + } catch { + return { cpuPercent: 0, memUsage: 0 }; + } + }); + + const statsResults = await Promise.all(statsPromises); + totalCpuPercent = statsResults.reduce((sum, r) => sum + r.cpuPercent, 0); + totalContainerMemUsed = statsResults.reduce((sum, r) => sum + r.memUsage, 0); + + // Get host memory info from Docker + const info = (await getDockerInfo(env.id)) as any; + const memTotal = info?.MemTotal || os.totalmem(); + + // Calculate memory: sum of all container memory vs host total + const memUsed = totalContainerMemUsed; + const memPercent = memTotal > 0 ? (memUsed / memTotal) * 100 : 0; + + // Normalize CPU by number of cores from the Docker host + const cpuCount = info?.NCPU || os.cpus().length; + const normalizedCpu = totalCpuPercent / cpuCount; + + // Validate values - skip if any are NaN, Infinity, or negative + const finalCpu = Number.isFinite(normalizedCpu) && normalizedCpu >= 0 ? normalizedCpu : 0; + const finalMemPercent = Number.isFinite(memPercent) && memPercent >= 0 ? memPercent : 0; + const finalMemUsed = Number.isFinite(memUsed) && memUsed >= 0 ? memUsed : 0; + const finalMemTotal = Number.isFinite(memTotal) && memTotal > 0 ? memTotal : 0; + + // Only send if we have valid memory total (otherwise metrics are meaningless) + if (finalMemTotal > 0) { + send({ + type: 'metric', + envId: env.id, + cpu: finalCpu, + memPercent: finalMemPercent, + memUsed: finalMemUsed, + memTotal: finalMemTotal + }); + } + } catch (error) { + // Skip this environment if it fails (might be offline) + console.error(`[MetricsSubprocess] Failed to collect metrics for ${env.name}:`, error); + } +} + +/** + * Collect metrics for all environments + */ +async function collectMetrics() { + if (isShuttingDown) return; + + try { + const environments = await getEnvironments(); + + // Filter enabled environments and collect metrics in parallel + const enabledEnvs = environments.filter((env) => env.collectMetrics !== false); + + // Process all environments in parallel with per-environment timeouts + // Use Promise.allSettled so one slow/failed env doesn't block others + const results = await Promise.allSettled( + enabledEnvs.map((env) => + withTimeout( + collectEnvMetrics(env).then(() => env.name), + ENV_METRICS_TIMEOUT, + null + ) + ) + ); + + // Log any environments that timed out + results.forEach((result, index) => { + if (result.status === 'fulfilled' && result.value === null) { + console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" metrics timed out after ${ENV_METRICS_TIMEOUT}ms`); + } else if (result.status === 'rejected') { + console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" metrics failed:`, result.reason); + } + }); + } catch (error) { + console.error('[MetricsSubprocess] Metrics collection error:', error); + send({ type: 'error', message: `Metrics collection error: ${error}` }); + } +} + +/** + * Parse size string like "107.4GB" to bytes + */ +function parseSize(sizeStr: string): number { + const units: Record = { + B: 1, + KB: 1024, + MB: 1024 * 1024, + GB: 1024 * 1024 * 1024, + TB: 1024 * 1024 * 1024 * 1024 + }; + + const match = sizeStr.match(/^([\d.]+)\s*([KMGT]?B)$/i); + if (!match) return 0; + + const value = parseFloat(match[1]); + const unit = match[2].toUpperCase(); + return value * (units[unit] || 1); +} + +/** + * Format bytes to human readable string + */ +function formatSize(bytes: number): string { + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let unitIndex = 0; + let size = bytes; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(1)} ${units[unitIndex]}`; +} + +/** + * Check disk space for a single environment + */ +async function checkEnvDiskSpace(env: { id: number; name: string; collectMetrics?: boolean; connectionType?: string }) { + try { + // Skip environments where metrics collection is disabled + if (env.collectMetrics === false) { + return; + } + + // Skip Hawser Edge environments (handled by main process) + if (env.connectionType === 'hawser-edge') { + return; + } + + // Check if we're in cooldown for this environment + const lastWarningTime = lastDiskWarning.get(env.id); + if (lastWarningTime && Date.now() - lastWarningTime < DISK_WARNING_COOLDOWN) { + return; // Skip this environment, still in cooldown + } + + // Get Docker disk usage data + const diskData = (await getDiskUsage(env.id)) as any; + if (!diskData) return; + + // Calculate total Docker disk usage using reduce for cleaner code + let totalUsed = 0; + if (diskData.Images) { + totalUsed += diskData.Images.reduce((sum: number, img: any) => sum + (img.Size || 0), 0); + } + if (diskData.Containers) { + totalUsed += diskData.Containers.reduce((sum: number, c: any) => sum + (c.SizeRw || 0), 0); + } + if (diskData.Volumes) { + totalUsed += diskData.Volumes.reduce( + (sum: number, v: any) => sum + (v.UsageData?.Size || 0), + 0 + ); + } + if (diskData.BuildCache) { + totalUsed += diskData.BuildCache.reduce((sum: number, bc: any) => sum + (bc.Size || 0), 0); + } + + // Get Docker root filesystem info from Docker info + const info = (await getDockerInfo(env.id)) as any; + const driverStatus = info?.DriverStatus; + + // Try to find "Data Space Total" from driver status + let dataSpaceTotal = 0; + let diskPercentUsed = 0; + + if (driverStatus) { + for (const [key, value] of driverStatus) { + if (key === 'Data Space Total' && typeof value === 'string') { + dataSpaceTotal = parseSize(value); + break; + } + } + } + + // If we found total disk space, calculate percentage + if (dataSpaceTotal > 0) { + diskPercentUsed = (totalUsed / dataSpaceTotal) * 100; + } else { + // Fallback: just report absolute usage if we can't determine percentage + const GB = 1024 * 1024 * 1024; + if (totalUsed > 50 * GB) { + send({ + type: 'disk_warning', + envId: env.id, + envName: env.name, + message: `Environment "${env.name}" is using ${formatSize(totalUsed)} of Docker disk space` + }); + lastDiskWarning.set(env.id, Date.now()); + } + return; + } + + // Check against threshold + const threshold = + (await getEnvSetting('disk_warning_threshold', env.id)) || DEFAULT_DISK_THRESHOLD; + if (diskPercentUsed >= threshold) { + console.log( + `[MetricsSubprocess] Docker disk usage for ${env.name}: ${diskPercentUsed.toFixed(1)}% (threshold: ${threshold}%)` + ); + + send({ + type: 'disk_warning', + envId: env.id, + envName: env.name, + message: `Environment "${env.name}" Docker disk usage is at ${diskPercentUsed.toFixed(1)}% (${formatSize(totalUsed)} used)`, + diskPercent: diskPercentUsed + }); + + lastDiskWarning.set(env.id, Date.now()); + } + } catch (error) { + // Skip this environment if it fails + console.error(`[MetricsSubprocess] Failed to check disk space for ${env.name}:`, error); + } +} + +/** + * Check disk space for all environments + */ +async function checkDiskSpace() { + if (isShuttingDown) return; + + try { + const environments = await getEnvironments(); + + // Filter enabled environments and check disk space in parallel + const enabledEnvs = environments.filter((env) => env.collectMetrics !== false); + + // Process all environments in parallel with per-environment timeouts + // Use Promise.allSettled so one slow/failed env doesn't block others + const results = await Promise.allSettled( + enabledEnvs.map((env) => + withTimeout( + checkEnvDiskSpace(env).then(() => env.name), + ENV_DISK_TIMEOUT, + null + ) + ) + ); + + // Log any environments that timed out + results.forEach((result, index) => { + if (result.status === 'fulfilled' && result.value === null) { + console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" disk check timed out after ${ENV_DISK_TIMEOUT}ms`); + } else if (result.status === 'rejected') { + console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" disk check failed:`, result.reason); + } + }); + } catch (error) { + console.error('[MetricsSubprocess] Disk space check error:', error); + send({ type: 'error', message: `Disk space check error: ${error}` }); + } +} + +/** + * Handle commands from main process + */ +function handleCommand(command: MainProcessCommand): void { + switch (command.type) { + case 'refresh_environments': + console.log('[MetricsSubprocess] Refreshing environments...'); + // The next collection cycle will pick up the new environments + break; + + case 'shutdown': + console.log('[MetricsSubprocess] Shutdown requested'); + shutdown(); + break; + } +} + +/** + * Graceful shutdown + */ +function shutdown(): void { + isShuttingDown = true; + + if (collectInterval) { + clearInterval(collectInterval); + collectInterval = null; + } + if (diskCheckInterval) { + clearInterval(diskCheckInterval); + diskCheckInterval = null; + } + + lastDiskWarning.clear(); + console.log('[MetricsSubprocess] Stopped'); + process.exit(0); +} + +/** + * Start the metrics collector + */ +function start(): void { + console.log('[MetricsSubprocess] Starting metrics collection (every 10s)...'); + + // Initial collection + collectMetrics(); + + // Schedule regular collection + collectInterval = setInterval(collectMetrics, COLLECT_INTERVAL); + + // Start disk space checking (every 5 minutes) + console.log('[MetricsSubprocess] Starting disk space monitoring (every 5 minutes)'); + checkDiskSpace(); // Initial check + diskCheckInterval = setInterval(checkDiskSpace, DISK_CHECK_INTERVAL); + + // Listen for commands from main process + process.on('message', (message: MainProcessCommand) => { + handleCommand(message); + }); + + // Handle termination signals + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); + + // Signal ready + send({ type: 'ready' }); + + console.log('[MetricsSubprocess] Started successfully'); +} + +// Start the subprocess +start(); diff --git a/lib/server/uptime.ts b/lib/server/uptime.ts new file mode 100644 index 0000000..14b05d7 --- /dev/null +++ b/lib/server/uptime.ts @@ -0,0 +1,15 @@ +// Track server start time for uptime calculation +let serverStartTime: number | null = null; + +export function setServerStartTime(): void { + if (serverStartTime === null) { + serverStartTime = Date.now(); + } +} + +export function getServerUptime(): number { + if (serverStartTime === null) { + return 0; + } + return Math.floor((Date.now() - serverStartTime) / 1000); +} diff --git a/lib/stores/audit-events.ts b/lib/stores/audit-events.ts new file mode 100644 index 0000000..c074307 --- /dev/null +++ b/lib/stores/audit-events.ts @@ -0,0 +1,121 @@ +import { writable, get } from 'svelte/store'; + +export interface AuditLogEntry { + id: number; + user_id: number | null; + username: string; + action: string; + entity_type: string; + entity_id: string | null; + entity_name: string | null; + environment_id: number | null; + description: string | null; + details: any | null; + ip_address: string | null; + user_agent: string | null; + timestamp: string; +} + +export type AuditEventCallback = (event: AuditLogEntry) => void; + +// Connection state +export const auditSseConnected = writable(false); +export const auditSseError = writable(null); +export const lastAuditEvent = writable(null); + +// Event listeners +const listeners: Set = new Set(); + +let eventSource: EventSource | null = null; +let reconnectTimeout: ReturnType | null = null; +let reconnectAttempts = 0; +const MAX_RECONNECT_ATTEMPTS = 5; +const RECONNECT_DELAY = 3000; + +// Subscribe to audit events +export function onAuditEvent(callback: AuditEventCallback): () => void { + listeners.add(callback); + return () => listeners.delete(callback); +} + +// Notify all listeners +function notifyListeners(event: AuditLogEntry) { + lastAuditEvent.set(event); + listeners.forEach(callback => { + try { + callback(event); + } catch (e) { + console.error('Audit event listener error:', e); + } + }); +} + +// Connect to SSE endpoint +export function connectAuditSSE() { + // Close existing connection + disconnectAuditSSE(); + + try { + eventSource = new EventSource('/api/audit/events'); + + eventSource.addEventListener('connected', (e) => { + console.log('Audit SSE connected'); + auditSseConnected.set(true); + auditSseError.set(null); + reconnectAttempts = 0; + }); + + eventSource.addEventListener('audit', (e) => { + try { + const event: AuditLogEntry = JSON.parse(e.data); + notifyListeners(event); + } catch (err) { + console.error('Failed to parse audit event:', err); + } + }); + + eventSource.addEventListener('heartbeat', () => { + // Connection is alive + }); + + eventSource.addEventListener('error', (e) => { + console.error('Audit SSE error:', e); + auditSseConnected.set(false); + + // Attempt reconnection + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + reconnectAttempts++; + auditSseError.set(`Connection lost. Reconnecting (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`); + reconnectTimeout = setTimeout(() => { + connectAuditSSE(); + }, RECONNECT_DELAY); + } else { + auditSseError.set('Connection failed. Refresh the page to retry.'); + } + }); + + eventSource.onerror = () => { + // Handled by error event listener + }; + + } catch (error: any) { + console.error('Failed to create Audit EventSource:', error); + auditSseError.set(error.message || 'Failed to connect'); + auditSseConnected.set(false); + } +} + +// Disconnect from SSE +export function disconnectAuditSSE() { + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); + reconnectTimeout = null; + } + if (eventSource) { + eventSource.close(); + eventSource = null; + } + auditSseConnected.set(false); + auditSseError.set(null); + reconnectAttempts = 0; +} diff --git a/lib/stores/auth.ts b/lib/stores/auth.ts new file mode 100644 index 0000000..984f0ca --- /dev/null +++ b/lib/stores/auth.ts @@ -0,0 +1,209 @@ +import { writable, derived } from 'svelte/store'; + +export interface Permissions { + containers: string[]; + images: string[]; + volumes: string[]; + networks: string[]; + stacks: string[]; + environments: string[]; + registries: string[]; + notifications: string[]; + configsets: string[]; + settings: string[]; + users: string[]; + git: string[]; + license: string[]; + audit_logs: string[]; + activity: string[]; + schedules: string[]; +} + +export interface AuthUser { + id: number; + username: string; + email?: string; + displayName?: string; + avatar?: string; + isAdmin: boolean; + provider: 'local' | 'ldap' | 'oidc'; + permissions: Permissions; +} + +export interface AuthState { + user: AuthUser | null; + loading: boolean; + authEnabled: boolean; + authenticated: boolean; +} + +function createAuthStore() { + const { subscribe, set, update } = writable({ + user: null, + loading: true, + authEnabled: false, + authenticated: false + }); + + return { + subscribe, + + /** + * Check current session status + * Should be called on app init + */ + async check() { + update(state => ({ ...state, loading: true })); + try { + const response = await fetch('/api/auth/session'); + const data = await response.json(); + + if (data.error) { + set({ + user: null, + loading: false, + authEnabled: false, + authenticated: false + }); + return; + } + + set({ + user: data.user || null, + loading: false, + authEnabled: data.authEnabled, + authenticated: data.authenticated + }); + } catch { + set({ + user: null, + loading: false, + authEnabled: false, + authenticated: false + }); + } + }, + + /** + * Login with username and password + */ + async login(username: string, password: string, mfaToken?: string, provider: string = 'local'): Promise<{ + success: boolean; + error?: string; + requiresMfa?: boolean; + }> { + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password, mfaToken, provider }) + }); + + const data = await response.json(); + + if (!response.ok) { + return { success: false, error: data.error || 'Login failed' }; + } + + if (data.requiresMfa) { + return { success: true, requiresMfa: true }; + } + + if (data.success && data.user) { + // Refresh session to get full user with permissions + await this.check(); + return { success: true }; + } + + return { success: false, error: 'Login failed' }; + } catch (error) { + return { success: false, error: 'Network error' }; + } + }, + + /** + * Logout and clear session + */ + async logout() { + try { + await fetch('/api/auth/logout', { method: 'POST' }); + } finally { + set({ + user: null, + loading: false, + authEnabled: true, // Keep authEnabled as we know it was on + authenticated: false + }); + } + }, + + /** + * Check if user has a specific permission + * When auth is disabled, returns true (full access) + */ + hasPermission(user: AuthUser | null, authEnabled: boolean, resource: keyof Permissions, action: string): boolean { + // If auth is disabled, everything is allowed + if (!authEnabled) return true; + + // If no user and auth is enabled, deny + if (!user) return false; + + // Admins can do anything + if (user.isAdmin) return true; + + // Check specific permission + const permissions = user.permissions[resource]; + return permissions?.includes(action) ?? false; + } + }; +} + +export const authStore = createAuthStore(); + +// Derived store for easy permission checking +export const canAccess = derived(authStore, ($auth) => { + return (resource: keyof Permissions, action: string): boolean => { + // If auth is disabled, everything is allowed + if (!$auth.authEnabled) return true; + + // If not authenticated and auth is enabled, deny + if (!$auth.authenticated || !$auth.user) return false; + + // Admins can do anything + if ($auth.user.isAdmin) return true; + + // Check specific permission + const permissions = $auth.user.permissions?.[resource]; + return permissions?.includes(action) ?? false; + }; +}); + +// Derived store to check if user has ANY permission for a resource +// Used for menu visibility - show menu if user has any access to that resource +export const hasAnyAccess = derived(authStore, ($auth) => { + return (resource: keyof Permissions): boolean => { + // If auth is disabled, everything is allowed + if (!$auth.authEnabled) return true; + + // If not authenticated and auth is enabled, deny + if (!$auth.authenticated || !$auth.user) return false; + + // Admins can do anything + if ($auth.user.isAdmin) return true; + + // Check if user has ANY permission for this resource + const permissions = $auth.user.permissions?.[resource]; + return permissions && permissions.length > 0; + }; +}); + +// Derived store for whether auth is required for the current session +export const requiresAuth = derived(authStore, ($auth) => { + return $auth.authEnabled && !$auth.authenticated; +}); + +// Derived store for admin check - true if auth disabled OR user is admin +export const isAdmin = derived(authStore, ($auth) => { + if (!$auth.authEnabled) return true; + return $auth.user?.isAdmin ?? false; +}); diff --git a/lib/stores/dashboard.ts b/lib/stores/dashboard.ts new file mode 100644 index 0000000..7c157d5 --- /dev/null +++ b/lib/stores/dashboard.ts @@ -0,0 +1,209 @@ +import { writable, get } from 'svelte/store'; +import type { EnvironmentStats } from '../../routes/api/dashboard/stats/+server'; + +// Grid item layout format for svelte-grid +export interface GridItem { + id: number; + x: number; + y: number; + w: number; + h: number; + [key: string]: unknown; // Allow svelte-grid internal properties +} + +export interface DashboardPreferences { + gridLayout: GridItem[]; +} + +const defaultPreferences: DashboardPreferences = { + gridLayout: [] +}; + +// Environment info from API +interface EnvironmentInfo { + id: number; + name: string; + host?: string; + icon: string; + socketPath?: string; + connectionType?: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge'; +} + +// Metrics history point for charts +export interface MetricsHistoryPoint { + cpu_percent: number; + memory_percent: number; + timestamp: string; +} + +// Tile item combining environment info and stats +export interface TileItem { + id: number; + stats: EnvironmentStats | null; + info: EnvironmentInfo | null; + loading: boolean; +} + +// Dashboard data store for caching between navigations +export interface DashboardData { + tiles: TileItem[]; + gridItems: GridItem[]; + lastFetchTime: number | null; + initialized: boolean; +} + +const defaultDashboardData: DashboardData = { + tiles: [], + gridItems: [], + lastFetchTime: null, + initialized: false +}; + +function createDashboardDataStore() { + const { subscribe, set, update } = writable(defaultDashboardData); + + return { + subscribe, + setTiles: (tiles: TileItem[]) => { + update(data => ({ ...data, tiles, lastFetchTime: Date.now() })); + }, + updateTile: (id: number, updates: Partial) => { + update(data => ({ + ...data, + tiles: data.tiles.map(t => t.id === id ? { ...t, ...updates } : t), + lastFetchTime: Date.now() + })); + }, + // Partial update for progressive loading - merges into existing stats + updateTilePartial: (id: number, partialStats: Partial) => { + update(data => ({ + ...data, + tiles: data.tiles.map(t => { + if (t.id === id && t.stats) { + return { + ...t, + stats: { + ...t.stats, + ...partialStats + } + }; + } + return t; + }), + lastFetchTime: Date.now() + })); + }, + setGridItems: (gridItems: GridItem[]) => { + update(data => ({ ...data, gridItems })); + }, + setInitialized: (initialized: boolean) => { + update(data => ({ ...data, initialized })); + }, + markAllLoading: () => { + update(data => ({ + ...data, + tiles: data.tiles.map(t => ({ ...t, loading: true })) + })); + }, + // Invalidate cache to force a fresh fetch on next dashboard visit + // Clear tiles so dashboard starts fresh with new data + invalidate: () => { + update(data => ({ + ...data, + tiles: [], + lastFetchTime: null, + initialized: false + })); + }, + reset: () => set(defaultDashboardData), + getData: () => get({ subscribe }) + }; +} + +export const dashboardData = createDashboardDataStore(); + +// Number of columns in the grid +export const GRID_COLS = 4; +// Row height for tiles - compact tiles (h=1) show basic info, larger tiles show more +// At height=2 (default), should fit: header, container counts, health, CPU/mem, resources, events +export const GRID_ROW_HEIGHT = 175; + +function createDashboardStore() { + const { subscribe, set, update } = writable(defaultPreferences); + let saveTimeout: ReturnType | null = null; + let initialized = false; + + async function load() { + try { + const response = await fetch('/api/dashboard/preferences'); + if (response.ok) { + const data = await response.json(); + // Handle migration from old format + if (data.gridLayout && Array.isArray(data.gridLayout)) { + set({ gridLayout: data.gridLayout }); + } else { + set({ gridLayout: [] }); + } + } else { + set({ gridLayout: [] }); + } + } catch (error) { + console.error('Failed to load dashboard preferences:', error); + set({ gridLayout: [] }); + } finally { + // Always mark as initialized so saves can proceed + initialized = true; + } + } + + async function save(prefs: DashboardPreferences) { + try { + await fetch('/api/dashboard/preferences', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(prefs) + }); + } catch (error) { + console.error('Failed to save dashboard preferences:', error); + } + } + + // Debounced save - auto-saves 500ms after last change + function scheduleSave(prefs: DashboardPreferences) { + if (saveTimeout) { + clearTimeout(saveTimeout); + } + saveTimeout = setTimeout(() => { + save(prefs); + saveTimeout = null; + }, 500); + } + + return { + subscribe, + load, + setGridLayout: (layout: GridItem[]) => { + update(prefs => { + // Only keep essential properties to avoid storing internal svelte-grid state + const cleanLayout = layout.map(item => ({ + id: item.id, + x: item.x, + y: item.y, + w: item.w, + h: item.h + })); + const newPrefs = { ...prefs, gridLayout: cleanLayout }; + if (initialized) { + scheduleSave(newPrefs); + } + return newPrefs; + }); + }, + reset: () => { + initialized = false; + set(defaultPreferences); + } + }; +} + +export const dashboardPreferences = createDashboardStore(); diff --git a/lib/stores/environment.ts b/lib/stores/environment.ts new file mode 100644 index 0000000..7087b60 --- /dev/null +++ b/lib/stores/environment.ts @@ -0,0 +1,163 @@ +import { writable, get } from 'svelte/store'; +import { browser } from '$app/environment'; + +export interface CurrentEnvironment { + id: number; + name: string; + highlightChanges?: boolean; +} + +export interface Environment { + id: number; + name: string; + icon?: string; + host?: string; + port?: number; + protocol?: string; + socketPath?: string; + connectionType?: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge'; + publicIp?: string | null; +} + +const STORAGE_KEY = 'dockhand:environment'; + +// Load initial state from localStorage +function getInitialEnvironment(): CurrentEnvironment | null { + if (browser) { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + return JSON.parse(stored); + } catch { + return null; + } + } + } + return null; +} + +// Create a writable store for the current environment +function createEnvironmentStore() { + const { subscribe, set, update } = writable(getInitialEnvironment()); + + return { + subscribe, + set: (value: CurrentEnvironment | null) => { + if (browser) { + if (value) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(value)); + } else { + localStorage.removeItem(STORAGE_KEY); + } + } + set(value); + }, + update + }; +} + +export const currentEnvironment = createEnvironmentStore(); + +/** + * Call this when an API returns 404 for the current environment. + * Clears the stale environment from localStorage and store. + */ +export function clearStaleEnvironment(envId: number) { + if (browser) { + const current = get(currentEnvironment); + // Use Number() for type-safe comparison + if (current && Number(current.id) === Number(envId)) { + console.warn(`Environment ${envId} no longer exists, clearing from localStorage`); + currentEnvironment.set(null); + } + } +} + +// Helper to get the environment ID for API calls +export function getEnvParam(envId: number | null | undefined): string { + return envId ? `?env=${envId}` : ''; +} + +// Helper to append env param to existing URL +export function appendEnvParam(url: string, envId: number | null | undefined): string { + if (!envId) return url; + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}env=${envId}`; +} + +// Store for environments list with auto-refresh capability +function createEnvironmentsStore() { + const { subscribe, set, update } = writable([]); + let loading = false; + + async function fetchEnvironments() { + if (!browser || loading) return; + loading = true; + try { + const response = await fetch('/api/environments'); + if (response.ok) { + const data: Environment[] = await response.json(); + set(data); + + // Auto-select environment if none selected or current one no longer exists + const current = get(currentEnvironment); + // Use Number() to handle any potential type mismatches from localStorage + const currentId = current ? Number(current.id) : null; + const currentExists = currentId !== null && data.some((e) => Number(e.id) === currentId); + + console.log(`[EnvStore] refresh: current=${currentId}, exists=${currentExists}, envCount=${data.length}`); + + if (data.length === 0) { + // No environments left - clear selection + console.log('[EnvStore] No environments, clearing selection'); + currentEnvironment.set(null); + } else if (!current) { + // No selection - select first + console.log(`[EnvStore] No current env, selecting first: ${data[0].name}`); + const firstEnv = data[0]; + currentEnvironment.set({ + id: firstEnv.id, + name: firstEnv.name + }); + } else if (!currentExists) { + // Current env was deleted - select first + console.warn(`[EnvStore] Environment ${currentId} no longer exists in list, selecting first: ${data[0].name}`); + const firstEnv = data[0]; + currentEnvironment.set({ + id: firstEnv.id, + name: firstEnv.name + }); + } else { + console.log(`[EnvStore] Current env ${currentId} still exists, keeping selection`); + } + } else { + // Clear environments on permission denied or other errors + set([]); + // Also clear the current environment from localStorage + localStorage.removeItem(STORAGE_KEY); + currentEnvironment.set(null); + } + } catch (error) { + console.error('Failed to fetch environments:', error); + set([]); + localStorage.removeItem(STORAGE_KEY); + currentEnvironment.set(null); + } finally { + loading = false; + } + } + + // Auto-fetch on browser load + if (browser) { + fetchEnvironments(); + } + + return { + subscribe, + refresh: fetchEnvironments, + set, + update + }; +} + +export const environments = createEnvironmentsStore(); diff --git a/lib/stores/events.ts b/lib/stores/events.ts new file mode 100644 index 0000000..157ebeb --- /dev/null +++ b/lib/stores/events.ts @@ -0,0 +1,221 @@ +import { writable, get } from 'svelte/store'; +import { currentEnvironment, environments } from './environment'; + +export interface DockerEvent { + type: 'container' | 'image' | 'volume' | 'network'; + action: string; + actor: { + id: string; + name: string; + attributes: Record; + }; + time: number; + timeNano: string; +} + +export type EventCallback = (event: DockerEvent) => void; + +// Connection state +export const sseConnected = writable(false); +export const sseError = writable(null); +export const lastEvent = writable(null); + +// Event listeners +const listeners: Set = new Set(); + +let eventSource: EventSource | null = null; +let reconnectTimeout: ReturnType | null = null; +let reconnectAttempts = 0; +let wantsConnection = false; // Track intent to be connected (even for edge envs without eventSource) +let isEdgeMode = false; // Track if current env is edge (no SSE needed) +const MAX_RECONNECT_ATTEMPTS = 5; +const RECONNECT_DELAY = 3000; + +// Check if environment is edge type (events come via Hawser WebSocket, not SSE) +function isEdgeEnvironment(envId: number | null | undefined): boolean { + if (!envId) return false; + const envList = get(environments); + const env = envList.find(e => e.id === envId); + return env?.connectionType === 'hawser-edge'; +} + +// Subscribe to events +export function onDockerEvent(callback: EventCallback): () => void { + listeners.add(callback); + return () => listeners.delete(callback); +} + +// Notify all listeners +function notifyListeners(event: DockerEvent) { + lastEvent.set(event); + listeners.forEach(callback => { + try { + callback(event); + } catch (e) { + console.error('Event listener error:', e); + } + }); +} + +// Connect to SSE endpoint +export function connectSSE(envId?: number | null) { + // Close existing connection + disconnectSSE(); + + // Mark that we want to be connected + wantsConnection = true; + reconnectAttempts = 0; + + // Don't connect if no environment is selected + if (!envId) { + sseConnected.set(false); + sseError.set(null); + return; + } + + // Edge environments receive events via Hawser agent WebSocket, not SSE + if (isEdgeEnvironment(envId)) { + isEdgeMode = true; + // For edge environments, we're "connected" but via a different mechanism + sseConnected.set(true); + sseError.set(null); + return; + } + + isEdgeMode = false; + const url = `/api/events?env=${envId}`; + + try { + eventSource = new EventSource(url); + + eventSource.addEventListener('connected', (e) => { + console.log('SSE connected:', JSON.parse(e.data)); + sseConnected.set(true); + sseError.set(null); + reconnectAttempts = 0; + }); + + eventSource.addEventListener('docker', (e) => { + try { + const event: DockerEvent = JSON.parse(e.data); + notifyListeners(event); + } catch (err) { + console.error('Failed to parse docker event:', err); + } + }); + + eventSource.addEventListener('heartbeat', () => { + // Connection is alive + }); + + // Handle SSE error events (both server-sent and connection errors) + eventSource.addEventListener('error', (e: Event) => { + // Check if this is a server-sent error message (MessageEvent with data) + const messageEvent = e as MessageEvent; + if (messageEvent.data) { + try { + const data = JSON.parse(messageEvent.data); + // Check if this is the edge environment message (fallback if env list wasn't loaded) + if (data.message?.includes('Edge environments')) { + isEdgeMode = true; + sseConnected.set(true); + sseError.set(null); + if (eventSource) { + eventSource.close(); + eventSource = null; + } + return; + } + } catch { + // Not JSON, fall through to generic error handling + } + } + + // Skip reconnection if we're in edge mode + if (isEdgeMode) { + return; + } + + console.error('SSE error:', e); + sseConnected.set(false); + + // Attempt reconnection + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + reconnectAttempts++; + sseError.set(`Connection lost. Reconnecting (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`); + reconnectTimeout = setTimeout(() => { + const env = get(currentEnvironment); + connectSSE(env?.id); + }, RECONNECT_DELAY); + } else { + sseError.set('Connection failed. Refresh the page to retry.'); + } + }); + + eventSource.onerror = () => { + // Handled by error event listener + }; + + } catch (error: any) { + console.error('Failed to create EventSource:', error); + sseError.set(error.message || 'Failed to connect'); + sseConnected.set(false); + } +} + +// Disconnect from SSE +export function disconnectSSE() { + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); + reconnectTimeout = null; + } + if (eventSource) { + eventSource.close(); + eventSource = null; + } + // Don't reset wantsConnection here - it's reset by explicit calls + sseConnected.set(false); + isEdgeMode = false; +} + +// Subscribe to environment changes and reconnect +let currentEnvId: number | null = null; +currentEnvironment.subscribe((env) => { + const newEnvId = env?.id ?? null; + if (newEnvId !== currentEnvId) { + currentEnvId = newEnvId; + // If no environment, disconnect + if (!newEnvId) { + disconnectSSE(); + wantsConnection = false; + } else if (wantsConnection) { + // Reconnect with new environment if we want to be connected + // (using wantsConnection because eventSource is null for edge envs) + connectSSE(newEnvId); + } + } +}); + +// Helper to check if action affects container list +export function isContainerListChange(event: DockerEvent): boolean { + if (event.type !== 'container') return false; + return ['create', 'destroy', 'start', 'stop', 'pause', 'unpause', 'die', 'kill', 'rename'].includes(event.action); +} + +// Helper to check if action affects image list +export function isImageListChange(event: DockerEvent): boolean { + if (event.type !== 'image') return false; + return ['pull', 'push', 'delete', 'tag', 'untag', 'import'].includes(event.action); +} + +// Helper to check if action affects volume list +export function isVolumeListChange(event: DockerEvent): boolean { + if (event.type !== 'volume') return false; + return ['create', 'destroy'].includes(event.action); +} + +// Helper to check if action affects network list +export function isNetworkListChange(event: DockerEvent): boolean { + if (event.type !== 'network') return false; + return ['create', 'destroy', 'connect', 'disconnect'].includes(event.action); +} diff --git a/lib/stores/grid-preferences.ts b/lib/stores/grid-preferences.ts new file mode 100644 index 0000000..3c174af --- /dev/null +++ b/lib/stores/grid-preferences.ts @@ -0,0 +1,226 @@ +/** + * Grid Preferences Store for Dockhand + * + * Manages column visibility and ordering preferences with: + * - localStorage sync for flash-free loading + * - Database persistence via API + * - Per-grid configuration + */ + +import { writable, get } from 'svelte/store'; +import type { AllGridPreferences, GridId, ColumnPreference, GridColumnPreferences } from '$lib/types'; +import { getDefaultColumnPreferences, getConfigurableColumns } from '$lib/config/grid-columns'; + +const STORAGE_KEY = 'dockhand-grid-preferences'; + +// Load initial state from localStorage +function loadFromStorage(): AllGridPreferences { + if (typeof window === 'undefined') return {}; + + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + return JSON.parse(stored); + } + } catch { + // Ignore parse errors + } + return {}; +} + +// Save to localStorage +function saveToStorage(prefs: AllGridPreferences) { + if (typeof window === 'undefined') return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); + } catch { + // Ignore storage errors + } +} + +// Create the store +function createGridPreferencesStore() { + const { subscribe, set, update } = writable(loadFromStorage()); + + return { + subscribe, + + // Initialize from API (called on mount) + async init() { + try { + const res = await fetch('/api/preferences/grid'); + if (res.ok) { + const data = await res.json(); + const prefs = data.preferences || {}; + set(prefs); + saveToStorage(prefs); + } + } catch { + // Use localStorage fallback + } + }, + + // Get visible columns for a grid (in order) + getVisibleColumns(gridId: GridId): ColumnPreference[] { + const prefs = get({ subscribe }); + const gridPrefs = prefs[gridId]; + + if (!gridPrefs?.columns?.length) { + // Return defaults (all visible) + return getDefaultColumnPreferences(gridId); + } + + // Return columns in saved order, filtering to visible ones + return gridPrefs.columns.filter((col) => col.visible); + }, + + // Get all columns for a grid (visible and hidden, in order) + getAllColumns(gridId: GridId): ColumnPreference[] { + const prefs = get({ subscribe }); + const gridPrefs = prefs[gridId]; + + if (!gridPrefs?.columns?.length) { + // Return defaults (all visible) + return getDefaultColumnPreferences(gridId); + } + + // Merge with defaults to ensure new columns are included + const defaults = getDefaultColumnPreferences(gridId); + const savedIds = new Set(gridPrefs.columns.map((c) => c.id)); + + // Start with saved columns, then add any new defaults + const result = [...gridPrefs.columns]; + for (const def of defaults) { + if (!savedIds.has(def.id)) { + result.push(def); + } + } + + return result; + }, + + // Check if a specific column is visible + isColumnVisible(gridId: GridId, columnId: string): boolean { + const prefs = get({ subscribe }); + const gridPrefs = prefs[gridId]; + + if (!gridPrefs?.columns?.length) { + // Defaults to visible + return true; + } + + const col = gridPrefs.columns.find((c) => c.id === columnId); + return col ? col.visible : true; + }, + + // Update column visibility/order for a grid + async setColumns(gridId: GridId, columns: ColumnPreference[]) { + update((prefs) => { + const newPrefs = { + ...prefs, + [gridId]: { columns } + }; + saveToStorage(newPrefs); + return newPrefs; + }); + + // Save to database (async, non-blocking) + try { + await fetch('/api/preferences/grid', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ gridId, columns }) + }); + } catch { + // Silently fail - localStorage has the value + } + }, + + // Toggle a column's visibility + async toggleColumn(gridId: GridId, columnId: string) { + const allCols = this.getAllColumns(gridId); + const newColumns = allCols.map((col) => + col.id === columnId ? { ...col, visible: !col.visible } : col + ); + await this.setColumns(gridId, newColumns); + }, + + // Reset a grid to default columns + async resetGrid(gridId: GridId) { + const defaults = getDefaultColumnPreferences(gridId); + + update((prefs) => { + const newPrefs = { ...prefs }; + delete newPrefs[gridId]; + saveToStorage(newPrefs); + return newPrefs; + }); + + // Delete from database + try { + await fetch(`/api/preferences/grid?gridId=${gridId}`, { + method: 'DELETE' + }); + } catch { + // Silently fail + } + }, + + // Get ordered column IDs for rendering + getColumnOrder(gridId: GridId): string[] { + const allCols = this.getAllColumns(gridId); + return allCols.filter((c) => c.visible).map((c) => c.id); + }, + + // Get saved width for a specific column + getColumnWidth(gridId: GridId, columnId: string): number | undefined { + const prefs = get({ subscribe }); + const gridPrefs = prefs[gridId]; + if (!gridPrefs?.columns?.length) return undefined; + const col = gridPrefs.columns.find((c) => c.id === columnId); + return col?.width; + }, + + // Get all saved widths as a Map + getColumnWidths(gridId: GridId): Map { + const prefs = get({ subscribe }); + const gridPrefs = prefs[gridId]; + const widths = new Map(); + if (gridPrefs?.columns) { + for (const col of gridPrefs.columns) { + if (col.width !== undefined) { + widths.set(col.id, col.width); + } + } + } + return widths; + }, + + // Set width for a specific column (works for both configurable and fixed columns) + async setColumnWidth(gridId: GridId, columnId: string, width: number) { + const allCols = this.getAllColumns(gridId); + let found = false; + const newColumns = allCols.map((col) => { + if (col.id === columnId) { + found = true; + return { ...col, width }; + } + return col; + }); + + // If column wasn't found (e.g., fixed column), add it + if (!found) { + newColumns.push({ id: columnId, visible: true, width }); + } + + await this.setColumns(gridId, newColumns); + }, + + // Get current preferences + get(): AllGridPreferences { + return get({ subscribe }); + } + }; +} + +export const gridPreferencesStore = createGridPreferencesStore(); diff --git a/lib/stores/license.ts b/lib/stores/license.ts new file mode 100644 index 0000000..b80c6b3 --- /dev/null +++ b/lib/stores/license.ts @@ -0,0 +1,75 @@ +import { writable, derived } from 'svelte/store'; + +export type LicenseType = 'enterprise' | 'smb'; + +export interface LicenseState { + isEnterprise: boolean; + isLicensed: boolean; + licenseType: LicenseType | null; + loading: boolean; + licensedTo: string | null; + expiresAt: string | null; +} + +function createLicenseStore() { + const { subscribe, set, update } = writable({ + isEnterprise: false, + isLicensed: false, + licenseType: null, + loading: true, + licensedTo: null, + expiresAt: null + }); + + return { + subscribe, + async check() { + update(state => ({ ...state, loading: true })); + try { + const response = await fetch('/api/license'); + const data = await response.json(); + const isValid = data.valid && data.active; + const licenseType = data.payload?.type as LicenseType | undefined; + set({ + isEnterprise: isValid && licenseType === 'enterprise', + isLicensed: isValid, + licenseType: isValid ? (licenseType || null) : null, + loading: false, + licensedTo: data.stored?.name || null, + expiresAt: data.payload?.expires || null + }); + } catch { + set({ isEnterprise: false, isLicensed: false, licenseType: null, loading: false, licensedTo: null, expiresAt: null }); + } + }, + setEnterprise(value: boolean) { + update(state => ({ ...state, isEnterprise: value })); + }, + /** Wait for the store to finish loading */ + waitUntilLoaded(): Promise { + return new Promise((resolve) => { + const unsubscribe = subscribe((state) => { + if (!state.loading) { + // Use setTimeout to avoid unsubscribing during callback + setTimeout(() => unsubscribe(), 0); + resolve(state); + } + }); + }); + } + }; +} + +export const licenseStore = createLicenseStore(); + +// Derived store for days until expiration +export const daysUntilExpiry = derived(licenseStore, ($license) => { + if (!$license.isLicensed || !$license.expiresAt) return null; + + const now = new Date(); + const expires = new Date($license.expiresAt); + const diffTime = expires.getTime() - now.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + return diffDays; +}); diff --git a/lib/stores/settings.ts b/lib/stores/settings.ts new file mode 100644 index 0000000..d067e7f --- /dev/null +++ b/lib/stores/settings.ts @@ -0,0 +1,365 @@ +import { writable, derived, get } from 'svelte/store'; +import { browser } from '$app/environment'; + +export type TimeFormat = '12h' | '24h'; +export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY'; +export type DownloadFormat = 'tar' | 'tar.gz'; + +export interface AppSettings { + confirmDestructive: boolean; + showStoppedContainers: boolean; + highlightUpdates: boolean; + timeFormat: TimeFormat; + dateFormat: DateFormat; + downloadFormat: DownloadFormat; + defaultGrypeArgs: string; + defaultTrivyArgs: string; + scheduleRetentionDays: number; + eventRetentionDays: number; + scheduleCleanupCron: string; + eventCleanupCron: string; + scheduleCleanupEnabled: boolean; + eventCleanupEnabled: boolean; + logBufferSizeKb: number; + defaultTimezone: string; +} + +const DEFAULT_SETTINGS: AppSettings = { + confirmDestructive: true, + showStoppedContainers: true, + highlightUpdates: true, + timeFormat: '24h', + dateFormat: 'DD.MM.YYYY', + downloadFormat: 'tar', + defaultGrypeArgs: '-o json -v {image}', + defaultTrivyArgs: 'image --format json {image}', + scheduleRetentionDays: 30, + eventRetentionDays: 30, + scheduleCleanupCron: '0 3 * * *', + eventCleanupCron: '30 3 * * *', + scheduleCleanupEnabled: true, + eventCleanupEnabled: true, + logBufferSizeKb: 500, + defaultTimezone: 'UTC' +}; + +// Create a writable store for app settings +function createSettingsStore() { + const { subscribe, set, update } = writable(DEFAULT_SETTINGS); + let initialized = false; + + // Load settings from database on initialization + async function loadSettings() { + if (!browser || initialized) return; + initialized = true; + + try { + const response = await fetch('/api/settings/general'); + if (response.ok) { + const settings = await response.json(); + set({ + confirmDestructive: settings.confirmDestructive ?? DEFAULT_SETTINGS.confirmDestructive, + showStoppedContainers: settings.showStoppedContainers ?? DEFAULT_SETTINGS.showStoppedContainers, + highlightUpdates: settings.highlightUpdates ?? DEFAULT_SETTINGS.highlightUpdates, + timeFormat: settings.timeFormat ?? DEFAULT_SETTINGS.timeFormat, + dateFormat: settings.dateFormat ?? DEFAULT_SETTINGS.dateFormat, + downloadFormat: settings.downloadFormat ?? DEFAULT_SETTINGS.downloadFormat, + defaultGrypeArgs: settings.defaultGrypeArgs ?? DEFAULT_SETTINGS.defaultGrypeArgs, + defaultTrivyArgs: settings.defaultTrivyArgs ?? DEFAULT_SETTINGS.defaultTrivyArgs, + scheduleRetentionDays: settings.scheduleRetentionDays ?? DEFAULT_SETTINGS.scheduleRetentionDays, + eventRetentionDays: settings.eventRetentionDays ?? DEFAULT_SETTINGS.eventRetentionDays, + scheduleCleanupCron: settings.scheduleCleanupCron ?? DEFAULT_SETTINGS.scheduleCleanupCron, + eventCleanupCron: settings.eventCleanupCron ?? DEFAULT_SETTINGS.eventCleanupCron, + scheduleCleanupEnabled: settings.scheduleCleanupEnabled ?? DEFAULT_SETTINGS.scheduleCleanupEnabled, + eventCleanupEnabled: settings.eventCleanupEnabled ?? DEFAULT_SETTINGS.eventCleanupEnabled, + logBufferSizeKb: settings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb, + defaultTimezone: settings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone + }); + } + } catch { + // Silently use defaults if settings can't be loaded + } + } + + // Save settings to database + async function saveSettings(settings: Partial) { + if (!browser) return; + + try { + const response = await fetch('/api/settings/general', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings) + }); + if (response.ok) { + const updatedSettings = await response.json(); + set({ + confirmDestructive: updatedSettings.confirmDestructive ?? DEFAULT_SETTINGS.confirmDestructive, + showStoppedContainers: updatedSettings.showStoppedContainers ?? DEFAULT_SETTINGS.showStoppedContainers, + highlightUpdates: updatedSettings.highlightUpdates ?? DEFAULT_SETTINGS.highlightUpdates, + timeFormat: updatedSettings.timeFormat ?? DEFAULT_SETTINGS.timeFormat, + dateFormat: updatedSettings.dateFormat ?? DEFAULT_SETTINGS.dateFormat, + downloadFormat: updatedSettings.downloadFormat ?? DEFAULT_SETTINGS.downloadFormat, + defaultGrypeArgs: updatedSettings.defaultGrypeArgs ?? DEFAULT_SETTINGS.defaultGrypeArgs, + defaultTrivyArgs: updatedSettings.defaultTrivyArgs ?? DEFAULT_SETTINGS.defaultTrivyArgs, + scheduleRetentionDays: updatedSettings.scheduleRetentionDays ?? DEFAULT_SETTINGS.scheduleRetentionDays, + eventRetentionDays: updatedSettings.eventRetentionDays ?? DEFAULT_SETTINGS.eventRetentionDays, + scheduleCleanupCron: updatedSettings.scheduleCleanupCron ?? DEFAULT_SETTINGS.scheduleCleanupCron, + eventCleanupCron: updatedSettings.eventCleanupCron ?? DEFAULT_SETTINGS.eventCleanupCron, + scheduleCleanupEnabled: updatedSettings.scheduleCleanupEnabled ?? DEFAULT_SETTINGS.scheduleCleanupEnabled, + eventCleanupEnabled: updatedSettings.eventCleanupEnabled ?? DEFAULT_SETTINGS.eventCleanupEnabled, + logBufferSizeKb: updatedSettings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb, + defaultTimezone: updatedSettings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone + }); + } + } catch (error) { + console.error('Failed to save settings:', error); + } + } + + // Load settings on store creation + if (browser) { + loadSettings(); + } + + return { + subscribe, + set: (value: AppSettings) => { + set(value); + saveSettings(value); + }, + update: (fn: (settings: AppSettings) => AppSettings) => { + update((current) => { + const newSettings = fn(current); + saveSettings(newSettings); + return newSettings; + }); + }, + // Convenience methods for individual settings + setConfirmDestructive: (value: boolean) => { + update((current) => { + const newSettings = { ...current, confirmDestructive: value }; + saveSettings({ confirmDestructive: value }); + return newSettings; + }); + }, + setShowStoppedContainers: (value: boolean) => { + update((current) => { + const newSettings = { ...current, showStoppedContainers: value }; + saveSettings({ showStoppedContainers: value }); + return newSettings; + }); + }, + setHighlightUpdates: (value: boolean) => { + update((current) => { + const newSettings = { ...current, highlightUpdates: value }; + saveSettings({ highlightUpdates: value }); + return newSettings; + }); + }, + setTimeFormat: (value: TimeFormat) => { + update((current) => { + const newSettings = { ...current, timeFormat: value }; + saveSettings({ timeFormat: value }); + return newSettings; + }); + }, + setDateFormat: (value: DateFormat) => { + update((current) => { + const newSettings = { ...current, dateFormat: value }; + saveSettings({ dateFormat: value }); + return newSettings; + }); + }, + setDownloadFormat: (value: DownloadFormat) => { + update((current) => { + const newSettings = { ...current, downloadFormat: value }; + saveSettings({ downloadFormat: value }); + return newSettings; + }); + }, + setDefaultGrypeArgs: (value: string) => { + update((current) => { + const newSettings = { ...current, defaultGrypeArgs: value }; + saveSettings({ defaultGrypeArgs: value }); + return newSettings; + }); + }, + setDefaultTrivyArgs: (value: string) => { + update((current) => { + const newSettings = { ...current, defaultTrivyArgs: value }; + saveSettings({ defaultTrivyArgs: value }); + return newSettings; + }); + }, + setScheduleRetentionDays: (value: number) => { + update((current) => { + const newSettings = { ...current, scheduleRetentionDays: value }; + saveSettings({ scheduleRetentionDays: value }); + return newSettings; + }); + }, + setEventRetentionDays: (value: number) => { + update((current) => { + const newSettings = { ...current, eventRetentionDays: value }; + saveSettings({ eventRetentionDays: value }); + return newSettings; + }); + }, + setScheduleCleanupCron: (value: string) => { + update((current) => { + const newSettings = { ...current, scheduleCleanupCron: value }; + saveSettings({ scheduleCleanupCron: value }); + return newSettings; + }); + }, + setEventCleanupCron: (value: string) => { + update((current) => { + const newSettings = { ...current, eventCleanupCron: value }; + saveSettings({ eventCleanupCron: value }); + return newSettings; + }); + }, + setScheduleCleanupEnabled: (value: boolean) => { + update((current) => { + const newSettings = { ...current, scheduleCleanupEnabled: value }; + saveSettings({ scheduleCleanupEnabled: value }); + return newSettings; + }); + }, + setEventCleanupEnabled: (value: boolean) => { + update((current) => { + const newSettings = { ...current, eventCleanupEnabled: value }; + saveSettings({ eventCleanupEnabled: value }); + return newSettings; + }); + }, + setLogBufferSizeKb: (value: number) => { + update((current) => { + const newSettings = { ...current, logBufferSizeKb: value }; + saveSettings({ logBufferSizeKb: value }); + return newSettings; + }); + }, + setDefaultTimezone: (value: string) => { + update((current) => { + const newSettings = { ...current, defaultTimezone: value }; + saveSettings({ defaultTimezone: value }); + return newSettings; + }); + }, + // Manual refresh from database + refresh: loadSettings + }; +} + +export const appSettings = createSettingsStore(); + +// Cache current settings for synchronous access (updated reactively) +let cachedTimeFormat: TimeFormat = DEFAULT_SETTINGS.timeFormat; +let cachedDateFormat: DateFormat = DEFAULT_SETTINGS.dateFormat; + +// Subscribe once to keep cache updated +if (browser) { + appSettings.subscribe((s) => { + cachedTimeFormat = s.timeFormat; + cachedDateFormat = s.dateFormat; + }); +} + +/** + * Format a date part according to user's date format preference. + * This is a low-level helper - prefer formatDateTime for most uses. + */ +function formatDatePart(d: Date): string { + const day = d.getDate().toString().padStart(2, '0'); + const month = (d.getMonth() + 1).toString().padStart(2, '0'); + const year = d.getFullYear(); + + switch (cachedDateFormat) { + case 'MM/DD/YYYY': + return `${month}/${day}/${year}`; + case 'DD/MM/YYYY': + return `${day}/${month}/${year}`; + case 'YYYY-MM-DD': + return `${year}-${month}-${day}`; + case 'DD.MM.YYYY': + default: + return `${day}.${month}.${year}`; + } +} + +/** + * Format a time part according to user's time format preference. + * This is a low-level helper - prefer formatDateTime for most uses. + */ +function formatTimePart(d: Date, includeSeconds = false): string { + const hours = d.getHours(); + const minutes = d.getMinutes().toString().padStart(2, '0'); + const seconds = d.getSeconds().toString().padStart(2, '0'); + + if (cachedTimeFormat === '12h') { + const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours; + const ampm = hours >= 12 ? 'PM' : 'AM'; + return includeSeconds + ? `${hour12}:${minutes}:${seconds} ${ampm}` + : `${hour12}:${minutes} ${ampm}`; + } else { + const hour24 = hours.toString().padStart(2, '0'); + return includeSeconds + ? `${hour24}:${minutes}:${seconds}` + : `${hour24}:${minutes}`; + } +} + +/** + * Format a timestamp according to user's time and date format preferences. + * Performant: uses cached settings, no store subscription per call. + * + * @param date - Date object, ISO string, or timestamp + * @param options - Formatting options + * @returns Formatted string + */ +export function formatTime( + date: Date | string | number, + options: { includeDate?: boolean; includeSeconds?: boolean } = {} +): string { + const d = date instanceof Date ? date : new Date(date); + const { includeDate = false, includeSeconds = false } = options; + + if (includeDate) { + return `${formatDatePart(d)} ${formatTimePart(d, includeSeconds)}`; + } + + return formatTimePart(d, includeSeconds); +} + +/** + * Format a timestamp with date according to user's preferences. + * Convenience wrapper around formatTime. + */ +export function formatDateTime(date: Date | string | number, includeSeconds = false): string { + return formatTime(date, { includeDate: true, includeSeconds }); +} + +/** + * Format just the date part according to user's preferences. + */ +export function formatDate(date: Date | string | number): string { + const d = date instanceof Date ? date : new Date(date); + return formatDatePart(d); +} + +/** + * Get the current time format setting (for components that need it). + */ +export function getTimeFormat(): TimeFormat { + return cachedTimeFormat; +} + +/** + * Get the current date format setting (for components that need it). + */ +export function getDateFormat(): DateFormat { + return cachedDateFormat; +} diff --git a/lib/stores/stats.ts b/lib/stores/stats.ts new file mode 100644 index 0000000..c7f147c --- /dev/null +++ b/lib/stores/stats.ts @@ -0,0 +1,134 @@ +import { writable, get } from 'svelte/store'; +import { currentEnvironment, appendEnvParam } from './environment'; + +export interface ContainerStats { + id: string; + name: string; + cpuPercent: number; + memoryUsage: number; + memoryLimit: number; + memoryPercent: number; +} + +export interface HostInfo { + hostname: string; + ipAddress: string; + platform: string; + arch: string; + cpus: number; + totalMemory: number; + freeMemory: number; + uptime: number; + dockerVersion: string; + dockerContainers: number; + dockerContainersRunning: number; + dockerImages: number; +} + +export interface HostMetric { + cpu_percent: number; + memory_percent: number; + memory_used: number; + memory_total: number; + timestamp: string; +} + +// Historical data settings +const MAX_HISTORY = 60; // 10 minutes at 10s intervals (server collects every 10s) +const POLL_INTERVAL = 5000; // 5 seconds + +// Stores +export const cpuHistory = writable([]); +export const memoryHistory = writable([]); +export const containerStats = writable([]); +export const hostInfo = writable(null); +export const lastUpdated = writable(new Date()); +export const isCollecting = writable(false); + +let pollInterval: ReturnType | null = null; +let envId: number | null = null; +let initialFetchDone = false; + +// Subscribe to environment changes +currentEnvironment.subscribe((env) => { + envId = env?.id ?? null; + // Reset history when environment changes + if (initialFetchDone) { + cpuHistory.set([]); + memoryHistory.set([]); + initialFetchDone = false; + } +}); + +// Helper for fetch with timeout +async function fetchWithTimeout(url: string, timeout = 5000): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + try { + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + return response.json(); + } catch { + clearTimeout(timeoutId); + return null; + } +} + +async function fetchStats() { + // Don't fetch if no environment is selected + if (!envId) return; + + // Fire all fetches independently - don't block on slow ones + fetchWithTimeout(appendEnvParam('/api/containers/stats?limit=5', envId), 5000).then(data => { + if (Array.isArray(data)) { + containerStats.set(data); + } + }); + + fetchWithTimeout(appendEnvParam('/api/host', envId), 5000).then(data => { + if (data && !data.error) { + hostInfo.set(data); + } + }); + + fetchWithTimeout(appendEnvParam('/api/metrics?limit=60', envId), 5000).then(data => { + if (data?.metrics && data.metrics.length > 0) { + const metrics: HostMetric[] = data.metrics; + const cpuValues = metrics.map(m => m.cpu_percent); + const memValues = metrics.map(m => m.memory_percent); + + cpuHistory.set(cpuValues.slice(-MAX_HISTORY)); + memoryHistory.set(memValues.slice(-MAX_HISTORY)); + initialFetchDone = true; + } + }); + + lastUpdated.set(new Date()); +} + +export function startStatsCollection() { + if (pollInterval) return; // Already running + + isCollecting.set(true); + fetchStats(); // Initial fetch + pollInterval = setInterval(fetchStats, POLL_INTERVAL); +} + +export function stopStatsCollection() { + if (pollInterval) { + clearInterval(pollInterval); + pollInterval = null; + } + isCollecting.set(false); +} + +// Get current values +export function getCurrentCpu(): number { + const history = get(cpuHistory); + return history.length > 0 ? history[history.length - 1] : 0; +} + +export function getCurrentMemory(): number { + const history = get(memoryHistory); + return history.length > 0 ? history[history.length - 1] : 0; +} diff --git a/lib/stores/theme.ts b/lib/stores/theme.ts new file mode 100644 index 0000000..4a00daa --- /dev/null +++ b/lib/stores/theme.ts @@ -0,0 +1,260 @@ +/** + * Theme Store for Dockhand + * + * Manages theme and font preferences with: + * - Immediate application (no page reload) + * - localStorage sync for flash-free loading + * - Database persistence via API + */ + +import { writable, get } from 'svelte/store'; +import { getFont, getMonospaceFont, type FontMeta } from '$lib/themes'; + +export type FontSize = 'xsmall' | 'small' | 'normal' | 'medium' | 'large' | 'xlarge'; + +export interface ThemePreferences { + lightTheme: string; + darkTheme: string; + font: string; + fontSize: FontSize; + gridFontSize: FontSize; + terminalFont: string; +} + +const STORAGE_KEY = 'dockhand-theme'; + +const defaultPrefs: ThemePreferences = { + lightTheme: 'default', + darkTheme: 'default', + font: 'system', + fontSize: 'normal', + gridFontSize: 'normal', + terminalFont: 'system-mono' +}; + +// Font size scale mapping +const fontSizeScales: Record = { + xsmall: 0.75, + small: 0.875, + normal: 1.0, + medium: 1.0625, + large: 1.125, + xlarge: 1.25 +}; + +// Grid font size scale - independent scaling for data grids +const gridFontSizeScales: Record = { + xsmall: 0.7, + small: 0.85, + normal: 1.0, + medium: 1.15, + large: 1.35, + xlarge: 1.7 +}; + +// Load initial state from localStorage (for flash-free loading) +function loadFromStorage(): ThemePreferences { + if (typeof window === 'undefined') return defaultPrefs; + + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + return { ...defaultPrefs, ...JSON.parse(stored) }; + } + } catch { + // Ignore parse errors + } + return defaultPrefs; +} + +// Create the store +function createThemeStore() { + const initialPrefs = loadFromStorage(); + const { subscribe, set, update } = writable(initialPrefs); + + // Apply theme immediately on store creation (for flash-free loading) + if (typeof document !== 'undefined') { + applyTheme(initialPrefs); + } + + return { + subscribe, + + // Initialize from API (called on mount) + async init(userId?: number) { + try { + const url = userId + ? `/api/profile/preferences` + : `/api/settings/general`; + + const res = await fetch(url); + if (res.ok) { + const data = await res.json(); + const prefs: ThemePreferences = { + lightTheme: data.lightTheme || data.theme_light || 'default', + darkTheme: data.darkTheme || data.theme_dark || 'default', + font: data.font || data.theme_font || 'system', + fontSize: data.fontSize || data.font_size || 'normal', + gridFontSize: data.gridFontSize || data.grid_font_size || 'normal', + terminalFont: data.terminalFont || data.terminal_font || 'system-mono' + }; + set(prefs); + saveToStorage(prefs); + applyTheme(prefs); + } + } catch { + // Use localStorage fallback + const prefs = loadFromStorage(); + applyTheme(prefs); + } + }, + + // Update a preference and apply immediately + async setPreference( + key: K, + value: ThemePreferences[K], + userId?: number + ) { + update((prefs) => { + const newPrefs = { ...prefs, [key]: value }; + saveToStorage(newPrefs); + applyTheme(newPrefs); + return newPrefs; + }); + + // Save to database (async, non-blocking) + try { + const url = userId + ? `/api/profile/preferences` + : `/api/settings/general`; + + await fetch(url, { + method: userId ? 'PUT' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ [key]: value }) + }); + } catch { + // Silently fail - localStorage has the value + } + }, + + // Get current preferences + get(): ThemePreferences { + return get({ subscribe }); + } + }; +} + +// Save to localStorage +function saveToStorage(prefs: ThemePreferences) { + if (typeof window === 'undefined') return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); + } catch { + // Ignore storage errors + } +} + +// Apply theme to document +export function applyTheme(prefs: ThemePreferences) { + if (typeof document === 'undefined') return; + + const root = document.documentElement; + const isDark = root.classList.contains('dark'); + + // Remove all theme classes + root.classList.forEach((cls) => { + if (cls.startsWith('theme-light-') || cls.startsWith('theme-dark-')) { + root.classList.remove(cls); + } + }); + + // Apply the appropriate theme class + if (isDark && prefs.darkTheme !== 'default') { + root.classList.add(`theme-dark-${prefs.darkTheme}`); + } else if (!isDark && prefs.lightTheme !== 'default') { + root.classList.add(`theme-light-${prefs.lightTheme}`); + } + + // Apply font + applyFont(prefs.font); + + // Apply font size + applyFontSize(prefs.fontSize); + + // Apply grid font size + applyGridFontSize(prefs.gridFontSize); + + // Apply terminal font + applyTerminalFont(prefs.terminalFont); +} + +// Apply font to document +function applyFont(fontId: string) { + if (typeof document === 'undefined') return; + + const fontMeta = getFont(fontId); + if (!fontMeta) return; + + // Load Google Font if needed + if (fontMeta.googleFont) { + loadGoogleFont(fontMeta); + } + + // Set CSS variable + document.documentElement.style.setProperty('--font-sans', fontMeta.family); +} + +// Apply font size to document +function applyFontSize(fontSize: FontSize) { + if (typeof document === 'undefined') return; + + const scale = fontSizeScales[fontSize] || 1.0; + document.documentElement.style.setProperty('--font-size-scale', scale.toString()); +} + +// Apply grid font size to document +function applyGridFontSize(gridFontSize: FontSize) { + if (typeof document === 'undefined') return; + + const gridScale = gridFontSizeScales[gridFontSize] || 1.0; + document.documentElement.style.setProperty('--grid-font-size-scale', gridScale.toString()); +} + +// Apply terminal font to document +function applyTerminalFont(fontId: string) { + if (typeof document === 'undefined') return; + + const fontMeta = getMonospaceFont(fontId); + if (!fontMeta) return; + + // Load Google Font if needed + if (fontMeta.googleFont) { + loadGoogleFont(fontMeta); + } + + // Set CSS variable + document.documentElement.style.setProperty('--font-mono', fontMeta.family); +} + +// Load Google Font dynamically +function loadGoogleFont(font: FontMeta) { + if (!font.googleFont) return; + + const linkId = `google-font-${font.id}`; + if (document.getElementById(linkId)) return; // Already loaded + + const link = document.createElement('link'); + link.id = linkId; + link.rel = 'stylesheet'; + link.href = `https://fonts.googleapis.com/css2?family=${font.googleFont}&display=swap`; + document.head.appendChild(link); +} + +// Re-apply theme when dark mode toggles +export function onDarkModeChange() { + const prefs = themeStore.get(); + applyTheme(prefs); +} + +export const themeStore = createThemeStore(); diff --git a/lib/themes.ts b/lib/themes.ts new file mode 100644 index 0000000..f887e9e --- /dev/null +++ b/lib/themes.ts @@ -0,0 +1,139 @@ +/** + * Theme and Font Metadata for Dockhand + * + * Color values are defined in app.css as CSS classes. + * This file only contains metadata for UI selectors. + * + * Theme colors are sourced from official theme specifications: + * - Catppuccin: https://catppuccin.com/palette/ + * - Nord: https://www.nordtheme.com/ + * - Dracula: https://draculatheme.com/spec + * - Gruvbox: https://github.com/morhetz/gruvbox + * - Solarized: https://ethanschoonover.com/solarized/ + * - One Dark: https://github.com/atom/one-dark-syntax + * - Rose Pine: https://rosepinetheme.com/palette/ + * - Tokyo Night: https://github.com/tokyo-night/tokyo-night-vscode-theme + * - GitHub: https://primer.style/primitives/colors + * - Material: https://material.io/design/color + * - Monokai: https://monokai.pro/ + * - Palenight: https://github.com/whizkydee/vscode-palenight-theme + */ + +export interface ThemeMeta { + id: string; + name: string; + preview: string; // Primary/accent color for swatch preview (hex) +} + +export interface FontMeta { + id: string; + name: string; + family: string; + googleFont?: string; // Only loaded when selected (on-demand) +} + +// Light theme options - colors defined in app.css +export const lightThemes: ThemeMeta[] = [ + { id: 'default', name: 'Default', preview: '#3b82f6' }, + { id: 'catppuccin', name: 'Catppuccin Latte', preview: '#8839ef' }, // Mauve + { id: 'rose-pine', name: 'Rose Pine Dawn', preview: '#907aa9' }, // Iris + { id: 'nord', name: 'Nord Light', preview: '#5e81ac' }, // Nord10 + { id: 'solarized', name: 'Solarized Light', preview: '#268bd2' }, // Blue + { id: 'gruvbox', name: 'Gruvbox Light', preview: '#458588' }, // Aqua + { id: 'alucard', name: 'Alucard (Dracula Light)', preview: '#644ac9' }, // Purple + { id: 'github', name: 'GitHub Light', preview: '#0969da' }, // Blue + { id: 'material', name: 'Material Light', preview: '#00acc1' }, // Cyan + { id: 'atom-one', name: 'Atom One Light', preview: '#4078f2' } // Blue +]; + +// Dark theme options - colors defined in app.css +export const darkThemes: ThemeMeta[] = [ + { id: 'default', name: 'Default', preview: '#3b82f6' }, + { id: 'catppuccin', name: 'Catppuccin Mocha', preview: '#cba6f7' }, // Mauve + { id: 'dracula', name: 'Dracula', preview: '#bd93f9' }, // Purple + { id: 'rose-pine', name: 'Rose Pine', preview: '#c4a7e7' }, // Iris + { id: 'rose-pine-moon', name: 'Rose Pine Moon', preview: '#c4a7e7' }, // Iris + { id: 'tokyo-night', name: 'Tokyo Night', preview: '#7aa2f7' }, // Blue + { id: 'nord', name: 'Nord', preview: '#81a1c1' }, // Nord9 + { id: 'one-dark', name: 'One Dark', preview: '#61afef' }, // Blue + { id: 'gruvbox', name: 'Gruvbox Dark', preview: '#b8bb26' }, // Green + { id: 'solarized', name: 'Solarized Dark', preview: '#268bd2' }, // Blue + { id: 'everforest', name: 'Everforest', preview: '#a7c080' }, // Green + { id: 'kanagawa', name: 'Kanagawa', preview: '#7e9cd8' }, // Blue + { id: 'monokai', name: 'Monokai', preview: '#f92672' }, // Pink + { id: 'monokai-pro', name: 'Monokai Pro', preview: '#ff6188' }, // Pink + { id: 'material', name: 'Material Dark', preview: '#80cbc4' }, // Teal + { id: 'palenight', name: 'Palenight', preview: '#c792ea' }, // Purple + { id: 'github', name: 'GitHub Dark', preview: '#58a6ff' } // Blue +]; + +// Font options - Google Fonts loaded on-demand, not kept in memory +export const fonts: FontMeta[] = [ + // System fonts (no external load) + { id: 'system', name: 'System UI', family: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' }, + + // Modern geometric sans-serif + { id: 'geist', name: 'Geist', family: "'Geist', sans-serif", googleFont: 'Geist:wght@400;500;600;700' }, + { id: 'inter', name: 'Inter', family: "'Inter', sans-serif", googleFont: 'Inter:wght@400;500;600;700' }, + { id: 'plus-jakarta', name: 'Plus Jakarta Sans', family: "'Plus Jakarta Sans', sans-serif", googleFont: 'Plus+Jakarta+Sans:wght@400;500;600;700' }, + { id: 'dm-sans', name: 'DM Sans', family: "'DM Sans', sans-serif", googleFont: 'DM+Sans:wght@400;500;600;700' }, + { id: 'outfit', name: 'Outfit', family: "'Outfit', sans-serif", googleFont: 'Outfit:wght@400;500;600;700' }, + { id: 'space-grotesk', name: 'Space Grotesk', family: "'Space Grotesk', sans-serif", googleFont: 'Space+Grotesk:wght@400;500;600;700' }, + + // Humanist sans-serif + { id: 'sofia-sans', name: 'Sofia Sans', family: "'Sofia Sans', sans-serif", googleFont: 'Sofia+Sans:wght@400;500;600;700' }, + { id: 'nunito', name: 'Nunito', family: "'Nunito', sans-serif", googleFont: 'Nunito:wght@400;500;600;700' }, + { id: 'poppins', name: 'Poppins', family: "'Poppins', sans-serif", googleFont: 'Poppins:wght@400;500;600;700' }, + { id: 'montserrat', name: 'Montserrat', family: "'Montserrat', sans-serif", googleFont: 'Montserrat:wght@400;500;600;700' }, + { id: 'raleway', name: 'Raleway', family: "'Raleway', sans-serif", googleFont: 'Raleway:wght@400;500;600;700' }, + { id: 'manrope', name: 'Manrope', family: "'Manrope', sans-serif", googleFont: 'Manrope:wght@400;500;600;700' }, + + // Classic sans-serif + { id: 'roboto', name: 'Roboto', family: "'Roboto', sans-serif", googleFont: 'Roboto:wght@400;500;600;700' }, + { id: 'open-sans', name: 'Open Sans', family: "'Open Sans', sans-serif", googleFont: 'Open+Sans:wght@400;500;600;700' }, + { id: 'lato', name: 'Lato', family: "'Lato', sans-serif", googleFont: 'Lato:wght@400;700' }, + { id: 'source-sans', name: 'Source Sans 3', family: "'Source Sans 3', sans-serif", googleFont: 'Source+Sans+3:wght@400;500;600;700' }, + { id: 'work-sans', name: 'Work Sans', family: "'Work Sans', sans-serif", googleFont: 'Work+Sans:wght@400;500;600;700' }, + { id: 'fira-sans', name: 'Fira Sans', family: "'Fira Sans', sans-serif", googleFont: 'Fira+Sans:wght@400;500;600;700' }, + + // Monospace (for a techy look) + { id: 'jetbrains-mono', name: 'JetBrains Mono', family: "'JetBrains Mono', monospace", googleFont: 'JetBrains+Mono:wght@400;500;600;700' }, + { id: 'fira-code', name: 'Fira Code', family: "'Fira Code', monospace", googleFont: 'Fira+Code:wght@400;500;600;700' }, + + // Rounded/friendly + { id: 'quicksand', name: 'Quicksand', family: "'Quicksand', sans-serif", googleFont: 'Quicksand:wght@400;500;600;700' }, + { id: 'comfortaa', name: 'Comfortaa', family: "'Comfortaa', sans-serif", googleFont: 'Comfortaa:wght@400;500;600;700' } +]; + +// Monospace fonts for terminal and logs +export const monospaceFonts: FontMeta[] = [ + // System monospace (no external load) + { id: 'system-mono', name: 'System Monospace', family: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' }, + + // Popular coding fonts (Google Fonts) + { id: 'jetbrains-mono', name: 'JetBrains Mono', family: "'JetBrains Mono', monospace", googleFont: 'JetBrains+Mono:wght@400;500;600;700' }, + { id: 'fira-code', name: 'Fira Code', family: "'Fira Code', monospace", googleFont: 'Fira+Code:wght@400;500;600;700' }, + { id: 'source-code-pro', name: 'Source Code Pro', family: "'Source Code Pro', monospace", googleFont: 'Source+Code+Pro:wght@400;500;600;700' }, + { id: 'cascadia-code', name: 'Cascadia Code', family: "'Cascadia Code', monospace", googleFont: 'Cascadia+Code:wght@400;500;600;700' }, + + // Platform-specific (no external load) + { id: 'menlo', name: 'Menlo', family: 'Menlo, Monaco, monospace' }, + { id: 'consolas', name: 'Consolas', family: 'Consolas, monospace' }, + { id: 'sf-mono', name: 'SF Mono', family: '"SF Mono", SFMono-Regular, monospace' } +]; + +export function getFont(id: string): FontMeta | undefined { + return fonts.find((f) => f.id === id); +} + +export function getMonospaceFont(id: string): FontMeta | undefined { + return monospaceFonts.find((f) => f.id === id); +} + +export function getLightTheme(id: string): ThemeMeta | undefined { + return lightThemes.find((t) => t.id === id); +} + +export function getDarkTheme(id: string): ThemeMeta | undefined { + return darkThemes.find((t) => t.id === id); +} diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..17bf013 --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,177 @@ +// Shared types that can be used in both client and server code + +export interface ContainerInfo { + id: string; + name: string; + image: string; + state: string; + status: string; + health?: string; + created: number; + ports: Array<{ + IP?: string; + PrivatePort: number; + PublicPort?: number; + Type: string; + }>; + labels: Record; + mounts: Array<{ + type: string; + source: string; + destination: string; + mode: string; + rw: boolean; + }>; + networkMode: string; + networks: string[]; +} + +export interface ImageInfo { + id: string; + repoTags: string[]; + tags: string[]; // Alias for repoTags, populated by API + created: number; + size: number; + virtualSize: number; + labels: Record; +} + +export interface VolumeUsage { + containerId: string; + containerName: string; +} + +export interface VolumeInfo { + name: string; + driver: string; + mountpoint: string; + scope: string; + labels: Record; + createdAt?: string; + created: string; // Alias for createdAt, populated by API + usedBy?: VolumeUsage[]; // Containers using this volume +} + +export interface NetworkInfo { + id: string; + name: string; + driver: string; + scope: string; + internal?: boolean; + ipam: { + driver: string; + config: Array<{ + subnet?: string; + gateway?: string; + }>; + }; + containers: Record; + labels: Record; +} + +export interface StackInfo { + name: string; + services: string[]; + status: 'running' | 'partial' | 'stopped'; + containers: Array<{ + id: string; + name: string; + service: string; + state: string; + status: string; + }>; + path?: string; +} + +export interface ContainerStats { + id: string; + name: string; + cpuPercent: number; + memoryUsage: number; + memoryLimit: number; + memoryPercent: number; + networkRx: number; + networkTx: number; + blockRead: number; + blockWrite: number; +} + +export interface StackContainer { + id: string; + name: string; + service: string; + state: string; + status: string; + health?: string; + image: string; + ports: Array<{ publicPort: number; privatePort: number; type: string; display: string }>; + networks: Array<{ name: string; ipAddress: string }>; + volumeCount: number; + restartCount: number; + created: number; +} + +export interface ComposeStackInfo { + name: string; + containers: string[]; + containerDetails: StackContainer[]; + status: string; + sourceType?: 'external' | 'internal' | 'git'; + repository?: { + id: number; + name: string; + url?: string; + branch?: string; + }; +} + +export interface GitRepository { + id: number; + name: string; + url: string; + branch: string; + composePath: string; + credentialId: number | null; + environmentId: number | null; + autoUpdate: boolean; + webhookEnabled: boolean; + webhookSecret: string | null; + lastSync: string | null; + lastCommit: string | null; + syncStatus: 'pending' | 'syncing' | 'synced' | 'error'; + syncError: string | null; + createdAt: string; + updatedAt: string; +} + +// Grid column configuration types +export type GridId = 'containers' | 'images' | 'imageTags' | 'networks' | 'stacks' | 'volumes' | 'activity' | 'schedules'; + +export interface ColumnConfig { + id: string; + label: string; + width?: number; + minWidth?: number; + resizable?: boolean; + sortable?: boolean; + sortField?: string; + fixed?: 'start' | 'end'; + align?: 'left' | 'center' | 'right'; + grow?: boolean; // If true, column expands to fill remaining space + noTruncate?: boolean; // If true, content won't be truncated with ellipsis +} + +export interface ColumnPreference { + id: string; + visible: boolean; + width?: number; +} + +export interface GridColumnPreferences { + columns: ColumnPreference[]; +} + +export type AllGridPreferences = Partial>; diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000..6de5afd --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,27 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChild = T extends { child?: any } ? Omit : T; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChildren = T extends { children?: any } ? Omit : T; +export type WithoutChildrenOrChild = WithoutChildren>; +export type WithElementRef = T & { ref?: U | null }; + +/** + * Focus the first editable input field in a dialog. + * Call this after a dialog opens to provide better keyboard UX. + */ +export function focusFirstInput() { + setTimeout(() => { + const input = document.querySelector( + '[data-slot="dialog-content"] input:not([disabled]):not([type="hidden"]), ' + + '[data-slot="dialog-content"] textarea:not([disabled])' + ); + input?.focus(); + }, 50); +} diff --git a/lib/utils/icons.ts b/lib/utils/icons.ts new file mode 100644 index 0000000..35715fc --- /dev/null +++ b/lib/utils/icons.ts @@ -0,0 +1,46 @@ +import { + Server, Globe, Cloud, Database, HardDrive, Cpu, Network, Box, Container, Monitor, + Laptop, Smartphone, Tablet, Tv, Router, Wifi, Cable, Radio, Satellite, Building, + Building2, Factory, Warehouse, House, Castle, Landmark, Store, School, Hospital, + Terminal, Code, Binary, Braces, FileCode, GitBranch, GitCommitHorizontal, GitPullRequest, + Settings, Cog, Wrench, Hammer, Package, Archive, FolderOpen, + Shield, ShieldCheck, Lock, Key, Eye, EyeOff, TriangleAlert, + Zap, Flame, Snowflake, Sun, Moon, Star, Sparkles, Heart, Crown, Gem, + Anchor, Ship, Plane, Rocket, Car, Bike, TrainFront, Bus, Truck, + Activity, BarChart3, ChartLine, ChartPie, TrendingUp, Gauge, Timer, + Mail, MessageSquare, Phone, Video, Camera, Music, Headphones, Volume2, + MapPin, Map, Compass, Navigation, Flag, Bookmark, Target +} from 'lucide-svelte'; +import type { ComponentType } from 'svelte'; + +// Icon mapping for rendering +const iconMap: Record = { + 'server': Server, 'globe': Globe, 'cloud': Cloud, 'database': Database, 'hard-drive': HardDrive, + 'cpu': Cpu, 'network': Network, 'box': Box, 'container': Container, 'monitor': Monitor, + 'laptop': Laptop, 'smartphone': Smartphone, 'tablet': Tablet, 'tv': Tv, 'router': Router, + 'wifi': Wifi, 'cable': Cable, 'radio': Radio, 'satellite': Satellite, 'building': Building, + 'building-2': Building2, 'factory': Factory, 'warehouse': Warehouse, 'home': House, 'castle': Castle, + 'landmark': Landmark, 'store': Store, 'school': School, 'hospital': Hospital, + 'terminal': Terminal, 'code': Code, 'binary': Binary, 'braces': Braces, 'file-code': FileCode, + 'git-branch': GitBranch, 'git-commit': GitCommitHorizontal, 'git-pull-request': GitPullRequest, + 'settings': Settings, 'cog': Cog, 'wrench': Wrench, 'hammer': Hammer, + 'package': Package, 'archive': Archive, 'folder-open': FolderOpen, + 'shield': Shield, 'shield-check': ShieldCheck, 'lock': Lock, 'key': Key, + 'eye': Eye, 'eye-off': EyeOff, 'alert-triangle': TriangleAlert, + 'zap': Zap, 'flame': Flame, 'snowflake': Snowflake, 'sun': Sun, 'moon': Moon, + 'star': Star, 'sparkles': Sparkles, 'heart': Heart, 'crown': Crown, 'gem': Gem, + 'anchor': Anchor, 'ship': Ship, 'plane': Plane, 'rocket': Rocket, 'car': Car, + 'bike': Bike, 'train': TrainFront, 'bus': Bus, 'truck': Truck, + 'activity': Activity, 'bar-chart': BarChart3, 'line-chart': ChartLine, 'pie-chart': ChartPie, + 'trending-up': TrendingUp, 'gauge': Gauge, 'timer': Timer, + 'mail': Mail, 'message-square': MessageSquare, 'phone': Phone, 'video': Video, + 'camera': Camera, 'music': Music, 'headphones': Headphones, 'volume-2': Volume2, + 'map-pin': MapPin, 'map': Map, 'compass': Compass, 'navigation': Navigation, + 'flag': Flag, 'bookmark': Bookmark, 'target': Target +}; + +export function getIconComponent(iconName: string): ComponentType { + return iconMap[iconName] || Globe; +} + +export { iconMap }; diff --git a/lib/utils/ip.ts b/lib/utils/ip.ts new file mode 100644 index 0000000..f804bf3 --- /dev/null +++ b/lib/utils/ip.ts @@ -0,0 +1,15 @@ +/** + * Convert IP address (with optional CIDR) to numeric value for sorting + * e.g., "192.168.1.0/24" -> 3232235776, "10.0.0.1" -> 167772161 + */ +export function ipToNumber(ip: string | undefined | null): number { + if (!ip || ip === '-') return Infinity; // Push empty IPs to the end + // Strip CIDR notation if present + const ipOnly = ip.split('/')[0]; + const parts = ipOnly.split('.'); + if (parts.length !== 4) return Infinity; + return parts.reduce((acc, octet) => { + const num = parseInt(octet, 10); + return isNaN(num) ? Infinity : (acc << 8) + num; + }, 0) >>> 0; // Convert to unsigned 32-bit +} diff --git a/lib/utils/label-colors.ts b/lib/utils/label-colors.ts new file mode 100644 index 0000000..c050986 --- /dev/null +++ b/lib/utils/label-colors.ts @@ -0,0 +1,107 @@ +/** + * Label color utilities for environment labels + * + * Provides consistent, deterministic color assignment based on label string hash. + * Colors are from Tailwind's color palette for visual consistency. + */ + +// Tailwind color palette - vibrant, distinguishable colors +const LABEL_COLORS = [ + '#ef4444', // red-500 + '#f97316', // orange-500 + '#eab308', // yellow-500 + '#22c55e', // green-500 + '#14b8a6', // teal-500 + '#3b82f6', // blue-500 + '#8b5cf6', // violet-500 + '#ec4899', // pink-500 + '#06b6d4', // cyan-500 + '#84cc16', // lime-500 + '#6366f1', // indigo-500 + '#d946ef' // fuchsia-500 +]; + +// Lighter variants for backgrounds (with alpha) +const LABEL_BG_COLORS = [ + 'rgba(239, 68, 68, 0.15)', // red + 'rgba(249, 115, 22, 0.15)', // orange + 'rgba(234, 179, 8, 0.15)', // yellow + 'rgba(34, 197, 94, 0.15)', // green + 'rgba(20, 184, 166, 0.15)', // teal + 'rgba(59, 130, 246, 0.15)', // blue + 'rgba(139, 92, 246, 0.15)', // violet + 'rgba(236, 72, 153, 0.15)', // pink + 'rgba(6, 182, 212, 0.15)', // cyan + 'rgba(132, 204, 22, 0.15)', // lime + 'rgba(99, 102, 241, 0.15)', // indigo + 'rgba(217, 70, 239, 0.15)' // fuchsia +]; + +/** + * Generate a hash from a string for consistent color assignment + */ +function hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash); +} + +/** + * Get the primary color for a label (for text/borders) + */ +export function getLabelColor(label: string): string { + const index = hashString(label) % LABEL_COLORS.length; + return LABEL_COLORS[index]; +} + +/** + * Get the background color for a label (lighter, with transparency) + */ +export function getLabelBgColor(label: string): string { + const index = hashString(label) % LABEL_BG_COLORS.length; + return LABEL_BG_COLORS[index]; +} + +/** + * Get both colors for a label as an object + */ +export function getLabelColors(label: string): { color: string; bgColor: string } { + const index = hashString(label) % LABEL_COLORS.length; + return { + color: LABEL_COLORS[index], + bgColor: LABEL_BG_COLORS[index] + }; +} + +/** + * Maximum number of labels allowed per environment + */ +export const MAX_LABELS = 10; + +/** + * Parse labels from JSON string or array (handles both database and API formats) + */ +export function parseLabels(labels: string | string[] | null | undefined): string[] { + if (!labels) return []; + // Already an array - return as-is + if (Array.isArray(labels)) return labels; + // JSON string from database + try { + const parsed = JSON.parse(labels); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +/** + * Serialize labels to JSON string for database storage + */ +export function serializeLabels(labels: string[]): string | null { + if (!labels || labels.length === 0) return null; + return JSON.stringify(labels); +} diff --git a/lib/utils/update-steps.ts b/lib/utils/update-steps.ts new file mode 100644 index 0000000..945bbed --- /dev/null +++ b/lib/utils/update-steps.ts @@ -0,0 +1,160 @@ +/** + * Shared utilities for update step visualization across: + * - BatchUpdateModal (manual update from containers grid) + * - Scheduled container auto-updates + * - Scheduled environment update checks + */ + +import { + Download, + Shield, + Square, + Trash2, + Box, + Play, + CheckCircle2, + XCircle, + ShieldBan, + Circle, + Loader2, + ShieldAlert, + ShieldCheck, + ShieldOff, + ShieldX +} from 'lucide-svelte'; +import type { ComponentType } from 'svelte'; +import type { VulnerabilityCriteria } from '$lib/server/db'; + +// Step types for update process +export type StepType = + | 'pulling' + | 'scanning' + | 'stopping' + | 'removing' + | 'creating' + | 'starting' + | 'done' + | 'failed' + | 'blocked' + | 'checked' + | 'skipped' + | 'updated'; + +// Get icon component for a step +export function getStepIcon(step: StepType): ComponentType { + switch (step) { + case 'pulling': + return Download; + case 'scanning': + return Shield; + case 'stopping': + return Square; + case 'removing': + return Trash2; + case 'creating': + return Box; + case 'starting': + return Play; + case 'done': + case 'updated': + return CheckCircle2; + case 'failed': + return XCircle; + case 'blocked': + return ShieldBan; + case 'checked': + case 'skipped': + return Circle; + default: + return Loader2; + } +} + +// Get human-readable label for a step +export function getStepLabel(step: StepType): string { + switch (step) { + case 'pulling': + return 'Pulling image'; + case 'scanning': + return 'Scanning for vulnerabilities'; + case 'stopping': + return 'Stopping'; + case 'removing': + return 'Removing'; + case 'creating': + return 'Creating'; + case 'starting': + return 'Starting'; + case 'done': + return 'Done'; + case 'updated': + return 'Updated'; + case 'failed': + return 'Failed'; + case 'blocked': + return 'Blocked by vulnerabilities'; + case 'checked': + return 'Checked'; + case 'skipped': + return 'Up-to-date'; + default: + return step; + } +} + +// Get color classes for a step +export function getStepColor(step: StepType): string { + switch (step) { + case 'done': + case 'updated': + return 'text-green-600 dark:text-green-400'; + case 'failed': + return 'text-red-600 dark:text-red-400'; + case 'blocked': + return 'text-amber-600 dark:text-amber-400'; + case 'scanning': + return 'text-purple-600 dark:text-purple-400'; + case 'checked': + case 'skipped': + return 'text-muted-foreground'; + default: + return 'text-blue-600 dark:text-blue-400'; + } +} + +// Vulnerability criteria labels +export const vulnerabilityCriteriaLabels: Record = { + never: 'Never block', + any: 'Any vulnerability', + critical_high: 'Critical or high', + critical: 'Critical only', + more_than_current: 'More than current image' +}; + +// Vulnerability criteria icons with colors and titles +export const vulnerabilityCriteriaIcons: Record< + VulnerabilityCriteria, + { component: ComponentType; class: string; title: string } +> = { + never: { component: ShieldOff, class: 'w-3.5 h-3.5 text-muted-foreground', title: 'No vulnerability blocking' }, + any: { component: ShieldAlert, class: 'w-3.5 h-3.5 text-amber-500', title: 'Block on any vulnerability' }, + critical_high: { component: ShieldX, class: 'w-3.5 h-3.5 text-orange-500', title: 'Block on critical or high' }, + critical: { component: ShieldX, class: 'w-3.5 h-3.5 text-red-500', title: 'Block on critical only' }, + more_than_current: { component: Shield, class: 'w-3.5 h-3.5 text-blue-500', title: 'Block if more than current' } +}; + +// Get badge variant based on criteria severity +export function getCriteriaBadgeClass(criteria: VulnerabilityCriteria): string { + switch (criteria) { + case 'any': + return 'bg-red-500/10 text-red-600 border-red-500/30'; + case 'critical_high': + return 'bg-orange-500/10 text-orange-600 border-orange-500/30'; + case 'critical': + return 'bg-amber-500/10 text-amber-600 border-amber-500/30'; + case 'more_than_current': + return 'bg-blue-500/10 text-blue-600 border-blue-500/30'; + default: + return 'bg-slate-500/10 text-slate-600 border-slate-500/30'; + } +} diff --git a/lib/utils/version.ts b/lib/utils/version.ts new file mode 100644 index 0000000..458ed25 --- /dev/null +++ b/lib/utils/version.ts @@ -0,0 +1,35 @@ +/** + * Compares two semantic version strings. + * @returns -1 if v1 < v2, 0 if equal, 1 if v1 > v2 + */ +export function compareVersions(v1: string, v2: string): number { + const normalize = (v: string) => + v + .replace(/^v/, '') + .split('.') + .map(Number); + const parts1 = normalize(v1); + const parts2 = normalize(v2); + + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { + const p1 = parts1[i] || 0; + const p2 = parts2[i] || 0; + if (p1 > p2) return 1; + if (p1 < p2) return -1; + } + return 0; +} + +/** + * Determines if the "What's New" popup should be shown. + * @param currentVersion - The current app version (from git tag) + * @param lastSeenVersion - The last version the user has seen (from localStorage) + */ +export function shouldShowWhatsNew( + currentVersion: string | null, + lastSeenVersion: string | null +): boolean { + if (!currentVersion || currentVersion === 'unknown') return false; + if (!lastSeenVersion) return true; // Never seen any version + return compareVersions(currentVersion, lastSeenVersion) > 0; +} diff --git a/routes/+layout.server.ts b/routes/+layout.server.ts new file mode 100644 index 0000000..c29b93c --- /dev/null +++ b/routes/+layout.server.ts @@ -0,0 +1,55 @@ +import type { LayoutServerLoad } from './$types'; +import { redirect } from '@sveltejs/kit'; +import { isAuthEnabled, validateSession } from '$lib/server/auth'; +import { hasAdminUser } from '$lib/server/db'; + +// Routes that don't require authentication +const PUBLIC_PATHS = ['/login']; + +export const load: LayoutServerLoad = async ({ cookies, url }) => { + const authEnabled = await isAuthEnabled(); + + // If auth is disabled, allow everything + if (!authEnabled) { + return { + authEnabled: false, + user: null + }; + } + + // Auth is enabled - validate session + const user = await validateSession(cookies); + + // Check if this is a public path + const isPublicPath = PUBLIC_PATHS.some(path => url.pathname === path || url.pathname.startsWith(path + '/')); + + // If not authenticated and not on a public path + if (!user && !isPublicPath) { + // Special case: allow access when no admin exists yet (initial setup) + const noAdminSetupMode = !(await hasAdminUser()); + if (noAdminSetupMode) { + return { + authEnabled: true, + user: null, + setupMode: true + }; + } + + // Redirect to login + const redirectUrl = encodeURIComponent(url.pathname + url.search); + redirect(307, `/login?redirect=${redirectUrl}`); + } + + return { + authEnabled: true, + user: user ? { + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + avatar: user.avatar, + isAdmin: user.isAdmin, + provider: user.provider + } : null + }; +}; diff --git a/routes/+layout.svelte b/routes/+layout.svelte new file mode 100644 index 0000000..c4aabd2 --- /dev/null +++ b/routes/+layout.svelte @@ -0,0 +1,175 @@ + + + + + Dockhand - Docker Management + + + + + +
    +
    + + +
    +
    + + {#if $licenseStore.isEnterprise && $daysUntilExpiry !== null && $daysUntilExpiry <= 30} + + + {#if $daysUntilExpiry <= 0} + License expired + {:else if $daysUntilExpiry === 1} + License expires tomorrow + {:else} + License expires in {$daysUntilExpiry} days + {/if} + + {/if} + +
    +
    +
    + {@render children?.()} +
    +
    +
    + + + + +{#if showWhatsNewModal && currentVersion} + +{/if} diff --git a/routes/+layout.ts b/routes/+layout.ts new file mode 100644 index 0000000..d16853d --- /dev/null +++ b/routes/+layout.ts @@ -0,0 +1,3 @@ +// Disable SSR for the entire app - it's a Docker management dashboard +// that relies entirely on client-side data fetching from the Docker API +export const ssr = false; diff --git a/routes/+page.svelte b/routes/+page.svelte new file mode 100644 index 0000000..a589049 --- /dev/null +++ b/routes/+page.svelte @@ -0,0 +1,1000 @@ + + +
    + +
    +
    + + + + {#if allLabels.length > 0} +
    + + {#each allLabels as label} + {@const isSelected = filterLabels.includes(label)} + + {/each} +
    + {/if} +
    + +
    + + + + + + + {#snippet child({ props })} + + {/snippet} + + + applyAutoLayout(1, 1)} class="flex items-center gap-2 cursor-pointer"> + + Compact + + applyAutoLayout(1, 2)} class="flex items-center gap-2 cursor-pointer"> + + Standard + + applyAutoLayout(1, 4)} class="flex items-center gap-2 cursor-pointer"> + + Detailed + + applyAutoLayout(2, 4)} class="flex items-center gap-2 cursor-pointer"> + + Full + + + + + + +
    +
    + + + {#if initialLoading && tiles.length === 0} +
    + + Loading environments... +
    + {:else if tiles.length === 0} + +
    +
    + +
    +

    No environments configured

    +

    Add an environment to start managing your Docker hosts

    + +
    + {:else if filteredGridItems.length === 0} + +
    +
    + +
    +

    No matching environments

    +

    No environments match the selected label filters

    + +
    + {:else} + + + {#snippet children({ item })} + {@const tile = getTileById(item.id)} + {#if tile} + {#if tile.loading && !tile.stats} + + + {:else if tile.stats} + + handleEventsClick(tile.stats!.id)} /> + {/if} + {/if} + {/snippet} + + {/if} +
    diff --git a/routes/activity/+page.svelte b/routes/activity/+page.svelte new file mode 100644 index 0000000..132d58f --- /dev/null +++ b/routes/activity/+page.svelte @@ -0,0 +1,931 @@ + + + + Activity - Dockhand + + +
    + +
    + 0 ? `${visibleStart}-${visibleEnd}` : undefined} total={total > 0 ? total : undefined} countClass="min-w-32" /> +
    + +
    + + e.key === 'Escape' && (filterContainerName = '')} + class="pl-8 h-8 w-36 text-sm" + /> +
    + + + + + + {#if environments.length > 0} + {@const selectedEnv = environments.find(e => e.id === filterEnvironmentId)} + {@const SelectedEnvIcon = selectedEnv ? getIconComponent(selectedEnv.icon || 'globe') : Server} + filterEnvironmentId = v ? parseInt(v) : null} + > + + + + {#if filterEnvironmentId === null} + Environment + {:else} + {selectedEnv?.name || 'Environment'} + {/if} + + + + + + All environments + + {#each environments as env} + {@const EnvIcon = getIconComponent(env.icon || 'globe')} + + + {env.name} + + {/each} + + + {/if} + + + { + selectedDatePreset = v || ''; + if (v !== 'custom') { + applyDatePreset(v || ''); + } + }} + > + + + + {#if selectedDatePreset === 'custom'} + Custom + {:else if selectedDatePreset} + {datePresets.find(d => d.value === selectedDatePreset)?.label || 'All time'} + {:else} + All time + {/if} + + + + All time + {#each datePresets as preset} + {preset.label} + {/each} + Custom range... + + + + + {#if selectedDatePreset === 'custom'} + + + {/if} + + + + + + + {#if $canAccess('activity', 'delete')} + showClearConfirm = open} + > + {#snippet children({ open })} + + {/snippet} + + {/if} +
    +
    + + + {#if $environmentsStore.length === 0} + + {:else} + showDetails(event)} + class="border-none" + wrapperClass="border rounded-lg" + > + {#snippet cell(column, event, rowState)} + {#if column.id === 'timestamp'} + {formatTimestamp(event.timestamp)} + {:else if column.id === 'environment'} + {#if event.environmentName} + {@const EventEnvIcon = getIconComponent(event.environmentIcon || 'globe')} +
    + + {event.environmentName} +
    + {:else} + - + {/if} + {:else if column.id === 'action'} +
    + + + +
    + {:else if column.id === 'container'} +
    + + + {event.containerName || (event.containerId ? event.containerId.slice(0, 12) : 'Unknown')} + +
    + {:else if column.id === 'image'} + + {event.image || '-'} + + {:else if column.id === 'exitCode'} + {#if event.actorAttributes?.exitCode !== undefined} + {@const exitCode = parseInt(event.actorAttributes.exitCode)} + + {exitCode} + + {:else} + - + {/if} + {:else if column.id === 'actions'} +
    + +
    + {/if} + {/snippet} + + {#snippet emptyState()} +
    + +

    No container events found

    +

    Events will appear here as containers start, stop, etc.

    +
    + {/snippet} + + {#snippet loadingState()} +
    + + Loading... +
    + {/snippet} +
    + + + {#if loadingMore} +
    + + Loading more... +
    + {/if} + + + {#if !hasMore && events.length > 0} +
    + End of results ({total.toLocaleString()} events) +
    + {/if} + {/if} +
    + + + + + + Event details + + {#if selectedEvent} +
    +
    +
    + +

    {formatTimestamp(selectedEvent.timestamp)}

    +
    +
    + +

    + + + {selectedEvent.action} + +

    +
    +
    + +

    + + {selectedEvent.containerName || '-'} +

    +
    +
    + +

    {selectedEvent.containerId}

    +
    + {#if selectedEvent.image} +
    + +

    {selectedEvent.image}

    +
    + {/if} + {#if selectedEvent.environmentName} +
    + +

    {selectedEvent.environmentName}

    +
    + {/if} +
    + + {#if selectedEvent.actorAttributes && Object.keys(selectedEvent.actorAttributes).length > 0} +
    + +
    + + + {#each Object.entries(selectedEvent.actorAttributes) as [key, value], i} + + + + + {/each} + +
    {key}{value}
    +
    +
    + {/if} +
    + {/if} + + + +
    +
    diff --git a/routes/alerts/+page.svelte b/routes/alerts/+page.svelte new file mode 100644 index 0000000..31e66db --- /dev/null +++ b/routes/alerts/+page.svelte @@ -0,0 +1,14 @@ + + +
    + + +
    + +

    No alerts configured

    +

    Alert configuration coming soon

    +
    +
    diff --git a/routes/api/activity/+server.ts b/routes/api/activity/+server.ts new file mode 100644 index 0000000..fe0a3c1 --- /dev/null +++ b/routes/api/activity/+server.ts @@ -0,0 +1,88 @@ +import { json } from '@sveltejs/kit'; +import { getContainerEvents, getContainerEventContainers, getContainerEventActions, getContainerEventStats, clearContainerEvents, type ContainerEventFilters, type ContainerEventAction } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + // Parse query parameters + const filters: ContainerEventFilters = {}; + + const envId = url.searchParams.get('environmentId'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('activity', 'view', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + if (envIdNum) { + // Specific environment requested - use it + filters.environmentId = envIdNum; + } else if (auth.isEnterprise && auth.authEnabled && !auth.isAdmin) { + // Enterprise with auth enabled and non-admin: filter by accessible environments + const accessibleEnvIds = await auth.getAccessibleEnvironmentIds(); + if (accessibleEnvIds !== null) { + // User has limited access - filter by their accessible environments + if (accessibleEnvIds.length === 0) { + // No access to any environment - return empty + return json({ events: [], total: 0, limit: 100, offset: 0 }); + } + filters.environmentIds = accessibleEnvIds; + } + // If accessibleEnvIds is null, user has access to all environments + } + + const containerId = url.searchParams.get('containerId'); + if (containerId) filters.containerId = containerId; + + const containerName = url.searchParams.get('containerName'); + if (containerName) filters.containerName = containerName; + + // Support multi-select actions filter (comma-separated) + const actions = url.searchParams.get('actions'); + if (actions) filters.actions = actions.split(',').filter(Boolean) as ContainerEventAction[]; + + // Labels filter (comma-separated) + const labels = url.searchParams.get('labels'); + if (labels) filters.labels = labels.split(',').filter(Boolean); + + const fromDate = url.searchParams.get('fromDate'); + if (fromDate) filters.fromDate = fromDate; + + const toDate = url.searchParams.get('toDate'); + if (toDate) filters.toDate = toDate; + + const limit = url.searchParams.get('limit'); + if (limit) filters.limit = parseInt(limit); + + const offset = url.searchParams.get('offset'); + if (offset) filters.offset = parseInt(offset); + + const result = await getContainerEvents(filters); + return json(result); + } catch (error) { + console.error('Error fetching container events:', error); + return json({ error: 'Failed to fetch container events' }, { status: 500 }); + } +}; + +export const DELETE: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + + // Check permission - admins or users with activity delete permission + // In free edition, all authenticated users can delete + if (auth.authEnabled && !await auth.can('activity', 'delete')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + await clearContainerEvents(); + return json({ success: true }); + } catch (error) { + console.error('Error clearing container events:', error); + return json({ error: 'Failed to clear container events' }, { status: 500 }); + } +}; diff --git a/routes/api/activity/containers/+server.ts b/routes/api/activity/containers/+server.ts new file mode 100644 index 0000000..836d438 --- /dev/null +++ b/routes/api/activity/containers/+server.ts @@ -0,0 +1,42 @@ +import { json } from '@sveltejs/kit'; +import { getContainerEventContainers } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('environment_id'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check for activity viewing + if (auth.authEnabled && !await auth.can('activity', 'view', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + let environmentIds: number[] | undefined; + + if (envIdNum) { + // Specific environment requested + const containers = await getContainerEventContainers(envIdNum); + return json(containers); + } else if (auth.isEnterprise && auth.authEnabled && !auth.isAdmin) { + // Enterprise with auth enabled and non-admin: filter by accessible environments + const accessibleEnvIds = await auth.getAccessibleEnvironmentIds(); + if (accessibleEnvIds !== null) { + if (accessibleEnvIds.length === 0) { + // No access to any environment - return empty list + return json([]); + } + environmentIds = accessibleEnvIds; + } + } + + const containers = await getContainerEventContainers(undefined, environmentIds); + return json(containers); + } catch (error) { + console.error('Error fetching container names:', error); + return json({ error: 'Failed to fetch container names' }, { status: 500 }); + } +}; diff --git a/routes/api/activity/events/+server.ts b/routes/api/activity/events/+server.ts new file mode 100644 index 0000000..392c915 --- /dev/null +++ b/routes/api/activity/events/+server.ts @@ -0,0 +1,107 @@ +import type { RequestHandler } from './$types'; +import { containerEventEmitter } from '$lib/server/event-collector'; +import { authorize } from '$lib/server/authorize'; +import { json } from '@sveltejs/kit'; + +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + + // Permission check for activity viewing + if (auth.authEnabled && !await auth.can('activity', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Get accessible environment IDs for filtering (enterprise only) + let accessibleEnvIds: number[] | null = null; + if (auth.isEnterprise && auth.authEnabled && !auth.isAdmin) { + accessibleEnvIds = await auth.getAccessibleEnvironmentIds(); + // If user has no access to any environment, return empty stream + if (accessibleEnvIds !== null && accessibleEnvIds.length === 0) { + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + controller.enqueue(encoder.encode(`event: connected\ndata: ${JSON.stringify({ timestamp: new Date().toISOString() })}\n\n`)); + } + }); + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no' + } + }); + } + } + + let heartbeatInterval: ReturnType; + let handleEvent: ((event: any) => void) | null = null; + let handleEnvStatus: ((status: any) => void) | null = null; + + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + + const sendEvent = (type: string, data: any) => { + try { + const event = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`; + controller.enqueue(encoder.encode(event)); + } catch { + // Ignore errors when client disconnects + } + }; + + // Send initial connection event + sendEvent('connected', { timestamp: new Date().toISOString() }); + + // Send heartbeat to keep connection alive (every 5s to prevent Traefik 10s idle timeout) + heartbeatInterval = setInterval(() => { + try { + sendEvent('heartbeat', { timestamp: new Date().toISOString() }); + } catch { + clearInterval(heartbeatInterval); + } + }, 5000); + + // Listen for new container events (filter by accessible environments) + handleEvent = (event: any) => { + // If accessibleEnvIds is null, user has access to all environments + // Otherwise, filter by accessible environment IDs + if (accessibleEnvIds === null || (event.environmentId && accessibleEnvIds.includes(event.environmentId))) { + sendEvent('activity', event); + } + }; + + // Listen for environment status changes (online/offline) + handleEnvStatus = (status: any) => { + if (accessibleEnvIds === null || (status.envId && accessibleEnvIds.includes(status.envId))) { + sendEvent('env_status', status); + } + }; + + containerEventEmitter.on('event', handleEvent); + containerEventEmitter.on('env_status', handleEnvStatus); + }, + cancel() { + // Cleanup when client disconnects + clearInterval(heartbeatInterval); + if (handleEvent) { + containerEventEmitter.off('event', handleEvent); + handleEvent = null; + } + if (handleEnvStatus) { + containerEventEmitter.off('env_status', handleEnvStatus); + handleEnvStatus = null; + } + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no' + } + }); +}; diff --git a/routes/api/activity/stats/+server.ts b/routes/api/activity/stats/+server.ts new file mode 100644 index 0000000..922cef2 --- /dev/null +++ b/routes/api/activity/stats/+server.ts @@ -0,0 +1,42 @@ +import { json } from '@sveltejs/kit'; +import { getContainerEventStats } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('environment_id'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check for activity viewing + if (auth.authEnabled && !await auth.can('activity', 'view', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + let environmentIds: number[] | undefined; + + if (envIdNum) { + // Specific environment requested + const stats = await getContainerEventStats(envIdNum); + return json(stats); + } else if (auth.isEnterprise && auth.authEnabled && !auth.isAdmin) { + // Enterprise with auth enabled and non-admin: filter by accessible environments + const accessibleEnvIds = await auth.getAccessibleEnvironmentIds(); + if (accessibleEnvIds !== null) { + if (accessibleEnvIds.length === 0) { + // No access to any environment - return empty stats + return json({ total: 0, today: 0, byAction: {} }); + } + environmentIds = accessibleEnvIds; + } + } + + const stats = await getContainerEventStats(undefined, environmentIds); + return json(stats); + } catch (error) { + console.error('Error fetching container event stats:', error); + return json({ error: 'Failed to fetch stats' }, { status: 500 }); + } +}; diff --git a/routes/api/audit/+server.ts b/routes/api/audit/+server.ts new file mode 100644 index 0000000..684be76 --- /dev/null +++ b/routes/api/audit/+server.ts @@ -0,0 +1,68 @@ +import { json } from '@sveltejs/kit'; +import { authorize, enterpriseRequired } from '$lib/server/authorize'; +import { getAuditLogs, getAuditLogUsers, type AuditLogFilters, type AuditEntityType, type AuditAction } from '$lib/server/db'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + // Audit log is Enterprise-only + if (!auth.isEnterprise) { + return json(enterpriseRequired(), { status: 403 }); + } + + // Check permission + if (!await auth.canViewAuditLog()) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + // Parse query parameters + const filters: AuditLogFilters = {}; + + // Support multi-select filters (comma-separated) + const usernames = url.searchParams.get('usernames'); + if (usernames) filters.usernames = usernames.split(',').filter(Boolean); + + const entityTypes = url.searchParams.get('entityTypes'); + if (entityTypes) filters.entityTypes = entityTypes.split(',').filter(Boolean) as AuditEntityType[]; + + const actions = url.searchParams.get('actions'); + if (actions) filters.actions = actions.split(',').filter(Boolean) as AuditAction[]; + + // Legacy single-value support + const username = url.searchParams.get('username'); + if (username) filters.usernames = [username]; + + const entityType = url.searchParams.get('entityType'); + if (entityType) filters.entityTypes = [entityType as AuditEntityType]; + + const action = url.searchParams.get('action'); + if (action) filters.actions = [action as AuditAction]; + + const envId = url.searchParams.get('environmentId'); + if (envId) filters.environmentId = parseInt(envId); + + // Labels filter (comma-separated) + const labels = url.searchParams.get('labels'); + if (labels) filters.labels = labels.split(',').filter(Boolean); + + const fromDate = url.searchParams.get('fromDate'); + if (fromDate) filters.fromDate = fromDate; + + const toDate = url.searchParams.get('toDate'); + if (toDate) filters.toDate = toDate; + + const limit = url.searchParams.get('limit'); + if (limit) filters.limit = parseInt(limit); + + const offset = url.searchParams.get('offset'); + if (offset) filters.offset = parseInt(offset); + + const result = await getAuditLogs(filters); + return json(result); + } catch (error) { + console.error('Error fetching audit logs:', error); + return json({ error: 'Failed to fetch audit logs' }, { status: 500 }); + } +}; diff --git a/routes/api/audit/events/+server.ts b/routes/api/audit/events/+server.ts new file mode 100644 index 0000000..a3c2668 --- /dev/null +++ b/routes/api/audit/events/+server.ts @@ -0,0 +1,79 @@ +import type { RequestHandler } from './$types'; +import { authorize, enterpriseRequired } from '$lib/server/authorize'; +import { auditEvents, type AuditEventData } from '$lib/server/audit-events'; + +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + + // Audit log is Enterprise-only + if (!auth.isEnterprise) { + return new Response(JSON.stringify(enterpriseRequired()), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Check permission + if (!await auth.canViewAuditLog()) { + return new Response(JSON.stringify({ error: 'Permission denied' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } + + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + + // Send SSE event + const sendEvent = (type: string, data: any) => { + const event = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`; + try { + controller.enqueue(encoder.encode(event)); + } catch (e) { + // Client disconnected + } + }; + + // Send initial connection event + sendEvent('connected', { timestamp: new Date().toISOString() }); + + // Send heartbeat to keep connection alive (every 5s to prevent Traefik 10s idle timeout) + const heartbeatInterval = setInterval(() => { + try { + sendEvent('heartbeat', { timestamp: new Date().toISOString() }); + } catch { + clearInterval(heartbeatInterval); + } + }, 5000); + + // Listen for audit events + const onAuditEvent = (data: AuditEventData) => { + sendEvent('audit', data); + }; + + auditEvents.on('audit', onAuditEvent); + + // Cleanup when client disconnects + const cleanup = () => { + clearInterval(heartbeatInterval); + auditEvents.off('audit', onAuditEvent); + }; + + // Note: SvelteKit doesn't provide a direct way to detect client disconnect + // The cleanup will happen when the stream errors or the server shuts down + // For production, consider using a WebSocket instead for better connection management + + return cleanup; + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no' // Disable nginx buffering + } + }); +}; diff --git a/routes/api/audit/export/+server.ts b/routes/api/audit/export/+server.ts new file mode 100644 index 0000000..02b7b55 --- /dev/null +++ b/routes/api/audit/export/+server.ts @@ -0,0 +1,182 @@ +import { authorize, enterpriseRequired } from '$lib/server/authorize'; +import { getAuditLogs, type AuditLogFilters, type AuditEntityType, type AuditAction, type AuditLog } from '$lib/server/db'; +import type { RequestHandler } from './$types'; + +function escapeCSV(value: string | null | undefined): string { + if (value === null || value === undefined) return ''; + const str = String(value); + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} + +function formatToJSON(logs: AuditLog[]): string { + return JSON.stringify(logs, null, 2); +} + +function formatToCSV(logs: AuditLog[]): string { + const headers = [ + 'ID', + 'Timestamp', + 'Username', + 'Action', + 'Entity Type', + 'Entity ID', + 'Entity Name', + 'Environment ID', + 'Description', + 'IP Address', + 'User Agent', + 'Details' + ]; + + const rows = logs.map((log) => [ + log.id, + log.createdAt, + escapeCSV(log.username), + escapeCSV(log.action), + escapeCSV(log.entityType), + escapeCSV(log.entityId), + escapeCSV(log.entityName), + log.environmentId ?? '', + escapeCSV(log.description), + escapeCSV(log.ipAddress), + escapeCSV(log.userAgent), + escapeCSV(log.details ? JSON.stringify(log.details) : '') + ]); + + return [headers.join(','), ...rows.map((row) => row.join(','))].join('\n'); +} + +function formatToMarkdown(logs: AuditLog[]): string { + const lines: string[] = []; + + lines.push('# Audit Log Export'); + lines.push(''); + lines.push(`Generated: ${new Date().toISOString()}`); + lines.push(''); + lines.push(`Total entries: ${logs.length}`); + lines.push(''); + lines.push('---'); + lines.push(''); + + for (const log of logs) { + lines.push(`## ${log.action.toUpperCase()} - ${log.entityType}`); + lines.push(''); + lines.push(`| Field | Value |`); + lines.push(`|-------|-------|`); + lines.push(`| Timestamp | ${log.createdAt} |`); + lines.push(`| User | ${log.username} |`); + lines.push(`| Action | ${log.action} |`); + lines.push(`| Entity Type | ${log.entityType} |`); + if (log.entityName) lines.push(`| Entity Name | ${log.entityName} |`); + if (log.entityId) lines.push(`| Entity ID | \`${log.entityId}\` |`); + if (log.environmentId) lines.push(`| Environment ID | ${log.environmentId} |`); + if (log.description) lines.push(`| Description | ${log.description} |`); + if (log.ipAddress) lines.push(`| IP Address | ${log.ipAddress} |`); + + if (log.details) { + lines.push(''); + lines.push('**Details:**'); + lines.push('```json'); + lines.push(JSON.stringify(log.details, null, 2)); + lines.push('```'); + } + + lines.push(''); + lines.push('---'); + lines.push(''); + } + + return lines.join('\n'); +} + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + // Audit log is Enterprise-only + if (!auth.isEnterprise) { + return new Response(JSON.stringify(enterpriseRequired()), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Check permission + if (!await auth.canViewAuditLog()) { + return new Response(JSON.stringify({ error: 'Permission denied' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } + + try { + // Parse query parameters + const filters: AuditLogFilters = {}; + + const username = url.searchParams.get('username'); + if (username) filters.username = username; + + const entityType = url.searchParams.get('entityType'); + if (entityType) filters.entityType = entityType as AuditEntityType; + + const action = url.searchParams.get('action'); + if (action) filters.action = action as AuditAction; + + const envId = url.searchParams.get('environmentId'); + if (envId) filters.environmentId = parseInt(envId); + + const fromDate = url.searchParams.get('fromDate'); + if (fromDate) filters.fromDate = fromDate; + + const toDate = url.searchParams.get('toDate'); + if (toDate) filters.toDate = toDate; + + // For export, get all matching records (no pagination) + filters.limit = 10000; // Reasonable max limit + + const result = await getAuditLogs(filters); + const logs = result.logs; + + const format = url.searchParams.get('format') || 'json'; + const timestamp = new Date().toISOString().split('T')[0]; + + let content: string; + let contentType: string; + let filename: string; + + switch (format) { + case 'csv': + content = formatToCSV(logs); + contentType = 'text/csv'; + filename = `audit-log-${timestamp}.csv`; + break; + case 'md': + content = formatToMarkdown(logs); + contentType = 'text/markdown'; + filename = `audit-log-${timestamp}.md`; + break; + case 'json': + default: + content = formatToJSON(logs); + contentType = 'application/json'; + filename = `audit-log-${timestamp}.json`; + break; + } + + return new Response(content, { + status: 200, + headers: { + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename="${filename}"` + } + }); + } catch (error) { + console.error('Error exporting audit logs:', error); + return new Response(JSON.stringify({ error: 'Failed to export audit logs' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } +}; diff --git a/routes/api/audit/users/+server.ts b/routes/api/audit/users/+server.ts new file mode 100644 index 0000000..8d4d161 --- /dev/null +++ b/routes/api/audit/users/+server.ts @@ -0,0 +1,26 @@ +import { json } from '@sveltejs/kit'; +import { authorize, enterpriseRequired } from '$lib/server/authorize'; +import { getAuditLogUsers } from '$lib/server/db'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + + // Audit log is Enterprise-only + if (!auth.isEnterprise) { + return json(enterpriseRequired(), { status: 403 }); + } + + // Check permission + if (!await auth.canViewAuditLog()) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const users = await getAuditLogUsers(); + return json(users); + } catch (error) { + console.error('Error fetching audit log users:', error); + return json({ error: 'Failed to fetch audit log users' }, { status: 500 }); + } +}; diff --git a/routes/api/auth/ldap/+server.ts b/routes/api/auth/ldap/+server.ts new file mode 100644 index 0000000..7759329 --- /dev/null +++ b/routes/api/auth/ldap/+server.ts @@ -0,0 +1,81 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { authorize } from '$lib/server/authorize'; +import { getLdapConfigs, createLdapConfig } from '$lib/server/db'; + +// GET /api/auth/ldap - List all LDAP configurations +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + + // Allow access when auth is disabled (setup mode) or when user is admin + if (auth.authEnabled && (!auth.isAuthenticated || !auth.isAdmin)) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (!auth.isEnterprise) { + return json({ error: 'Enterprise license required' }, { status: 403 }); + } + + try { + const configs = await getLdapConfigs(); + // Don't return passwords + const sanitized = configs.map(config => ({ + ...config, + bindPassword: config.bindPassword ? '********' : undefined + })); + return json(sanitized); + } catch (error) { + console.error('Failed to get LDAP configs:', error); + return json({ error: 'Failed to get LDAP configurations' }, { status: 500 }); + } +}; + +// POST /api/auth/ldap - Create a new LDAP configuration +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + + // Allow access when auth is disabled (setup mode) or when user is admin + if (auth.authEnabled && (!auth.isAuthenticated || !auth.isAdmin)) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (!auth.isEnterprise) { + return json({ error: 'Enterprise license required' }, { status: 403 }); + } + + try { + const data = await request.json(); + + // Validate required fields + if (!data.name || !data.serverUrl || !data.baseDn) { + return json({ error: 'Name, server URL, and base DN are required' }, { status: 400 }); + } + + const config = await createLdapConfig({ + name: data.name, + enabled: data.enabled ?? false, + serverUrl: data.serverUrl, + bindDn: data.bindDn || undefined, + bindPassword: data.bindPassword || undefined, + baseDn: data.baseDn, + userFilter: data.userFilter || '(uid={{username}})', + usernameAttribute: data.usernameAttribute || 'uid', + emailAttribute: data.emailAttribute || 'mail', + displayNameAttribute: data.displayNameAttribute || 'cn', + groupBaseDn: data.groupBaseDn || undefined, + groupFilter: data.groupFilter || undefined, + adminGroup: data.adminGroup || undefined, + roleMappings: data.roleMappings || undefined, + tlsEnabled: data.tlsEnabled ?? false, + tlsCa: data.tlsCa || undefined + }); + + return json({ + ...config, + bindPassword: config.bindPassword ? '********' : undefined + }, { status: 201 }); + } catch (error) { + console.error('Failed to create LDAP config:', error); + return json({ error: 'Failed to create LDAP configuration' }, { status: 500 }); + } +}; diff --git a/routes/api/auth/ldap/[id]/+server.ts b/routes/api/auth/ldap/[id]/+server.ts new file mode 100644 index 0000000..f19f1a7 --- /dev/null +++ b/routes/api/auth/ldap/[id]/+server.ts @@ -0,0 +1,131 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { authorize } from '$lib/server/authorize'; +import { getLdapConfig, updateLdapConfig, deleteLdapConfig } from '$lib/server/db'; + +// GET /api/auth/ldap/[id] - Get a specific LDAP configuration +export const GET: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + + // Allow access when auth is disabled (setup mode) or when user is admin + if (auth.authEnabled && (!auth.isAuthenticated || !auth.isAdmin)) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (!auth.isEnterprise) { + return json({ error: 'Enterprise license required' }, { status: 403 }); + } + + const id = parseInt(params.id!, 10); + if (isNaN(id)) { + return json({ error: 'Invalid ID' }, { status: 400 }); + } + + try { + const config = await getLdapConfig(id); + if (!config) { + return json({ error: 'LDAP configuration not found' }, { status: 404 }); + } + + return json({ + ...config, + bindPassword: config.bindPassword ? '********' : undefined + }); + } catch (error) { + console.error('Failed to get LDAP config:', error); + return json({ error: 'Failed to get LDAP configuration' }, { status: 500 }); + } +}; + +// PUT /api/auth/ldap/[id] - Update a LDAP configuration +export const PUT: RequestHandler = async ({ params, request, cookies }) => { + const auth = await authorize(cookies); + + // Allow access when auth is disabled (setup mode) or when user is admin + if (auth.authEnabled && (!auth.isAuthenticated || !auth.isAdmin)) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (!auth.isEnterprise) { + return json({ error: 'Enterprise license required' }, { status: 403 }); + } + + const id = parseInt(params.id!, 10); + if (isNaN(id)) { + return json({ error: 'Invalid ID' }, { status: 400 }); + } + + try { + const existing = await getLdapConfig(id); + if (!existing) { + return json({ error: 'LDAP configuration not found' }, { status: 404 }); + } + + const data = await request.json(); + + // Don't update password if it's the masked value + const updateData: any = {}; + if (data.name !== undefined) updateData.name = data.name; + if (data.enabled !== undefined) updateData.enabled = data.enabled; + if (data.serverUrl !== undefined) updateData.serverUrl = data.serverUrl; + if (data.bindDn !== undefined) updateData.bindDn = data.bindDn; + if (data.bindPassword !== undefined && data.bindPassword !== '********') { + updateData.bindPassword = data.bindPassword; + } + if (data.baseDn !== undefined) updateData.baseDn = data.baseDn; + if (data.userFilter !== undefined) updateData.userFilter = data.userFilter; + if (data.usernameAttribute !== undefined) updateData.usernameAttribute = data.usernameAttribute; + if (data.emailAttribute !== undefined) updateData.emailAttribute = data.emailAttribute; + if (data.displayNameAttribute !== undefined) updateData.displayNameAttribute = data.displayNameAttribute; + if (data.groupBaseDn !== undefined) updateData.groupBaseDn = data.groupBaseDn; + if (data.groupFilter !== undefined) updateData.groupFilter = data.groupFilter; + if (data.adminGroup !== undefined) updateData.adminGroup = data.adminGroup; + if (data.roleMappings !== undefined) updateData.roleMappings = data.roleMappings; + if (data.tlsEnabled !== undefined) updateData.tlsEnabled = data.tlsEnabled; + if (data.tlsCa !== undefined) updateData.tlsCa = data.tlsCa; + + const config = await updateLdapConfig(id, updateData); + if (!config) { + return json({ error: 'Failed to update configuration' }, { status: 500 }); + } + + return json({ + ...config, + bindPassword: config.bindPassword ? '********' : undefined + }); + } catch (error) { + console.error('Failed to update LDAP config:', error); + return json({ error: 'Failed to update LDAP configuration' }, { status: 500 }); + } +}; + +// DELETE /api/auth/ldap/[id] - Delete a LDAP configuration +export const DELETE: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + + // Allow access when auth is disabled (setup mode) or when user is admin + if (auth.authEnabled && (!auth.isAuthenticated || !auth.isAdmin)) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (!auth.isEnterprise) { + return json({ error: 'Enterprise license required' }, { status: 403 }); + } + + const id = parseInt(params.id!, 10); + if (isNaN(id)) { + return json({ error: 'Invalid ID' }, { status: 400 }); + } + + try { + const deleted = await deleteLdapConfig(id); + if (!deleted) { + return json({ error: 'LDAP configuration not found' }, { status: 404 }); + } + + return json({ success: true }); + } catch (error) { + console.error('Failed to delete LDAP config:', error); + return json({ error: 'Failed to delete LDAP configuration' }, { status: 500 }); + } +}; diff --git a/routes/api/auth/ldap/[id]/test/+server.ts b/routes/api/auth/ldap/[id]/test/+server.ts new file mode 100644 index 0000000..ed5089b --- /dev/null +++ b/routes/api/auth/ldap/[id]/test/+server.ts @@ -0,0 +1,37 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { testLdapConnection } from '$lib/server/auth'; +import { authorize } from '$lib/server/authorize'; +import { getLdapConfig } from '$lib/server/db'; + +// POST /api/auth/ldap/[id]/test - Test LDAP connection +export const POST: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + + // Allow access when auth is disabled (setup mode) or when user is admin + if (auth.authEnabled && (!auth.isAuthenticated || !auth.isAdmin)) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (!auth.isEnterprise) { + return json({ error: 'Enterprise license required' }, { status: 403 }); + } + + const id = parseInt(params.id!, 10); + if (isNaN(id)) { + return json({ error: 'Invalid ID' }, { status: 400 }); + } + + try { + const config = await getLdapConfig(id); + if (!config) { + return json({ error: 'LDAP configuration not found' }, { status: 404 }); + } + + const result = await testLdapConnection(id); + return json(result); + } catch (error) { + console.error('Failed to test LDAP connection:', error); + return json({ error: 'Failed to test LDAP connection' }, { status: 500 }); + } +}; diff --git a/routes/api/auth/login/+server.ts b/routes/api/auth/login/+server.ts new file mode 100644 index 0000000..4c0c2e8 --- /dev/null +++ b/routes/api/auth/login/+server.ts @@ -0,0 +1,117 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { + authenticateLocal, + authenticateLdap, + getEnabledLdapConfigs, + createUserSession, + isRateLimited, + recordFailedAttempt, + clearRateLimit, + verifyMfaToken, + isAuthEnabled +} from '$lib/server/auth'; +import { getUser, getUserByUsername } from '$lib/server/db'; + +// POST /api/auth/login - Authenticate user +export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => { + // Check if auth is enabled + if (!(await isAuthEnabled())) { + return json({ error: 'Authentication is not enabled' }, { status: 400 }); + } + + try { + const { username, password, mfaToken, provider = 'local' } = await request.json(); + + if (!username || !password) { + return json({ error: 'Username and password are required' }, { status: 400 }); + } + + // Rate limiting by IP and username + const clientIp = getClientAddress(); + const rateLimitKey = `${clientIp}:${username}`; + + const { limited, retryAfter } = isRateLimited(rateLimitKey); + if (limited) { + return json( + { error: `Too many login attempts. Please try again in ${retryAfter} seconds.` }, + { status: 429 } + ); + } + + // Attempt authentication based on provider + let result: any; + let authProviderType: 'local' | 'ldap' | 'oidc' = 'local'; + + if (provider.startsWith('ldap:')) { + // LDAP provider with specific config ID (e.g., "ldap:1") + const configId = parseInt(provider.split(':')[1], 10); + result = await authenticateLdap(username, password, configId); + authProviderType = 'ldap'; + } else if (provider === 'ldap') { + // Generic LDAP (will try all enabled configs) + result = await authenticateLdap(username, password); + authProviderType = 'ldap'; + } else { + result = await authenticateLocal(username, password); + authProviderType = 'local'; + } + + if (!result.success) { + recordFailedAttempt(rateLimitKey); + return json({ error: result.error || 'Authentication failed' }, { status: 401 }); + } + + // Handle MFA if required + if (result.requiresMfa) { + if (!mfaToken) { + // Return that MFA is required + return json({ requiresMfa: true }, { status: 200 }); + } + + // Verify MFA token + const user = await getUserByUsername(username); + if (!user || !(await verifyMfaToken(user.id, mfaToken))) { + recordFailedAttempt(rateLimitKey); + return json({ error: 'Invalid MFA code' }, { status: 401 }); + } + + // MFA verified, create session + const session = await createUserSession(user.id, authProviderType, cookies); + clearRateLimit(rateLimitKey); + + return json({ + success: true, + user: { + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + isAdmin: user.isAdmin + } + }); + } + + // No MFA, create session directly + if (result.user) { + const session = await createUserSession(result.user.id, authProviderType, cookies); + clearRateLimit(rateLimitKey); + + return json({ + success: true, + user: { + id: result.user.id, + username: result.user.username, + email: result.user.email, + displayName: result.user.displayName, + isAdmin: result.user.isAdmin + } + }); + } + + return json({ error: 'Authentication failed' }, { status: 401 }); + } catch (error) { + console.error('Login error:', error); + return json({ error: 'Login failed' }, { status: 500 }); + } +}; diff --git a/routes/api/auth/logout/+server.ts b/routes/api/auth/logout/+server.ts new file mode 100644 index 0000000..b52a4e4 --- /dev/null +++ b/routes/api/auth/logout/+server.ts @@ -0,0 +1,14 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { destroySession } from '$lib/server/auth'; + +// POST /api/auth/logout - End session +export const POST: RequestHandler = async ({ cookies }) => { + try { + await destroySession(cookies); + return json({ success: true }); + } catch (error) { + console.error('Logout error:', error); + return json({ error: 'Logout failed' }, { status: 500 }); + } +}; diff --git a/routes/api/auth/oidc/+server.ts b/routes/api/auth/oidc/+server.ts new file mode 100644 index 0000000..e14e77a --- /dev/null +++ b/routes/api/auth/oidc/+server.ts @@ -0,0 +1,88 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { authorize } from '$lib/server/authorize'; +import { + getOidcConfigs, + createOidcConfig, + type OidcConfig +} from '$lib/server/db'; + +// GET /api/auth/oidc - List all OIDC configurations +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + + // When auth is enabled, require authentication and settings:view permission + if (auth.authEnabled) { + if (!auth.isAuthenticated) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + if (!await auth.can('settings', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + } + + try { + const configs = await getOidcConfigs(); + // Sanitize sensitive data + const sanitized = configs.map(config => ({ + ...config, + clientSecret: config.clientSecret ? '********' : '' + })); + return json(sanitized); + } catch (error) { + console.error('Failed to get OIDC configs:', error); + return json({ error: 'Failed to get OIDC configurations' }, { status: 500 }); + } +}; + +// POST /api/auth/oidc - Create new OIDC configuration +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + + // When auth is enabled, require authentication and settings:edit permission + if (auth.authEnabled) { + if (!auth.isAuthenticated) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + if (!await auth.can('settings', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + } + + try { + const data = await request.json(); + + // Validate required fields + const required = ['name', 'issuerUrl', 'clientId', 'clientSecret', 'redirectUri']; + for (const field of required) { + if (!data[field]) { + return json({ error: `Missing required field: ${field}` }, { status: 400 }); + } + } + + const config = await createOidcConfig({ + name: data.name, + enabled: data.enabled ?? false, + issuerUrl: data.issuerUrl, + clientId: data.clientId, + clientSecret: data.clientSecret, + redirectUri: data.redirectUri, + scopes: data.scopes || 'openid profile email', + usernameClaim: data.usernameClaim || 'preferred_username', + emailClaim: data.emailClaim || 'email', + displayNameClaim: data.displayNameClaim || 'name', + adminClaim: data.adminClaim || undefined, + adminValue: data.adminValue || undefined, + roleMappingsClaim: data.roleMappingsClaim || 'groups', + roleMappings: data.roleMappings || undefined + }); + + return json({ + ...config, + clientSecret: '********' + }, { status: 201 }); + } catch (error: any) { + console.error('Failed to create OIDC config:', error); + return json({ error: error.message || 'Failed to create OIDC configuration' }, { status: 500 }); + } +}; diff --git a/routes/api/auth/oidc/[id]/+server.ts b/routes/api/auth/oidc/[id]/+server.ts new file mode 100644 index 0000000..c344dde --- /dev/null +++ b/routes/api/auth/oidc/[id]/+server.ts @@ -0,0 +1,136 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { authorize } from '$lib/server/authorize'; +import { + getOidcConfig, + updateOidcConfig, + deleteOidcConfig +} from '$lib/server/db'; + +// GET /api/auth/oidc/[id] - Get specific OIDC configuration +export const GET: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + + // When auth is enabled, require authentication and settings:view permission + if (auth.authEnabled) { + if (!auth.isAuthenticated) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + if (!await auth.can('settings', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + } + + const id = parseInt(params.id || ''); + if (isNaN(id)) { + return json({ error: 'Invalid configuration ID' }, { status: 400 }); + } + + try { + const config = await getOidcConfig(id); + if (!config) { + return json({ error: 'OIDC configuration not found' }, { status: 404 }); + } + + return json({ + ...config, + clientSecret: config.clientSecret ? '********' : '' + }); + } catch (error) { + console.error('Failed to get OIDC config:', error); + return json({ error: 'Failed to get OIDC configuration' }, { status: 500 }); + } +}; + +// PUT /api/auth/oidc/[id] - Update OIDC configuration +export const PUT: RequestHandler = async ({ params, request, cookies }) => { + const auth = await authorize(cookies); + + // When auth is enabled, require authentication and settings:edit permission + if (auth.authEnabled) { + if (!auth.isAuthenticated) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + if (!await auth.can('settings', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + } + + const id = parseInt(params.id || ''); + if (isNaN(id)) { + return json({ error: 'Invalid configuration ID' }, { status: 400 }); + } + + try { + const existing = await getOidcConfig(id); + if (!existing) { + return json({ error: 'OIDC configuration not found' }, { status: 404 }); + } + + const data = await request.json(); + + // Don't update clientSecret if it's the masked value + const updateData: any = {}; + if (data.name !== undefined) updateData.name = data.name; + if (data.enabled !== undefined) updateData.enabled = data.enabled; + if (data.issuerUrl !== undefined) updateData.issuerUrl = data.issuerUrl; + if (data.clientId !== undefined) updateData.clientId = data.clientId; + if (data.clientSecret !== undefined && data.clientSecret !== '********') { + updateData.clientSecret = data.clientSecret; + } + if (data.redirectUri !== undefined) updateData.redirectUri = data.redirectUri; + if (data.scopes !== undefined) updateData.scopes = data.scopes; + if (data.usernameClaim !== undefined) updateData.usernameClaim = data.usernameClaim; + if (data.emailClaim !== undefined) updateData.emailClaim = data.emailClaim; + if (data.displayNameClaim !== undefined) updateData.displayNameClaim = data.displayNameClaim; + if (data.adminClaim !== undefined) updateData.adminClaim = data.adminClaim; + if (data.adminValue !== undefined) updateData.adminValue = data.adminValue; + if (data.roleMappingsClaim !== undefined) updateData.roleMappingsClaim = data.roleMappingsClaim; + if (data.roleMappings !== undefined) updateData.roleMappings = data.roleMappings; + + const config = await updateOidcConfig(id, updateData); + if (!config) { + return json({ error: 'Failed to update OIDC configuration' }, { status: 500 }); + } + + return json({ + ...config, + clientSecret: config.clientSecret ? '********' : '' + }); + } catch (error: any) { + console.error('Failed to update OIDC config:', error); + return json({ error: error.message || 'Failed to update OIDC configuration' }, { status: 500 }); + } +}; + +// DELETE /api/auth/oidc/[id] - Delete OIDC configuration +export const DELETE: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + + // When auth is enabled, require authentication and settings:edit permission + if (auth.authEnabled) { + if (!auth.isAuthenticated) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + if (!await auth.can('settings', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + } + + const id = parseInt(params.id || ''); + if (isNaN(id)) { + return json({ error: 'Invalid configuration ID' }, { status: 400 }); + } + + try { + const deleted = await deleteOidcConfig(id); + if (!deleted) { + return json({ error: 'OIDC configuration not found' }, { status: 404 }); + } + + return json({ success: true }); + } catch (error) { + console.error('Failed to delete OIDC config:', error); + return json({ error: 'Failed to delete OIDC configuration' }, { status: 500 }); + } +}; diff --git a/routes/api/auth/oidc/[id]/initiate/+server.ts b/routes/api/auth/oidc/[id]/initiate/+server.ts new file mode 100644 index 0000000..a2f2ea5 --- /dev/null +++ b/routes/api/auth/oidc/[id]/initiate/+server.ts @@ -0,0 +1,77 @@ +import { json, redirect } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { buildOidcAuthorizationUrl, isAuthEnabled } from '$lib/server/auth'; +import { getOidcConfig } from '$lib/server/db'; + +// GET /api/auth/oidc/[id]/initiate - Start OIDC authentication flow +export const GET: RequestHandler = async ({ params, url }) => { + // Check if auth is enabled + if (!isAuthEnabled()) { + return json({ error: 'Authentication is not enabled' }, { status: 400 }); + } + + const id = parseInt(params.id || ''); + if (isNaN(id)) { + return json({ error: 'Invalid configuration ID' }, { status: 400 }); + } + + // Get redirect URL from query params + const redirectUrl = url.searchParams.get('redirect') || '/'; + + try { + const config = await getOidcConfig(id); + if (!config || !config.enabled) { + return json({ error: 'OIDC provider not found or disabled' }, { status: 404 }); + } + + const result = await buildOidcAuthorizationUrl(id, redirectUrl); + + if ('error' in result) { + return json({ error: result.error }, { status: 500 }); + } + + // Redirect to the IdP + throw redirect(302, result.url); + } catch (error: any) { + // Re-throw redirect + if (error.status === 302) { + throw error; + } + console.error('Failed to initiate OIDC:', error); + return json({ error: error.message || 'Failed to initiate SSO' }, { status: 500 }); + } +}; + +// POST /api/auth/oidc/[id]/initiate - Get authorization URL without redirect +export const POST: RequestHandler = async ({ params, request }) => { + // Check if auth is enabled + if (!isAuthEnabled()) { + return json({ error: 'Authentication is not enabled' }, { status: 400 }); + } + + const id = parseInt(params.id || ''); + if (isNaN(id)) { + return json({ error: 'Invalid configuration ID' }, { status: 400 }); + } + + try { + const body = await request.json().catch(() => ({})); + const redirectUrl = body.redirect || '/'; + + const config = await getOidcConfig(id); + if (!config || !config.enabled) { + return json({ error: 'OIDC provider not found or disabled' }, { status: 404 }); + } + + const result = await buildOidcAuthorizationUrl(id, redirectUrl); + + if ('error' in result) { + return json({ error: result.error }, { status: 500 }); + } + + return json({ url: result.url }); + } catch (error: any) { + console.error('Failed to get OIDC authorization URL:', error); + return json({ error: error.message || 'Failed to initiate SSO' }, { status: 500 }); + } +}; diff --git a/routes/api/auth/oidc/[id]/test/+server.ts b/routes/api/auth/oidc/[id]/test/+server.ts new file mode 100644 index 0000000..17df7a5 --- /dev/null +++ b/routes/api/auth/oidc/[id]/test/+server.ts @@ -0,0 +1,28 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { validateSession, testOidcConnection, isAuthEnabled } from '$lib/server/auth'; + +// POST /api/auth/oidc/[id]/test - Test OIDC connection +export const POST: RequestHandler = async ({ params, cookies }) => { + // When auth is disabled, allow access (for initial setup) + // When auth is enabled, require admin + if (isAuthEnabled()) { + const user = await validateSession(cookies); + if (!user || !user.isAdmin) { + return json({ error: 'Admin access required' }, { status: 403 }); + } + } + + const id = parseInt(params.id || ''); + if (isNaN(id)) { + return json({ error: 'Invalid configuration ID' }, { status: 400 }); + } + + try { + const result = await testOidcConnection(id); + return json(result); + } catch (error: any) { + console.error('Failed to test OIDC connection:', error); + return json({ success: false, error: error.message || 'Test failed' }, { status: 500 }); + } +}; diff --git a/routes/api/auth/oidc/callback/+server.ts b/routes/api/auth/oidc/callback/+server.ts new file mode 100644 index 0000000..8010e6e --- /dev/null +++ b/routes/api/auth/oidc/callback/+server.ts @@ -0,0 +1,53 @@ +import { json, redirect } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { handleOidcCallback, createUserSession, isAuthEnabled } from '$lib/server/auth'; + +// GET /api/auth/oidc/callback - Handle OIDC callback from IdP +export const GET: RequestHandler = async ({ url, cookies }) => { + // Check if auth is enabled + if (!isAuthEnabled()) { + throw redirect(302, '/login?error=auth_disabled'); + } + + // Get parameters from URL + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + const errorDescription = url.searchParams.get('error_description'); + + // Handle error from IdP + if (error) { + console.error('OIDC error from IdP:', error, errorDescription); + const errorMsg = encodeURIComponent(errorDescription || error); + throw redirect(302, `/login?error=${errorMsg}`); + } + + // Validate required parameters + if (!code || !state) { + throw redirect(302, '/login?error=invalid_callback'); + } + + try { + const result = await handleOidcCallback(code, state); + + if (!result.success || !result.user) { + const errorMsg = encodeURIComponent(result.error || 'Authentication failed'); + throw redirect(302, `/login?error=${errorMsg}`); + } + + // Create session + await createUserSession(result.user.id, 'oidc', cookies); + + // Redirect to the original destination or home + const redirectUrl = result.redirectUrl || '/'; + throw redirect(302, redirectUrl); + } catch (error: any) { + // Re-throw redirect + if (error.status === 302) { + throw error; + } + console.error('OIDC callback error:', error); + const errorMsg = encodeURIComponent(error.message || 'Authentication failed'); + throw redirect(302, `/login?error=${errorMsg}`); + } +}; diff --git a/routes/api/auth/providers/+server.ts b/routes/api/auth/providers/+server.ts new file mode 100644 index 0000000..966b1cf --- /dev/null +++ b/routes/api/auth/providers/+server.ts @@ -0,0 +1,54 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { isAuthEnabled, getEnabledLdapConfigs, getEnabledOidcConfigs } from '$lib/server/auth'; +import { getAuthSettings } from '$lib/server/db'; +import { isEnterprise } from '$lib/server/license'; + +// GET /api/auth/providers - Get available authentication providers +export const GET: RequestHandler = async () => { + if (!(await isAuthEnabled())) { + return json({ providers: [] }); + } + + try { + // Fetch all provider configs in parallel + const [settings, enterpriseEnabled, oidcConfigs] = await Promise.all([ + getAuthSettings(), + isEnterprise(), + getEnabledOidcConfigs() + ]); + const ldapConfigs = enterpriseEnabled ? await getEnabledLdapConfigs() : []; + + const providers: { id: string; name: string; type: 'local' | 'ldap' | 'oidc'; initiateUrl?: string }[] = []; + + // Local auth is always available when auth is enabled + providers.push({ id: 'local', name: 'Local', type: 'local' }); + + // Add enabled LDAP providers (enterprise only) + for (const config of ldapConfigs) { + providers.push({ + id: `ldap:${config.id}`, + name: config.name, + type: 'ldap' + }); + } + + // Add enabled OIDC providers (free for all) + for (const config of oidcConfigs) { + providers.push({ + id: `oidc:${config.id}`, + name: config.name, + type: 'oidc', + initiateUrl: `/api/auth/oidc/${config.id}/initiate` + }); + } + + return json({ + providers, + defaultProvider: settings.defaultProvider || 'local' + }); + } catch (error) { + console.error('Failed to get auth providers:', error); + return json({ providers: [{ id: 'local', name: 'Local', type: 'local' }] }); + } +}; diff --git a/routes/api/auth/session/+server.ts b/routes/api/auth/session/+server.ts new file mode 100644 index 0000000..48ad444 --- /dev/null +++ b/routes/api/auth/session/+server.ts @@ -0,0 +1,46 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { validateSession, isAuthEnabled } from '$lib/server/auth'; +import { getAuthSettings } from '$lib/server/db'; + +// GET /api/auth/session - Get current session/user +export const GET: RequestHandler = async ({ cookies }) => { + try { + const authEnabled = await isAuthEnabled(); + + if (!authEnabled) { + // Auth is disabled, return anonymous session + return json({ + authenticated: false, + authEnabled: false + }); + } + + const user = await validateSession(cookies); + + if (!user) { + return json({ + authenticated: false, + authEnabled: true + }); + } + + return json({ + authenticated: true, + authEnabled: true, + user: { + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + avatar: user.avatar, + isAdmin: user.isAdmin, + provider: user.provider, + permissions: user.permissions + } + }); + } catch (error) { + console.error('Session check error:', error); + return json({ error: 'Failed to check session' }, { status: 500 }); + } +}; diff --git a/routes/api/auth/settings/+server.ts b/routes/api/auth/settings/+server.ts new file mode 100644 index 0000000..8e4ecc9 --- /dev/null +++ b/routes/api/auth/settings/+server.ts @@ -0,0 +1,71 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { getAuthSettings, updateAuthSettings, countAdminUsers } from '$lib/server/db'; +import { isEnterprise } from '$lib/server/license'; +import { authorize } from '$lib/server/authorize'; + +// GET /api/auth/settings - Get auth settings +// Public when auth is disabled, requires authentication when enabled +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + + // When auth is enabled, require authentication first, then settings:view permission + if (auth.authEnabled) { + if (!auth.isAuthenticated) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + if (!await auth.can('settings', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + } + + try { + const settings = await getAuthSettings(); + return json(settings); + } catch (error) { + console.error('Failed to get auth settings:', error); + return json({ error: 'Failed to get auth settings' }, { status: 500 }); + } +}; + +// PUT /api/auth/settings - Update auth settings +// Requires authentication and settings:edit permission +export const PUT: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + + // When auth is enabled, require authentication first, then settings:edit permission + if (auth.authEnabled) { + if (!auth.isAuthenticated) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + if (!await auth.can('settings', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + } + + try { + const data = await request.json(); + + // Check if trying to enable auth without required users + if (data.authEnabled === true) { + const userCount = await countAdminUsers(); + // PostgreSQL returns bigint for count(*), convert to number for comparison + if (Number(userCount) === 0) { + const enterprise = await isEnterprise(); + const errorMessage = enterprise + ? 'Cannot enable authentication without an admin user. Create a user and assign them the Admin role first.' + : 'Cannot enable authentication without any users. Create a user first.'; + return json({ + error: errorMessage, + requiresUser: true + }, { status: 400 }); + } + } + + const settings = await updateAuthSettings(data); + return json(settings); + } catch (error) { + console.error('Failed to update auth settings:', error); + return json({ error: 'Failed to update auth settings' }, { status: 500 }); + } +}; diff --git a/routes/api/auto-update/+server.ts b/routes/api/auto-update/+server.ts new file mode 100644 index 0000000..02f4554 --- /dev/null +++ b/routes/api/auto-update/+server.ts @@ -0,0 +1,40 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getAutoUpdateSettings } from '$lib/server/db'; + +/** + * Batch endpoint to get all auto-update settings for an environment. + * Returns a map of containerName -> settings for efficient lookup. + */ +export const GET: RequestHandler = async ({ url }) => { + try { + const envIdParam = url.searchParams.get('env'); + const envId = envIdParam ? parseInt(envIdParam) : undefined; + + const settings = await getAutoUpdateSettings(envId); + + // Convert to a map keyed by container name for efficient frontend lookup + const settingsMap: Record = {}; + + for (const setting of settings) { + if (setting.enabled) { + settingsMap[setting.containerName] = { + enabled: setting.enabled, + scheduleType: setting.scheduleType, + cronExpression: setting.cronExpression, + vulnerabilityCriteria: setting.vulnerabilityCriteria || 'never' + }; + } + } + + return json(settingsMap); + } catch (error) { + console.error('Failed to get auto-update settings:', error); + return json({ error: 'Failed to get auto-update settings' }, { status: 500 }); + } +}; diff --git a/routes/api/auto-update/[containerName]/+server.ts b/routes/api/auto-update/[containerName]/+server.ts new file mode 100644 index 0000000..df3c142 --- /dev/null +++ b/routes/api/auto-update/[containerName]/+server.ts @@ -0,0 +1,126 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { + getAutoUpdateSetting, + upsertAutoUpdateSetting, + deleteAutoUpdateSetting, + deleteAutoUpdateSchedule +} from '$lib/server/db'; +import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler'; + +export const GET: RequestHandler = async ({ params, url }) => { + try { + const containerName = decodeURIComponent(params.containerName); + const envIdParam = url.searchParams.get('env'); + const envId = envIdParam ? parseInt(envIdParam) : undefined; + + const setting = await getAutoUpdateSetting(containerName, envId); + + if (!setting) { + return json({ + enabled: false, + scheduleType: 'daily', + cronExpression: '0 3 * * *', + vulnerabilityCriteria: 'never' + }); + } + + // Return with camelCase keys + return json({ + ...setting, + scheduleType: setting.scheduleType, + cronExpression: setting.cronExpression, + vulnerabilityCriteria: setting.vulnerabilityCriteria || 'never' + }); + } catch (error) { + console.error('Failed to get auto-update setting:', error); + return json({ error: 'Failed to get auto-update setting' }, { status: 500 }); + } +}; + +export const POST: RequestHandler = async ({ params, url, request }) => { + try { + const containerName = decodeURIComponent(params.containerName); + const envIdParam = url.searchParams.get('env'); + const envId = envIdParam ? parseInt(envIdParam) : undefined; + + const body = await request.json(); + // Accept both camelCase and snake_case for backward compatibility + const enabled = body.enabled; + const cronExpression = body.cronExpression ?? body.cron_expression; + const vulnerabilityCriteria = body.vulnerabilityCriteria ?? body.vulnerability_criteria; + + // Hard delete when disabled + if (enabled === false) { + await deleteAutoUpdateSchedule(containerName, envId); + return json({ success: true, deleted: true }); + } + + // Auto-detect schedule type from cron expression for backward compatibility + let scheduleType: 'daily' | 'weekly' | 'custom' = 'custom'; + if (cronExpression) { + const parts = cronExpression.split(' '); + if (parts.length >= 5) { + const [, , day, month, dow] = parts; + if (dow !== '*' && day === '*' && month === '*') { + scheduleType = 'weekly'; + } else if (day === '*' && month === '*' && dow === '*') { + scheduleType = 'daily'; + } + } + } + + const setting = await upsertAutoUpdateSetting( + containerName, + { + enabled: Boolean(enabled), + scheduleType: scheduleType, + cronExpression: cronExpression || null, + vulnerabilityCriteria: vulnerabilityCriteria || 'never' + }, + envId + ); + + // Register or unregister schedule with croner + if (setting.enabled && setting.cronExpression) { + await registerSchedule(setting.id, 'container_update', setting.environmentId); + } else { + unregisterSchedule(setting.id, 'container_update'); + } + + // Return with camelCase keys + return json({ + ...setting, + scheduleType: setting.scheduleType, + cronExpression: setting.cronExpression, + vulnerabilityCriteria: setting.vulnerabilityCriteria || 'never' + }); + } catch (error) { + console.error('Failed to save auto-update setting:', error); + return json({ error: 'Failed to save auto-update setting' }, { status: 500 }); + } +}; + +export const DELETE: RequestHandler = async ({ params, url }) => { + try { + const containerName = decodeURIComponent(params.containerName); + const envIdParam = url.searchParams.get('env'); + const envId = envIdParam ? parseInt(envIdParam) : undefined; + + // Get the setting ID before deleting + const setting = await getAutoUpdateSetting(containerName, envId); + const settingId = setting?.id; + + const deleted = await deleteAutoUpdateSetting(containerName, envId); + + // Unregister schedule from croner + if (deleted && settingId) { + unregisterSchedule(settingId, 'container_update'); + } + + return json({ success: deleted }); + } catch (error) { + console.error('Failed to delete auto-update setting:', error); + return json({ error: 'Failed to delete auto-update setting' }, { status: 500 }); + } +}; diff --git a/routes/api/batch/+server.ts b/routes/api/batch/+server.ts new file mode 100644 index 0000000..940b2c3 --- /dev/null +++ b/routes/api/batch/+server.ts @@ -0,0 +1,463 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { authorize } from '$lib/server/authorize'; +import { + startContainer, + stopContainer, + restartContainer, + pauseContainer, + unpauseContainer, + removeContainer, + inspectContainer, + listContainers, + removeImage, + removeVolume, + removeNetwork +} from '$lib/server/docker'; +import { + startStack, + stopStack, + restartStack, + downStack, + removeStack +} from '$lib/server/stacks'; +import { deleteAutoUpdateSchedule, getAutoUpdateSetting } from '$lib/server/db'; +import { unregisterSchedule } from '$lib/server/scheduler'; + +// SSE Event types +export type BatchEventType = 'start' | 'progress' | 'complete' | 'error'; +export type ItemStatus = 'pending' | 'processing' | 'success' | 'error'; + +export interface BatchStartEvent { + type: 'start'; + total: number; +} + +export interface BatchProgressEvent { + type: 'progress'; + id: string; + name: string; + status: ItemStatus; + message?: string; + error?: string; + current: number; + total: number; +} + +export interface BatchCompleteEvent { + type: 'complete'; + summary: { + total: number; + success: number; + failed: number; + }; +} + +export interface BatchErrorEvent { + type: 'error'; + error: string; +} + +export type BatchEvent = BatchStartEvent | BatchProgressEvent | BatchCompleteEvent | BatchErrorEvent; + +// Supported operations per entity type +const ENTITY_OPERATIONS: Record = { + containers: ['start', 'stop', 'restart', 'pause', 'unpause', 'remove'], + images: ['remove'], + volumes: ['remove'], + networks: ['remove'], + stacks: ['start', 'stop', 'restart', 'down', 'remove'] +}; + +// Permission mapping for entity operations +const PERMISSION_MAP: Record> = { + containers: { + start: 'start', + stop: 'stop', + restart: 'restart', + pause: 'stop', + unpause: 'start', + remove: 'remove' + }, + images: { remove: 'remove' }, + volumes: { remove: 'remove' }, + networks: { remove: 'remove' }, + stacks: { + start: 'start', + stop: 'stop', + restart: 'restart', + down: 'stop', + remove: 'remove' + } +}; + +interface BatchRequest { + operation: string; + entityType: string; + items: Array<{ id: string; name: string }>; + options?: { + force?: boolean; + removeVolumes?: boolean; + }; +} + +// Concurrent execution helper with controlled parallelism +async function processWithConcurrency( + items: T[], + concurrency: number, + processor: (item: T, index: number) => Promise, + signal: AbortSignal +): Promise { + let currentIndex = 0; + const total = items.length; + + async function processNext(): Promise { + while (currentIndex < total) { + if (signal.aborted) return; + const index = currentIndex++; + await processor(items[index], index); + } + } + + // Start 'concurrency' number of workers + const workers = Array(Math.min(concurrency, total)) + .fill(null) + .map(() => processNext()); + + await Promise.all(workers); +} + +/** + * Unified batch operations endpoint with SSE streaming. + * Handles bulk operations for containers, images, volumes, networks, and stacks. + */ +export const POST: RequestHandler = async ({ url, cookies, request }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Parse request body + let body: BatchRequest; + try { + body = await request.json(); + } catch { + return json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const { operation, entityType, items, options = {} } = body; + + // Validate entity type + if (!ENTITY_OPERATIONS[entityType]) { + return json( + { error: `Invalid entity type: ${entityType}. Supported: ${Object.keys(ENTITY_OPERATIONS).join(', ')}` }, + { status: 400 } + ); + } + + // Validate operation for entity type + if (!ENTITY_OPERATIONS[entityType].includes(operation)) { + return json( + { error: `Invalid operation '${operation}' for ${entityType}. Supported: ${ENTITY_OPERATIONS[entityType].join(', ')}` }, + { status: 400 } + ); + } + + // Validate items + if (!items || !Array.isArray(items) || items.length === 0) { + return json({ error: 'items array is required and must not be empty' }, { status: 400 }); + } + + // Permission check + const permissionAction = PERMISSION_MAP[entityType][operation]; + if (auth.authEnabled && !(await auth.can(entityType as any, permissionAction, envIdNum))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !(await auth.canAccessEnvironment(envIdNum))) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + // Check if audit is needed (enterprise only) + const needsAudit = auth.isEnterprise; + + // Create abort controller for cancellation + const abortController = new AbortController(); + + const encoder = new TextEncoder(); + let controllerClosed = false; + let keepaliveInterval: ReturnType | null = null; + + const stream = new ReadableStream({ + async start(controller) { + const safeEnqueue = (data: BatchEvent) => { + if (!controllerClosed) { + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); + } catch { + controllerClosed = true; + abortController.abort(); + } + } + }; + + // Send SSE keepalive comments every 5s + keepaliveInterval = setInterval(() => { + if (controllerClosed) return; + try { + controller.enqueue(encoder.encode(`: keepalive\n\n`)); + } catch { + controllerClosed = true; + abortController.abort(); + } + }, 5000); + + let successCount = 0; + let failCount = 0; + + // Send start event + safeEnqueue({ + type: 'start', + total: items.length + }); + + // Process items with concurrency of 3 + await processWithConcurrency( + items, + 3, + async (item, index) => { + if (abortController.signal.aborted) return; + + const { id, name } = item; + + // Send processing status + safeEnqueue({ + type: 'progress', + id, + name, + status: 'processing', + current: index + 1, + total: items.length + }); + + try { + await executeOperation(entityType, operation, id, name, envIdNum, options, needsAudit); + + safeEnqueue({ + type: 'progress', + id, + name, + status: 'success', + current: index + 1, + total: items.length + }); + successCount++; + } catch (error: any) { + safeEnqueue({ + type: 'progress', + id, + name, + status: 'error', + error: error.message || 'Unknown error', + current: index + 1, + total: items.length + }); + failCount++; + } + }, + abortController.signal + ); + + // Send complete event + safeEnqueue({ + type: 'complete', + summary: { + total: items.length, + success: successCount, + failed: failCount + } + }); + + if (keepaliveInterval) { + clearInterval(keepaliveInterval); + } + controller.close(); + }, + cancel() { + controllerClosed = true; + abortController.abort(); + if (keepaliveInterval) { + clearInterval(keepaliveInterval); + } + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + } + }); +}; + +/** + * Execute a single operation on an entity. + * Centralized operation execution to keep code DRY. + */ +async function executeOperation( + entityType: string, + operation: string, + id: string, + name: string, + envIdNum: number | undefined, + options: { force?: boolean; removeVolumes?: boolean }, + needsAudit: boolean +): Promise { + switch (entityType) { + case 'containers': + await executeContainerOperation(operation, id, name, envIdNum, options, needsAudit); + break; + case 'images': + await executeImageOperation(operation, id, envIdNum, options); + break; + case 'volumes': + await executeVolumeOperation(operation, id, envIdNum, options); + break; + case 'networks': + await executeNetworkOperation(operation, id, envIdNum); + break; + case 'stacks': + await executeStackOperation(operation, id, envIdNum, options); + break; + default: + throw new Error(`Unsupported entity type: ${entityType}`); + } +} + +async function executeContainerOperation( + operation: string, + id: string, + name: string, + envIdNum: number | undefined, + options: { force?: boolean }, + needsAudit: boolean +): Promise { + switch (operation) { + case 'start': + await startContainer(id, envIdNum); + break; + case 'stop': + await stopContainer(id, envIdNum); + break; + case 'restart': + await restartContainer(id, envIdNum); + break; + case 'pause': + await pauseContainer(id, envIdNum); + break; + case 'unpause': + await unpauseContainer(id, envIdNum); + break; + case 'remove': + // In free edition, skip inspect (no audit needed) + // In enterprise, we might want to audit but for batch ops we skip for performance + await removeContainer(id, options.force ?? true, envIdNum); + + // Clean up auto-update schedule if exists + try { + const setting = await getAutoUpdateSetting(name, envIdNum); + if (setting) { + unregisterSchedule(setting.id, 'container_update'); + await deleteAutoUpdateSchedule(name, envIdNum); + } + } catch { + // Ignore cleanup errors + } + break; + default: + throw new Error(`Unsupported container operation: ${operation}`); + } +} + +async function executeImageOperation( + operation: string, + id: string, + envIdNum: number | undefined, + options: { force?: boolean } +): Promise { + switch (operation) { + case 'remove': + await removeImage(id, options.force ?? false, envIdNum); + break; + default: + throw new Error(`Unsupported image operation: ${operation}`); + } +} + +async function executeVolumeOperation( + operation: string, + name: string, + envIdNum: number | undefined, + options: { force?: boolean } +): Promise { + switch (operation) { + case 'remove': + await removeVolume(name, options.force ?? false, envIdNum); + break; + default: + throw new Error(`Unsupported volume operation: ${operation}`); + } +} + +async function executeNetworkOperation( + operation: string, + id: string, + envIdNum: number | undefined +): Promise { + switch (operation) { + case 'remove': + await removeNetwork(id, envIdNum); + break; + default: + throw new Error(`Unsupported network operation: ${operation}`); + } +} + +async function executeStackOperation( + operation: string, + name: string, + envIdNum: number | undefined, + options: { removeVolumes?: boolean; force?: boolean } +): Promise { + switch (operation) { + case 'start': { + const result = await startStack(name, envIdNum); + if (!result.success) throw new Error(result.error || 'Failed to start stack'); + break; + } + case 'stop': { + const result = await stopStack(name, envIdNum); + if (!result.success) throw new Error(result.error || 'Failed to stop stack'); + break; + } + case 'restart': { + const result = await restartStack(name, envIdNum); + if (!result.success) throw new Error(result.error || 'Failed to restart stack'); + break; + } + case 'down': { + const result = await downStack(name, envIdNum, options.removeVolumes ?? false); + if (!result.success) throw new Error(result.error || 'Failed to down stack'); + break; + } + case 'remove': { + const result = await removeStack(name, envIdNum, options.force ?? false); + if (!result.success) throw new Error(result.error || 'Failed to remove stack'); + break; + } + default: + throw new Error(`Unsupported stack operation: ${operation}`); + } +} diff --git a/routes/api/changelog/+server.ts b/routes/api/changelog/+server.ts new file mode 100644 index 0000000..8c48096 --- /dev/null +++ b/routes/api/changelog/+server.ts @@ -0,0 +1,7 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import changelog from '$lib/data/changelog.json'; + +export const GET: RequestHandler = async () => { + return json(changelog); +}; diff --git a/routes/api/config-sets/+server.ts b/routes/api/config-sets/+server.ts new file mode 100644 index 0000000..0501718 --- /dev/null +++ b/routes/api/config-sets/+server.ts @@ -0,0 +1,53 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getConfigSets, createConfigSet } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; + +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('configsets', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const configSets = await getConfigSets(); + return json(configSets); + } catch (error) { + console.error('Failed to fetch config sets:', error); + return json({ error: 'Failed to fetch config sets' }, { status: 500 }); + } +}; + +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('configsets', 'create')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const body = await request.json(); + + if (!body.name?.trim()) { + return json({ error: 'Name is required' }, { status: 400 }); + } + + const configSet = await createConfigSet({ + name: body.name.trim(), + description: body.description?.trim() || undefined, + envVars: body.envVars || [], + labels: body.labels || [], + ports: body.ports || [], + volumes: body.volumes || [], + networkMode: body.networkMode || 'bridge', + restartPolicy: body.restartPolicy || 'no' + }); + + return json(configSet, { status: 201 }); + } catch (error: any) { + console.error('Failed to create config set:', error); + if (error.message?.includes('UNIQUE constraint')) { + return json({ error: 'A config set with this name already exists' }, { status: 400 }); + } + return json({ error: 'Failed to create config set' }, { status: 500 }); + } +}; diff --git a/routes/api/config-sets/[id]/+server.ts b/routes/api/config-sets/[id]/+server.ts new file mode 100644 index 0000000..8ad0d68 --- /dev/null +++ b/routes/api/config-sets/[id]/+server.ts @@ -0,0 +1,91 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getConfigSet, updateConfigSet, deleteConfigSet } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; + +export const GET: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('configsets', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid ID' }, { status: 400 }); + } + + const configSet = await getConfigSet(id); + if (!configSet) { + return json({ error: 'Config set not found' }, { status: 404 }); + } + + return json(configSet); + } catch (error) { + console.error('Failed to fetch config set:', error); + return json({ error: 'Failed to fetch config set' }, { status: 500 }); + } +}; + +export const PUT: RequestHandler = async ({ params, request, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('configsets', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid ID' }, { status: 400 }); + } + + const body = await request.json(); + + const configSet = await updateConfigSet(id, { + name: body.name?.trim(), + description: body.description?.trim(), + envVars: body.envVars, + labels: body.labels, + ports: body.ports, + volumes: body.volumes, + networkMode: body.networkMode, + restartPolicy: body.restartPolicy + }); + + if (!configSet) { + return json({ error: 'Config set not found' }, { status: 404 }); + } + + return json(configSet); + } catch (error: any) { + console.error('Failed to update config set:', error); + if (error.message?.includes('UNIQUE constraint')) { + return json({ error: 'A config set with this name already exists' }, { status: 400 }); + } + return json({ error: 'Failed to update config set' }, { status: 500 }); + } +}; + +export const DELETE: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('configsets', 'delete')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid ID' }, { status: 400 }); + } + + const deleted = await deleteConfigSet(id); + if (!deleted) { + return json({ error: 'Config set not found' }, { status: 404 }); + } + + return json({ success: true }); + } catch (error) { + console.error('Failed to delete config set:', error); + return json({ error: 'Failed to delete config set' }, { status: 500 }); + } +}; diff --git a/routes/api/containers/+server.ts b/routes/api/containers/+server.ts new file mode 100644 index 0000000..243e0ca --- /dev/null +++ b/routes/api/containers/+server.ts @@ -0,0 +1,123 @@ +import { json } from '@sveltejs/kit'; +import { listContainers, createContainer, pullImage, EnvironmentNotFoundError, type CreateContainerOptions } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import { auditContainer } from '$lib/server/audit'; +import { hasEnvironments } from '$lib/server/db'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + const all = url.searchParams.get('all') !== 'false'; + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + // Early return if no environments configured (fresh install) + if (!await hasEnvironments()) { + return json([]); + } + + // Early return if no environment specified + if (!envIdNum) { + return json([]); + } + + try { + const containers = await listContainers(all, envIdNum); + return json(containers); + } catch (error: any) { + // Return 404 for missing environment so frontend can clear stale localStorage + if (error instanceof EnvironmentNotFoundError) { + return json({ error: 'Environment not found' }, { status: 404 }); + } + console.error('Error listing containers:', error); + // Return empty array instead of error to allow UI to load + return json([]); + } +}; + +export const POST: RequestHandler = async (event) => { + const { request, url, cookies } = event; + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + const body = await request.json(); + const { startAfterCreate, ...options } = body; + + // Check if image needs to be pulled + try { + console.log(`Attempting to create container with image: ${options.image}`); + const container = await createContainer(options, envIdNum); + + // Start the container if requested + if (startAfterCreate) { + await container.start(); + } + + // Audit log + await auditContainer(event, 'create', container.id, options.name, envIdNum, { image: options.image }); + + return json({ success: true, id: container.id }); + } catch (createError: any) { + // If error is due to missing image, try to pull it first + if (createError.statusCode === 404 && createError.json?.message?.includes('No such image')) { + console.log(`Image ${options.image} not found locally. Pulling...`); + + try { + // Pull the image + await pullImage(options.image, undefined, envIdNum); + console.log(`Successfully pulled image: ${options.image}`); + + // Retry creating the container + const container = await createContainer(options, envIdNum); + + // Start the container if requested + if (startAfterCreate) { + await container.start(); + } + + // Audit log + await auditContainer(event, 'create', container.id, options.name, envIdNum, { image: options.image, imagePulled: true }); + + return json({ success: true, id: container.id, imagePulled: true }); + } catch (pullError) { + console.error('Error pulling image:', pullError); + return json({ + error: 'Failed to pull image', + details: `Could not pull image ${options.image}: ${String(pullError)}` + }, { status: 500 }); + } + } + + // If it's a different error, rethrow it + throw createError; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[Container] Create failed: ${message}`); + return json({ error: 'Failed to create container', details: message }, { status: 500 }); + } +}; diff --git a/routes/api/containers/[id]/+server.ts b/routes/api/containers/[id]/+server.ts new file mode 100644 index 0000000..504168e --- /dev/null +++ b/routes/api/containers/[id]/+server.ts @@ -0,0 +1,93 @@ +import { json } from '@sveltejs/kit'; +import { + inspectContainer, + removeContainer, + getContainerLogs +} from '$lib/server/docker'; +import { deleteAutoUpdateSchedule, getAutoUpdateSetting } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; +import { auditContainer } from '$lib/server/audit'; +import { unregisterSchedule } from '$lib/server/scheduler'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + + const details = await inspectContainer(params.id, envIdNum); + return json(details); + } catch (error) { + console.error('Error inspecting container:', error); + return json({ error: 'Failed to inspect container' }, { status: 500 }); + } +}; + +export const DELETE: RequestHandler = async (event) => { + const { params, url, cookies } = event; + const auth = await authorize(cookies); + + const force = url.searchParams.get('force') === 'true'; + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('containers', 'remove', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + + // Get container name before deletion for audit + let containerName = params.id; + try { + const details = await inspectContainer(params.id, envIdNum); + containerName = details.Name?.replace(/^\//, '') || params.id; + } catch { + // Container might not exist or other error, use ID + } + + await removeContainer(params.id, force, envIdNum); + + // Audit log + await auditContainer(event, 'delete', params.id, containerName, envIdNum, { force }); + + // Clean up auto-update schedule if exists + try { + // Get the schedule ID before deleting + const setting = await getAutoUpdateSetting(containerName, envIdNum); + if (setting) { + // Unregister from croner + unregisterSchedule(setting.id, 'container_update'); + // Delete from database + await deleteAutoUpdateSchedule(containerName, envIdNum); + } + } catch (error) { + console.error('Failed to cleanup auto-update schedule:', error); + // Don't fail the deletion if schedule cleanup fails + } + + return json({ success: true }); + } catch (error) { + console.error('Error removing container:', error); + return json({ error: 'Failed to remove container' }, { status: 500 }); + } +}; diff --git a/routes/api/containers/[id]/exec/+server.ts b/routes/api/containers/[id]/exec/+server.ts new file mode 100644 index 0000000..19a118b --- /dev/null +++ b/routes/api/containers/[id]/exec/+server.ts @@ -0,0 +1,59 @@ +/** + * Container Exec API + * + * POST: Creates an exec instance for terminal attachment + * Returns exec ID that can be used for WebSocket connection + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { createExec, getDockerConnectionInfo } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; + +export const POST: RequestHandler = async ({ params, request, cookies, url }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !auth.isAuthenticated) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + const containerId = params.id; + const envIdParam = url.searchParams.get('envId'); + const envId = envIdParam ? parseInt(envIdParam, 10) : undefined; + + // Permission check with environment context + if (!await auth.can('containers', 'exec', envId)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const body = await request.json().catch(() => ({})); + const shell = body.shell || '/bin/sh'; + const user = body.user || 'root'; + + // Create exec instance + const exec = await createExec({ + containerId, + cmd: [shell], + user, + envId + }); + + // Get connection info for the frontend + const connectionInfo = await getDockerConnectionInfo(envId); + + return json({ + execId: exec.Id, + connectionInfo: { + type: connectionInfo.type, + host: connectionInfo.host, + port: connectionInfo.port + } + }); + } catch (error: any) { + console.error('Failed to create exec:', error); + return json( + { error: error.message || 'Failed to create exec instance' }, + { status: 500 } + ); + } +}; diff --git a/routes/api/containers/[id]/files/+server.ts b/routes/api/containers/[id]/files/+server.ts new file mode 100644 index 0000000..cb3e91d --- /dev/null +++ b/routes/api/containers/[id]/files/+server.ts @@ -0,0 +1,32 @@ +import { json } from '@sveltejs/kit'; +import { listContainerDirectory } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + + const path = url.searchParams.get('path') || '/'; + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + const simpleLs = url.searchParams.get('simpleLs') === 'true'; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const result = await listContainerDirectory( + params.id, + path, + envIdNum, + simpleLs + ); + + return json(result); + } catch (error: any) { + console.error('Error listing container directory:', error); + return json({ error: error.message || 'Failed to list directory' }, { status: 500 }); + } +}; diff --git a/routes/api/containers/[id]/files/chmod/+server.ts b/routes/api/containers/[id]/files/chmod/+server.ts new file mode 100644 index 0000000..59d2941 --- /dev/null +++ b/routes/api/containers/[id]/files/chmod/+server.ts @@ -0,0 +1,57 @@ +import { json } from '@sveltejs/kit'; +import { chmodContainerPath } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ params, url, cookies, request }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const body = await request.json(); + const { path, mode, recursive } = body; + + if (!path || typeof path !== 'string') { + return json({ error: 'Path is required' }, { status: 400 }); + } + + if (!mode || typeof mode !== 'string') { + return json({ error: 'Mode is required (e.g., "755" or "u+x")' }, { status: 400 }); + } + + await chmodContainerPath(params.id, path, mode, recursive === true, envIdNum); + + return json({ success: true, path, mode, recursive: recursive === true }); + } catch (error: any) { + console.error('Error changing permissions:', error); + const msg = error.message || String(error); + + if (msg.includes('Permission denied')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + if (msg.includes('No such file or directory')) { + return json({ error: 'Path not found' }, { status: 404 }); + } + if (msg.includes('Invalid chmod mode')) { + return json({ error: msg }, { status: 400 }); + } + if (msg.includes('Read-only file system')) { + return json({ error: 'File system is read-only' }, { status: 403 }); + } + if (msg.includes('Operation not permitted')) { + return json({ error: 'Operation not permitted' }, { status: 403 }); + } + if (msg.includes('container is not running')) { + return json({ error: 'Container is not running' }, { status: 400 }); + } + + return json({ error: `Failed to change permissions: ${msg}` }, { status: 500 }); + } +}; diff --git a/routes/api/containers/[id]/files/content/+server.ts b/routes/api/containers/[id]/files/content/+server.ts new file mode 100644 index 0000000..1904c3e --- /dev/null +++ b/routes/api/containers/[id]/files/content/+server.ts @@ -0,0 +1,116 @@ +import { json } from '@sveltejs/kit'; +import { readContainerFile, writeContainerFile } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +// Max file size for reading (1MB) +const MAX_FILE_SIZE = 1024 * 1024; + +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + + const path = url.searchParams.get('path'); + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + if (!path) { + return json({ error: 'Path is required' }, { status: 400 }); + } + + const content = await readContainerFile( + params.id, + path, + envIdNum + ); + + // Check if content is too large + if (content.length > MAX_FILE_SIZE) { + return json({ error: 'File is too large to edit (max 1MB)' }, { status: 413 }); + } + + return json({ content, path }); + } catch (error: any) { + console.error('Error reading container file:', error); + const msg = error.message || String(error); + + if (msg.includes('No such file or directory')) { + return json({ error: 'File not found' }, { status: 404 }); + } + if (msg.includes('Permission denied')) { + return json({ error: 'Permission denied to read this file' }, { status: 403 }); + } + if (msg.includes('Is a directory')) { + return json({ error: 'Cannot read a directory' }, { status: 400 }); + } + if (msg.includes('container is not running')) { + return json({ error: 'Container is not running' }, { status: 400 }); + } + + return json({ error: `Failed to read file: ${msg}` }, { status: 500 }); + } +}; + +export const PUT: RequestHandler = async ({ params, url, cookies, request }) => { + const auth = await authorize(cookies); + + const path = url.searchParams.get('path'); + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + if (!path) { + return json({ error: 'Path is required' }, { status: 400 }); + } + + const body = await request.json(); + if (typeof body.content !== 'string') { + return json({ error: 'Content is required' }, { status: 400 }); + } + + // Check content size + if (body.content.length > MAX_FILE_SIZE) { + return json({ error: 'Content is too large (max 1MB)' }, { status: 413 }); + } + + await writeContainerFile( + params.id, + path, + body.content, + envIdNum + ); + + return json({ success: true, path }); + } catch (error: any) { + console.error('Error writing container file:', error); + const msg = error.message || String(error); + + if (msg.includes('Permission denied')) { + return json({ error: 'Permission denied to write this file' }, { status: 403 }); + } + if (msg.includes('No such file or directory')) { + return json({ error: 'Directory not found' }, { status: 404 }); + } + if (msg.includes('Read-only file system')) { + return json({ error: 'File system is read-only' }, { status: 403 }); + } + if (msg.includes('No space left on device')) { + return json({ error: 'No space left on device' }, { status: 507 }); + } + if (msg.includes('container is not running')) { + return json({ error: 'Container is not running' }, { status: 400 }); + } + + return json({ error: `Failed to write file: ${msg}` }, { status: 500 }); + } +}; diff --git a/routes/api/containers/[id]/files/create/+server.ts b/routes/api/containers/[id]/files/create/+server.ts new file mode 100644 index 0000000..2d0e399 --- /dev/null +++ b/routes/api/containers/[id]/files/create/+server.ts @@ -0,0 +1,55 @@ +import { json } from '@sveltejs/kit'; +import { createContainerFile, createContainerDirectory } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ params, url, cookies, request }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const body = await request.json(); + const { path, type } = body; + + if (!path || typeof path !== 'string') { + return json({ error: 'Path is required' }, { status: 400 }); + } + + if (type !== 'file' && type !== 'directory') { + return json({ error: 'Type must be "file" or "directory"' }, { status: 400 }); + } + + if (type === 'file') { + await createContainerFile(params.id, path, envIdNum); + } else { + await createContainerDirectory(params.id, path, envIdNum); + } + + return json({ success: true, path, type }); + } catch (error: any) { + console.error('Error creating path:', error); + const msg = error.message || String(error); + + if (msg.includes('Permission denied')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + if (msg.includes('File exists')) { + return json({ error: 'Path already exists' }, { status: 409 }); + } + if (msg.includes('No such file or directory')) { + return json({ error: 'Parent directory not found' }, { status: 404 }); + } + if (msg.includes('container is not running')) { + return json({ error: 'Container is not running' }, { status: 400 }); + } + + return json({ error: `Failed to create: ${msg}` }, { status: 500 }); + } +}; diff --git a/routes/api/containers/[id]/files/delete/+server.ts b/routes/api/containers/[id]/files/delete/+server.ts new file mode 100644 index 0000000..c401264 --- /dev/null +++ b/routes/api/containers/[id]/files/delete/+server.ts @@ -0,0 +1,51 @@ +import { json } from '@sveltejs/kit'; +import { deleteContainerPath } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +export const DELETE: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + + const path = url.searchParams.get('path'); + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + if (!path) { + return json({ error: 'Path is required' }, { status: 400 }); + } + + await deleteContainerPath(params.id, path, envIdNum); + + return json({ success: true, path }); + } catch (error: any) { + console.error('Error deleting path:', error); + const msg = error.message || String(error); + + if (msg.includes('Permission denied')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + if (msg.includes('No such file or directory')) { + return json({ error: 'Path not found' }, { status: 404 }); + } + if (msg.includes('Cannot delete critical')) { + return json({ error: msg }, { status: 400 }); + } + if (msg.includes('Read-only file system')) { + return json({ error: 'File system is read-only' }, { status: 403 }); + } + if (msg.includes('Directory not empty')) { + return json({ error: 'Directory is not empty' }, { status: 400 }); + } + if (msg.includes('container is not running')) { + return json({ error: 'Container is not running' }, { status: 400 }); + } + + return json({ error: `Failed to delete: ${msg}` }, { status: 500 }); + } +}; diff --git a/routes/api/containers/[id]/files/download/+server.ts b/routes/api/containers/[id]/files/download/+server.ts new file mode 100644 index 0000000..b271f75 --- /dev/null +++ b/routes/api/containers/[id]/files/download/+server.ts @@ -0,0 +1,98 @@ +import { getContainerArchive, statContainerPath } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + + const path = url.searchParams.get('path'); + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) { + return new Response(JSON.stringify({ error: 'Permission denied' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } + + if (!path) { + return new Response(JSON.stringify({ error: 'Path is required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + try { + // Get format from query parameter (defaults to tar) + const format = url.searchParams.get('format') || 'tar'; + + // Get stat info to determine filename + let filename: string; + try { + const stat = await statContainerPath(params.id, path, envIdNum); + filename = stat.name || path.split('/').pop() || 'download'; + } catch { + filename = path.split('/').pop() || 'download'; + } + + // Get the archive from Docker + const response = await getContainerArchive( + params.id, + path, + envIdNum + ); + + // Prepare response based on format + let body: ReadableStream | Uint8Array = response.body!; + let contentType = 'application/x-tar'; + let extension = '.tar'; + + if (format === 'tar.gz') { + // Compress with gzip using Bun's native implementation + const tarData = new Uint8Array(await response.arrayBuffer()); + body = Bun.gzipSync(tarData); + contentType = 'application/gzip'; + extension = '.tar.gz'; + } + + const headers: Record = { + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename="${filename}${extension}"` + }; + + // Set content length for compressed data + if (body instanceof Uint8Array) { + headers['Content-Length'] = body.length.toString(); + } else { + // Pass through content length for streaming tar + const contentLength = response.headers.get('Content-Length'); + if (contentLength) { + headers['Content-Length'] = contentLength; + } + } + + return new Response(body, { headers }); + } catch (error: any) { + console.error('Error downloading container file:', error); + + if (error.message?.includes('No such file or directory')) { + return new Response(JSON.stringify({ error: 'File not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' } + }); + } + if (error.message?.includes('Permission denied')) { + return new Response(JSON.stringify({ error: 'Permission denied to access this path' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } + + return new Response(JSON.stringify({ error: 'Failed to download file' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } +}; diff --git a/routes/api/containers/[id]/files/rename/+server.ts b/routes/api/containers/[id]/files/rename/+server.ts new file mode 100644 index 0000000..64d79d2 --- /dev/null +++ b/routes/api/containers/[id]/files/rename/+server.ts @@ -0,0 +1,54 @@ +import { json } from '@sveltejs/kit'; +import { renameContainerPath } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ params, url, cookies, request }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const body = await request.json(); + const { oldPath, newPath } = body; + + if (!oldPath || typeof oldPath !== 'string') { + return json({ error: 'Old path is required' }, { status: 400 }); + } + + if (!newPath || typeof newPath !== 'string') { + return json({ error: 'New path is required' }, { status: 400 }); + } + + await renameContainerPath(params.id, oldPath, newPath, envIdNum); + + return json({ success: true, oldPath, newPath }); + } catch (error: any) { + console.error('Error renaming path:', error); + const msg = error.message || String(error); + + if (msg.includes('Permission denied')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + if (msg.includes('No such file or directory')) { + return json({ error: 'Source path not found' }, { status: 404 }); + } + if (msg.includes('File exists') || msg.includes('Directory not empty')) { + return json({ error: 'Destination already exists' }, { status: 409 }); + } + if (msg.includes('Read-only file system')) { + return json({ error: 'File system is read-only' }, { status: 403 }); + } + if (msg.includes('container is not running')) { + return json({ error: 'Container is not running' }, { status: 400 }); + } + + return json({ error: `Failed to rename: ${msg}` }, { status: 500 }); + } +}; diff --git a/routes/api/containers/[id]/files/upload/+server.ts b/routes/api/containers/[id]/files/upload/+server.ts new file mode 100644 index 0000000..c76b3c4 --- /dev/null +++ b/routes/api/containers/[id]/files/upload/+server.ts @@ -0,0 +1,154 @@ +import { json } from '@sveltejs/kit'; +import { putContainerArchive } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +/** + * Create a simple tar archive from a single file + * TAR format: 512-byte header followed by file content padded to 512 bytes + */ +function createTarArchive(filename: string, content: Uint8Array): Uint8Array { + // TAR header is 512 bytes + const header = new Uint8Array(512); + const encoder = new TextEncoder(); + + // File name (100 bytes) + const nameBytes = encoder.encode(filename.slice(0, 99)); + header.set(nameBytes, 0); + + // File mode (8 bytes) - 0644 + header.set(encoder.encode('0000644\0'), 100); + + // Owner UID (8 bytes) + header.set(encoder.encode('0000000\0'), 108); + + // Owner GID (8 bytes) + header.set(encoder.encode('0000000\0'), 116); + + // File size in octal (12 bytes) + const sizeOctal = content.length.toString(8).padStart(11, '0'); + header.set(encoder.encode(sizeOctal + '\0'), 124); + + // Modification time (12 bytes) - current time in octal + const mtime = Math.floor(Date.now() / 1000).toString(8).padStart(11, '0'); + header.set(encoder.encode(mtime + '\0'), 136); + + // Checksum placeholder (8 spaces initially) + header.set(encoder.encode(' '), 148); + + // Type flag - '0' for regular file + header[156] = 48; // '0' + + // Link name (100 bytes) - empty + // Magic (6 bytes) - 'ustar\0' + header.set(encoder.encode('ustar\0'), 257); + + // Version (2 bytes) - '00' + header.set(encoder.encode('00'), 263); + + // Owner name (32 bytes) + header.set(encoder.encode('root'), 265); + + // Group name (32 bytes) + header.set(encoder.encode('root'), 297); + + // Calculate checksum + let checksum = 0; + for (let i = 0; i < 512; i++) { + checksum += header[i]; + } + const checksumOctal = checksum.toString(8).padStart(6, '0') + '\0 '; + header.set(encoder.encode(checksumOctal), 148); + + // Calculate padding to 512-byte boundary + const paddingSize = (512 - (content.length % 512)) % 512; + const padding = new Uint8Array(paddingSize); + + // End of archive marker (two 512-byte zero blocks) + const endMarker = new Uint8Array(1024); + + // Combine all parts + const totalSize = header.length + content.length + paddingSize + endMarker.length; + const tar = new Uint8Array(totalSize); + + let offset = 0; + tar.set(header, offset); + offset += header.length; + tar.set(content, offset); + offset += content.length; + tar.set(padding, offset); + offset += paddingSize; + tar.set(endMarker, offset); + + return tar; +} + +export const POST: RequestHandler = async ({ params, url, request, cookies }) => { + const auth = await authorize(cookies); + + const path = url.searchParams.get('path'); + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + if (!path) { + return json({ error: 'Target path is required' }, { status: 400 }); + } + + try { + const formData = await request.formData(); + const files = formData.getAll('files') as File[]; + + if (files.length === 0) { + return json({ error: 'No files provided' }, { status: 400 }); + } + + // For simplicity, we'll upload files one at a time + // A more sophisticated implementation could pack multiple files into one tar + const uploaded: string[] = []; + const errors: string[] = []; + + for (const file of files) { + try { + const content = new Uint8Array(await file.arrayBuffer()); + const tar = createTarArchive(file.name, content); + + await putContainerArchive( + params.id, + path, + tar, + envId ? parseInt(envId) : undefined + ); + + uploaded.push(file.name); + } catch (err: any) { + errors.push(`${file.name}: ${err.message}`); + } + } + + if (errors.length > 0 && uploaded.length === 0) { + return json({ error: 'Failed to upload files', details: errors }, { status: 500 }); + } + + return json({ + success: true, + uploaded, + errors: errors.length > 0 ? errors : undefined + }); + } catch (error: any) { + console.error('Error uploading to container:', error); + + if (error.message?.includes('Permission denied')) { + return json({ error: 'Permission denied to write to this path' }, { status: 403 }); + } + if (error.message?.includes('No such file or directory')) { + return json({ error: 'Target directory not found' }, { status: 404 }); + } + + return json({ error: 'Failed to upload files' }, { status: 500 }); + } +}; diff --git a/routes/api/containers/[id]/inspect/+server.ts b/routes/api/containers/[id]/inspect/+server.ts new file mode 100644 index 0000000..330ad35 --- /dev/null +++ b/routes/api/containers/[id]/inspect/+server.ts @@ -0,0 +1,24 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { inspectContainer } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; + +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('containers', 'inspect', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const containerData = await inspectContainer(params.id, envIdNum); + return json(containerData); + } catch (error) { + console.error('Failed to inspect container:', error); + return json({ error: 'Failed to inspect container' }, { status: 500 }); + } +}; diff --git a/routes/api/containers/[id]/logs/+server.ts b/routes/api/containers/[id]/logs/+server.ts new file mode 100644 index 0000000..3a4ff9d --- /dev/null +++ b/routes/api/containers/[id]/logs/+server.ts @@ -0,0 +1,25 @@ +import { json } from '@sveltejs/kit'; +import { getContainerLogs } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + + const tail = parseInt(url.searchParams.get('tail') || '100'); + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('containers', 'logs', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const logs = await getContainerLogs(params.id, tail, envIdNum); + return json({ logs }); + } catch (error: any) { + console.error('Error getting container logs:', error?.message || error, error?.stack); + return json({ error: 'Failed to get container logs', details: error?.message }, { status: 500 }); + } +}; diff --git a/routes/api/containers/[id]/logs/stream/+server.ts b/routes/api/containers/[id]/logs/stream/+server.ts new file mode 100644 index 0000000..9e2b77d --- /dev/null +++ b/routes/api/containers/[id]/logs/stream/+server.ts @@ -0,0 +1,452 @@ +import type { RequestHandler } from './$types'; +import { authorize } from '$lib/server/authorize'; +import { getEnvironment } from '$lib/server/db'; +import { sendEdgeRequest, sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; + +// Detect Docker socket path +function detectDockerSocket(): string { + if (process.env.DOCKER_SOCKET && existsSync(process.env.DOCKER_SOCKET)) { + return process.env.DOCKER_SOCKET; + } + if (process.env.DOCKER_HOST?.startsWith('unix://')) { + const socketPath = process.env.DOCKER_HOST.replace('unix://', ''); + if (existsSync(socketPath)) return socketPath; + } + const possibleSockets = [ + '/var/run/docker.sock', + `${homedir()}/.docker/run/docker.sock`, + `${homedir()}/.orbstack/run/docker.sock`, + '/run/docker.sock' + ]; + for (const socket of possibleSockets) { + if (existsSync(socket)) return socket; + } + return '/var/run/docker.sock'; +} + +const socketPath = detectDockerSocket(); + +interface DockerClientConfig { + type: 'socket' | 'http' | 'https' | 'hawser-edge'; + socketPath?: string; + host?: string; + port?: number; + ca?: string; + cert?: string; + key?: string; + hawserToken?: string; + environmentId?: number; +} + +async function getDockerConfig(envId?: number | null): Promise { + if (!envId) { + return null; + } + const env = await getEnvironment(envId); + if (!env) { + return null; + } + if (env.connectionType === 'socket' || !env.connectionType) { + return { type: 'socket', socketPath: env.socketPath || socketPath }; + } + if (env.connectionType === 'hawser-edge') { + return { type: 'hawser-edge', environmentId: envId }; + } + const protocol = (env.protocol as 'http' | 'https') || 'http'; + return { + type: protocol, + host: env.host || 'localhost', + port: env.port || 2375, + ca: env.tlsCa || undefined, + cert: env.tlsCert || undefined, + key: env.tlsKey || undefined, + hawserToken: env.connectionType === 'hawser-standard' ? env.hawserToken || undefined : undefined + }; +} + +/** + * Demultiplex Docker stream frame - returns payload and stream type + */ +function parseDockerFrame(buffer: Buffer, offset: number): { type: number; size: number; payload: string } | null { + if (buffer.length < offset + 8) return null; + + const streamType = buffer.readUInt8(offset); + const frameSize = buffer.readUInt32BE(offset + 4); + + if (buffer.length < offset + 8 + frameSize) return null; + + const payload = buffer.slice(offset + 8, offset + 8 + frameSize).toString('utf-8'); + return { type: streamType, size: 8 + frameSize, payload }; +} + +/** + * Handle logs streaming for Hawser Edge connections + */ +async function handleEdgeLogsStream(containerId: string, tail: string, environmentId: number): Promise { + // Check if edge agent is connected + if (!isEdgeConnected(environmentId)) { + return new Response(JSON.stringify({ error: 'Edge agent not connected' }), { + status: 503, + headers: { 'Content-Type': 'application/json' } + }); + } + + // First, check if container has TTY enabled and get container name + let hasTty = false; + let containerName = containerId.substring(0, 12); // Default to short ID + try { + const inspectPath = `/containers/${containerId}/json`; + const inspectResponse = await sendEdgeRequest(environmentId, 'GET', inspectPath); + if (inspectResponse.statusCode === 200) { + const info = JSON.parse(inspectResponse.body as string); + hasTty = info.Config?.Tty ?? false; + // Get container name (strip leading /) + containerName = info.Name?.replace(/^\//, '') || containerName; + } + } catch { + // Ignore - default to demux mode + } + + const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&tail=${tail}×tamps=true`; + + let controllerClosed = false; + let cancelStream: (() => void) | null = null; + let heartbeatInterval: ReturnType | null = null; + + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + + const safeEnqueue = (data: string) => { + if (!controllerClosed) { + try { + controller.enqueue(encoder.encode(data)); + } catch { + controllerClosed = true; + } + } + }; + + // Send heartbeat to keep connection alive (every 5s for Traefik) + heartbeatInterval = setInterval(() => { + safeEnqueue(`: keepalive\n\n`); + }, 5000); + + // Buffer for non-TTY stream demuxing + let buffer = Buffer.alloc(0); + + // Send connected event + safeEnqueue(`event: connected\ndata: ${JSON.stringify({ containerId, containerName, hasTty })}\n\n`); + + // Start streaming logs via Edge + const { cancel } = sendEdgeStreamRequest( + environmentId, + 'GET', + logsPath, + { + onData: (data: string, streamType?: 'stdout' | 'stderr') => { + if (controllerClosed) return; + + if (hasTty) { + // TTY mode: data is raw text, may be base64 encoded + let text = data; + try { + // Try to decode as base64 + text = Buffer.from(data, 'base64').toString('utf-8'); + } catch { + // Not base64, use as-is + } + if (text) { + safeEnqueue(`event: log\ndata: ${JSON.stringify({ text, containerName })}\n\n`); + } + } else { + // Non-TTY mode: data might be base64 encoded Docker multiplexed stream + let rawData: Buffer; + try { + rawData = Buffer.from(data, 'base64'); + } catch { + rawData = Buffer.from(data, 'utf-8'); + } + + buffer = Buffer.concat([buffer, rawData]); + + // Process complete frames + let offset = 0; + while (true) { + const frame = parseDockerFrame(buffer, offset); + if (!frame) break; + + if (frame.payload) { + safeEnqueue(`event: log\ndata: ${JSON.stringify({ + text: frame.payload, + containerName, + stream: frame.type === 2 ? 'stderr' : 'stdout' + })}\n\n`); + } + offset += frame.size; + } + + // Keep remaining incomplete frame data + buffer = buffer.slice(offset); + } + }, + onEnd: (reason?: string) => { + if (buffer.length > 0) { + const text = buffer.toString('utf-8'); + if (text.trim()) { + safeEnqueue(`event: log\ndata: ${JSON.stringify({ text, containerName })}\n\n`); + } + } + safeEnqueue(`event: end\ndata: ${JSON.stringify({ reason: reason || 'stream ended' })}\n\n`); + if (!controllerClosed) { + try { + controller.close(); + } catch { + // Already closed + } + } + }, + onError: (error: string) => { + safeEnqueue(`event: error\ndata: ${JSON.stringify({ error })}\n\n`); + if (!controllerClosed) { + try { + controller.close(); + } catch { + // Already closed + } + } + } + } + ); + + cancelStream = cancel; + }, + cancel() { + controllerClosed = true; + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + if (cancelStream) { + cancelStream(); + cancelStream = null; + } + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no' + } + }); +} + +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + + const containerId = params.id; + const tail = url.searchParams.get('tail') || '100'; + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('containers', 'logs', envIdNum)) { + return new Response(JSON.stringify({ error: 'Permission denied' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } + + const config = await getDockerConfig(envIdNum); + + // Handle Hawser Edge mode separately + if (config.type === 'hawser-edge') { + return handleEdgeLogsStream(containerId, tail, config.environmentId!); + } + + // First, check if container has TTY enabled and get container name + let hasTty = false; + let containerName = containerId.substring(0, 12); // Default to short ID + try { + const inspectPath = `/containers/${containerId}/json`; + let inspectResponse: Response; + + if (config.type === 'socket') { + inspectResponse = await fetch(`http://localhost${inspectPath}`, { + // @ts-ignore - Bun supports unix socket + unix: config.socketPath + }); + } else { + const inspectUrl = `${config.type}://${config.host}:${config.port}${inspectPath}`; + const inspectHeaders: Record = {}; + if (config.hawserToken) inspectHeaders['X-Hawser-Token'] = config.hawserToken; + inspectResponse = await fetch(inspectUrl, { + headers: inspectHeaders, + // @ts-ignore + tls: config.type === 'https' ? { ca: config.ca, cert: config.cert, key: config.key } : undefined + }); + } + + if (inspectResponse.ok) { + const info = await inspectResponse.json(); + hasTty = info.Config?.Tty ?? false; + // Get container name (strip leading /) + containerName = info.Name?.replace(/^\//, '') || containerName; + } + } catch { + // Ignore - default to demux mode + } + + // Build the logs URL with follow=true for streaming + const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&tail=${tail}×tamps=true`; + + let controllerClosed = false; + let abortController: AbortController | null = new AbortController(); + let heartbeatInterval: ReturnType | null = null; + + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + + const safeEnqueue = (data: string) => { + if (!controllerClosed) { + try { + controller.enqueue(encoder.encode(data)); + } catch { + controllerClosed = true; + } + } + }; + + // Send heartbeat to keep connection alive (every 5s for Traefik) + heartbeatInterval = setInterval(() => { + safeEnqueue(`: keepalive\n\n`); + }, 5000); + + try { + let response: Response; + + if (config.type === 'socket') { + response = await fetch(`http://localhost${logsPath}`, { + // @ts-ignore - Bun supports unix socket + unix: config.socketPath, + signal: abortController?.signal + }); + } else { + const logsUrl = `${config.type}://${config.host}:${config.port}${logsPath}`; + const logsHeaders: Record = {}; + if (config.hawserToken) logsHeaders['X-Hawser-Token'] = config.hawserToken; + response = await fetch(logsUrl, { + headers: logsHeaders, + signal: abortController?.signal, + // @ts-ignore + tls: config.type === 'https' ? { ca: config.ca, cert: config.cert, key: config.key } : undefined + }); + } + + if (!response.ok) { + safeEnqueue(`event: error\ndata: ${JSON.stringify({ error: `Docker API error: ${response.status}` })}\n\n`); + if (!controllerClosed) controller.close(); + return; + } + + // Send connected event + safeEnqueue(`event: connected\ndata: ${JSON.stringify({ containerId, containerName, hasTty })}\n\n`); + + const reader = response.body?.getReader(); + if (!reader) { + safeEnqueue(`event: error\ndata: ${JSON.stringify({ error: 'No response body' })}\n\n`); + if (!controllerClosed) controller.close(); + return; + } + + let buffer = Buffer.alloc(0); + + while (!controllerClosed) { + const { done, value } = await reader.read(); + + if (done) { + // Send any remaining buffer content + if (buffer.length > 0) { + const text = buffer.toString('utf-8'); + if (text.trim()) { + safeEnqueue(`event: log\ndata: ${JSON.stringify({ text, containerName })}\n\n`); + } + } + safeEnqueue(`event: end\ndata: ${JSON.stringify({ reason: 'stream ended' })}\n\n`); + break; + } + + if (value) { + if (hasTty) { + // TTY mode: raw text, no demux needed + const text = new TextDecoder().decode(value); + if (text) { + safeEnqueue(`event: log\ndata: ${JSON.stringify({ text, containerName })}\n\n`); + } + } else { + // Non-TTY mode: demux Docker stream frames + buffer = Buffer.concat([buffer, Buffer.from(value)]); + + // Process complete frames + let offset = 0; + while (true) { + const frame = parseDockerFrame(buffer, offset); + if (!frame) break; + + // Stream type 1 = stdout, 2 = stderr + if (frame.payload) { + safeEnqueue(`event: log\ndata: ${JSON.stringify({ text: frame.payload, containerName, stream: frame.type === 2 ? 'stderr' : 'stdout' })}\n\n`); + } + offset += frame.size; + } + + // Keep remaining incomplete frame data + buffer = buffer.slice(offset); + } + } + } + + reader.releaseLock(); + } catch (error) { + if (!controllerClosed) { + const errorMsg = error instanceof Error ? error.message : String(error); + if (!errorMsg.includes('abort')) { + safeEnqueue(`event: error\ndata: ${JSON.stringify({ error: errorMsg })}\n\n`); + } + } + } + + if (!controllerClosed) { + try { + controller.close(); + } catch { + // Already closed + } + } + }, + cancel() { + controllerClosed = true; + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + abortController?.abort(); + abortController = null; + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no' + } + }); +}; diff --git a/routes/api/containers/[id]/pause/+server.ts b/routes/api/containers/[id]/pause/+server.ts new file mode 100644 index 0000000..88042de --- /dev/null +++ b/routes/api/containers/[id]/pause/+server.ts @@ -0,0 +1,32 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { pauseContainer, inspectContainer } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import { auditContainer } from '$lib/server/audit'; + +export const POST: RequestHandler = async (event) => { + const { params, url, cookies } = event; + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context (pause/unpause uses 'stop' permission) + if (auth.authEnabled && !await auth.can('containers', 'stop', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const details = await inspectContainer(params.id, envIdNum); + const containerName = details.Name.replace(/^\//, ''); + await pauseContainer(params.id, envIdNum); + + // Audit log + await auditContainer(event, 'pause', params.id, containerName, envIdNum); + + return json({ success: true }); + } catch (error) { + console.error('Failed to pause container:', error); + return json({ error: 'Failed to pause container' }, { status: 500 }); + } +}; diff --git a/routes/api/containers/[id]/rename/+server.ts b/routes/api/containers/[id]/rename/+server.ts new file mode 100644 index 0000000..78860d7 --- /dev/null +++ b/routes/api/containers/[id]/rename/+server.ts @@ -0,0 +1,53 @@ +import { json } from '@sveltejs/kit'; +import { renameContainer, inspectContainer } from '$lib/server/docker'; +import { renameAutoUpdateSchedule } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; +import { auditContainer } from '$lib/server/audit'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async (event) => { + const { params, request, url, cookies } = event; + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context (renaming requires create permission) + if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const { name } = await request.json(); + if (!name || typeof name !== 'string') { + return json({ error: 'New name is required' }, { status: 400 }); + } + + // Get old container name before renaming + let oldName = params.id; + try { + const details = await inspectContainer(params.id, envIdNum); + oldName = details.Name?.replace(/^\//, '') || params.id; + } catch { + // Container might not exist or other error, use ID + } + + await renameContainer(params.id, name, envIdNum); + + // Audit log + await auditContainer(event, 'rename', params.id, name, envIdNum, { previousId: params.id, newName: name }); + + // Update schedule if exists + try { + await renameAutoUpdateSchedule(oldName, name, envIdNum); + } catch (error) { + console.error('Failed to update schedule name:', error); + // Don't fail the rename if schedule update fails + } + + return json({ success: true }); + } catch (error) { + console.error('Error renaming container:', error); + return json({ error: 'Failed to rename container' }, { status: 500 }); + } +}; diff --git a/routes/api/containers/[id]/restart/+server.ts b/routes/api/containers/[id]/restart/+server.ts new file mode 100644 index 0000000..20698ca --- /dev/null +++ b/routes/api/containers/[id]/restart/+server.ts @@ -0,0 +1,45 @@ +import { json } from '@sveltejs/kit'; +import { restartContainer, inspectContainer } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import { auditContainer } from '$lib/server/audit'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async (event) => { + const { params, url, cookies } = event; + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('containers', 'restart', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + + // Get container name for audit + let containerName = params.id; + try { + const details = await inspectContainer(params.id, envIdNum); + containerName = details.Name?.replace(/^\//, '') || params.id; + } catch { + // Use ID if can't get name + } + + await restartContainer(params.id, envIdNum); + + // Audit log + await auditContainer(event, 'restart', params.id, containerName, envIdNum); + + return json({ success: true }); + } catch (error) { + console.error('Error restarting container:', error); + return json({ error: 'Failed to restart container' }, { status: 500 }); + } +}; diff --git a/routes/api/containers/[id]/start/+server.ts b/routes/api/containers/[id]/start/+server.ts new file mode 100644 index 0000000..3bfbd88 --- /dev/null +++ b/routes/api/containers/[id]/start/+server.ts @@ -0,0 +1,38 @@ +import { json } from '@sveltejs/kit'; +import { startContainer, inspectContainer } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import { auditContainer } from '$lib/server/audit'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async (event) => { + const { params, url, cookies } = event; + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('containers', 'start', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + + await startContainer(params.id, envIdNum); + const details = await inspectContainer(params.id, envIdNum); + const containerName = details.Name.replace(/^\//, ''); + + // Audit log + await auditContainer(event, 'start', params.id, containerName, envIdNum); + + return json({ success: true }); + } catch (error) { + console.error('Error starting container:', error); + return json({ error: 'Failed to start container' }, { status: 500 }); + } +}; diff --git a/routes/api/containers/[id]/stats/+server.ts b/routes/api/containers/[id]/stats/+server.ts new file mode 100644 index 0000000..5690d85 --- /dev/null +++ b/routes/api/containers/[id]/stats/+server.ts @@ -0,0 +1,91 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getContainerStats } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import { hasEnvironments } from '$lib/server/db'; + +function calculateCpuPercent(stats: any): number { + const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage; + const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage; + const cpuCount = stats.cpu_stats.online_cpus || stats.cpu_stats.cpu_usage.percpu_usage?.length || 1; + + if (systemDelta > 0 && cpuDelta > 0) { + return (cpuDelta / systemDelta) * cpuCount * 100; + } + return 0; +} + +function calculateNetworkIO(stats: any): { rx: number; tx: number } { + let rx = 0; + let tx = 0; + + if (stats.networks) { + for (const iface of Object.values(stats.networks) as any[]) { + rx += iface.rx_bytes || 0; + tx += iface.tx_bytes || 0; + } + } + + return { rx, tx }; +} + +function calculateBlockIO(stats: any): { read: number; write: number } { + let read = 0; + let write = 0; + + const ioStats = stats.blkio_stats?.io_service_bytes_recursive; + if (Array.isArray(ioStats)) { + for (const entry of ioStats) { + if (entry.op === 'read' || entry.op === 'Read') { + read += entry.value || 0; + } else if (entry.op === 'write' || entry.op === 'Write') { + write += entry.value || 0; + } + } + } + + return { read, write }; +} + +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context (stats uses view permission) + if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Early return if no environments configured (fresh install) + if (!await hasEnvironments()) { + return json({ error: 'No environment configured' }, { status: 404 }); + } + + try { + const stats = await getContainerStats(params.id, envIdNum) as any; + + const cpuPercent = calculateCpuPercent(stats); + const memoryUsage = stats.memory_stats?.usage || 0; + const memoryLimit = stats.memory_stats?.limit || 1; + const memoryPercent = (memoryUsage / memoryLimit) * 100; + const networkIO = calculateNetworkIO(stats); + const blockIO = calculateBlockIO(stats); + + return json({ + cpuPercent: Math.round(cpuPercent * 100) / 100, + memoryUsage, + memoryLimit, + memoryPercent: Math.round(memoryPercent * 100) / 100, + networkRx: networkIO.rx, + networkTx: networkIO.tx, + blockRead: blockIO.read, + blockWrite: blockIO.write, + timestamp: Date.now() + }); + } catch (error: any) { + console.error('Failed to get container stats:', error); + return json({ error: error.message || 'Failed to get stats' }, { status: 500 }); + } +}; diff --git a/routes/api/containers/[id]/stop/+server.ts b/routes/api/containers/[id]/stop/+server.ts new file mode 100644 index 0000000..8befb03 --- /dev/null +++ b/routes/api/containers/[id]/stop/+server.ts @@ -0,0 +1,38 @@ +import { json } from '@sveltejs/kit'; +import { stopContainer, inspectContainer } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import { auditContainer } from '$lib/server/audit'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async (event) => { + const { params, url, cookies } = event; + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('containers', 'stop', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + + const details = await inspectContainer(params.id, envIdNum); + const containerName = details.Name.replace(/^\//, ''); + await stopContainer(params.id, envIdNum); + + // Audit log + await auditContainer(event, 'stop', params.id, containerName, envIdNum); + + return json({ success: true }); + } catch (error) { + console.error('Error stopping container:', error); + return json({ error: 'Failed to stop container' }, { status: 500 }); + } +}; diff --git a/routes/api/containers/[id]/top/+server.ts b/routes/api/containers/[id]/top/+server.ts new file mode 100644 index 0000000..58100e1 --- /dev/null +++ b/routes/api/containers/[id]/top/+server.ts @@ -0,0 +1,73 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { execInContainer, getContainerTop } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; + +function parsePsOutput(output: string): { Titles: string[]; Processes: string[][] } | null { + const lines = output.trim().split('\n').filter(line => line.trim()); + if (lines.length === 0) return null; + + const headerLine = lines[0]; + const headers = headerLine.trim().split(/\s+/); + + // Find the index of COMMAND (last column, can have spaces) + const commandIndex = headers.findIndex(h => h === 'COMMAND' || h === 'CMD'); + + const processes = lines.slice(1).map(line => { + const parts = line.trim().split(/\s+/); + // COMMAND can have spaces, so join everything from commandIndex onwards + if (commandIndex !== -1 && parts.length > commandIndex) { + const beforeCommand = parts.slice(0, commandIndex); + const command = parts.slice(commandIndex).join(' '); + return [...beforeCommand, command]; + } + return parts; + }); + + return { Titles: headers, Processes: processes }; +} + +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context (process list uses inspect permission) + if (auth.authEnabled && !await auth.can('containers', 'inspect', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + + // Try different ps commands in order of preference + const psCommands = [ + ['ps', 'aux', '--sort=-pcpu'], // GNU ps with CPU sort + ['ps', 'aux'], // GNU ps without sort + ['ps', '-ef'], // POSIX ps + ]; + + for (const cmd of psCommands) { + try { + const output = await execInContainer(params.id, cmd, envIdNum); + // Check if output looks like an error message (BusyBox error, etc.) + if (output.includes('unrecognized option') || output.includes('Usage:') || output.includes('BusyBox')) { + continue; + } + const result = parsePsOutput(output); + if (result && result.Processes.length > 0) { + return json({ ...result, source: 'ps' }); + } + } catch { + // Try next command + } + } + + // Fallback to docker top API + const top = await getContainerTop(params.id, envIdNum); + return json({ ...top, source: 'top' }); + } catch (error: any) { + console.error('Failed to get container processes:', error); + return json({ error: error.message || 'Failed to get processes' }, { status: 500 }); + } +}; diff --git a/routes/api/containers/[id]/unpause/+server.ts b/routes/api/containers/[id]/unpause/+server.ts new file mode 100644 index 0000000..3763e05 --- /dev/null +++ b/routes/api/containers/[id]/unpause/+server.ts @@ -0,0 +1,32 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { unpauseContainer, inspectContainer } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import { auditContainer } from '$lib/server/audit'; + +export const POST: RequestHandler = async (event) => { + const { params, url, cookies } = event; + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context (unpause uses 'start' permission) + if (auth.authEnabled && !await auth.can('containers', 'start', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const details = await inspectContainer(params.id, envIdNum); + const containerName = details.Name.replace(/^\//, ''); + await unpauseContainer(params.id, envIdNum); + + // Audit log + await auditContainer(event, 'unpause', params.id, containerName, envIdNum); + + return json({ success: true }); + } catch (error) { + console.error('Failed to unpause container:', error); + return json({ error: 'Failed to unpause container' }, { status: 500 }); + } +}; diff --git a/routes/api/containers/[id]/update/+server.ts b/routes/api/containers/[id]/update/+server.ts new file mode 100644 index 0000000..7bfe4ae --- /dev/null +++ b/routes/api/containers/[id]/update/+server.ts @@ -0,0 +1,43 @@ +import { json } from '@sveltejs/kit'; +import { updateContainer, type CreateContainerOptions } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import { auditContainer } from '$lib/server/audit'; +import { removePendingContainerUpdate } from '$lib/server/db'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async (event) => { + const { params, request, url, cookies } = event; + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context (update requires create permission) + if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const body = await request.json(); + const { startAfterUpdate, ...options } = body; + + console.log(`Updating container ${params.id} with name: ${options.name}`); + + const container = await updateContainer(params.id, options, startAfterUpdate, envIdNum); + + // Clear pending update indicator (if any) since container was just updated + if (envIdNum) { + await removePendingContainerUpdate(envIdNum, params.id).catch(() => { + // Ignore errors - record may not exist + }); + } + + // Audit log - include full options to see what was modified + await auditContainer(event, 'update', container.id, options.name, envIdNum, { ...options, startAfterUpdate }); + + return json({ success: true, id: container.id }); + } catch (error) { + console.error('Error updating container:', error); + return json({ error: 'Failed to update container', details: String(error) }, { status: 500 }); + } +}; diff --git a/routes/api/containers/batch-update-stream/+server.ts b/routes/api/containers/batch-update-stream/+server.ts new file mode 100644 index 0000000..acf9429 --- /dev/null +++ b/routes/api/containers/batch-update-stream/+server.ts @@ -0,0 +1,548 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { authorize } from '$lib/server/authorize'; +import { + listContainers, + inspectContainer, + stopContainer, + removeContainer, + createContainer, + pullImage, + getTempImageTag, + isDigestBasedImage, + getImageIdByTag, + removeTempImage, + tagImage +} from '$lib/server/docker'; +import { auditContainer } from '$lib/server/audit'; +import { getScannerSettings, scanImage } from '$lib/server/scanner'; +import { saveVulnerabilityScan, removePendingContainerUpdate, type VulnerabilityCriteria } from '$lib/server/db'; +import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isDockhandContainer } from '$lib/server/scheduler/tasks/update-utils'; + +export interface ScanResult { + critical: number; + high: number; + medium: number; + low: number; + negligible?: number; + unknown?: number; +} + +export interface ScannerResult extends ScanResult { + scanner: 'grype' | 'trivy'; +} + +export interface UpdateProgress { + type: 'start' | 'progress' | 'pull_log' | 'scan_start' | 'scan_log' | 'scan_complete' | 'blocked' | 'complete' | 'error'; + containerId?: string; + containerName?: string; + step?: 'pulling' | 'scanning' | 'stopping' | 'removing' | 'creating' | 'starting' | 'done' | 'failed' | 'blocked' | 'skipped'; + message?: string; + current?: number; + total?: number; + success?: boolean; + error?: string; + summary?: { + total: number; + success: number; + failed: number; + blocked: number; + skipped: number; + }; + // Pull log specific fields + pullStatus?: string; + pullId?: string; + pullProgress?: string; + // Scan specific fields + scanResult?: ScanResult; + scannerResults?: ScannerResult[]; + blockReason?: string; + scanner?: string; +} + +/** + * Batch update containers with streaming progress. + * Expects JSON body: { containerIds: string[], vulnerabilityCriteria?: VulnerabilityCriteria } + */ +export const POST: RequestHandler = async (event) => { + const { url, cookies, request } = event; + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Need create permission to recreate containers + if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + let body: { containerIds: string[]; vulnerabilityCriteria?: VulnerabilityCriteria }; + try { + body = await request.json(); + } catch { + return json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const { containerIds, vulnerabilityCriteria = 'never' } = body; + + if (!containerIds || !Array.isArray(containerIds) || containerIds.length === 0) { + return json({ error: 'containerIds array is required' }, { status: 400 }); + } + + const encoder = new TextEncoder(); + let controllerClosed = false; + let keepaliveInterval: ReturnType | null = null; + + const stream = new ReadableStream({ + async start(controller) { + const safeEnqueue = (data: UpdateProgress) => { + if (!controllerClosed) { + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); + } catch { + controllerClosed = true; + } + } + }; + + // Send SSE keepalive comments every 5s to prevent Traefik (10s idle timeout) from closing connection + keepaliveInterval = setInterval(() => { + if (controllerClosed) return; + try { + controller.enqueue(encoder.encode(`: keepalive\n\n`)); + } catch { + controllerClosed = true; + } + }, 5000); + + let successCount = 0; + let failCount = 0; + let blockedCount = 0; + let skippedCount = 0; + + // Get scanner settings for this environment + const scannerSettings = await getScannerSettings(envIdNum); + // Scan if scanning is enabled (scanner !== 'none') + // The vulnerabilityCriteria only controls whether to BLOCK updates, not whether to SCAN + const shouldScan = scannerSettings.scanner !== 'none'; + + // Send start event + safeEnqueue({ + type: 'start', + total: containerIds.length, + message: `Starting update of ${containerIds.length} container${containerIds.length > 1 ? 's' : ''}${shouldScan ? ' with vulnerability scanning' : ''}` + }); + + // Process containers sequentially + for (let i = 0; i < containerIds.length; i++) { + const containerId = containerIds[i]; + let containerName = 'unknown'; + + try { + // Find container + const containers = await listContainers(true, envIdNum); + const container = containers.find(c => c.id === containerId); + + if (!container) { + safeEnqueue({ + type: 'progress', + containerId, + containerName: 'unknown', + step: 'failed', + current: i + 1, + total: containerIds.length, + success: false, + error: 'Container not found' + }); + failCount++; + continue; + } + + containerName = container.name; + + // Get full container config + const inspectData = await inspectContainer(containerId, envIdNum) as any; + const wasRunning = inspectData.State.Running; + const config = inspectData.Config; + const hostConfig = inspectData.HostConfig; + const imageName = config.Image; + const currentImageId = inspectData.Image; + + // Skip Dockhand container - cannot update itself + if (isDockhandContainer(imageName)) { + safeEnqueue({ + type: 'progress', + containerId, + containerName, + step: 'skipped', + current: i + 1, + total: containerIds.length, + success: true, + message: `Skipping ${containerName} - cannot update Dockhand itself` + }); + skippedCount++; + continue; + } + + // Step 1: Pull latest image + safeEnqueue({ + type: 'progress', + containerId, + containerName, + step: 'pulling', + current: i + 1, + total: containerIds.length, + message: `Pulling ${imageName}...` + }); + + try { + await pullImage(imageName, (data: any) => { + // Send pull progress as log entries + if (data.status) { + safeEnqueue({ + type: 'pull_log', + containerId, + containerName, + pullStatus: data.status, + pullId: data.id, + pullProgress: data.progress + }); + } + }, envIdNum); + } catch (pullError: any) { + safeEnqueue({ + type: 'progress', + containerId, + containerName, + step: 'failed', + current: i + 1, + total: containerIds.length, + success: false, + error: `Pull failed: ${pullError.message}` + }); + failCount++; + continue; + } + + // SAFE-PULL FLOW with vulnerability scanning + if (shouldScan && !isDigestBasedImage(imageName)) { + const tempTag = getTempImageTag(imageName); + + // Get new image ID + const newImageId = await getImageIdByTag(imageName, envIdNum); + if (!newImageId) { + safeEnqueue({ + type: 'progress', + containerId, + containerName, + step: 'failed', + current: i + 1, + total: containerIds.length, + success: false, + error: 'Failed to get new image ID after pull' + }); + failCount++; + continue; + } + + // Restore original tag to old image (safety) + const [oldRepo, oldTag] = parseImageNameAndTag(imageName); + try { + await tagImage(currentImageId, oldRepo, oldTag, envIdNum); + } catch { + // Ignore - old image might have been removed + } + + // Tag new image with temp suffix + const [tempRepo, tempTagName] = parseImageNameAndTag(tempTag); + await tagImage(newImageId, tempRepo, tempTagName, envIdNum); + + // Step 2: Scan temp image + safeEnqueue({ + type: 'scan_start', + containerId, + containerName, + step: 'scanning', + current: i + 1, + total: containerIds.length, + message: `Scanning ${imageName} for vulnerabilities...` + }); + + let scanBlocked = false; + let blockReason = ''; + let finalScanResult: ScanResult | undefined; + let individualScannerResults: ScannerResult[] = []; + + try { + const scanResults = await scanImage(tempTag, envIdNum, (progress) => { + if (progress.message) { + safeEnqueue({ + type: 'scan_log', + containerId, + containerName, + scanner: progress.scanner, + message: progress.message + }); + } + }); + + if (scanResults.length > 0) { + const scanSummary = combineScanSummaries(scanResults); + finalScanResult = { + critical: scanSummary.critical, + high: scanSummary.high, + medium: scanSummary.medium, + low: scanSummary.low, + negligible: scanSummary.negligible, + unknown: scanSummary.unknown + }; + + // Build individual scanner results + individualScannerResults = scanResults.map(result => ({ + scanner: result.scanner as 'grype' | 'trivy', + critical: result.summary.critical, + high: result.summary.high, + medium: result.summary.medium, + low: result.summary.low, + negligible: result.summary.negligible, + unknown: result.summary.unknown + })); + + // Save scan results + for (const result of scanResults) { + try { + await saveVulnerabilityScan({ + environmentId: envIdNum, + imageId: newImageId, + imageName: result.imageName, + scanner: result.scanner, + scannedAt: result.scannedAt, + scanDuration: result.scanDuration, + criticalCount: result.summary.critical, + highCount: result.summary.high, + mediumCount: result.summary.medium, + lowCount: result.summary.low, + negligibleCount: result.summary.negligible, + unknownCount: result.summary.unknown, + vulnerabilities: result.vulnerabilities, + error: result.error ?? null + }); + } catch { /* ignore save errors */ } + } + + // Check if blocked + const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, undefined); + if (blocked) { + scanBlocked = true; + blockReason = reason; + } + } + + safeEnqueue({ + type: 'scan_complete', + containerId, + containerName, + scanResult: finalScanResult, + scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined, + message: finalScanResult + ? `Scan complete: ${finalScanResult.critical} critical, ${finalScanResult.high} high, ${finalScanResult.medium} medium, ${finalScanResult.low} low` + : 'Scan complete: no vulnerabilities found' + }); + + } catch (scanErr: any) { + safeEnqueue({ + type: 'progress', + containerId, + containerName, + step: 'failed', + current: i + 1, + total: containerIds.length, + success: false, + error: `Scan failed: ${scanErr.message}` + }); + + // Clean up temp image on scan failure + try { + await removeTempImage(newImageId, envIdNum); + } catch { /* ignore cleanup errors */ } + + failCount++; + continue; + } + + if (scanBlocked) { + // BLOCKED - Remove temp image and skip this container + safeEnqueue({ + type: 'blocked', + containerId, + containerName, + step: 'blocked', + current: i + 1, + total: containerIds.length, + success: false, + scanResult: finalScanResult, + scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined, + blockReason, + message: `Update blocked: ${blockReason}` + }); + + try { + await removeTempImage(newImageId, envIdNum); + } catch { /* ignore cleanup errors */ } + + blockedCount++; + continue; + } + + // APPROVED - Re-tag to original + await tagImage(newImageId, oldRepo, oldTag, envIdNum); + try { + await removeTempImage(tempTag, envIdNum); + } catch { /* ignore cleanup errors */ } + } + + // Step 3: Stop container if running + if (wasRunning) { + safeEnqueue({ + type: 'progress', + containerId, + containerName, + step: 'stopping', + current: i + 1, + total: containerIds.length, + message: `Stopping ${containerName}...` + }); + await stopContainer(containerId, envIdNum); + } + + // Step 4: Remove old container + safeEnqueue({ + type: 'progress', + containerId, + containerName, + step: 'removing', + current: i + 1, + total: containerIds.length, + message: `Removing old container ${containerName}...` + }); + await removeContainer(containerId, true, envIdNum); + + // Prepare port bindings + const ports: { [key: string]: { HostPort: string } } = {}; + if (hostConfig.PortBindings) { + for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) { + if (bindings && (bindings as any[]).length > 0) { + ports[containerPort] = { HostPort: (bindings as any[])[0].HostPort || '' }; + } + } + } + + // Step 5: Create new container + safeEnqueue({ + type: 'progress', + containerId, + containerName, + step: 'creating', + current: i + 1, + total: containerIds.length, + message: `Creating new container ${containerName}...` + }); + + const newContainer = await createContainer({ + name: containerName, + image: imageName, + ports, + volumeBinds: hostConfig.Binds || [], + env: config.Env || [], + labels: config.Labels || {}, + cmd: config.Cmd || undefined, + restartPolicy: hostConfig.RestartPolicy?.Name || 'no', + networkMode: hostConfig.NetworkMode || undefined + }, envIdNum); + + // Step 6: Start if was running + if (wasRunning) { + safeEnqueue({ + type: 'progress', + containerId, + containerName, + step: 'starting', + current: i + 1, + total: containerIds.length, + message: `Starting ${containerName}...` + }); + await newContainer.start(); + } + + // Audit log + await auditContainer(event, 'update', newContainer.id, containerName, envIdNum, { batchUpdate: true }); + + // Done with this container - use original containerId for UI consistency + safeEnqueue({ + type: 'progress', + containerId, + containerName, + step: 'done', + current: i + 1, + total: containerIds.length, + success: true, + message: `${containerName} updated successfully` + }); + successCount++; + + // Clear pending update indicator from database + if (envIdNum) { + await removePendingContainerUpdate(envIdNum, containerId).catch(() => { + // Ignore errors - record may not exist + }); + } + + } catch (error: any) { + safeEnqueue({ + type: 'progress', + containerId, + containerName, + step: 'failed', + current: i + 1, + total: containerIds.length, + success: false, + error: error.message + }); + failCount++; + } + } + + // Send complete event + safeEnqueue({ + type: 'complete', + summary: { + total: containerIds.length, + success: successCount, + failed: failCount, + blocked: blockedCount, + skipped: skippedCount + }, + message: skippedCount > 0 || blockedCount > 0 + ? `Updated ${successCount} of ${containerIds.length} containers${blockedCount > 0 ? ` (${blockedCount} blocked)` : ''}${skippedCount > 0 ? ` (${skippedCount} skipped)` : ''}` + : `Updated ${successCount} of ${containerIds.length} containers` + }); + + clearInterval(keepaliveInterval); + controller.close(); + }, + cancel() { + controllerClosed = true; + if (keepaliveInterval) { + clearInterval(keepaliveInterval); + } + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + } + }); +}; diff --git a/routes/api/containers/batch-update/+server.ts b/routes/api/containers/batch-update/+server.ts new file mode 100644 index 0000000..90a8df8 --- /dev/null +++ b/routes/api/containers/batch-update/+server.ts @@ -0,0 +1,154 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { authorize } from '$lib/server/authorize'; +import { + listContainers, + inspectContainer, + stopContainer, + removeContainer, + createContainer, + pullImage +} from '$lib/server/docker'; +import { auditContainer } from '$lib/server/audit'; + +export interface BatchUpdateResult { + containerId: string; + containerName: string; + success: boolean; + error?: string; +} + +/** + * Batch update containers by recreating them with latest images. + * Expects JSON body: { containerIds: string[] } + */ +export const POST: RequestHandler = async (event) => { + const { url, cookies, request } = event; + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Need create permission to recreate containers + if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const body = await request.json(); + const { containerIds } = body as { containerIds: string[] }; + + if (!containerIds || !Array.isArray(containerIds) || containerIds.length === 0) { + return json({ error: 'containerIds array is required' }, { status: 400 }); + } + + const results: BatchUpdateResult[] = []; + + // Process containers sequentially to avoid resource conflicts + for (const containerId of containerIds) { + try { + const containers = await listContainers(true, envIdNum); + const container = containers.find(c => c.id === containerId); + + if (!container) { + results.push({ + containerId, + containerName: 'unknown', + success: false, + error: 'Container not found' + }); + continue; + } + + // Get full container config + const inspectData = await inspectContainer(containerId, envIdNum) as any; + const wasRunning = inspectData.State.Running; + const config = inspectData.Config; + const hostConfig = inspectData.HostConfig; + const imageName = config.Image; + const containerName = container.name; + + // Pull latest image first + try { + await pullImage(imageName, undefined, envIdNum); + } catch (pullError: any) { + results.push({ + containerId, + containerName, + success: false, + error: `Pull failed: ${pullError.message}` + }); + continue; + } + + // Stop container if running + if (wasRunning) { + await stopContainer(containerId, envIdNum); + } + + // Remove old container + await removeContainer(containerId, true, envIdNum); + + // Prepare port bindings + const ports: { [key: string]: { HostPort: string } } = {}; + if (hostConfig.PortBindings) { + for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) { + if (bindings && (bindings as any[]).length > 0) { + ports[containerPort] = { HostPort: (bindings as any[])[0].HostPort || '' }; + } + } + } + + // Create new container + const newContainer = await createContainer({ + name: containerName, + image: imageName, + ports, + volumeBinds: hostConfig.Binds || [], + env: config.Env || [], + labels: config.Labels || {}, + cmd: config.Cmd || undefined, + restartPolicy: hostConfig.RestartPolicy?.Name || 'no', + networkMode: hostConfig.NetworkMode || undefined + }, envIdNum); + + // Start if was running + if (wasRunning) { + await newContainer.start(); + } + + // Audit log + await auditContainer(event, 'update', newContainer.id, containerName, envIdNum, { batchUpdate: true }); + + results.push({ + containerId: newContainer.id, + containerName, + success: true + }); + } catch (error: any) { + results.push({ + containerId, + containerName: 'unknown', + success: false, + error: error.message + }); + } + } + + const successCount = results.filter(r => r.success).length; + const failCount = results.filter(r => !r.success).length; + + return json({ + success: failCount === 0, + results, + summary: { + total: results.length, + success: successCount, + failed: failCount + } + }); + } catch (error: any) { + console.error('Error in batch update:', error); + return json({ error: 'Failed to batch update containers', details: error.message }, { status: 500 }); + } +}; diff --git a/routes/api/containers/check-updates/+server.ts b/routes/api/containers/check-updates/+server.ts new file mode 100644 index 0000000..45dd49d --- /dev/null +++ b/routes/api/containers/check-updates/+server.ts @@ -0,0 +1,111 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { authorize } from '$lib/server/authorize'; +import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker'; +import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db'; + +export interface UpdateCheckResult { + containerId: string; + containerName: string; + imageName: string; + hasUpdate: boolean; + currentDigest?: string; + newDigest?: string; + error?: string; + isLocalImage?: boolean; +} + +/** + * Check all containers for available image updates. + * Returns all results at once after checking in parallel. + */ +export const POST: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Need at least view permission + if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + // Clear existing pending updates for this environment before checking + if (envIdNum) { + await clearPendingContainerUpdates(envIdNum); + } + + const containers = await listContainers(true, envIdNum); + + // Check container for updates + const checkContainer = async (container: typeof containers[0]): Promise => { + try { + // Get container's image name from config + const inspectData = await inspectContainer(container.id, envIdNum) as any; + const imageName = inspectData.Config?.Image; + const currentImageId = inspectData.Image; + + if (!imageName) { + return { + containerId: container.id, + containerName: container.name, + imageName: container.image, + hasUpdate: false, + error: 'Could not determine image name' + }; + } + + // Use shared update detection function + const result = await checkImageUpdateAvailable(imageName, currentImageId, envIdNum); + + return { + containerId: container.id, + containerName: container.name, + imageName, + hasUpdate: result.hasUpdate, + currentDigest: result.currentDigest, + newDigest: result.registryDigest, + error: result.error, + isLocalImage: result.isLocalImage + }; + } catch (error: any) { + return { + containerId: container.id, + containerName: container.name, + imageName: container.image, + hasUpdate: false, + error: error.message + }; + } + }; + + // Check all containers in parallel + const results = await Promise.all(containers.map(checkContainer)); + + const updatesFound = results.filter(r => r.hasUpdate).length; + + // Save containers with updates to the database for persistence + if (envIdNum) { + for (const result of results) { + if (result.hasUpdate) { + await addPendingContainerUpdate( + envIdNum, + result.containerId, + result.containerName, + result.imageName + ); + } + } + } + + return json({ + total: containers.length, + updatesFound, + results + }); + } catch (error: any) { + console.error('Error checking for updates:', error); + return json({ error: 'Failed to check for updates', details: error.message }, { status: 500 }); + } +}; diff --git a/routes/api/containers/pending-updates/+server.ts b/routes/api/containers/pending-updates/+server.ts new file mode 100644 index 0000000..23ffdf0 --- /dev/null +++ b/routes/api/containers/pending-updates/+server.ts @@ -0,0 +1,67 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { authorize } from '$lib/server/authorize'; +import { getPendingContainerUpdates, removePendingContainerUpdate } from '$lib/server/db'; + +/** + * Get pending container updates for an environment. + */ +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + if (!envIdNum) { + return json({ error: 'Environment ID required' }, { status: 400 }); + } + + // Need at least view permission + if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const pendingUpdates = await getPendingContainerUpdates(envIdNum); + return json({ + environmentId: envIdNum, + pendingUpdates: pendingUpdates.map(u => ({ + containerId: u.containerId, + containerName: u.containerName, + currentImage: u.currentImage, + checkedAt: u.checkedAt + })) + }); + } catch (error: any) { + console.error('Error getting pending updates:', error); + return json({ error: 'Failed to get pending updates', details: error.message }, { status: 500 }); + } +}; + +/** + * Remove a pending container update (e.g., after manual update). + */ +export const DELETE: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const containerId = url.searchParams.get('containerId'); + const envIdNum = envId ? parseInt(envId) : undefined; + + if (!envIdNum || !containerId) { + return json({ error: 'Environment ID and container ID required' }, { status: 400 }); + } + + // Need manage permission to delete + if (auth.authEnabled && !await auth.can('containers', 'manage', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + await removePendingContainerUpdate(envIdNum, containerId); + return json({ success: true }); + } catch (error: any) { + console.error('Error removing pending update:', error); + return json({ error: 'Failed to remove pending update', details: error.message }, { status: 500 }); + } +}; diff --git a/routes/api/containers/sizes/+server.ts b/routes/api/containers/sizes/+server.ts new file mode 100644 index 0000000..1f8fdff --- /dev/null +++ b/routes/api/containers/sizes/+server.ts @@ -0,0 +1,24 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listContainersWithSize } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const sizes = await listContainersWithSize(true, envIdNum); + return json(sizes); + } catch (error) { + console.error('Failed to get container sizes:', error); + return json({}, { status: 500 }); + } +}; diff --git a/routes/api/containers/stats/+server.ts b/routes/api/containers/stats/+server.ts new file mode 100644 index 0000000..60b78a5 --- /dev/null +++ b/routes/api/containers/stats/+server.ts @@ -0,0 +1,148 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listContainers, getContainerStats } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import { hasEnvironments } from '$lib/server/db'; +import type { ContainerStats } from '$lib/types'; + +function calculateCpuPercent(stats: any): number { + const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage; + const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage; + const cpuCount = stats.cpu_stats.online_cpus || stats.cpu_stats.cpu_usage.percpu_usage?.length || 1; + + if (systemDelta > 0 && cpuDelta > 0) { + return (cpuDelta / systemDelta) * cpuCount * 100; + } + return 0; +} + +function calculateNetworkIO(stats: any): { rx: number; tx: number } { + let rx = 0; + let tx = 0; + + if (stats.networks) { + for (const iface of Object.values(stats.networks) as any[]) { + rx += iface.rx_bytes || 0; + tx += iface.tx_bytes || 0; + } + } + + return { rx, tx }; +} + +function calculateBlockIO(stats: any): { read: number; write: number } { + let read = 0; + let write = 0; + + const ioStats = stats.blkio_stats?.io_service_bytes_recursive; + if (Array.isArray(ioStats)) { + for (const entry of ioStats) { + if (entry.op === 'read' || entry.op === 'Read') { + read += entry.value || 0; + } else if (entry.op === 'write' || entry.op === 'Write') { + write += entry.value || 0; + } + } + } + + return { read, write }; +} + +// Helper to add timeout to promises +function withTimeout(promise: Promise, ms: number, fallback: T): Promise { + return Promise.race([ + promise, + new Promise((resolve) => setTimeout(() => resolve(fallback), ms)) + ]); +} + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + const debugContainer = url.searchParams.get('debug'); // Get raw stats for specific container + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Early return if no environments configured (fresh install) + if (!await hasEnvironments()) { + return json([]); + } + + // Early return if no environment specified + if (!envIdNum) { + return json([]); + } + + try { + // Get all running containers with timeout + const containers = await withTimeout( + listContainers(true, envIdNum), + 5000, // 5 second timeout + [] + ); + const runningContainers = containers.filter(c => c.state === 'running'); + + // Debug mode: return raw stats for specific container + if (debugContainer) { + const container = runningContainers.find(c => c.name === debugContainer); + if (container) { + const rawStats = await getContainerStats(container.id, envIdNum); + return json({ + name: container.name, + memory_stats: (rawStats as any).memory_stats + }); + } + return json({ error: 'Container not found' }, { status: 404 }); + } + + // Get stats for each running container (in parallel with timeout) + const statsPromises = runningContainers.map(async (container) => { + try { + const stats = await withTimeout( + getContainerStats(container.id, envIdNum) as Promise, + 3000, // 3 second timeout per container + null + ); + + if (!stats) return null; + + const cpuPercent = calculateCpuPercent(stats); + // Use raw memory usage (total memory attributed to container) + const memoryUsage = stats.memory_stats?.usage || 0; + const memoryLimit = stats.memory_stats?.limit || 1; + const memoryPercent = (memoryUsage / memoryLimit) * 100; + const networkIO = calculateNetworkIO(stats); + const blockIO = calculateBlockIO(stats); + + return { + id: container.id, + name: container.name, + cpuPercent: Math.round(cpuPercent * 100) / 100, + memoryUsage, + memoryLimit, + memoryPercent: Math.round(memoryPercent * 100) / 100, + networkRx: networkIO.rx, + networkTx: networkIO.tx, + blockRead: blockIO.read, + blockWrite: blockIO.write + }; + } catch (err) { + // Silently skip failed containers + return null; + } + }); + + const allStats = await Promise.all(statsPromises); + const validStats = allStats.filter((s): s is ContainerStats => s !== null); + + return json(validStats); + } catch (error: any) { + console.error('Failed to get container stats:', error); + return json([], { status: 200 }); // Return empty array instead of error + } +}; diff --git a/routes/api/dashboard/preferences/+server.ts b/routes/api/dashboard/preferences/+server.ts new file mode 100644 index 0000000..b98184a --- /dev/null +++ b/routes/api/dashboard/preferences/+server.ts @@ -0,0 +1,53 @@ +import { json, type RequestHandler } from '@sveltejs/kit'; +import { getDashboardPreferences, saveDashboardPreferences } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; + +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + + try { + // Get user-specific preferences, or fall back to global preferences + const userId = auth.user?.id ?? null; + const prefs = await getDashboardPreferences(userId); + + // If no preferences exist, return empty gridLayout + if (!prefs) { + return json({ + id: 0, + userId: null, + gridLayout: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }); + } + + return json(prefs); + } catch (error) { + console.error('Failed to get dashboard preferences:', error); + return json({ error: 'Failed to get dashboard preferences' }, { status: 500 }); + } +}; + +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + + try { + const body = await request.json(); + const { gridLayout } = body; + + if (!gridLayout || !Array.isArray(gridLayout)) { + return json({ error: 'gridLayout is required and must be an array' }, { status: 400 }); + } + + const userId = auth.user?.id ?? null; + const prefs = await saveDashboardPreferences({ + userId, + gridLayout + }); + + return json(prefs); + } catch (error) { + console.error('Failed to save dashboard preferences:', error); + return json({ error: 'Failed to save dashboard preferences' }, { status: 500 }); + } +}; diff --git a/routes/api/dashboard/stats/+server.ts b/routes/api/dashboard/stats/+server.ts new file mode 100644 index 0000000..02bfe99 --- /dev/null +++ b/routes/api/dashboard/stats/+server.ts @@ -0,0 +1,307 @@ +import { json, type RequestHandler } from '@sveltejs/kit'; +import { + getEnvironments, + getLatestHostMetrics, + getContainerEventStats, + getEnvSetting, + hasEnvironments, + getEnvUpdateCheckSettings +} from '$lib/server/db'; +import { + listContainers, + listImages, + listVolumes, + listNetworks, + getDockerInfo, + getDiskUsage +} from '$lib/server/docker'; +import { listComposeStacks } from '$lib/server/stacks'; +import { authorize } from '$lib/server/authorize'; +import { parseLabels } from '$lib/utils/label-colors'; + +// Helper to add timeout to promises +function withTimeout(promise: Promise, ms: number, fallback: T): Promise { + return Promise.race([ + promise, + new Promise((resolve) => setTimeout(() => resolve(fallback), ms)) + ]); +} + +// Loading states for progressive tile updates +export interface LoadingStates { + containers?: boolean; + images?: boolean; + volumes?: boolean; + networks?: boolean; + stacks?: boolean; + diskUsage?: boolean; + topContainers?: boolean; +} + +export interface EnvironmentStats { + id: number; + name: string; + host?: string; + port?: number | null; + icon: string; + socketPath?: string; + collectActivity: boolean; + collectMetrics: boolean; + scannerEnabled: boolean; + updateCheckEnabled: boolean; + updateCheckAutoUpdate: boolean; + labels?: string[]; + connectionType: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge'; + online: boolean; + error?: string; + containers: { + total: number; + running: number; + stopped: number; + paused: number; + restarting: number; + unhealthy: number; + }; + images: { + total: number; + totalSize: number; + }; + volumes: { + total: number; + totalSize: number; + }; + containersSize: number; + buildCacheSize: number; + networks: { + total: number; + }; + stacks: { + total: number; + running: number; + partial: number; + stopped: number; + }; + metrics: { + cpuPercent: number; + memoryPercent: number; + memoryUsed: number; + memoryTotal: number; + } | null; + events: { + total: number; + today: number; + }; + topContainers: Array<{ + id: string; + name: string; + cpuPercent: number; + memoryPercent: number; + }>; + metricsHistory?: Array<{ + cpu_percent: number; + memory_percent: number; + timestamp: string; + }>; + recentEvents?: Array<{ + container_name: string; + action: string; + timestamp: string; + }>; + // Progressive loading states + loading?: LoadingStates; +} + +export const GET: RequestHandler = async ({ cookies, url }) => { + const auth = await authorize(cookies); + + // Support single environment query for real-time updates + const envIdParam = url.searchParams.get('env'); + const envIdNum = envIdParam ? parseInt(envIdParam) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('environments', 'view', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Early return if no environments configured (fresh install) + if (!await hasEnvironments()) { + return json([]); + } + + try { + let environments = await getEnvironments(); + + // Filter to single environment if specified + if (envIdNum) { + environments = environments.filter(env => env.id === envIdNum); + if (environments.length === 0) { + return json({ error: 'Environment not found' }, { status: 404 }); + } + } + + // 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)); + } + } + + // Fetch stats for all environments in parallel + const promises = environments.map(async (env): Promise => { + const envStats: EnvironmentStats = { + id: env.id, + name: env.name, + host: env.host ?? undefined, + port: env.port ?? undefined, + icon: env.icon || 'globe', + socketPath: env.socketPath ?? undefined, + collectActivity: env.collectActivity, + collectMetrics: env.collectMetrics ?? true, + scannerEnabled: false, + updateCheckEnabled: false, + updateCheckAutoUpdate: false, + labels: parseLabels(env.labels), + connectionType: (env.connectionType as 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge') || 'socket', + online: false, + containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0 }, + images: { total: 0, totalSize: 0 }, + volumes: { total: 0, totalSize: 0 }, + containersSize: 0, + buildCacheSize: 0, + networks: { total: 0 }, + stacks: { total: 0, running: 0, partial: 0, stopped: 0 }, + metrics: null, + events: { total: 0, today: 0 }, + topContainers: [] + }; + + try { + // Check scanner settings - scanner type is stored in 'vulnerability_scanner' + const scannerType = await getEnvSetting('vulnerability_scanner', env.id); + envStats.scannerEnabled = scannerType && scannerType !== 'none'; + + // Check update check settings + const updateCheckSettings = await getEnvUpdateCheckSettings(env.id); + if (updateCheckSettings && updateCheckSettings.enabled) { + envStats.updateCheckEnabled = true; + envStats.updateCheckAutoUpdate = updateCheckSettings.autoUpdate; + } + + // Check if Docker is accessible (with 5 second timeout) + const dockerInfo = await withTimeout(getDockerInfo(env.id), 5000, null); + if (!dockerInfo) { + envStats.error = 'Connection timeout or Docker not accessible'; + return envStats; + } + envStats.online = true; + + // Fetch all data in parallel (with 10 second timeout per operation) + const [containers, images, volumes, networks, stacks, diskUsage] = await Promise.all([ + withTimeout(listContainers(true, env.id).catch(() => []), 10000, []), + withTimeout(listImages(env.id).catch(() => []), 10000, []), + withTimeout(listVolumes(env.id).catch(() => []), 10000, []), + withTimeout(listNetworks(env.id).catch(() => []), 10000, []), + withTimeout(listComposeStacks(env.id).catch(() => []), 10000, []), + withTimeout(getDiskUsage(env.id).catch(() => null), 10000, null) + ]); + + // Process containers + envStats.containers.total = containers.length; + envStats.containers.running = containers.filter((c: any) => c.state === 'running').length; + envStats.containers.stopped = containers.filter((c: any) => c.state === 'exited').length; + envStats.containers.paused = containers.filter((c: any) => c.state === 'paused').length; + envStats.containers.restarting = containers.filter((c: any) => c.state === 'restarting').length; + envStats.containers.unhealthy = containers.filter((c: any) => c.health === 'unhealthy').length; + + // Helper to get valid size (Docker API returns -1 for uncalculated sizes) + const getValidSize = (size: number | undefined | null): number => { + return size && size > 0 ? size : 0; + }; + + // Process disk usage from /system/df for accurate size data + if (diskUsage) { + // Images: use Size from /system/df + envStats.images.total = diskUsage.Images?.length || images.length; + envStats.images.totalSize = diskUsage.Images?.reduce((sum: number, img: any) => sum + getValidSize(img.Size), 0) || 0; + + // Volumes: use UsageData.Size from /system/df + envStats.volumes.total = diskUsage.Volumes?.length || volumes.length; + envStats.volumes.totalSize = diskUsage.Volumes?.reduce((sum: number, vol: any) => sum + getValidSize(vol.UsageData?.Size), 0) || 0; + + // Containers: use SizeRw (writable layer size) + envStats.containersSize = diskUsage.Containers?.reduce((sum: number, c: any) => sum + getValidSize(c.SizeRw), 0) || 0; + + // Build cache: total size + envStats.buildCacheSize = diskUsage.BuildCache?.reduce((sum: number, bc: any) => sum + getValidSize(bc.Size), 0) || 0; + } else { + // Fallback to original method if /system/df failed + envStats.images.total = images.length; + envStats.images.totalSize = images.reduce((sum: number, img: any) => sum + getValidSize(img.size), 0); + envStats.volumes.total = volumes.length; + envStats.volumes.totalSize = 0; + } + + // Process networks + envStats.networks.total = networks.length; + + // Process stacks + envStats.stacks.total = stacks.length; + envStats.stacks.running = stacks.filter((s: any) => s.status === 'running').length; + envStats.stacks.partial = stacks.filter((s: any) => s.status === 'partial').length; + envStats.stacks.stopped = stacks.filter((s: any) => s.status === 'stopped').length; + + // Get latest metrics and event stats in parallel + const [latestMetrics, eventStats] = await Promise.all([ + getLatestHostMetrics(env.id), + getContainerEventStats(env.id) + ]); + + if (latestMetrics) { + envStats.metrics = { + cpuPercent: latestMetrics.cpuPercent, + memoryPercent: latestMetrics.memoryPercent, + memoryUsed: latestMetrics.memoryUsed, + memoryTotal: latestMetrics.memoryTotal + }; + } + + envStats.events = { + total: eventStats.total, + today: eventStats.today + }; + + } catch (error) { + // Convert technical error messages to user-friendly ones + const errorStr = String(error); + if (errorStr.includes('FailedToOpenSocket') || errorStr.includes('ECONNREFUSED')) { + envStats.error = 'Docker socket not accessible'; + } else if (errorStr.includes('ECONNRESET') || errorStr.includes('connection was closed')) { + envStats.error = 'Connection lost'; + } else if (errorStr.includes('verbose: true') || errorStr.includes('verbose')) { + envStats.error = 'Connection failed'; + } else if (errorStr.includes('timeout') || errorStr.includes('Timeout')) { + envStats.error = 'Connection timeout'; + } else { + const match = errorStr.match(/^(?:Error:\s*)?([^.!?]+[.!?]?)/); + envStats.error = match ? match[1].trim() : 'Connection error'; + } + } + + return envStats; + }); + + const results = await Promise.all(promises); + + // Return single object if single env was requested + if (envIdParam && results.length === 1) { + return json(results[0]); + } + + return json(results); + } catch (error: any) { + console.error('Failed to get dashboard stats:', error); + return json({ error: 'Failed to get dashboard stats' }, { status: 500 }); + } +}; diff --git a/routes/api/dashboard/stats/stream/+server.ts b/routes/api/dashboard/stats/stream/+server.ts new file mode 100644 index 0000000..4fc1f8b --- /dev/null +++ b/routes/api/dashboard/stats/stream/+server.ts @@ -0,0 +1,531 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { + getEnvironments, + getLatestHostMetrics, + getHostMetrics, + getContainerEventStats, + getContainerEvents, + getEnvSetting, + getEnvUpdateCheckSettings +} from '$lib/server/db'; +import { + listContainers, + listImages, + listNetworks, + getDockerInfo, + getContainerStats, + getDiskUsage +} from '$lib/server/docker'; +import { listComposeStacks } from '$lib/server/stacks'; +import { authorize } from '$lib/server/authorize'; +import type { EnvironmentStats } from '../+server'; +import { parseLabels } from '$lib/utils/label-colors'; + +// Helper to add timeout to promises +function withTimeout(promise: Promise, ms: number, fallback: T): Promise { + return Promise.race([ + promise, + new Promise((resolve) => setTimeout(() => resolve(fallback), ms)) + ]); +} + +// Disk usage cache - getDiskUsage() is very slow (30s timeout) but data changes rarely +// Cache per environment with 5-minute TTL +interface DiskUsageCache { + data: any; + timestamp: number; +} +const diskUsageCache: Map = new Map(); +const DISK_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes +const MAX_CACHE_SIZE = 100; // Maximum environments to cache + +// Cleanup expired cache entries periodically to prevent unbounded growth +// Also limits cache size for environments that were deleted +setInterval(() => { + const now = Date.now(); + // Remove expired entries + for (const [envId, cached] of diskUsageCache.entries()) { + if (now - cached.timestamp > DISK_CACHE_TTL_MS * 2) { + diskUsageCache.delete(envId); + } + } + // Enforce max size by removing oldest entries + if (diskUsageCache.size > MAX_CACHE_SIZE) { + const entries = Array.from(diskUsageCache.entries()) + .sort((a, b) => a[1].timestamp - b[1].timestamp); + const toRemove = entries.slice(0, entries.length - MAX_CACHE_SIZE); + for (const [envId] of toRemove) { + diskUsageCache.delete(envId); + } + } +}, 10 * 60 * 1000); // Every 10 minutes + +async function getCachedDiskUsage(envId: number): Promise { + const cached = diskUsageCache.get(envId); + const now = Date.now(); + + // Return cached data if still valid + if (cached && (now - cached.timestamp) < DISK_CACHE_TTL_MS) { + return cached.data; + } + + // Fetch fresh data with timeout + const data = await withTimeout(getDiskUsage(envId).catch(() => null), 30000, null); + + // Only cache successful results - if fetch failed, retry on next request + if (data !== null) { + diskUsageCache.set(envId, { data, timestamp: now }); + } + + return data; +} + +// Limit for per-container stats (reduced from 15 to improve performance) +const TOP_CONTAINERS_LIMIT = 8; + +// Calculate CPU percentage from Docker stats (same logic as container stats endpoint) +function calculateCpuPercent(stats: any): number { + const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage; + const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage; + const cpuCount = stats.cpu_stats.online_cpus || stats.cpu_stats.cpu_usage.percpu_usage?.length || 1; + + if (systemDelta > 0 && cpuDelta > 0) { + return (cpuDelta / systemDelta) * cpuCount * 100; + } + return 0; +} + +// Progressive stats loading - returns stats object and emits partial updates via callback +async function getEnvironmentStatsProgressive( + env: any, + onPartialUpdate: (stats: Partial & { id: number }) => void +): Promise { + const envStats: EnvironmentStats = { + id: env.id, + name: env.name, + host: env.host ?? undefined, + port: env.port ?? undefined, + icon: env.icon || 'globe', + socketPath: env.socketPath ?? undefined, + collectActivity: env.collectActivity, + collectMetrics: env.collectMetrics ?? true, + scannerEnabled: false, + updateCheckEnabled: false, + updateCheckAutoUpdate: false, + labels: parseLabels(env.labels), + connectionType: (env.connectionType as 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge') || 'socket', + online: false, + containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0 }, + images: { total: 0, totalSize: 0 }, + volumes: { total: 0, totalSize: 0 }, + containersSize: 0, + buildCacheSize: 0, + networks: { total: 0 }, + stacks: { total: 0, running: 0, partial: 0, stopped: 0 }, + metrics: null, + events: { total: 0, today: 0 }, + topContainers: [], + recentEvents: [], + // Loading states for progressive display + loading: { + containers: true, + images: true, + volumes: true, + networks: true, + stacks: true, + diskUsage: true, + topContainers: true + } + }; + + try { + // Check scanner settings - scanner type is stored in 'vulnerability_scanner' + const scannerType = await getEnvSetting('vulnerability_scanner', env.id); + envStats.scannerEnabled = scannerType && scannerType !== 'none'; + + // Check update check settings + const updateCheckSettings = await getEnvUpdateCheckSettings(env.id); + if (updateCheckSettings && updateCheckSettings.enabled) { + envStats.updateCheckEnabled = true; + envStats.updateCheckAutoUpdate = updateCheckSettings.autoUpdate; + } + + // Check if Docker is accessible (with 5 second timeout) + const dockerInfo = await withTimeout(getDockerInfo(env.id), 5000, null); + if (!dockerInfo) { + envStats.error = 'Connection timeout or Docker not accessible'; + envStats.loading = undefined; // Clear loading states on error + // Send offline status to client + onPartialUpdate({ + id: env.id, + online: false, + error: envStats.error, + loading: undefined + }); + return envStats; + } + envStats.online = true; + + // Get all database stats in parallel for better performance + const [latestMetrics, eventStats, recentEventsResult, metricsHistory] = await Promise.all([ + getLatestHostMetrics(env.id), + getContainerEventStats(env.id), + getContainerEvents({ environmentId: env.id, limit: 10 }), + getHostMetrics(30, env.id) + ]); + + if (latestMetrics) { + envStats.metrics = { + cpuPercent: latestMetrics.cpuPercent, + memoryPercent: latestMetrics.memoryPercent, + memoryUsed: latestMetrics.memoryUsed, + memoryTotal: latestMetrics.memoryTotal + }; + } + + envStats.events = { + total: eventStats.total, + today: eventStats.today + }; + + if (recentEventsResult.events.length > 0) { + envStats.recentEvents = recentEventsResult.events.map(e => ({ + container_name: e.containerName || 'unknown', + action: e.action, + timestamp: e.timestamp + })); + } + + if (metricsHistory.length > 0) { + envStats.metricsHistory = metricsHistory.reverse().map(m => ({ + cpu_percent: m.cpuPercent, + memory_percent: m.memoryPercent, + timestamp: m.timestamp + })); + } + + // Send initial update with DB data and online status + onPartialUpdate({ + id: env.id, + online: true, + metrics: envStats.metrics, + events: envStats.events, + recentEvents: envStats.recentEvents, + metricsHistory: envStats.metricsHistory, + scannerEnabled: envStats.scannerEnabled, + updateCheckEnabled: envStats.updateCheckEnabled, + updateCheckAutoUpdate: envStats.updateCheckAutoUpdate, + loading: { ...envStats.loading } + }); + + // Helper to get valid size + const getValidSize = (size: number | undefined | null): number => { + return size && size > 0 ? size : 0; + }; + + // PHASE 1: Containers (usually fast) + const containersPromise = withTimeout(listContainers(true, env.id).catch(() => []), 10000, []) + .then(async (containers) => { + envStats.containers.total = containers.length; + envStats.containers.running = containers.filter((c: any) => c.state === 'running').length; + envStats.containers.stopped = containers.filter((c: any) => c.state === 'exited').length; + envStats.containers.paused = containers.filter((c: any) => c.state === 'paused').length; + envStats.containers.restarting = containers.filter((c: any) => c.state === 'restarting').length; + envStats.containers.unhealthy = containers.filter((c: any) => c.health === 'unhealthy').length; + envStats.loading!.containers = false; + + onPartialUpdate({ + id: env.id, + containers: { ...envStats.containers }, + loading: { ...envStats.loading! } + }); + + return containers; + }); + + // PHASE 2: Images, Networks, Stacks (medium speed) - run in parallel + const imagesPromise = withTimeout(listImages(env.id).catch(() => []), 10000, []) + .then((images) => { + envStats.images.total = images.length; + envStats.images.totalSize = images.reduce((sum: number, img: any) => sum + getValidSize(img.size), 0); + envStats.loading!.images = false; + + onPartialUpdate({ + id: env.id, + images: { ...envStats.images }, + loading: { ...envStats.loading! } + }); + + return images; + }); + + const networksPromise = withTimeout(listNetworks(env.id).catch(() => []), 10000, []) + .then((networks) => { + envStats.networks.total = networks.length; + envStats.loading!.networks = false; + + onPartialUpdate({ + id: env.id, + networks: { ...envStats.networks }, + loading: { ...envStats.loading! } + }); + + return networks; + }); + + const stacksPromise = withTimeout(listComposeStacks(env.id).catch(() => []), 10000, []) + .then((stacks) => { + envStats.stacks.total = stacks.length; + envStats.stacks.running = stacks.filter((s: any) => s.status === 'running').length; + envStats.stacks.partial = stacks.filter((s: any) => s.status === 'partial').length; + envStats.stacks.stopped = stacks.filter((s: any) => s.status === 'stopped').length; + envStats.loading!.stacks = false; + + onPartialUpdate({ + id: env.id, + stacks: { ...envStats.stacks }, + loading: { ...envStats.loading! } + }); + + return stacks; + }); + + // PHASE 3: Disk usage (slow - includes volumes) - uses cache for better performance + const diskUsagePromise = getCachedDiskUsage(env.id) + .then((diskUsage) => { + if (diskUsage) { + // Update images with disk usage data (more accurate) + envStats.images.total = diskUsage.Images?.length || envStats.images.total; + envStats.images.totalSize = diskUsage.Images?.reduce((sum: number, img: any) => sum + getValidSize(img.Size), 0) || envStats.images.totalSize; + + // Volumes from disk usage + envStats.volumes.total = diskUsage.Volumes?.length || 0; + envStats.volumes.totalSize = diskUsage.Volumes?.reduce((sum: number, vol: any) => sum + getValidSize(vol.UsageData?.Size), 0) || 0; + + // Containers disk size + envStats.containersSize = diskUsage.Containers?.reduce((sum: number, c: any) => sum + getValidSize(c.SizeRw), 0) || 0; + + // Build cache + envStats.buildCacheSize = diskUsage.BuildCache?.reduce((sum: number, bc: any) => sum + getValidSize(bc.Size), 0) || 0; + } + envStats.loading!.volumes = false; + envStats.loading!.diskUsage = false; + + onPartialUpdate({ + id: env.id, + images: { ...envStats.images }, + volumes: { ...envStats.volumes }, + containersSize: envStats.containersSize, + buildCacheSize: envStats.buildCacheSize, + loading: { ...envStats.loading! } + }); + + return diskUsage; + }); + + // PHASE 4: Top containers (slow - requires per-container stats) + // Limited to TOP_CONTAINERS_LIMIT containers to reduce API calls + const topContainersPromise = containersPromise.then(async (containers) => { + const runningContainersList = containers.filter((c: any) => c.state === 'running'); + + const topContainersPromises = runningContainersList.slice(0, TOP_CONTAINERS_LIMIT).map(async (container: any) => { + try { + // 5 second timeout per container (increased from 2s for Hawser environments) + const stats = await withTimeout( + getContainerStats(container.id, env.id) as Promise, + 5000, + null + ); + if (!stats) return null; + + const cpuPercent = calculateCpuPercent(stats); + const memoryUsage = stats.memory_stats?.usage || 0; + const memoryLimit = stats.memory_stats?.limit || 1; + const memoryPercent = (memoryUsage / memoryLimit) * 100; + + return { + name: container.name, + cpuPercent: Math.round(cpuPercent * 100) / 100, + memoryPercent: Math.round(memoryPercent * 100) / 100 + }; + } catch { + return null; + } + }); + + const topContainersResults = await Promise.all(topContainersPromises); + envStats.topContainers = topContainersResults + .filter((c): c is { name: string; cpuPercent: number; memoryPercent: number } => c !== null) + .sort((a, b) => b.cpuPercent - a.cpuPercent) + .slice(0, 10); + envStats.loading!.topContainers = false; + + onPartialUpdate({ + id: env.id, + topContainers: [...envStats.topContainers], + loading: { ...envStats.loading! } + }); + + return envStats.topContainers; + }); + + // Wait for all to complete + await Promise.all([ + containersPromise, + imagesPromise, + networksPromise, + stacksPromise, + diskUsagePromise, + topContainersPromise + ]); + + // Clear loading states when complete + envStats.loading = undefined; + + } catch (error) { + // Convert technical error messages to user-friendly ones + const errorStr = String(error); + if (errorStr.includes('not connected') || errorStr.includes('Edge agent')) { + envStats.error = 'Agent not connected'; + } else if (errorStr.includes('FailedToOpenSocket') || errorStr.includes('ECONNREFUSED')) { + envStats.error = 'Docker socket not accessible'; + } else if (errorStr.includes('ECONNRESET') || errorStr.includes('connection was closed')) { + envStats.error = 'Connection lost'; + } else if (errorStr.includes('verbose: true') || errorStr.includes('verbose')) { + envStats.error = 'Connection failed'; + } else if (errorStr.includes('timeout') || errorStr.includes('Timeout')) { + envStats.error = 'Connection timeout'; + } else { + // Extract just the error message, not the full stack/details + const match = errorStr.match(/^(?:Error:\s*)?([^.!?]+[.!?]?)/); + envStats.error = match ? match[1].trim() : 'Connection error'; + } + envStats.loading = undefined; + // Send offline status to client + onPartialUpdate({ + id: env.id, + online: false, + error: envStats.error, + loading: undefined + }); + } + + return envStats; +} + +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('environments', 'view')) { + return new Response(JSON.stringify({ error: 'Permission denied' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } + + 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)); + } + } + + // Create a readable stream that sends environment stats progressively + let controllerClosed = false; + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + + // Safe enqueue that checks if controller is still open + const safeEnqueue = (data: string) => { + if (!controllerClosed) { + try { + controller.enqueue(encoder.encode(data)); + } catch { + controllerClosed = true; + } + } + }; + + // First, send the list of environments so the UI can show skeletons with loading states + const envList = environments.map(env => ({ + id: env.id, + name: env.name, + host: env.host ?? undefined, + port: env.port ?? undefined, + icon: env.icon || 'globe', + socketPath: env.socketPath ?? undefined, + collectActivity: env.collectActivity, + collectMetrics: env.collectMetrics ?? true, + labels: parseLabels(env.labels), + connectionType: (env.connectionType as 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge') || 'socket', + // Initial loading state for all sections + loading: { + containers: true, + images: true, + volumes: true, + networks: true, + stacks: true, + diskUsage: true, + topContainers: true + } + })); + safeEnqueue(`event: environments\ndata: ${JSON.stringify(envList)}\n\n`); + + // Fetch stats for each environment with progressive updates + const promises = environments.map(async (env) => { + try { + await getEnvironmentStatsProgressive(env, (partialStats) => { + // Send partial update as it arrives + safeEnqueue(`event: partial\ndata: ${JSON.stringify(partialStats)}\n\n`); + }); + // Send final complete stats event for this environment + safeEnqueue(`event: complete\ndata: ${JSON.stringify({ id: env.id })}\n\n`); + } catch (error) { + console.error(`Failed to get stats for ${env.name}:`, error); + // Convert technical error to user-friendly message + const errorStr = String(error); + let friendlyError = 'Connection error'; + if (errorStr.includes('FailedToOpenSocket') || errorStr.includes('ECONNREFUSED')) { + friendlyError = 'Docker socket not accessible'; + } else if (errorStr.includes('ECONNRESET') || errorStr.includes('connection was closed')) { + friendlyError = 'Connection lost'; + } else if (errorStr.includes('verbose') || errorStr.includes('typo')) { + friendlyError = 'Connection failed'; + } else if (errorStr.includes('timeout') || errorStr.includes('Timeout')) { + friendlyError = 'Connection timeout'; + } + safeEnqueue(`event: error\ndata: ${JSON.stringify({ id: env.id, error: friendlyError })}\n\n`); + } + }); + + // Wait for all to complete + await Promise.all(promises); + + // Send done event and close + if (!controllerClosed) { + safeEnqueue(`event: done\ndata: {}\n\n`); + try { + controller.close(); + } catch { + // Already closed + } + } + }, + cancel() { + // Called when the client disconnects + controllerClosed = true; + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + } + }); +}; diff --git a/routes/api/dependencies/+server.ts b/routes/api/dependencies/+server.ts new file mode 100644 index 0000000..c669d5d --- /dev/null +++ b/routes/api/dependencies/+server.ts @@ -0,0 +1,26 @@ +import { json, type RequestHandler } from '@sveltejs/kit'; +import dependencies from '$lib/data/dependencies.json'; + +// External tools used by Dockhand (Docker images) +const externalTools = [ + { + name: 'anchore/grype', + version: 'latest', + license: 'Apache-2.0', + repository: 'https://github.com/anchore/grype' + }, + { + name: 'aquasec/trivy', + version: 'latest', + license: 'Apache-2.0', + repository: 'https://github.com/aquasecurity/trivy' + } +]; + +export const GET: RequestHandler = async () => { + // Combine npm dependencies with external tools, exclude dockhand itself + const allDependencies = [...dependencies, ...externalTools] + .filter((dep) => dep.name !== 'dockhand') + .sort((a, b) => a.name.localeCompare(b.name)); + return json(allDependencies); +}; diff --git a/routes/api/environments/+server.ts b/routes/api/environments/+server.ts new file mode 100644 index 0000000..790d946 --- /dev/null +++ b/routes/api/environments/+server.ts @@ -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(); + 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 }); + } +}; diff --git a/routes/api/environments/[id]/+server.ts b/routes/api/environments/[id]/+server.ts new file mode 100644 index 0000000..e290ac2 --- /dev/null +++ b/routes/api/environments/[id]/+server.ts @@ -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 }); + } +}; diff --git a/routes/api/environments/[id]/notifications/+server.ts b/routes/api/environments/[id]/notifications/+server.ts new file mode 100644 index 0000000..b8664fc --- /dev/null +++ b/routes/api/environments/[id]/notifications/+server.ts @@ -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 }); + } +}; diff --git a/routes/api/environments/[id]/notifications/[notificationId]/+server.ts b/routes/api/environments/[id]/notifications/[notificationId]/+server.ts new file mode 100644 index 0000000..7397d36 --- /dev/null +++ b/routes/api/environments/[id]/notifications/[notificationId]/+server.ts @@ -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 }); + } +}; diff --git a/routes/api/environments/[id]/test/+server.ts b/routes/api/environments/[id]/test/+server.ts new file mode 100644 index 0000000..d4e8af7 --- /dev/null +++ b/routes/api/environments/[id]/test/+server.ts @@ -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 = {}; + 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 }); + } +}; diff --git a/routes/api/environments/[id]/timezone/+server.ts b/routes/api/environments/[id]/timezone/+server.ts new file mode 100644 index 0000000..06606e1 --- /dev/null +++ b/routes/api/environments/[id]/timezone/+server.ts @@ -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 }); + } +}; diff --git a/routes/api/environments/[id]/update-check/+server.ts b/routes/api/environments/[id]/update-check/+server.ts new file mode 100644 index 0000000..2850966 --- /dev/null +++ b/routes/api/environments/[id]/update-check/+server.ts @@ -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 }); + } +}; diff --git a/routes/api/environments/detect-socket/+server.ts b/routes/api/environments/detect-socket/+server.ts new file mode 100644 index 0000000..373fdb4 --- /dev/null +++ b/routes/api/environments/detect-socket/+server.ts @@ -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 + }); +}; diff --git a/routes/api/environments/test/+server.ts b/routes/api/environments/test/+server.ts new file mode 100644 index 0000000..f7221ff --- /dev/null +++ b/routes/api/environments/test/+server.ts @@ -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 = { + '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 = {}; + 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 }); + } +}; diff --git a/routes/api/events/+server.ts b/routes/api/events/+server.ts new file mode 100644 index 0000000..caeb8c0 --- /dev/null +++ b/routes/api/events/+server.ts @@ -0,0 +1,137 @@ +import type { RequestHandler } from './$types'; +import { getDockerEvents } from '$lib/server/docker'; +import { getEnvironment } from '$lib/server/db'; + +export const GET: RequestHandler = async ({ url }) => { + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Early return if no environment specified + if (!envIdNum) { + return new Response( + `event: info\ndata: ${JSON.stringify({ message: 'No environment selected' })}\n\n`, + { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache' + } + } + ); + } + + // Check if this is an edge mode environment - events are pushed by the agent, not pulled + const env = await getEnvironment(envIdNum); + if (env?.connectionType === 'hawser-edge') { + return new Response( + `event: error\ndata: ${JSON.stringify({ message: 'Edge environments receive events via agent push, not this endpoint' })}\n\n`, + { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache' + } + } + ); + } + + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + + // Send initial connection event + const sendEvent = (type: string, data: any) => { + const event = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`; + controller.enqueue(encoder.encode(event)); + }; + + // Send heartbeat to keep connection alive (every 5s to prevent Traefik 10s idle timeout) + const heartbeatInterval = setInterval(() => { + try { + sendEvent('heartbeat', { timestamp: new Date().toISOString() }); + } catch { + clearInterval(heartbeatInterval); + } + }, 5000); + + sendEvent('connected', { timestamp: new Date().toISOString(), envId: envIdNum }); + + try { + // Get Docker events stream + const eventStream = await getDockerEvents( + { type: ['container', 'image', 'volume', 'network'] }, + envIdNum + ); + + if (!eventStream) { + sendEvent('error', { message: 'Failed to connect to Docker events' }); + clearInterval(heartbeatInterval); + controller.close(); + return; + } + + const reader = eventStream.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + const processEvents = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.trim()) { + try { + const event = JSON.parse(line); + + // Map Docker event to our format + const mappedEvent = { + type: event.Type, + action: event.Action, + actor: { + id: event.Actor?.ID, + name: event.Actor?.Attributes?.name || event.Actor?.Attributes?.image, + attributes: event.Actor?.Attributes + }, + time: event.time, + timeNano: event.timeNano + }; + + sendEvent('docker', mappedEvent); + } catch { + // Ignore parse errors for partial chunks + } + } + } + } + } catch (error: any) { + console.error('Docker event stream error:', error); + sendEvent('error', { message: error.message }); + } finally { + clearInterval(heartbeatInterval); + controller.close(); + } + }; + + processEvents(); + } catch (error: any) { + console.error('Failed to connect to Docker events:', error); + sendEvent('error', { message: error.message || 'Failed to connect to Docker' }); + clearInterval(heartbeatInterval); + controller.close(); + } + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no' // Disable nginx buffering + } + }); +}; diff --git a/routes/api/git/credentials/+server.ts b/routes/api/git/credentials/+server.ts new file mode 100644 index 0000000..77449f1 --- /dev/null +++ b/routes/api/git/credentials/+server.ts @@ -0,0 +1,88 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { + getGitCredentials, + createGitCredential, + type GitAuthType +} from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; + +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('git', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const credentials = await getGitCredentials(); + // Don't expose sensitive data in list view + const sanitized = credentials.map(cred => ({ + id: cred.id, + name: cred.name, + authType: cred.authType, + username: cred.username, + hasPassword: !!cred.password, + hasSshKey: !!cred.sshPrivateKey, + createdAt: cred.createdAt, + updatedAt: cred.updatedAt + })); + return json(sanitized); + } catch (error) { + console.error('Failed to get git credentials:', error); + return json({ error: 'Failed to get git credentials' }, { status: 500 }); + } +}; + +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('git', 'create')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const data = await request.json(); + + if (!data.name || typeof data.name !== 'string') { + return json({ error: 'Name is required' }, { status: 400 }); + } + + const authType = (data.authType || 'none') as GitAuthType; + if (!['none', 'password', 'ssh'].includes(authType)) { + return json({ error: 'Invalid auth type' }, { status: 400 }); + } + + if (authType === 'password' && !data.password) { + return json({ error: 'Password is required for password authentication' }, { status: 400 }); + } + + if (authType === 'ssh' && !data.sshPrivateKey) { + return json({ error: 'SSH private key is required for SSH authentication' }, { status: 400 }); + } + + const credential = await createGitCredential({ + name: data.name, + authType, + username: data.username, + password: data.password, + sshPrivateKey: data.sshPrivateKey, + sshPassphrase: data.sshPassphrase + }); + + return json({ + id: credential.id, + name: credential.name, + authType: credential.authType, + username: credential.username, + hasPassword: !!credential.password, + hasSshKey: !!credential.sshPrivateKey, + createdAt: credential.createdAt, + updatedAt: credential.updatedAt + }); + } catch (error: any) { + console.error('Failed to create git credential:', error); + if (error.message?.includes('UNIQUE constraint failed')) { + return json({ error: 'A credential with this name already exists' }, { status: 400 }); + } + return json({ error: 'Failed to create git credential' }, { status: 500 }); + } +}; diff --git a/routes/api/git/credentials/[id]/+server.ts b/routes/api/git/credentials/[id]/+server.ts new file mode 100644 index 0000000..ba774f7 --- /dev/null +++ b/routes/api/git/credentials/[id]/+server.ts @@ -0,0 +1,122 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { + getGitCredential, + updateGitCredential, + deleteGitCredential, + type GitAuthType +} from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; + +export const GET: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('git', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid credential ID' }, { status: 400 }); + } + + const credential = await getGitCredential(id); + if (!credential) { + return json({ error: 'Credential not found' }, { status: 404 }); + } + + // Don't expose sensitive data + return json({ + id: credential.id, + name: credential.name, + authType: credential.authType, + username: credential.username, + hasPassword: !!credential.password, + hasSshKey: !!credential.sshPrivateKey, + createdAt: credential.createdAt, + updatedAt: credential.updatedAt + }); + } catch (error) { + console.error('Failed to get git credential:', error); + return json({ error: 'Failed to get git credential' }, { status: 500 }); + } +}; + +export const PUT: RequestHandler = async ({ params, request, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('git', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid credential ID' }, { status: 400 }); + } + + const existing = await getGitCredential(id); + if (!existing) { + return json({ error: 'Credential not found' }, { status: 404 }); + } + + const data = await request.json(); + + if (data.authType && !['none', 'password', 'ssh'].includes(data.authType)) { + return json({ error: 'Invalid auth type' }, { status: 400 }); + } + + const credential = await updateGitCredential(id, { + name: data.name, + authType: data.authType as GitAuthType, + username: data.username, + password: data.password, + sshPrivateKey: data.sshPrivateKey, + sshPassphrase: data.sshPassphrase + }); + + if (!credential) { + return json({ error: 'Failed to update credential' }, { status: 500 }); + } + + return json({ + id: credential.id, + name: credential.name, + authType: credential.authType, + username: credential.username, + hasPassword: !!credential.password, + hasSshKey: !!credential.sshPrivateKey, + createdAt: credential.createdAt, + updatedAt: credential.updatedAt + }); + } catch (error: any) { + console.error('Failed to update git credential:', error); + if (error.message?.includes('UNIQUE constraint failed')) { + return json({ error: 'A credential with this name already exists' }, { status: 400 }); + } + return json({ error: 'Failed to update git credential' }, { status: 500 }); + } +}; + +export const DELETE: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('git', 'delete')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid credential ID' }, { status: 400 }); + } + + const deleted = await deleteGitCredential(id); + if (!deleted) { + return json({ error: 'Credential not found' }, { status: 404 }); + } + + return json({ success: true }); + } catch (error) { + console.error('Failed to delete git credential:', error); + return json({ error: 'Failed to delete git credential' }, { status: 500 }); + } +}; diff --git a/routes/api/git/repositories/+server.ts b/routes/api/git/repositories/+server.ts new file mode 100644 index 0000000..a75adaa --- /dev/null +++ b/routes/api/git/repositories/+server.ts @@ -0,0 +1,70 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { + getGitRepositories, + createGitRepository, + getGitCredentials +} from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('git', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + // Note: envId parameter is kept for backwards compatibility but repositories + // are now global (not tied to environments). Use git stacks for env-specific deployments. + const repositories = await getGitRepositories(); + return json(repositories); + } catch (error) { + console.error('Failed to get git repositories:', error); + return json({ error: 'Failed to get git repositories' }, { status: 500 }); + } +}; + +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('git', 'create')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const data = await request.json(); + + if (!data.name || typeof data.name !== 'string') { + return json({ error: 'Name is required' }, { status: 400 }); + } + + if (!data.url || typeof data.url !== 'string') { + return json({ error: 'Repository URL is required' }, { status: 400 }); + } + + // Validate credential if provided + if (data.credentialId) { + const credentials = await getGitCredentials(); + const credential = credentials.find(c => c.id === data.credentialId); + if (!credential) { + return json({ error: 'Invalid credential ID' }, { status: 400 }); + } + } + + // Create repository with just the basic fields + // Deployment-specific config (composePath, autoUpdate, webhook) now belongs to git_stacks + const repository = await createGitRepository({ + name: data.name, + url: data.url, + branch: data.branch || 'main', + credentialId: data.credentialId || null + }); + + return json(repository); + } catch (error: any) { + console.error('Failed to create git repository:', error); + if (error.message?.includes('UNIQUE constraint failed')) { + return json({ error: 'A repository with this name already exists' }, { status: 400 }); + } + return json({ error: 'Failed to create git repository' }, { status: 500 }); + } +}; diff --git a/routes/api/git/repositories/[id]/+server.ts b/routes/api/git/repositories/[id]/+server.ts new file mode 100644 index 0000000..b643a98 --- /dev/null +++ b/routes/api/git/repositories/[id]/+server.ts @@ -0,0 +1,112 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { + getGitRepository, + updateGitRepository, + deleteGitRepository, + getGitCredentials +} from '$lib/server/db'; +import { deleteRepositoryFiles } from '$lib/server/git'; +import { authorize } from '$lib/server/authorize'; + +export const GET: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('git', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid repository ID' }, { status: 400 }); + } + + const repository = await getGitRepository(id); + if (!repository) { + return json({ error: 'Repository not found' }, { status: 404 }); + } + + return json(repository); + } catch (error) { + console.error('Failed to get git repository:', error); + return json({ error: 'Failed to get git repository' }, { status: 500 }); + } +}; + +export const PUT: RequestHandler = async ({ params, request, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('git', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid repository ID' }, { status: 400 }); + } + + const existing = await getGitRepository(id); + if (!existing) { + return json({ error: 'Repository not found' }, { status: 404 }); + } + + const data = await request.json(); + + // Validate credential if provided + if (data.credentialId) { + const credentials = await getGitCredentials(); + const credential = credentials.find(c => c.id === data.credentialId); + if (!credential) { + return json({ error: 'Invalid credential ID' }, { status: 400 }); + } + } + + // Update only the basic repository fields + // Deployment-specific config (composePath, autoUpdate, webhook) now belongs to git_stacks + const repository = await updateGitRepository(id, { + name: data.name, + url: data.url, + branch: data.branch, + credentialId: data.credentialId + }); + + if (!repository) { + return json({ error: 'Failed to update repository' }, { status: 500 }); + } + + return json(repository); + } catch (error: any) { + console.error('Failed to update git repository:', error); + if (error.message?.includes('UNIQUE constraint failed')) { + return json({ error: 'A repository with this name already exists' }, { status: 400 }); + } + return json({ error: 'Failed to update git repository' }, { status: 500 }); + } +}; + +export const DELETE: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('git', 'delete')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid repository ID' }, { status: 400 }); + } + + // Delete repository files first + deleteRepositoryFiles(id); + + const deleted = await deleteGitRepository(id); + if (!deleted) { + return json({ error: 'Repository not found' }, { status: 404 }); + } + + return json({ success: true }); + } catch (error) { + console.error('Failed to delete git repository:', error); + return json({ error: 'Failed to delete git repository' }, { status: 500 }); + } +}; diff --git a/routes/api/git/repositories/[id]/deploy/+server.ts b/routes/api/git/repositories/[id]/deploy/+server.ts new file mode 100644 index 0000000..249bc0b --- /dev/null +++ b/routes/api/git/repositories/[id]/deploy/+server.ts @@ -0,0 +1,24 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getGitRepository } from '$lib/server/db'; +import { deployFromRepository } from '$lib/server/git'; + +export const POST: RequestHandler = async ({ params }) => { + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid repository ID' }, { status: 400 }); + } + + const repository = await getGitRepository(id); + if (!repository) { + return json({ error: 'Repository not found' }, { status: 404 }); + } + + const result = await deployFromRepository(id); + return json(result); + } catch (error: any) { + console.error('Failed to deploy from git repository:', error); + return json({ success: false, error: error.message }, { status: 500 }); + } +}; diff --git a/routes/api/git/repositories/[id]/sync/+server.ts b/routes/api/git/repositories/[id]/sync/+server.ts new file mode 100644 index 0000000..a5367e0 --- /dev/null +++ b/routes/api/git/repositories/[id]/sync/+server.ts @@ -0,0 +1,45 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getGitRepository } from '$lib/server/db'; +import { syncRepository, checkForUpdates } from '$lib/server/git'; + +export const POST: RequestHandler = async ({ params }) => { + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid repository ID' }, { status: 400 }); + } + + const repository = await getGitRepository(id); + if (!repository) { + return json({ error: 'Repository not found' }, { status: 404 }); + } + + const result = await syncRepository(id); + return json(result); + } catch (error: any) { + console.error('Failed to sync git repository:', error); + return json({ success: false, error: error.message }, { status: 500 }); + } +}; + +export const GET: RequestHandler = async ({ params }) => { + // Check for updates without syncing + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid repository ID' }, { status: 400 }); + } + + const repository = await getGitRepository(id); + if (!repository) { + return json({ error: 'Repository not found' }, { status: 404 }); + } + + const result = await checkForUpdates(id); + return json(result); + } catch (error: any) { + console.error('Failed to check for updates:', error); + return json({ hasUpdates: false, error: error.message }, { status: 500 }); + } +}; diff --git a/routes/api/git/repositories/[id]/test/+server.ts b/routes/api/git/repositories/[id]/test/+server.ts new file mode 100644 index 0000000..9d1f91c --- /dev/null +++ b/routes/api/git/repositories/[id]/test/+server.ts @@ -0,0 +1,24 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getGitRepository } from '$lib/server/db'; +import { testRepository } from '$lib/server/git'; + +export const POST: RequestHandler = async ({ params }) => { + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid repository ID' }, { status: 400 }); + } + + const repository = await getGitRepository(id); + if (!repository) { + return json({ error: 'Repository not found' }, { status: 404 }); + } + + const result = await testRepository(id); + return json(result); + } catch (error: any) { + console.error('Failed to test git repository:', error); + return json({ success: false, error: error.message }, { status: 500 }); + } +}; diff --git a/routes/api/git/repositories/test/+server.ts b/routes/api/git/repositories/test/+server.ts new file mode 100644 index 0000000..2d09abd --- /dev/null +++ b/routes/api/git/repositories/test/+server.ts @@ -0,0 +1,41 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { testRepositoryConfig } from '$lib/server/git'; +import { authorize } from '$lib/server/authorize'; + +/** + * POST /api/git/repositories/test + * Test a git repository configuration before saving. + * Uses stored credentials via credentialId. + * + * Body: { + * url: string; // Repository URL to test + * branch: string; // Branch name to verify + * credentialId?: number; // Optional credential ID from database + * } + */ +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('settings', 'manage')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const body = await request.json(); + + if (!body.url || typeof body.url !== 'string') { + return json({ error: 'Repository URL is required' }, { status: 400 }); + } + + const result = await testRepositoryConfig({ + url: body.url, + branch: body.branch || 'main', + credentialId: body.credentialId ?? null + }); + + return json(result); + } catch (error) { + console.error('Failed to test repository:', error); + return json({ success: false, error: 'Failed to test repository' }, { status: 500 }); + } +}; diff --git a/routes/api/git/stacks/+server.ts b/routes/api/git/stacks/+server.ts new file mode 100644 index 0000000..22e5f28 --- /dev/null +++ b/routes/api/git/stacks/+server.ts @@ -0,0 +1,144 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { + getGitStacks, + createGitStack, + getGitCredentials, + getGitRepository, + createGitRepository, + upsertStackSource +} from '$lib/server/db'; +import { deployGitStack } from '$lib/server/git'; +import { authorize } from '$lib/server/authorize'; +import { registerSchedule } from '$lib/server/scheduler'; +import crypto from 'node:crypto'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('stacks', 'view', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + + const stacks = await getGitStacks(envIdNum); + return json(stacks); + } catch (error) { + console.error('Failed to get git stacks:', error); + return json({ error: 'Failed to get git stacks' }, { status: 500 }); + } +}; + +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + + try { + const data = await request.json(); + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('stacks', 'create', data.environmentId || undefined)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + if (!data.stackName || typeof data.stackName !== 'string') { + return json({ error: 'Stack name is required' }, { status: 400 }); + } + + // Either repositoryId or new repo details (url, branch) must be provided + let repositoryId = data.repositoryId; + + if (!repositoryId) { + // Create a new repository if URL is provided + if (!data.url || typeof data.url !== 'string') { + return json({ error: 'Repository URL or existing repository ID is required' }, { status: 400 }); + } + + // Validate credential if provided + if (data.credentialId) { + const credentials = await getGitCredentials(); + const credential = credentials.find(c => c.id === data.credentialId); + if (!credential) { + return json({ error: 'Invalid credential ID' }, { status: 400 }); + } + } + + // Create the repository first + const repoName = data.repoName || data.stackName; + try { + const repo = await createGitRepository({ + name: repoName, + url: data.url, + branch: data.branch || 'main', + credentialId: data.credentialId || null + }); + repositoryId = repo.id; + } catch (error: any) { + if (error.message?.includes('UNIQUE constraint failed')) { + return json({ error: 'A repository with this name already exists' }, { status: 400 }); + } + throw error; + } + } else { + // Verify repository exists + const repo = await getGitRepository(repositoryId); + if (!repo) { + return json({ error: 'Repository not found' }, { status: 400 }); + } + } + + // Generate webhook secret if webhook is enabled + let webhookSecret = data.webhookSecret; + if (data.webhookEnabled && !webhookSecret) { + webhookSecret = crypto.randomBytes(32).toString('hex'); + } + + const gitStack = await createGitStack({ + stackName: data.stackName, + environmentId: data.environmentId || null, + repositoryId: repositoryId, + composePath: data.composePath || 'docker-compose.yml', + envFilePath: data.envFilePath || null, + autoUpdate: data.autoUpdate || false, + autoUpdateSchedule: data.autoUpdateSchedule || 'daily', + autoUpdateCron: data.autoUpdateCron || '0 3 * * *', + webhookEnabled: data.webhookEnabled || false, + webhookSecret: webhookSecret + }); + + // Create stack_sources entry so the stack appears in the list immediately + await upsertStackSource({ + stackName: data.stackName, + environmentId: data.environmentId || null, + sourceType: 'git', + gitRepositoryId: repositoryId, + gitStackId: gitStack.id + }); + + // Register schedule with croner if auto-update is enabled + if (gitStack.autoUpdate && gitStack.autoUpdateCron) { + await registerSchedule(gitStack.id, 'git_stack_sync', gitStack.environmentId); + } + + // If deployNow is set, deploy immediately + if (data.deployNow) { + const deployResult = await deployGitStack(gitStack.id); + return json({ + ...gitStack, + deployResult: deployResult + }); + } + + return json(gitStack); + } catch (error: any) { + console.error('Failed to create git stack:', error); + if (error.message?.includes('UNIQUE constraint failed')) { + return json({ error: 'A git stack with this name already exists for this environment' }, { status: 400 }); + } + return json({ error: 'Failed to create git stack' }, { status: 500 }); + } +}; diff --git a/routes/api/git/stacks/[id]/+server.ts b/routes/api/git/stacks/[id]/+server.ts new file mode 100644 index 0000000..c05ea95 --- /dev/null +++ b/routes/api/git/stacks/[id]/+server.ts @@ -0,0 +1,112 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getGitStack, updateGitStack, deleteGitStack } from '$lib/server/db'; +import { deleteGitStackFiles, deployGitStack } from '$lib/server/git'; +import { authorize } from '$lib/server/authorize'; +import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler'; + +export const GET: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + + try { + const id = parseInt(params.id); + const gitStack = await getGitStack(id); + if (!gitStack) { + return json({ error: 'Git stack not found' }, { status: 404 }); + } + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('stacks', 'view', gitStack.environmentId || undefined)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + return json(gitStack); + } catch (error) { + console.error('Failed to get git stack:', error); + return json({ error: 'Failed to get git stack' }, { status: 500 }); + } +}; + +export const PUT: RequestHandler = async ({ params, request, cookies }) => { + const auth = await authorize(cookies); + + try { + const id = parseInt(params.id); + const existing = await getGitStack(id); + if (!existing) { + return json({ error: 'Git stack not found' }, { status: 404 }); + } + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('stacks', 'edit', existing.environmentId || undefined)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + const data = await request.json(); + const updated = await updateGitStack(id, { + stackName: data.stackName, + composePath: data.composePath, + envFilePath: data.envFilePath, + autoUpdate: data.autoUpdate, + autoUpdateSchedule: data.autoUpdateSchedule, + autoUpdateCron: data.autoUpdateCron, + webhookEnabled: data.webhookEnabled, + webhookSecret: data.webhookSecret + }); + + // Register or unregister schedule with croner + if (updated.autoUpdate && updated.autoUpdateCron) { + await registerSchedule(id, 'git_stack_sync', updated.environmentId); + } else { + unregisterSchedule(id, 'git_stack_sync'); + } + + // If deployNow is set, deploy after saving + if (data.deployNow) { + const deployResult = await deployGitStack(id); + return json({ + ...updated, + deployResult + }); + } + + return json(updated); + } catch (error: any) { + console.error('Failed to update git stack:', error); + if (error.message?.includes('UNIQUE constraint failed')) { + return json({ error: 'A git stack with this name already exists for this environment' }, { status: 400 }); + } + return json({ error: 'Failed to update git stack' }, { status: 500 }); + } +}; + +export const DELETE: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + + try { + const id = parseInt(params.id); + const existing = await getGitStack(id); + if (!existing) { + return json({ error: 'Git stack not found' }, { status: 404 }); + } + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('stacks', 'remove', existing.environmentId || undefined)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Unregister schedule from croner + unregisterSchedule(id, 'git_stack_sync'); + + // Delete git files first + deleteGitStackFiles(id); + + // Delete from database + await deleteGitStack(id); + + return json({ success: true }); + } catch (error) { + console.error('Failed to delete git stack:', error); + return json({ error: 'Failed to delete git stack' }, { status: 500 }); + } +}; diff --git a/routes/api/git/stacks/[id]/deploy-stream/+server.ts b/routes/api/git/stacks/[id]/deploy-stream/+server.ts new file mode 100644 index 0000000..b2b8435 --- /dev/null +++ b/routes/api/git/stacks/[id]/deploy-stream/+server.ts @@ -0,0 +1,54 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getGitStack } from '$lib/server/db'; +import { deployGitStackWithProgress } from '$lib/server/git'; +import { authorize } from '$lib/server/authorize'; + +export const POST: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + + const id = parseInt(params.id); + const gitStack = await getGitStack(id); + + if (!gitStack) { + return new Response(JSON.stringify({ error: 'Git stack not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('stacks', 'start', gitStack.environmentId || undefined)) { + return new Response(JSON.stringify({ error: 'Permission denied' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Create a readable stream for SSE + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + + const sendEvent = (data: any) => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); + }; + + try { + await deployGitStackWithProgress(id, sendEvent); + } catch (error: any) { + sendEvent({ status: 'error', error: error.message || 'Unknown error' }); + } finally { + controller.close(); + } + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + } + }); +}; diff --git a/routes/api/git/stacks/[id]/deploy/+server.ts b/routes/api/git/stacks/[id]/deploy/+server.ts new file mode 100644 index 0000000..64ef0e5 --- /dev/null +++ b/routes/api/git/stacks/[id]/deploy/+server.ts @@ -0,0 +1,28 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getGitStack } from '$lib/server/db'; +import { deployGitStack } from '$lib/server/git'; +import { authorize } from '$lib/server/authorize'; + +export const POST: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + + try { + const id = parseInt(params.id); + const gitStack = await getGitStack(id); + if (!gitStack) { + return json({ error: 'Git stack not found' }, { status: 404 }); + } + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('stacks', 'start', gitStack.environmentId || undefined)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + const result = await deployGitStack(id); + return json(result); + } catch (error) { + console.error('Failed to deploy git stack:', error); + return json({ error: 'Failed to deploy git stack' }, { status: 500 }); + } +}; diff --git a/routes/api/git/stacks/[id]/env-files/+server.ts b/routes/api/git/stacks/[id]/env-files/+server.ts new file mode 100644 index 0000000..cc23e05 --- /dev/null +++ b/routes/api/git/stacks/[id]/env-files/+server.ts @@ -0,0 +1,75 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getGitStack } from '$lib/server/db'; +import { listGitStackEnvFiles, readGitStackEnvFile } from '$lib/server/git'; +import { authorize } from '$lib/server/authorize'; + +/** + * GET /api/git/stacks/[id]/env-files + * List all .env files in the git stack's repository. + * Returns: { files: string[] } + */ +export const GET: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + + try { + const id = parseInt(params.id); + const gitStack = await getGitStack(id); + if (!gitStack) { + return json({ error: 'Git stack not found' }, { status: 404 }); + } + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('stacks', 'view', gitStack.environmentId || undefined)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + const result = await listGitStackEnvFiles(id); + if (result.error) { + return json({ files: [], error: result.error }, { status: 400 }); + } + + return json({ files: result.files }); + } catch (error) { + console.error('Failed to list env files:', error); + return json({ error: 'Failed to list env files' }, { status: 500 }); + } +}; + +/** + * POST /api/git/stacks/[id]/env-files + * Read and parse a specific .env file from the git stack's repository. + * Body: { path: string } + * Returns: { vars: Record } + */ +export const POST: RequestHandler = async ({ params, cookies, request }) => { + const auth = await authorize(cookies); + + try { + const id = parseInt(params.id); + const gitStack = await getGitStack(id); + if (!gitStack) { + return json({ error: 'Git stack not found' }, { status: 404 }); + } + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('stacks', 'view', gitStack.environmentId || undefined)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + const body = await request.json(); + if (!body.path || typeof body.path !== 'string') { + return json({ error: 'File path is required' }, { status: 400 }); + } + + const result = await readGitStackEnvFile(id, body.path); + if (result.error) { + return json({ vars: {}, error: result.error }, { status: 400 }); + } + + return json({ vars: result.vars }); + } catch (error) { + console.error('Failed to read env file:', error); + return json({ error: 'Failed to read env file' }, { status: 500 }); + } +}; diff --git a/routes/api/git/stacks/[id]/sync/+server.ts b/routes/api/git/stacks/[id]/sync/+server.ts new file mode 100644 index 0000000..59d237c --- /dev/null +++ b/routes/api/git/stacks/[id]/sync/+server.ts @@ -0,0 +1,28 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getGitStack } from '$lib/server/db'; +import { syncGitStack } from '$lib/server/git'; +import { authorize } from '$lib/server/authorize'; + +export const POST: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + + try { + const id = parseInt(params.id); + const gitStack = await getGitStack(id); + if (!gitStack) { + return json({ error: 'Git stack not found' }, { status: 404 }); + } + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('stacks', 'edit', gitStack.environmentId || undefined)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + const result = await syncGitStack(id); + return json(result); + } catch (error) { + console.error('Failed to sync git stack:', error); + return json({ error: 'Failed to sync git stack' }, { status: 500 }); + } +}; diff --git a/routes/api/git/stacks/[id]/test/+server.ts b/routes/api/git/stacks/[id]/test/+server.ts new file mode 100644 index 0000000..4cd46c2 --- /dev/null +++ b/routes/api/git/stacks/[id]/test/+server.ts @@ -0,0 +1,28 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getGitStack } from '$lib/server/db'; +import { testGitStack } from '$lib/server/git'; +import { authorize } from '$lib/server/authorize'; + +export const POST: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + + try { + const id = parseInt(params.id); + const gitStack = await getGitStack(id); + if (!gitStack) { + return json({ error: 'Git stack not found' }, { status: 404 }); + } + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('stacks', 'view', gitStack.environmentId || undefined)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + const result = await testGitStack(id); + return json(result); + } catch (error) { + console.error('Failed to test git stack:', error); + return json({ error: 'Failed to test git stack' }, { status: 500 }); + } +}; diff --git a/routes/api/git/stacks/[id]/webhook/+server.ts b/routes/api/git/stacks/[id]/webhook/+server.ts new file mode 100644 index 0000000..a2cc624 --- /dev/null +++ b/routes/api/git/stacks/[id]/webhook/+server.ts @@ -0,0 +1,97 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getGitStack } from '$lib/server/db'; +import { deployGitStack } from '$lib/server/git'; +import crypto from 'node:crypto'; + +function verifySignature(payload: string, signature: string | null, secret: string): boolean { + if (!signature) return false; + + // Support both GitHub and GitLab webhook signatures + // GitHub: sha256= + // GitLab: just the token value in X-Gitlab-Token header + + if (signature.startsWith('sha256=')) { + const expectedSignature = 'sha256=' + crypto + .createHmac('sha256', secret) + .update(payload) + .digest('hex'); + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature) + ); + } + + // GitLab uses X-Gitlab-Token which should match exactly + return signature === secret; +} + +export const POST: RequestHandler = async ({ params, request }) => { + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid stack ID' }, { status: 400 }); + } + + const gitStack = await getGitStack(id); + if (!gitStack) { + return json({ error: 'Git stack not found' }, { status: 404 }); + } + + if (!gitStack.webhookEnabled) { + return json({ error: 'Webhook is not enabled for this stack' }, { status: 403 }); + } + + // Verify webhook secret if set + if (gitStack.webhookSecret) { + const payload = await request.text(); + const githubSignature = request.headers.get('x-hub-signature-256'); + const gitlabToken = request.headers.get('x-gitlab-token'); + + const signature = githubSignature || gitlabToken; + + if (!verifySignature(payload, signature, gitStack.webhookSecret)) { + return json({ error: 'Invalid webhook signature' }, { status: 401 }); + } + } + + // Deploy the git stack (syncs and deploys only if there are changes) + const result = await deployGitStack(id, { force: false }); + return json(result); + } catch (error: any) { + console.error('Webhook error:', error); + return json({ success: false, error: error.message }, { status: 500 }); + } +}; + +// Also support GET for simple polling/manual triggers +export const GET: RequestHandler = async ({ params, url }) => { + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid stack ID' }, { status: 400 }); + } + + const gitStack = await getGitStack(id); + if (!gitStack) { + return json({ error: 'Git stack not found' }, { status: 404 }); + } + + if (!gitStack.webhookEnabled) { + return json({ error: 'Webhook is not enabled for this stack' }, { status: 403 }); + } + + // Verify secret via query parameter for GET requests + const secret = url.searchParams.get('secret'); + if (gitStack.webhookSecret && secret !== gitStack.webhookSecret) { + return json({ error: 'Invalid webhook secret' }, { status: 401 }); + } + + // Deploy the git stack (syncs and deploys only if there are changes) + const result = await deployGitStack(id, { force: false }); + return json(result); + } catch (error: any) { + console.error('Webhook GET error:', error); + return json({ success: false, error: error.message }, { status: 500 }); + } +}; diff --git a/routes/api/git/webhook/[id]/+server.ts b/routes/api/git/webhook/[id]/+server.ts new file mode 100644 index 0000000..f301355 --- /dev/null +++ b/routes/api/git/webhook/[id]/+server.ts @@ -0,0 +1,103 @@ +import { json, text } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getGitRepository } from '$lib/server/db'; +import { deployFromRepository } from '$lib/server/git'; +import crypto from 'node:crypto'; + +function verifySignature(payload: string, signature: string | null, secret: string): boolean { + if (!signature) return false; + + // Support both GitHub and GitLab webhook signatures + // GitHub: sha256= + // GitLab: just the token value in X-Gitlab-Token header + + if (signature.startsWith('sha256=')) { + const expectedSignature = 'sha256=' + crypto + .createHmac('sha256', secret) + .update(payload) + .digest('hex'); + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature) + ); + } + + // GitLab uses X-Gitlab-Token which should match exactly + return signature === secret; +} + +export const POST: RequestHandler = async ({ params, request }) => { + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid repository ID' }, { status: 400 }); + } + + const repository = await getGitRepository(id); + if (!repository) { + return json({ error: 'Repository not found' }, { status: 404 }); + } + + if (!repository.webhookEnabled) { + return json({ error: 'Webhook is not enabled for this repository' }, { status: 403 }); + } + + // Verify webhook secret if set + if (repository.webhookSecret) { + const payload = await request.text(); + const githubSignature = request.headers.get('x-hub-signature-256'); + const gitlabToken = request.headers.get('x-gitlab-token'); + + const signature = githubSignature || gitlabToken; + + if (!verifySignature(payload, signature, repository.webhookSecret)) { + return json({ error: 'Invalid webhook signature' }, { status: 401 }); + } + } + + // Optionally check which branch was pushed (for GitHub) + // const body = await request.json(); + // if (body.ref && body.ref !== `refs/heads/${repository.branch}`) { + // return json({ message: 'Push was not to tracked branch, skipping' }); + // } + + // Deploy from repository + const result = await deployFromRepository(id); + return json(result); + } catch (error: any) { + console.error('Webhook error:', error); + return json({ success: false, error: error.message }, { status: 500 }); + } +}; + +// Also support GET for simple polling/manual triggers +export const GET: RequestHandler = async ({ params, url }) => { + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid repository ID' }, { status: 400 }); + } + + const repository = await getGitRepository(id); + if (!repository) { + return json({ error: 'Repository not found' }, { status: 404 }); + } + + if (!repository.webhookEnabled) { + return json({ error: 'Webhook is not enabled for this repository' }, { status: 403 }); + } + + // Verify secret via query parameter for GET requests + const secret = url.searchParams.get('secret'); + if (repository.webhookSecret && secret !== repository.webhookSecret) { + return json({ error: 'Invalid webhook secret' }, { status: 401 }); + } + + // Deploy from repository + const result = await deployFromRepository(id); + return json(result); + } catch (error: any) { + console.error('Webhook GET error:', error); + return json({ success: false, error: error.message }, { status: 500 }); + } +}; diff --git a/routes/api/hawser/connect/+server.ts b/routes/api/hawser/connect/+server.ts new file mode 100644 index 0000000..71f1da9 --- /dev/null +++ b/routes/api/hawser/connect/+server.ts @@ -0,0 +1,61 @@ +/** + * Hawser Edge WebSocket Connect Endpoint + * + * This endpoint handles WebSocket connections from Hawser agents running in Edge mode. + * In development: WebSocket is handled by Bun.serve in vite.config.ts on port 5174 + * In production: WebSocket is handled by the server wrapper in server.ts + * + * The HTTP GET endpoint returns connection info for clients. + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { isEdgeConnected, getAllEdgeConnections } from '$lib/server/hawser'; + +/** + * GET /api/hawser/connect + * Returns status of the Hawser Edge connection endpoint + * This is used for health checks and debugging + */ +export const GET: RequestHandler = async () => { + const connections = getAllEdgeConnections(); + const connectionList = Array.from(connections.entries()).map(([envId, conn]) => ({ + environmentId: envId, + agentId: conn.agentId, + agentName: conn.agentName, + agentVersion: conn.agentVersion, + dockerVersion: conn.dockerVersion, + hostname: conn.hostname, + capabilities: conn.capabilities, + connectedAt: conn.connectedAt.toISOString(), + lastHeartbeat: conn.lastHeartbeat.toISOString() + })); + + return json({ + status: 'ready', + message: 'Hawser Edge WebSocket endpoint. Connect via WebSocket.', + protocol: 'wss:///api/hawser/connect', + activeConnections: connectionList.length, + connections: connectionList + }); +}; + +/** + * POST /api/hawser/connect + * This is a fallback for non-WebSocket clients. + * Returns instructions for connecting via WebSocket. + */ +export const POST: RequestHandler = async () => { + return json( + { + error: 'WebSocket required', + message: 'This endpoint requires a WebSocket connection. Use the ws:// or wss:// protocol.', + instructions: [ + '1. Generate a token in Settings > Environments > [Environment] > Hawser', + '2. Configure your Hawser agent with DOCKHAND_SERVER_URL and TOKEN', + '3. The agent will connect automatically' + ] + }, + { status: 426 } + ); // 426 Upgrade Required +}; diff --git a/routes/api/hawser/tokens/+server.ts b/routes/api/hawser/tokens/+server.ts new file mode 100644 index 0000000..675b978 --- /dev/null +++ b/routes/api/hawser/tokens/+server.ts @@ -0,0 +1,122 @@ +/** + * Hawser Token Management API + * + * Handles CRUD operations for Hawser agent tokens. + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { authorize } from '$lib/server/authorize'; +import { db, hawserTokens, eq, desc } from '$lib/server/db/drizzle'; +import { generateHawserToken, revokeHawserToken } from '$lib/server/hawser'; + +/** + * GET /api/hawser/tokens + * List all Hawser tokens (without revealing full token values) + */ +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + + if (auth.authEnabled && !auth.isAuthenticated) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (auth.authEnabled && !auth.isAdmin) { + return json({ error: 'Admin access required' }, { status: 403 }); + } + + try { + const tokens = await db + .select({ + id: hawserTokens.id, + tokenPrefix: hawserTokens.tokenPrefix, + name: hawserTokens.name, + environmentId: hawserTokens.environmentId, + isActive: hawserTokens.isActive, + lastUsed: hawserTokens.lastUsed, + createdAt: hawserTokens.createdAt, + expiresAt: hawserTokens.expiresAt + }) + .from(hawserTokens) + .orderBy(desc(hawserTokens.createdAt)); + + return json(tokens); + } catch (error) { + console.error('Error fetching Hawser tokens:', error); + return json({ error: 'Failed to fetch tokens' }, { status: 500 }); + } +}; + +/** + * POST /api/hawser/tokens + * Generate a new Hawser token + * + * Body: { name: string, environmentId: number, expiresAt?: string } + * Returns: { token: string, tokenId: number } - token is only shown ONCE + */ +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + + if (auth.authEnabled && !auth.isAuthenticated) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (auth.authEnabled && !auth.isAdmin) { + return json({ error: 'Admin access required' }, { status: 403 }); + } + + try { + const body = await request.json(); + const { name, environmentId, expiresAt, rawToken } = body; + + if (!name || typeof name !== 'string') { + return json({ error: 'Token name is required' }, { status: 400 }); + } + + if (!environmentId || typeof environmentId !== 'number') { + return json({ error: 'Environment ID is required' }, { status: 400 }); + } + + const result = await generateHawserToken(name, environmentId, expiresAt, rawToken); + + return json({ + token: result.token, + tokenId: result.tokenId, + message: 'Token generated successfully. Save this token - it will not be shown again.' + }); + } catch (error) { + console.error('Error generating Hawser token:', error); + return json({ error: 'Failed to generate token' }, { status: 500 }); + } +}; + +/** + * DELETE /api/hawser/tokens + * Delete (revoke) a token by ID + * + * Query: ?id= + */ +export const DELETE: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + if (auth.authEnabled && !auth.isAuthenticated) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (auth.authEnabled && !auth.isAdmin) { + return json({ error: 'Admin access required' }, { status: 403 }); + } + + const tokenId = url.searchParams.get('id'); + if (!tokenId) { + return json({ error: 'Token ID is required' }, { status: 400 }); + } + + try { + await revokeHawserToken(parseInt(tokenId, 10)); + return json({ success: true, message: 'Token revoked' }); + } catch (error) { + console.error('Error revoking Hawser token:', error); + return json({ error: 'Failed to revoke token' }, { status: 500 }); + } +}; diff --git a/routes/api/health/+server.ts b/routes/api/health/+server.ts new file mode 100644 index 0000000..2e53030 --- /dev/null +++ b/routes/api/health/+server.ts @@ -0,0 +1,6 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async () => { + return json({ status: 'ok', timestamp: new Date().toISOString() }); +}; diff --git a/routes/api/health/database/+server.ts b/routes/api/health/database/+server.ts new file mode 100644 index 0000000..72497a3 --- /dev/null +++ b/routes/api/health/database/+server.ts @@ -0,0 +1,58 @@ +/** + * Database Health Check Endpoint + * + * Returns detailed information about the database schema state, + * including migration status, table existence, and connection info. + * + * GET /api/health/database + * + * Response: + * { + * healthy: boolean, + * database: 'sqlite' | 'postgresql', + * connection: string, + * migrationsTable: boolean, + * appliedMigrations: number, + * pendingMigrations: number, + * schemaVersion: string | null, + * tables: { + * expected: number, + * found: number, + * missing: string[] + * }, + * timestamp: string + * } + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { checkSchemaHealth } from '$lib/server/db/drizzle'; + +export const GET: RequestHandler = async () => { + try { + const health = await checkSchemaHealth(); + + return json(health, { + status: health.healthy ? 200 : 503, + headers: { + 'Cache-Control': 'no-cache, no-store, must-revalidate' + } + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + + return json( + { + healthy: false, + error: message, + timestamp: new Date().toISOString() + }, + { + status: 500, + headers: { + 'Cache-Control': 'no-cache, no-store, must-revalidate' + } + } + ); + } +}; diff --git a/routes/api/host/+server.ts b/routes/api/host/+server.ts new file mode 100644 index 0000000..a7417d1 --- /dev/null +++ b/routes/api/host/+server.ts @@ -0,0 +1,158 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getDockerInfo, getHawserInfo } from '$lib/server/docker'; +import { getEnvironment } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; +import { getEdgeConnectionInfo } from '$lib/server/hawser'; +import os from 'node:os'; + +export interface HostInfo { + hostname: string; + ipAddress: string; + platform: string; + arch: string; + cpus: number; + totalMemory: number; + freeMemory: number; + uptime: number; + dockerVersion: string; + dockerContainers: number; + dockerContainersRunning: number; + dockerImages: number; + environment: { + id: number; + name: string; + icon?: string; + socketPath?: string; + connectionType?: string; + hawserVersion?: string; + highlightChanges?: boolean; + }; +} + +function getLocalIpAddress(): string { + const interfaces = os.networkInterfaces(); + for (const name of Object.keys(interfaces)) { + const netInterface = interfaces[name]; + if (!netInterface) continue; + for (const net of netInterface) { + // Skip internal and non-IPv4 addresses + if (!net.internal && net.family === 'IPv4') { + return net.address; + } + } + } + return '127.0.0.1'; +} + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + // Check basic environment view permission + if (auth.authEnabled && !await auth.can('environments', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + // Get environment ID from query param, or use default + const envIdParam = url.searchParams.get('env'); + let env; + + if (envIdParam) { + const envId = parseInt(envIdParam); + // Check if user can access this specific environment + if (auth.authEnabled && auth.isEnterprise && !await auth.canAccessEnvironment(envId)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + env = await getEnvironment(envId); + } + + if (!env) { + // No environment specified - return basic local info + return json({ + hostname: os.hostname(), + ipAddress: getLocalIpAddress(), + platform: os.platform(), + arch: os.arch(), + cpus: os.cpus().length, + totalMemory: os.totalmem(), + freeMemory: os.freemem(), + uptime: os.uptime(), + dockerVersion: null, + dockerContainers: 0, + dockerContainersRunning: 0, + dockerImages: 0, + environment: null + }); + } + + // Determine if this is a truly local connection (socket without remote host) + const isSocketType = env.connectionType === 'socket' || !env.connectionType; + const isLocalConnection = isSocketType && (!env.host || env.host === 'localhost' || env.host === '127.0.0.1'); + + // Fetch Docker info and Hawser info in parallel for hawser-standard mode + let dockerInfo: any; + let uptime = 0; + let hawserVersion: string | undefined; + + if (env.connectionType === 'hawser-standard') { + // Parallel fetch for hawser-standard + const [dockerResult, hawserInfo] = await Promise.all([ + getDockerInfo(env.id), + getHawserInfo(env.id) + ]); + dockerInfo = dockerResult; + if (hawserInfo?.uptime) { + uptime = hawserInfo.uptime; + } + if (hawserInfo?.hawserVersion) { + hawserVersion = hawserInfo.hawserVersion; + } + } else { + // Sequential for other connection types + dockerInfo = await getDockerInfo(env.id); + + if (isLocalConnection) { + uptime = os.uptime(); + } else if (env.connectionType === 'hawser-edge') { + // For Hawser edge mode, get from edge connection metrics (sync lookup) + const edgeConn = getEdgeConnectionInfo(env.id); + if (edgeConn?.lastMetrics?.uptime) { + uptime = edgeConn.lastMetrics.uptime; + } + } + // For 'direct' connections without Hawser, uptime remains 0 (not available) + } + + const hostInfo: HostInfo = { + // For local connections, show local system info; for remote, show Docker host info + hostname: isLocalConnection ? os.hostname() : (dockerInfo.Name || env.host || 'unknown'), + ipAddress: isLocalConnection ? getLocalIpAddress() : (env.host || 'unknown'), + platform: isLocalConnection ? os.platform() : (dockerInfo.OperatingSystem || 'unknown'), + arch: isLocalConnection ? os.arch() : (dockerInfo.Architecture || 'unknown'), + cpus: isLocalConnection ? os.cpus().length : (dockerInfo.NCPU || 0), + totalMemory: isLocalConnection ? os.totalmem() : (dockerInfo.MemTotal || 0), + freeMemory: isLocalConnection ? os.freemem() : 0, // Not available from Docker API + uptime, + dockerVersion: dockerInfo.ServerVersion || 'unknown', + dockerContainers: dockerInfo.Containers || 0, + dockerContainersRunning: dockerInfo.ContainersRunning || 0, + dockerImages: dockerInfo.Images || 0, + environment: { + id: env.id, + name: env.name, + icon: env.icon, + socketPath: env.socketPath, + connectionType: env.connectionType || 'socket', + // For standard mode, use live-fetched version; for edge mode, use stored version + hawserVersion: hawserVersion || env.hawserVersion, + highlightChanges: env.highlightChanges + } + }; + + return json(hostInfo); + } catch (error) { + console.error('Failed to get host info:', error); + return json({ error: 'Failed to get host info' }, { status: 500 }); + } +}; diff --git a/routes/api/images/+server.ts b/routes/api/images/+server.ts new file mode 100644 index 0000000..104f397 --- /dev/null +++ b/routes/api/images/+server.ts @@ -0,0 +1,39 @@ +import { json } from '@sveltejs/kit'; +import { listImages, EnvironmentNotFoundError } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import { hasEnvironments } from '$lib/server/db'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('images', 'view', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + // Early return if no environment specified + if (!envIdNum) { + return json([]); + } + + try { + const images = await listImages(envIdNum); + return json(images); + } catch (error) { + if (error instanceof EnvironmentNotFoundError) { + return json({ error: 'Environment not found' }, { status: 404 }); + } + console.error('Error listing images:', error); + // Return empty array instead of error to allow UI to load + return json([]); + } +}; diff --git a/routes/api/images/[id]/+server.ts b/routes/api/images/[id]/+server.ts new file mode 100644 index 0000000..d11fb59 --- /dev/null +++ b/routes/api/images/[id]/+server.ts @@ -0,0 +1,61 @@ +import { json } from '@sveltejs/kit'; +import { removeImage, inspectImage } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import { auditImage } from '$lib/server/audit'; +import type { RequestHandler } from './$types'; + +export const DELETE: RequestHandler = async (event) => { + const { params, url, cookies } = event; + const auth = await authorize(cookies); + + const force = url.searchParams.get('force') === 'true'; + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('images', 'remove', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + console.log('Delete image request - params.id:', params.id, 'force:', force, 'envId:', envIdNum); + + // Get image name for audit before deleting + let imageName = params.id; + try { + const imageInfo = await inspectImage(params.id, envIdNum); + imageName = imageInfo.RepoTags?.[0] || params.id; + } catch (e) { + console.log('Could not inspect image:', e); + // Use ID if can't get name + } + + await removeImage(params.id, force, envIdNum); + + // Audit log + await auditImage(event, 'delete', params.id, imageName, envIdNum, { force }); + + return json({ success: true }); + } catch (error: any) { + console.error('Error removing image:', error.message, 'statusCode:', error.statusCode, 'json:', error.json); + + // Handle specific Docker errors + if (error.statusCode === 409) { + const message = error.json?.message || error.message || ''; + if (message.includes('being used by running container')) { + return json({ error: 'Cannot delete image: it is being used by a running container. Stop the container first.' }, { status: 409 }); + } + if (message.includes('has dependent child images')) { + return json({ error: 'Cannot delete image: it has dependent child images. Delete those first or use force delete.' }, { status: 409 }); + } + return json({ error: message || 'Image is in use and cannot be deleted' }, { status: 409 }); + } + + return json({ error: 'Failed to remove image' }, { status: 500 }); + } +}; diff --git a/routes/api/images/[id]/export/+server.ts b/routes/api/images/[id]/export/+server.ts new file mode 100644 index 0000000..3783022 --- /dev/null +++ b/routes/api/images/[id]/export/+server.ts @@ -0,0 +1,79 @@ +import { json } from '@sveltejs/kit'; +import { exportImage, inspectImage } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import { createGzip } from 'zlib'; +import { Readable } from 'stream'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + const compress = url.searchParams.get('compress') === 'true'; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('images', 'inspect', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + + // Get image info for filename + let imageName = params.id; + try { + const imageInfo = await inspectImage(params.id, envIdNum); + if (imageInfo.RepoTags?.[0]) { + // Use first tag, replace : and / with _ for filename safety + imageName = imageInfo.RepoTags[0].replace(/[:/]/g, '_'); + } else { + // Use short ID + imageName = params.id.replace('sha256:', '').slice(0, 12); + } + } catch { + // Use ID as fallback + imageName = params.id.replace('sha256:', '').slice(0, 12); + } + + // Get the tar stream from Docker + const dockerResponse = await exportImage(params.id, envIdNum); + + if (!dockerResponse.body) { + return json({ error: 'No response body from Docker' }, { status: 500 }); + } + + const extension = compress ? 'tar.gz' : 'tar'; + const filename = `${imageName}.${extension}`; + const contentType = compress ? 'application/gzip' : 'application/x-tar'; + + if (compress) { + // Create a gzip stream and pipe the tar through it + const gzip = createGzip(); + const nodeStream = Readable.fromWeb(dockerResponse.body as any); + const compressedStream = nodeStream.pipe(gzip); + + // Convert back to web stream + const webStream = Readable.toWeb(compressedStream) as ReadableStream; + + return new Response(webStream, { + headers: { + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'no-cache' + } + }); + } else { + // Return the tar stream directly + return new Response(dockerResponse.body, { + headers: { + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'no-cache' + } + }); + } + } catch (error: any) { + console.error('Error exporting image:', error); + return json({ error: error.message || 'Failed to export image' }, { status: 500 }); + } +}; diff --git a/routes/api/images/[id]/history/+server.ts b/routes/api/images/[id]/history/+server.ts new file mode 100644 index 0000000..032a7e6 --- /dev/null +++ b/routes/api/images/[id]/history/+server.ts @@ -0,0 +1,24 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getImageHistory } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; + +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('images', 'inspect', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const history = await getImageHistory(params.id, envIdNum); + return json(history); + } catch (error) { + console.error('Failed to get image history:', error); + return json({ error: 'Failed to get image history' }, { status: 500 }); + } +}; diff --git a/routes/api/images/[id]/tag/+server.ts b/routes/api/images/[id]/tag/+server.ts new file mode 100644 index 0000000..380ae63 --- /dev/null +++ b/routes/api/images/[id]/tag/+server.ts @@ -0,0 +1,28 @@ +import { json } from '@sveltejs/kit'; +import { tagImage } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ params, request, url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context (Tagging is similar to building/modifying) + if (auth.authEnabled && !await auth.can('images', 'build', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const { repo, tag } = await request.json(); + if (!repo || typeof repo !== 'string') { + return json({ error: 'Repository name is required' }, { status: 400 }); + } + await tagImage(params.id, repo, tag || 'latest', envIdNum); + return json({ success: true }); + } catch (error) { + console.error('Error tagging image:', error); + return json({ error: 'Failed to tag image' }, { status: 500 }); + } +}; diff --git a/routes/api/images/pull/+server.ts b/routes/api/images/pull/+server.ts new file mode 100644 index 0000000..7da37d7 --- /dev/null +++ b/routes/api/images/pull/+server.ts @@ -0,0 +1,265 @@ +import { json } from '@sveltejs/kit'; +import { pullImage } from '$lib/server/docker'; +import type { RequestHandler } from './$types'; +import { getScannerSettings, scanImage } from '$lib/server/scanner'; +import { saveVulnerabilityScan, getEnvironment } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; +import { auditImage } from '$lib/server/audit'; +import { sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser'; + +/** + * Check if environment is edge mode + */ +async function isEdgeMode(envId?: number): Promise<{ isEdge: boolean; environmentId?: number }> { + if (!envId) { + return { isEdge: false }; + } + const env = await getEnvironment(envId); + if (env?.connectionType === 'hawser-edge') { + return { isEdge: true, environmentId: envId }; + } + return { isEdge: false }; +} + +/** + * Build image pull URL with proper tag handling + */ +function buildPullUrl(imageName: string): string { + let fromImage = imageName; + let tag = 'latest'; + + if (imageName.includes('@')) { + fromImage = imageName; + tag = ''; + } else if (imageName.includes(':')) { + const lastColonIndex = imageName.lastIndexOf(':'); + const potentialTag = imageName.substring(lastColonIndex + 1); + if (!potentialTag.includes('/')) { + fromImage = imageName.substring(0, lastColonIndex); + tag = potentialTag; + } + } + + return tag + ? `/images/create?fromImage=${encodeURIComponent(fromImage)}&tag=${encodeURIComponent(tag)}` + : `/images/create?fromImage=${encodeURIComponent(fromImage)}`; +} + +export const POST: RequestHandler = async (event) => { + const { request, url, cookies } = event; + const auth = await authorize(cookies); + + const envIdParam = url.searchParams.get('env'); + const envId = envIdParam ? parseInt(envIdParam) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('images', 'pull', envId)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envId && auth.isEnterprise && !await auth.canAccessEnvironment(envId)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + const { image, scanAfterPull } = await request.json(); + + // If scanAfterPull is explicitly false, skip scan-on-pull (caller will handle scanning) + const skipScanOnPull = scanAfterPull === false; + + // Audit log the pull attempt + await auditImage(event, 'pull', image, image, envId); + + // Check if this is an edge environment + const edgeCheck = await isEdgeMode(envId); + + const encoder = new TextEncoder(); + let controllerClosed = false; + let controller: ReadableStreamDefaultController; + let heartbeatInterval: ReturnType | null = null; + let cancelEdgeStream: (() => void) | null = null; + + const safeEnqueue = (data: string) => { + if (!controllerClosed) { + try { + controller.enqueue(encoder.encode(data)); + } catch { + controllerClosed = true; + } + } + }; + + const cleanup = () => { + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + if (cancelEdgeStream) { + cancelEdgeStream(); + cancelEdgeStream = null; + } + controllerClosed = true; + }; + + /** + * Handle scan-on-pull after image is pulled + */ + const handleScanOnPull = async () => { + // Skip if caller explicitly requested no scan (e.g., CreateContainerModal handles scanning separately) + if (skipScanOnPull) return; + + const { scanner } = await getScannerSettings(envId); + // Scan if scanning is enabled (scanner !== 'none') + if (scanner !== 'none') { + safeEnqueue(`data: ${JSON.stringify({ status: 'scanning', message: 'Starting vulnerability scan...' })}\n\n`); + + try { + const results = await scanImage(image, envId, (progress) => { + safeEnqueue(`data: ${JSON.stringify({ status: 'scan-progress', ...progress })}\n\n`); + }); + + for (const result of results) { + await saveVulnerabilityScan({ + environmentId: envId ?? null, + imageId: result.imageId, + imageName: result.imageName, + scanner: result.scanner, + scannedAt: result.scannedAt, + scanDuration: result.scanDuration, + criticalCount: result.summary.critical, + highCount: result.summary.high, + mediumCount: result.summary.medium, + lowCount: result.summary.low, + negligibleCount: result.summary.negligible, + unknownCount: result.summary.unknown, + vulnerabilities: result.vulnerabilities, + error: result.error ?? null + }); + } + + const totalVulns = results.reduce((sum, r) => sum + r.vulnerabilities.length, 0); + safeEnqueue(`data: ${JSON.stringify({ + status: 'scan-complete', + message: `Scan complete - found ${totalVulns} vulnerabilities`, + results + })}\n\n`); + } catch (scanError) { + console.error('Scan-on-pull failed:', scanError); + safeEnqueue(`data: ${JSON.stringify({ + status: 'scan-error', + error: scanError instanceof Error ? scanError.message : String(scanError) + })}\n\n`); + } + } + }; + + const stream = new ReadableStream({ + async start(ctrl) { + controller = ctrl; + + // Start heartbeat to keep connection alive through Traefik (10s idle timeout) + heartbeatInterval = setInterval(() => { + safeEnqueue(`: keepalive\n\n`); + }, 5000); + + console.log(`Starting pull for image: ${image}${edgeCheck.isEdge ? ' (edge mode)' : ''}`); + + // Handle edge mode with streaming + if (edgeCheck.isEdge && edgeCheck.environmentId) { + if (!isEdgeConnected(edgeCheck.environmentId)) { + safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: 'Edge agent not connected' })}\n\n`); + cleanup(); + controller.close(); + return; + } + + const pullUrl = buildPullUrl(image); + + const { cancel } = sendEdgeStreamRequest( + edgeCheck.environmentId, + 'POST', + pullUrl, + { + onData: (data: string) => { + // Data is base64 encoded JSON lines from Docker + try { + const decoded = Buffer.from(data, 'base64').toString('utf-8'); + // Docker sends newline-delimited JSON + const lines = decoded.split('\n').filter(line => line.trim()); + for (const line of lines) { + try { + const progress = JSON.parse(line); + safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`); + } catch { + // Ignore parse errors for partial lines + } + } + } catch { + // If not base64, try as-is + try { + const progress = JSON.parse(data); + safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`); + } catch { + // Ignore + } + } + }, + onEnd: async () => { + safeEnqueue(`data: ${JSON.stringify({ status: 'complete' })}\n\n`); + + // Handle scan-on-pull + await handleScanOnPull(); + + cleanup(); + controller.close(); + }, + onError: (error: string) => { + console.error('Edge pull error:', error); + safeEnqueue(`data: ${JSON.stringify({ status: 'error', error })}\n\n`); + cleanup(); + controller.close(); + } + } + ); + + cancelEdgeStream = cancel; + } else { + // Non-edge mode: use existing pullImage function + try { + await pullImage(image, (progress) => { + const data = JSON.stringify(progress) + '\n'; + safeEnqueue(`data: ${data}\n\n`); + }, envId); + + safeEnqueue(`data: ${JSON.stringify({ status: 'complete' })}\n\n`); + + // Handle scan-on-pull + await handleScanOnPull(); + + cleanup(); + controller.close(); + } catch (error) { + console.error('Error pulling image:', error); + safeEnqueue(`data: ${JSON.stringify({ + status: 'error', + error: String(error) + })}\n\n`); + cleanup(); + controller.close(); + } + } + }, + cancel() { + cleanup(); + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no' + } + }); +}; diff --git a/routes/api/images/push/+server.ts b/routes/api/images/push/+server.ts new file mode 100644 index 0000000..0a4b32b --- /dev/null +++ b/routes/api/images/push/+server.ts @@ -0,0 +1,303 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { inspectImage, tagImage, pushImage } from '$lib/server/docker'; +import { getRegistry, getEnvironment } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; +import { auditImage } from '$lib/server/audit'; +import { sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser'; + +/** + * Check if environment is edge mode + */ +async function isEdgeMode(envId?: number): Promise<{ isEdge: boolean; environmentId?: number }> { + if (!envId) { + return { isEdge: false }; + } + const env = await getEnvironment(envId); + if (env?.connectionType === 'hawser-edge') { + return { isEdge: true, environmentId: envId }; + } + return { isEdge: false }; +} + +export const POST: RequestHandler = async (event) => { + const { request, url, cookies } = event; + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('images', 'push', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const { imageId, imageName, registryId, newTag } = await request.json(); + + if (!imageId || !registryId) { + return json({ error: 'Image ID and registry ID are required' }, { status: 400 }); + } + + const registry = await getRegistry(registryId); + if (!registry) { + return json({ error: 'Registry not found' }, { status: 404 }); + } + + // Get the image info + const imageInfo = await inspectImage(imageId, envIdNum) as any; + + // Determine the source tag to use + let sourceTag = imageName; + if (!sourceTag && imageInfo.RepoTags && imageInfo.RepoTags.length > 0) { + sourceTag = imageInfo.RepoTags[0]; + } + + if (!sourceTag || sourceTag === ':') { + return json({ error: 'Image has no tag. Please provide a tag name.' }, { status: 400 }); + } + + // Extract just the image name (without registry prefix if any) + let baseImageName = sourceTag; + // Remove any existing registry prefix (e.g., "registry.example.com/myimage:tag" -> "myimage:tag") + if (baseImageName.includes('/')) { + const parts = baseImageName.split('/'); + // Check if first part looks like a registry (contains . or :) + if (parts[0].includes('.') || parts[0].includes(':')) { + baseImageName = parts.slice(1).join('/'); + } + } + + // Build the target tag + const registryUrl = new URL(registry.url); + const registryHost = registryUrl.host; + + // Check if this is Docker Hub + const isDockerHub = registryHost.includes('docker.io') || + registryHost.includes('hub.docker.com') || + registryHost.includes('registry.hub.docker.com') || + registryHost.includes('index.docker.io'); + + // Use custom tag if provided, otherwise use the base image name + const targetImageName = newTag || baseImageName; + // Docker Hub doesn't need host prefix - just username/image:tag + const targetTag = isDockerHub ? targetImageName : `${registryHost}/${targetImageName}`; + + // Parse repo and tag properly (handle registry:port/image:tag format) + // Find the last colon that's after the last slash (that's the tag separator) + const lastSlashIndex = targetTag.lastIndexOf('/'); + const tagPart = targetTag.substring(lastSlashIndex + 1); + const colonInTagIndex = tagPart.lastIndexOf(':'); + + let repo: string; + let tag: string; + + if (colonInTagIndex !== -1) { + // Tag exists after the last slash + repo = targetTag.substring(0, lastSlashIndex + 1 + colonInTagIndex); + tag = tagPart.substring(colonInTagIndex + 1); + } else { + // No tag, use 'latest' + repo = targetTag; + tag = 'latest'; + } + + // Prepare auth config + // Docker Hub uses index.docker.io/v1 for auth + const authServerAddress = isDockerHub ? 'https://index.docker.io/v1/' : registryHost; + const authConfig = registry.username && registry.password + ? { + username: registry.username, + password: registry.password, + serveraddress: authServerAddress + } + : { + serveraddress: authServerAddress + }; + + // Check if this is an edge environment + const edgeCheck = await isEdgeMode(envIdNum); + + // Stream the push progress + const encoder = new TextEncoder(); + let controllerClosed = false; + let controller: ReadableStreamDefaultController; + let heartbeatInterval: ReturnType | null = null; + let cancelEdgeStream: (() => void) | null = null; + + const safeEnqueue = (data: string) => { + if (!controllerClosed) { + try { + controller.enqueue(encoder.encode(data)); + } catch { + controllerClosed = true; + } + } + }; + + const cleanup = () => { + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + if (cancelEdgeStream) { + cancelEdgeStream(); + cancelEdgeStream = null; + } + controllerClosed = true; + }; + + const formatError = (error: any): string => { + const errorMessage = error.message || error || ''; + let userMessage = errorMessage || 'Failed to push image'; + + if (error.statusCode === 401 || errorMessage.includes('401')) { + userMessage = 'Authentication failed. Check registry credentials.'; + } else if (error.statusCode === 404 || errorMessage.includes('404')) { + userMessage = 'Image not found'; + } else if (errorMessage.includes('https') || errorMessage.includes('tls') || errorMessage.includes('certificate') || errorMessage.includes('x509')) { + userMessage = `TLS/HTTPS error. If your registry uses HTTP, add it to Docker's insecure-registries in /etc/docker/daemon.json`; + } + + return userMessage; + }; + + const stream = new ReadableStream({ + async start(ctrl) { + controller = ctrl; + + // Start heartbeat to keep connection alive through Traefik (10s idle timeout) + heartbeatInterval = setInterval(() => { + safeEnqueue(`: keepalive\n\n`); + }, 5000); + + try { + // Send tagging status + safeEnqueue(`data: ${JSON.stringify({ status: 'tagging', message: 'Tagging image...' })}\n\n`); + + // Tag the image with the target registry + await tagImage(imageId, repo, tag, envIdNum); + + // Send pushing status + safeEnqueue(`data: ${JSON.stringify({ status: 'pushing', message: 'Pushing to registry...' })}\n\n`); + + // Handle edge mode with streaming + if (edgeCheck.isEdge && edgeCheck.environmentId) { + if (!isEdgeConnected(edgeCheck.environmentId)) { + safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: 'Edge agent not connected' })}\n\n`); + cleanup(); + controller.close(); + return; + } + + // Create X-Registry-Auth header + const authHeader = Buffer.from(JSON.stringify(authConfig)).toString('base64'); + + const { cancel } = sendEdgeStreamRequest( + edgeCheck.environmentId, + 'POST', + `/images/${encodeURIComponent(targetTag)}/push`, + { + onData: (data: string) => { + // Data is base64 encoded JSON lines from Docker + try { + const decoded = Buffer.from(data, 'base64').toString('utf-8'); + const lines = decoded.split('\n').filter(line => line.trim()); + for (const line of lines) { + try { + const progress = JSON.parse(line); + if (progress.error) { + safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: formatError(progress.error) })}\n\n`); + } else { + safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`); + } + } catch { + // Ignore parse errors for partial lines + } + } + } catch { + // If not base64, try as-is + try { + const progress = JSON.parse(data); + if (progress.error) { + safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: formatError(progress.error) })}\n\n`); + } else { + safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`); + } + } catch { + // Ignore + } + } + }, + onEnd: async () => { + // Audit log + await auditImage(event, 'push', imageId, imageName || targetTag, envIdNum, { targetTag, registry: registry.name }); + + safeEnqueue(`data: ${JSON.stringify({ + status: 'complete', + message: `Image pushed to ${targetTag}`, + targetTag + })}\n\n`); + + cleanup(); + controller.close(); + }, + onError: (error: string) => { + console.error('Edge push error:', error); + safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: formatError(error) })}\n\n`); + cleanup(); + controller.close(); + } + }, + undefined, + { 'X-Registry-Auth': authHeader } + ); + + cancelEdgeStream = cancel; + } else { + // Non-edge mode: use existing pushImage function + await pushImage(targetTag, authConfig, (progress) => { + safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`); + }, envIdNum); + + // Audit log + await auditImage(event, 'push', imageId, imageName || targetTag, envIdNum, { targetTag, registry: registry.name }); + + // Send completion message + safeEnqueue(`data: ${JSON.stringify({ + status: 'complete', + message: `Image pushed to ${targetTag}`, + targetTag + })}\n\n`); + + cleanup(); + controller.close(); + } + } catch (error: any) { + console.error('Error pushing image:', error); + safeEnqueue(`data: ${JSON.stringify({ + status: 'error', + error: formatError(error) + })}\n\n`); + cleanup(); + controller.close(); + } + }, + cancel() { + cleanup(); + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no' + } + }); + } catch (error: any) { + console.error('Error setting up push:', error); + return json({ error: error.message || 'Failed to push image' }, { status: 500 }); + } +}; diff --git a/routes/api/images/scan/+server.ts b/routes/api/images/scan/+server.ts new file mode 100644 index 0000000..ae503dc --- /dev/null +++ b/routes/api/images/scan/+server.ts @@ -0,0 +1,149 @@ +import { json, type RequestHandler } from '@sveltejs/kit'; +import { scanImage, type ScanProgress, type ScanResult } from '$lib/server/scanner'; +import { saveVulnerabilityScan, getLatestScanForImage } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; + +// Helper to convert ScanResult to database format +function scanResultToDbFormat(result: ScanResult, envId?: number) { + return { + environmentId: envId ?? null, + imageId: result.imageId || result.imageName, // Fallback to imageName if imageId is undefined + imageName: result.imageName, + scanner: result.scanner, + scannedAt: result.scannedAt, + scanDuration: result.scanDuration, + criticalCount: result.summary.critical, + highCount: result.summary.high, + mediumCount: result.summary.medium, + lowCount: result.summary.low, + negligibleCount: result.summary.negligible, + unknownCount: result.summary.unknown, + vulnerabilities: JSON.stringify(result.vulnerabilities), + error: result.error ?? null + }; +} + +// POST - Start a scan (returns SSE stream for progress) +export const POST: RequestHandler = async ({ request, url, cookies }) => { + const auth = await authorize(cookies); + + const envIdParam = url.searchParams.get('env'); + const envId = envIdParam ? parseInt(envIdParam) : undefined; + + // Permission check with environment context (Scanning is an inspect operation) + if (auth.authEnabled && !await auth.can('images', 'inspect', envId)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + const body = await request.json(); + const { imageName, scanner: forceScannerType } = body; + + if (!imageName) { + return json({ error: 'Image name is required' }, { status: 400 }); + } + + // Create a readable stream for SSE + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + let controllerClosed = false; + + const sendProgress = (progress: ScanProgress) => { + if (controllerClosed) return; + try { + const data = `data: ${JSON.stringify(progress)}\n\n`; + controller.enqueue(encoder.encode(data)); + } catch { + controllerClosed = true; + } + }; + + // Send SSE keepalive comments every 5s to prevent Traefik timeout + const keepaliveInterval = setInterval(() => { + if (controllerClosed) return; + try { + controller.enqueue(encoder.encode(`: keepalive\n\n`)); + } catch { + controllerClosed = true; + } + }, 5000); + + try { + const results = await scanImage(imageName, envId, sendProgress, forceScannerType); + + // Save results to database + for (const result of results) { + await saveVulnerabilityScan(scanResultToDbFormat(result, envId)); + } + + // Send final complete message with all results + sendProgress({ + stage: 'complete', + message: `Scan complete - found ${results.reduce((sum, r) => sum + r.vulnerabilities.length, 0)} vulnerabilities`, + progress: 100, + result: results[0], + results: results // Include all scanner results + }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + sendProgress({ + stage: 'error', + message: `Scan failed: ${errorMsg}`, + error: errorMsg + }); + } finally { + clearInterval(keepaliveInterval); + if (!controllerClosed) { + try { + controller.close(); + } catch { + // Already closed + } + } + } + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + } + }); +}; + +// GET - Get cached scan results for an image +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + const imageName = url.searchParams.get('image'); + const envIdParam = url.searchParams.get('env'); + const envId = envIdParam ? parseInt(envIdParam) : undefined; + const scanner = url.searchParams.get('scanner') as 'grype' | 'trivy' | undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('images', 'view', envId)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + if (!imageName) { + return json({ error: 'Image name is required' }, { status: 400 }); + } + + try { + // Note: getLatestScanForImage signature is (imageId, scanner, environmentId) + const result = await getLatestScanForImage(imageName, scanner, envId); + if (!result) { + return json({ found: false }); + } + + return json({ + found: true, + result + }); + } catch (error) { + console.error('Failed to get scan results:', error); + return json({ error: 'Failed to get scan results' }, { status: 500 }); + } +}; diff --git a/routes/api/legal/license/+server.ts b/routes/api/legal/license/+server.ts new file mode 100644 index 0000000..908d53b --- /dev/null +++ b/routes/api/legal/license/+server.ts @@ -0,0 +1,21 @@ +import { json, text } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export const GET: RequestHandler = async ({ url }) => { + try { + const licensePath = join(process.cwd(), 'LICENSE.txt'); + const content = readFileSync(licensePath, 'utf-8'); + + // Return as plain text if requested + if (url.searchParams.get('format') === 'text') { + return text(content); + } + + return json({ content }); + } catch (error) { + console.error('Failed to read LICENSE.txt:', error); + return json({ error: 'License file not found' }, { status: 404 }); + } +}; diff --git a/routes/api/legal/privacy/+server.ts b/routes/api/legal/privacy/+server.ts new file mode 100644 index 0000000..f14a994 --- /dev/null +++ b/routes/api/legal/privacy/+server.ts @@ -0,0 +1,21 @@ +import { json, text } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export const GET: RequestHandler = async ({ url }) => { + try { + const privacyPath = join(process.cwd(), 'PRIVACY.txt'); + const content = readFileSync(privacyPath, 'utf-8'); + + // Return as plain text if requested + if (url.searchParams.get('format') === 'text') { + return text(content); + } + + return json({ content }); + } catch (error) { + console.error('Failed to read PRIVACY.txt:', error); + return json({ error: 'Privacy policy file not found' }, { status: 404 }); + } +}; diff --git a/routes/api/license/+server.ts b/routes/api/license/+server.ts new file mode 100644 index 0000000..0c1392f --- /dev/null +++ b/routes/api/license/+server.ts @@ -0,0 +1,92 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { + getLicenseStatus, + activateLicense, + deactivateLicense, + getHostname +} from '$lib/server/license'; +import { authorize } from '$lib/server/authorize'; + +// GET /api/license - Get current license status +// Any authenticated user can view license status (needed to determine if RBAC applies) +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !auth.isAuthenticated) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + + try { + const status = await getLicenseStatus(); + const hostname = getHostname(); + + return json({ + ...status, + hostname + }); + } catch (error) { + console.error('Error getting license status:', error); + return json( + { error: 'Failed to get license status' }, + { status: 500 } + ); + } +}; + +// POST /api/license - Activate a license +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('license', 'manage')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const { name, key } = await request.json(); + + if (!name || !key) { + return json( + { error: 'Name and key are required' }, + { status: 400 } + ); + } + + const result = await activateLicense(name, key); + + if (!result.success) { + return json( + { error: result.error }, + { status: 400 } + ); + } + + return json({ + success: true, + license: result.license + }); + } catch (error) { + console.error('Error activating license:', error); + return json( + { error: 'Failed to activate license' }, + { status: 500 } + ); + } +}; + +// DELETE /api/license - Deactivate license +export const DELETE: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('license', 'manage')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + await deactivateLicense(); + return json({ success: true }); + } catch (error) { + console.error('Error deactivating license:', error); + return json( + { error: 'Failed to deactivate license' }, + { status: 500 } + ); + } +}; diff --git a/routes/api/logs/merged/+server.ts b/routes/api/logs/merged/+server.ts new file mode 100644 index 0000000..e3d0715 --- /dev/null +++ b/routes/api/logs/merged/+server.ts @@ -0,0 +1,667 @@ +import type { RequestHandler } from './$types'; +import { authorize } from '$lib/server/authorize'; +import { getEnvironment } from '$lib/server/db'; +import { sendEdgeRequest, sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; + +// Detect Docker socket path +function detectDockerSocket(): string { + if (process.env.DOCKER_SOCKET && existsSync(process.env.DOCKER_SOCKET)) { + return process.env.DOCKER_SOCKET; + } + if (process.env.DOCKER_HOST?.startsWith('unix://')) { + const socketPath = process.env.DOCKER_HOST.replace('unix://', ''); + if (existsSync(socketPath)) return socketPath; + } + const possibleSockets = [ + '/var/run/docker.sock', + `${homedir()}/.docker/run/docker.sock`, + `${homedir()}/.orbstack/run/docker.sock`, + '/run/docker.sock' + ]; + for (const socket of possibleSockets) { + if (existsSync(socket)) return socket; + } + return '/var/run/docker.sock'; +} + +const socketPath = detectDockerSocket(); + +interface DockerClientConfig { + type: 'socket' | 'http' | 'https' | 'hawser-edge'; + socketPath?: string; + host?: string; + port?: number; + ca?: string; + cert?: string; + key?: string; + hawserToken?: string; + environmentId?: number; +} + +async function getDockerConfig(envId?: number | null): Promise { + if (!envId) { + return null; + } + const env = await getEnvironment(envId); + if (!env) { + return null; + } + if (env.connectionType === 'socket' || !env.connectionType) { + return { type: 'socket', socketPath: env.socketPath || socketPath }; + } + if (env.connectionType === 'hawser-edge') { + return { type: 'hawser-edge', environmentId: envId }; + } + const protocol = (env.protocol as 'http' | 'https') || 'http'; + return { + type: protocol, + host: env.host || 'localhost', + port: env.port || 2375, + ca: env.tlsCa || undefined, + cert: env.tlsCert || undefined, + key: env.tlsKey || undefined, + hawserToken: env.connectionType === 'hawser-standard' ? env.hawserToken || undefined : undefined + }; +} + +/** + * Parse Docker log line with timestamp + * Format: 2024-01-15T10:30:00.123456789Z log content here + */ +function parseTimestampedLog(line: string): { timestamp: Date | null; content: string } { + // Match RFC3339Nano timestamp at start of line + const match = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)\s*/); + if (match) { + return { + timestamp: new Date(match[1]), + content: line.slice(match[0].length) + }; + } + return { timestamp: null, content: line }; +} + +/** + * Demultiplex Docker stream frame - returns payload and stream type + */ +function parseDockerFrame(buffer: Buffer, offset: number): { type: number; size: number; payload: string } | null { + if (buffer.length < offset + 8) return null; + + const streamType = buffer.readUInt8(offset); + const frameSize = buffer.readUInt32BE(offset + 4); + + if (buffer.length < offset + 8 + frameSize) return null; + + const payload = buffer.slice(offset + 8, offset + 8 + frameSize).toString('utf-8'); + return { type: streamType, size: 8 + frameSize, payload }; +} + +// Color palette for different containers +const CONTAINER_COLORS = [ + '#60a5fa', // blue + '#4ade80', // green + '#f472b6', // pink + '#facc15', // yellow + '#a78bfa', // purple + '#fb923c', // orange + '#22d3ee', // cyan + '#f87171', // red + '#34d399', // emerald + '#c084fc', // violet +]; + +interface ContainerLogSource { + containerId: string; + containerName: string; + color: string; + hasTty: boolean; + reader: ReadableStreamDefaultReader | null; + buffer: Buffer; + done: boolean; +} + +interface EdgeContainerLogSource { + containerId: string; + containerName: string; + color: string; + hasTty: boolean; + buffer: Buffer; + done: boolean; + cancel: () => void; +} + +/** + * Handle merged logs streaming for Hawser Edge connections + */ +async function handleEdgeMergedLogs(containerIds: string[], tail: string, environmentId: number): Promise { + // Check if edge agent is connected + if (!isEdgeConnected(environmentId)) { + return new Response(JSON.stringify({ error: 'Edge agent not connected' }), { + status: 503, + headers: { 'Content-Type': 'application/json' } + }); + } + + let controllerClosed = false; + let heartbeatInterval: ReturnType | null = null; + const sources: EdgeContainerLogSource[] = []; + + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + + const safeEnqueue = (data: string) => { + if (!controllerClosed) { + try { + controller.enqueue(encoder.encode(data)); + } catch { + controllerClosed = true; + } + } + }; + + // Send heartbeat to keep connection alive (every 5s to prevent Traefik 10s idle timeout) + heartbeatInterval = setInterval(() => { + safeEnqueue(`event: heartbeat\ndata: ${JSON.stringify({ timestamp: new Date().toISOString() })}\n\n`); + }, 5000); + + // Setup function for a single container via Edge + const setupEdgeContainer = async (containerId: string, index: number): Promise => { + try { + // Get container info (name and TTY status) + const inspectPath = `/containers/${containerId}/json`; + const inspectResponse = await sendEdgeRequest(environmentId, 'GET', inspectPath); + + if (inspectResponse.statusCode !== 200) { + console.log(`[merged-logs-edge] Inspect failed for ${containerId.slice(0, 12)}, skipping`); + return null; + } + + const info = JSON.parse(inspectResponse.body as string); + const containerName = info.Name?.replace(/^\//, '') || containerId.slice(0, 12); + const hasTty = info.Config?.Tty ?? false; + + const source: EdgeContainerLogSource = { + containerId, + containerName, + color: CONTAINER_COLORS[index % CONTAINER_COLORS.length], + hasTty, + buffer: Buffer.alloc(0), + done: false, + cancel: () => {} + }; + + // Start log stream for this container via Edge + const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&tail=${tail}×tamps=true`; + + const { cancel } = sendEdgeStreamRequest( + environmentId, + 'GET', + logsPath, + { + onData: (data: string, streamType?: 'stdout' | 'stderr') => { + if (controllerClosed || source.done) return; + + if (hasTty) { + // TTY mode: data is raw text, may be base64 encoded + let text = data; + try { + text = Buffer.from(data, 'base64').toString('utf-8'); + } catch { + // Not base64, use as-is + } + + const lines = text.split('\n'); + for (const line of lines) { + if (line.trim()) { + const { timestamp, content } = parseTimestampedLog(line); + safeEnqueue(`event: log\ndata: ${JSON.stringify({ + containerId: source.containerId, + containerName: source.containerName, + color: source.color, + text: content + '\n', + timestamp: timestamp?.toISOString() + })}\n\n`); + } + } + } else { + // Non-TTY mode: data might be base64 encoded Docker multiplexed stream + let rawData: Buffer; + try { + rawData = Buffer.from(data, 'base64'); + } catch { + rawData = Buffer.from(data, 'utf-8'); + } + + source.buffer = Buffer.concat([source.buffer, rawData]); + + // Process complete frames + let offset = 0; + while (true) { + const frame = parseDockerFrame(source.buffer, offset); + if (!frame) break; + + if (frame.payload) { + const lines = frame.payload.split('\n'); + for (const line of lines) { + if (line.trim()) { + const { timestamp, content } = parseTimestampedLog(line); + safeEnqueue(`event: log\ndata: ${JSON.stringify({ + containerId: source.containerId, + containerName: source.containerName, + color: source.color, + text: content + '\n', + timestamp: timestamp?.toISOString(), + stream: frame.type === 2 ? 'stderr' : 'stdout' + })}\n\n`); + } + } + } + offset += frame.size; + } + + source.buffer = source.buffer.slice(offset); + } + }, + onEnd: (reason?: string) => { + source.done = true; + // Check if all sources are done + if (sources.every(s => s.done)) { + safeEnqueue(`event: end\ndata: ${JSON.stringify({ reason: 'all streams ended' })}\n\n`); + if (!controllerClosed) { + try { + controller.close(); + } catch { + // Already closed + } + } + } + }, + onError: (error: string) => { + console.error(`[merged-logs-edge] Error from ${containerName}:`, error); + source.done = true; + } + } + ); + + source.cancel = cancel; + return source; + } catch (error) { + console.error(`[merged-logs-edge] Error setting up log source for ${containerId}:`, error); + return null; + } + }; + + // Setup all containers in parallel + console.log(`[merged-logs-edge] Setting up ${containerIds.length} containers in parallel...`); + const setupStart = Date.now(); + const results = await Promise.all( + containerIds.map((id, index) => setupEdgeContainer(id, index)) + ); + console.log(`[merged-logs-edge] Parallel setup completed in ${Date.now() - setupStart}ms`); + + // Filter out failed containers + for (const result of results) { + if (result) { + sources.push(result); + } + } + + if (sources.length === 0) { + console.log('[merged-logs-edge] No valid sources, returning error'); + safeEnqueue(`event: error\ndata: ${JSON.stringify({ error: 'No valid containers found' })}\n\n`); + if (!controllerClosed) controller.close(); + return; + } + + console.log(`[merged-logs-edge] Sources ready: ${sources.length}, sending connected event`); + // Send connected event with container info + safeEnqueue(`event: connected\ndata: ${JSON.stringify({ + containers: sources.map(s => ({ + id: s.containerId, + name: s.containerName, + color: s.color + })) + })}\n\n`); + + // Edge streaming is handled by callbacks, no polling loop needed + }, + cancel() { + controllerClosed = true; + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + // Cancel all active streams + for (const source of sources) { + if (source.cancel) { + source.cancel(); + } + } + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no' + } + }); +} + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + // Parse container IDs from comma-separated list + const containerIds = url.searchParams.get('containers')?.split(',').filter(Boolean) || []; + const tail = url.searchParams.get('tail') || '100'; + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('containers', 'logs', envIdNum)) { + return new Response(JSON.stringify({ error: 'Permission denied' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } + + if (containerIds.length === 0) { + return new Response(JSON.stringify({ error: 'No containers specified' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + console.log(`[merged-logs] Request: containers=${containerIds.length}, env=${envId}`); + const config = await getDockerConfig(envIdNum); + console.log(`[merged-logs] Config: type=${config.type}, host=${config.host}, port=${config.port}`); + + // Handle Hawser Edge mode separately + if (config.type === 'hawser-edge') { + return handleEdgeMergedLogs(containerIds, tail, config.environmentId!); + } + + let controllerClosed = false; + const abortControllers: AbortController[] = []; + let heartbeatInterval: ReturnType | null = null; + + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + + const safeEnqueue = (data: string) => { + if (!controllerClosed) { + try { + controller.enqueue(encoder.encode(data)); + } catch { + controllerClosed = true; + } + } + }; + + // Send heartbeat to keep connection alive (every 5s to prevent Traefik 10s idle timeout) + heartbeatInterval = setInterval(() => { + safeEnqueue(`event: heartbeat\ndata: ${JSON.stringify({ timestamp: new Date().toISOString() })}\n\n`); + }, 5000); + + // Initialize log sources for each container - PARALLEL setup for better performance + const sources: ContainerLogSource[] = []; + + // Setup function for a single container + const setupContainer = async (containerId: string, index: number): Promise => { + const abortController = new AbortController(); + abortControllers.push(abortController); + + try { + // Get container info (name and TTY status) + const inspectPath = `/containers/${containerId}/json`; + let inspectResponse: Response; + + if (config.type === 'socket') { + inspectResponse = await fetch(`http://localhost${inspectPath}`, { + // @ts-ignore - Bun supports unix socket + unix: config.socketPath + }); + } else { + const inspectUrl = `${config.type}://${config.host}:${config.port}${inspectPath}`; + const inspectHeaders: Record = {}; + if (config.hawserToken) inspectHeaders['X-Hawser-Token'] = config.hawserToken; + + // Build fetch options - only include tls for HTTPS + const fetchOptions: RequestInit & { tls?: unknown } = { + headers: inspectHeaders, + signal: AbortSignal.timeout(30000) // 30 second timeout for inspect + }; + if (config.type === 'https' && config.ca) { + // @ts-ignore - Bun TLS option + fetchOptions.tls = { ca: config.ca, cert: config.cert, key: config.key }; + } + + inspectResponse = await fetch(inspectUrl, fetchOptions); + } + + if (!inspectResponse.ok) { + console.log(`[merged-logs] Inspect failed for ${containerId.slice(0, 12)}, skipping`); + return null; + } + + const info = await inspectResponse.json(); + const containerName = info.Name?.replace(/^\//, '') || containerId.slice(0, 12); + const hasTty = info.Config?.Tty ?? false; + + // Start log stream for this container + const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&tail=${tail}×tamps=true`; + let logsResponse: Response; + + if (config.type === 'socket') { + logsResponse = await fetch(`http://localhost${logsPath}`, { + // @ts-ignore - Bun supports unix socket + unix: config.socketPath, + signal: abortController.signal + }); + } else { + const logsUrl = `${config.type}://${config.host}:${config.port}${logsPath}`; + const logsHeaders: Record = {}; + if (config.hawserToken) logsHeaders['X-Hawser-Token'] = config.hawserToken; + + // For logs streaming, use the cleanup abort controller without a timeout + // (the stream needs to stay open indefinitely) + const fetchOptions: RequestInit & { tls?: unknown } = { + headers: logsHeaders, + signal: abortController.signal + }; + if (config.type === 'https' && config.ca) { + // @ts-ignore - Bun TLS option + fetchOptions.tls = { ca: config.ca, cert: config.cert, key: config.key }; + } + + logsResponse = await fetch(logsUrl, fetchOptions); + } + + if (!logsResponse.ok) { + console.error(`[merged-logs] Failed to get logs for container ${containerId}: ${logsResponse.status}`); + return null; + } + + const reader = logsResponse.body?.getReader() || null; + + return { + containerId, + containerName, + color: CONTAINER_COLORS[index % CONTAINER_COLORS.length], + hasTty, + reader, + buffer: Buffer.alloc(0), + done: false + }; + } catch (error) { + console.error(`Error setting up log source for ${containerId}:`, error); + return null; + } + }; + + // Setup all containers in parallel + console.log(`[merged-logs] Setting up ${containerIds.length} containers in parallel...`); + const setupStart = Date.now(); + const results = await Promise.all( + containerIds.map((id, index) => setupContainer(id, index)) + ); + console.log(`[merged-logs] Parallel setup completed in ${Date.now() - setupStart}ms`); + + // Filter out failed containers + for (const result of results) { + if (result) { + sources.push(result); + } + } + + if (sources.length === 0) { + console.log('[merged-logs] No valid sources, returning error'); + safeEnqueue(`event: error\ndata: ${JSON.stringify({ error: 'No valid containers found' })}\n\n`); + if (!controllerClosed) controller.close(); + return; + } + + console.log(`[merged-logs] Sources ready: ${sources.length}, sending connected event`); + // Send connected event with container info + safeEnqueue(`event: connected\ndata: ${JSON.stringify({ + containers: sources.map(s => ({ + id: s.containerId, + name: s.containerName, + color: s.color + })) + })}\n\n`); + + // Process logs from all sources + const processSource = async (source: ContainerLogSource) => { + if (!source.reader || source.done) return; + + try { + const { done, value } = await source.reader.read(); + + if (done) { + source.done = true; + return; + } + + if (value) { + if (source.hasTty) { + // TTY mode: raw text + const text = new TextDecoder().decode(value); + const lines = text.split('\n'); + for (const line of lines) { + if (line.trim()) { + const { timestamp, content } = parseTimestampedLog(line); + safeEnqueue(`event: log\ndata: ${JSON.stringify({ + containerId: source.containerId, + containerName: source.containerName, + color: source.color, + text: content + '\n', + timestamp: timestamp?.toISOString() + })}\n\n`); + } + } + } else { + // Non-TTY mode: demux Docker stream frames + source.buffer = Buffer.concat([source.buffer, Buffer.from(value)]); + + let offset = 0; + while (true) { + const frame = parseDockerFrame(source.buffer, offset); + if (!frame) break; + + if (frame.payload) { + const lines = frame.payload.split('\n'); + for (const line of lines) { + if (line.trim()) { + const { timestamp, content } = parseTimestampedLog(line); + safeEnqueue(`event: log\ndata: ${JSON.stringify({ + containerId: source.containerId, + containerName: source.containerName, + color: source.color, + text: content + '\n', + timestamp: timestamp?.toISOString(), + stream: frame.type === 2 ? 'stderr' : 'stdout' + })}\n\n`); + } + } + } + offset += frame.size; + } + + source.buffer = source.buffer.slice(offset); + } + } + } catch (error) { + if (!String(error).includes('abort')) { + console.error(`Error reading logs from ${source.containerName}:`, error); + } + source.done = true; + } + }; + + // Continuously process all sources + console.log('[merged-logs] Starting processing loop'); + let loopCount = 0; + while (!controllerClosed) { + const activeSources = sources.filter(s => !s.done && s.reader); + if (activeSources.length === 0) { + safeEnqueue(`event: end\ndata: ${JSON.stringify({ reason: 'all streams ended' })}\n\n`); + break; + } + + if (loopCount === 0) { + console.log(`[merged-logs] Processing ${activeSources.length} active sources, first read...`); + } + loopCount++; + + await Promise.all(activeSources.map(processSource)); + + // Small delay to prevent tight loop + await new Promise(resolve => setTimeout(resolve, 10)); + } + + // Cleanup readers + for (const source of sources) { + if (source.reader) { + try { + source.reader.releaseLock(); + } catch { + // Ignore + } + } + } + + if (!controllerClosed) { + try { + controller.close(); + } catch { + // Already closed + } + } + }, + cancel() { + controllerClosed = true; + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + for (const ac of abortControllers) { + ac.abort(); + } + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no' + } + }); +}; diff --git a/routes/api/metrics/+server.ts b/routes/api/metrics/+server.ts new file mode 100644 index 0000000..266fbd8 --- /dev/null +++ b/routes/api/metrics/+server.ts @@ -0,0 +1,24 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getHostMetrics } from '$lib/server/db'; + +export const GET: RequestHandler = async ({ url }) => { + try { + const limit = parseInt(url.searchParams.get('limit') || '60'); + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + const metrics = await getHostMetrics(limit, envIdNum); + + // Return metrics in chronological order (oldest first) for graphing + const chronological = metrics.reverse(); + + return json({ + metrics: chronological, + latest: metrics.length > 0 ? metrics[metrics.length - 1] : null + }); + } catch (error) { + console.error('Failed to get host metrics:', error); + return json({ error: 'Failed to get host metrics' }, { status: 500 }); + } +}; diff --git a/routes/api/networks/+server.ts b/routes/api/networks/+server.ts new file mode 100644 index 0000000..b00ffc0 --- /dev/null +++ b/routes/api/networks/+server.ts @@ -0,0 +1,99 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listNetworks, createNetwork, EnvironmentNotFoundError, type CreateNetworkOptions } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import { auditNetwork } from '$lib/server/audit'; +import { hasEnvironments } from '$lib/server/db'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('networks', 'view', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + // Early return if no environment specified + if (!envIdNum) { + return json([]); + } + + try { + const networks = await listNetworks(envIdNum); + return json(networks); + } catch (error) { + if (error instanceof EnvironmentNotFoundError) { + return json({ error: 'Environment not found' }, { status: 404 }); + } + console.error('Failed to list networks:', error); + return json({ error: 'Failed to list networks' }, { status: 500 }); + } +}; + +export const POST: RequestHandler = async (event) => { + const { url, request, cookies } = event; + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('networks', 'create', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + const body = await request.json(); + + // Validate required fields + if (!body.name) { + return json({ error: 'Network name is required' }, { status: 400 }); + } + + const options: CreateNetworkOptions = { + name: body.name, + driver: body.driver || 'bridge', + internal: body.internal || false, + attachable: body.attachable || false, + ingress: body.ingress || false, + enableIPv6: body.enableIPv6 || false, + options: body.options || {}, + labels: body.labels || {} + }; + + // Add IPAM configuration if provided + if (body.ipam) { + options.ipam = { + driver: body.ipam.driver || 'default', + config: body.ipam.config || [], + options: body.ipam.options || {} + }; + } + + const network = await createNetwork(options, envIdNum); + + // Audit log + await auditNetwork(event, 'create', network.Id, body.name, envIdNum, { driver: options.driver }); + + return json({ success: true, id: network.Id }); + } catch (error: any) { + console.error('Failed to create network:', error); + return json({ + error: 'Failed to create network', + details: error.message || String(error) + }, { status: 500 }); + } +}; diff --git a/routes/api/networks/[id]/+server.ts b/routes/api/networks/[id]/+server.ts new file mode 100644 index 0000000..391ac13 --- /dev/null +++ b/routes/api/networks/[id]/+server.ts @@ -0,0 +1,71 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { removeNetwork, inspectNetwork } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import { auditNetwork } from '$lib/server/audit'; + +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('networks', 'inspect', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + + const network = await inspectNetwork(params.id, envIdNum); + return json(network); + } catch (error) { + console.error('Failed to inspect network:', error); + return json({ error: 'Failed to inspect network' }, { status: 500 }); + } +}; + +export const DELETE: RequestHandler = async (event) => { + const { params, url, cookies } = event; + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('networks', 'remove', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + + // Get network name before deletion for audit + let networkName = params.id; + try { + const networkInfo = await inspectNetwork(params.id, envIdNum); + networkName = networkInfo.Name || params.id; + } catch { + // Use ID if can't get name + } + + await removeNetwork(params.id, envIdNum); + + // Audit log + await auditNetwork(event, 'delete', params.id, networkName, envIdNum); + + return json({ success: true }); + } catch (error: any) { + console.error('Failed to remove network:', error); + return json({ error: 'Failed to remove network', details: error.message }, { status: 500 }); + } +}; diff --git a/routes/api/networks/[id]/connect/+server.ts b/routes/api/networks/[id]/connect/+server.ts new file mode 100644 index 0000000..5dc8300 --- /dev/null +++ b/routes/api/networks/[id]/connect/+server.ts @@ -0,0 +1,53 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { connectContainerToNetwork, inspectNetwork } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import { auditNetwork } from '$lib/server/audit'; + +export const POST: RequestHandler = async (event) => { + const { params, url, request, cookies } = event; + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('networks', 'connect', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + + const body = await request.json(); + const { containerId, containerName } = body; + + if (!containerId) { + return json({ error: 'Container ID is required' }, { status: 400 }); + } + + // Get network name for audit + let networkName = params.id; + try { + const networkInfo = await inspectNetwork(params.id, envIdNum); + networkName = networkInfo.Name || params.id; + } catch { + // Use ID if can't get name + } + + await connectContainerToNetwork(params.id, containerId, envIdNum); + + // Audit log + await auditNetwork(event, 'connect', params.id, networkName, envIdNum, { + containerId, + containerName: containerName || containerId + }); + + return json({ success: true }); + } catch (error: any) { + console.error('Failed to connect container to network:', error); + return json({ + error: 'Failed to connect container to network', + details: error.message + }, { status: 500 }); + } +}; diff --git a/routes/api/networks/[id]/disconnect/+server.ts b/routes/api/networks/[id]/disconnect/+server.ts new file mode 100644 index 0000000..35c21a0 --- /dev/null +++ b/routes/api/networks/[id]/disconnect/+server.ts @@ -0,0 +1,53 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { disconnectContainerFromNetwork, inspectNetwork } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import { auditNetwork } from '$lib/server/audit'; + +export const POST: RequestHandler = async (event) => { + const { params, url, request, cookies } = event; + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('networks', 'disconnect', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + + const body = await request.json(); + const { containerId, containerName, force } = body; + + if (!containerId) { + return json({ error: 'Container ID is required' }, { status: 400 }); + } + + // Get network name for audit + let networkName = params.id; + try { + const networkInfo = await inspectNetwork(params.id, envIdNum); + networkName = networkInfo.Name || params.id; + } catch { + // Use ID if can't get name + } + + await disconnectContainerFromNetwork(params.id, containerId, force ?? false, envIdNum); + + // Audit log + await auditNetwork(event, 'disconnect', params.id, networkName, envIdNum, { + containerId, + containerName: containerName || containerId + }); + + return json({ success: true }); + } catch (error: any) { + console.error('Failed to disconnect container from network:', error); + return json({ + error: 'Failed to disconnect container from network', + details: error.message + }, { status: 500 }); + } +}; diff --git a/routes/api/networks/[id]/inspect/+server.ts b/routes/api/networks/[id]/inspect/+server.ts new file mode 100644 index 0000000..4638b1b --- /dev/null +++ b/routes/api/networks/[id]/inspect/+server.ts @@ -0,0 +1,24 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { inspectNetwork } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; + +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('networks', 'inspect', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const networkData = await inspectNetwork(params.id, envIdNum); + return json(networkData); + } catch (error) { + console.error('Failed to inspect network:', error); + return json({ error: 'Failed to inspect network' }, { status: 500 }); + } +}; diff --git a/routes/api/notifications/+server.ts b/routes/api/notifications/+server.ts new file mode 100644 index 0000000..a04b51a --- /dev/null +++ b/routes/api/notifications/+server.ts @@ -0,0 +1,81 @@ +import { json } from '@sveltejs/kit'; +import { + getNotificationSettings, + createNotificationSetting, + type SmtpConfig, + type AppriseConfig, + type NotificationEventType +} from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('notifications', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const settings = await getNotificationSettings(); + // Don't expose passwords + const safeSettings = settings.map(s => ({ + ...s, + config: s.type === 'smtp' ? { + ...s.config, + password: s.config.password ? '********' : undefined + } : s.config + })); + return json(safeSettings); + } catch (error) { + console.error('Error fetching notification settings:', error); + return json({ error: 'Failed to fetch notification settings' }, { status: 500 }); + } +}; + +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('notifications', 'create')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const body = await request.json(); + const { type, name, enabled, config, event_types, eventTypes } = body; + // Support both snake_case (legacy) and camelCase (new) for event types + const resolvedEventTypes = eventTypes || event_types; + + if (!type || !name || !config) { + return json({ error: 'Type, name, and config are required' }, { status: 400 }); + } + + if (type !== 'smtp' && type !== 'apprise') { + return json({ error: 'Type must be smtp or apprise' }, { status: 400 }); + } + + // Validate config based on type + if (type === 'smtp') { + const smtpConfig = config as SmtpConfig; + if (!smtpConfig.host || !smtpConfig.port || !smtpConfig.from_email || !smtpConfig.to_emails?.length) { + return json({ error: 'SMTP config requires host, port, from_email, and to_emails' }, { status: 400 }); + } + } else if (type === 'apprise') { + const appriseConfig = config as AppriseConfig; + if (!appriseConfig.urls?.length) { + return json({ error: 'Apprise config requires at least one URL' }, { status: 400 }); + } + } + + const setting = await createNotificationSetting({ + type, + name, + enabled: enabled !== false, + config, + eventTypes: resolvedEventTypes as NotificationEventType[] + }); + + return json(setting); + } catch (error: any) { + console.error('Error creating notification setting:', error); + return json({ error: error.message || 'Failed to create notification setting' }, { status: 500 }); + } +}; diff --git a/routes/api/notifications/[id]/+server.ts b/routes/api/notifications/[id]/+server.ts new file mode 100644 index 0000000..869e0eb --- /dev/null +++ b/routes/api/notifications/[id]/+server.ts @@ -0,0 +1,135 @@ +import { json } from '@sveltejs/kit'; +import { + getNotificationSetting, + updateNotificationSetting, + deleteNotificationSetting, + type SmtpConfig, + type AppriseConfig, + type NotificationEventType +} from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +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 }); + } + + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid ID' }, { status: 400 }); + } + + const setting = await getNotificationSetting(id); + if (!setting) { + return json({ error: 'Notification setting not found' }, { status: 404 }); + } + + // Don't expose passwords + const safeSetting = { + ...setting, + config: setting.type === 'smtp' ? { + ...setting.config, + password: setting.config.password ? '********' : undefined + } : setting.config + }; + + return json(safeSetting); + } catch (error) { + console.error('Error fetching notification setting:', error); + return json({ error: 'Failed to fetch notification setting' }, { status: 500 }); + } +}; + +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 }); + } + + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid ID' }, { status: 400 }); + } + + const existing = await getNotificationSetting(id); + if (!existing) { + return json({ error: 'Notification setting not found' }, { status: 404 }); + } + + const body = await request.json(); + const { name, enabled, config, event_types, eventTypes } = body; + // Support both snake_case (legacy) and camelCase (new) for event types + const resolvedEventTypes = eventTypes || event_types; + + // If updating config, validate based on type + if (config) { + if (existing.type === 'smtp') { + const smtpConfig = config as SmtpConfig; + if (!smtpConfig.host || !smtpConfig.port || !smtpConfig.from_email || !smtpConfig.to_emails?.length) { + return json({ error: 'SMTP config requires host, port, from_email, and to_emails' }, { status: 400 }); + } + // If password is masked, keep the existing one + if (smtpConfig.password === '********') { + smtpConfig.password = (existing.config as SmtpConfig).password; + } + } else if (existing.type === 'apprise') { + const appriseConfig = config as AppriseConfig; + if (!appriseConfig.urls?.length) { + return json({ error: 'Apprise config requires at least one URL' }, { status: 400 }); + } + } + } + + const updated = await updateNotificationSetting(id, { + name, + enabled, + config, + eventTypes: resolvedEventTypes as NotificationEventType[] + }); + if (!updated) { + return json({ error: 'Failed to update notification setting' }, { status: 500 }); + } + + // Don't expose passwords in response + const safeSetting = { + ...updated, + config: updated.type === 'smtp' ? { + ...updated.config, + password: updated.config.password ? '********' : undefined + } : updated.config + }; + + return json(safeSetting); + } catch (error: any) { + console.error('Error updating notification setting:', error); + return json({ error: error.message || 'Failed to update notification setting' }, { status: 500 }); + } +}; + +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 }); + } + + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid ID' }, { status: 400 }); + } + + const deleted = await deleteNotificationSetting(id); + if (!deleted) { + return json({ error: 'Notification setting not found' }, { status: 404 }); + } + + return json({ success: true }); + } catch (error) { + console.error('Error deleting notification setting:', error); + return json({ error: 'Failed to delete notification setting' }, { status: 500 }); + } +}; diff --git a/routes/api/notifications/[id]/test/+server.ts b/routes/api/notifications/[id]/test/+server.ts new file mode 100644 index 0000000..e84f1dd --- /dev/null +++ b/routes/api/notifications/[id]/test/+server.ts @@ -0,0 +1,31 @@ +import { json } from '@sveltejs/kit'; +import { getNotificationSetting } from '$lib/server/db'; +import { testNotification } from '$lib/server/notifications'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ params }) => { + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid ID' }, { status: 400 }); + } + + const setting = await getNotificationSetting(id); + if (!setting) { + return json({ error: 'Notification setting not found' }, { status: 404 }); + } + + const success = await testNotification(setting); + + return json({ + success, + message: success ? 'Test notification sent successfully' : 'Failed to send test notification' + }); + } catch (error: any) { + console.error('Error testing notification:', error); + return json({ + success: false, + error: error.message || 'Failed to test notification' + }, { status: 500 }); + } +}; diff --git a/routes/api/notifications/test/+server.ts b/routes/api/notifications/test/+server.ts new file mode 100644 index 0000000..8ac0bbb --- /dev/null +++ b/routes/api/notifications/test/+server.ts @@ -0,0 +1,61 @@ +import { json } from '@sveltejs/kit'; +import { testNotification } from '$lib/server/notifications'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +// Test notification with provided config (without saving) +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('settings', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const data = await request.json(); + + if (!data.type || !data.config) { + return json({ error: 'Type and config are required' }, { status: 400 }); + } + + // Validate SMTP config + if (data.type === 'smtp') { + const config = data.config; + if (!config.host || !config.from_email || !config.to_emails?.length) { + return json({ error: 'Host, from email, and at least one recipient are required' }, { status: 400 }); + } + } + + // Validate Apprise config + if (data.type === 'apprise') { + const config = data.config; + if (!config.urls?.length) { + return json({ error: 'At least one Apprise URL is required' }, { status: 400 }); + } + } + + // Create a fake notification setting object for testing + const setting = { + id: 0, + name: data.name || 'Test', + type: data.type as 'smtp' | 'apprise', + enabled: true, + config: data.config, + event_types: [], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() + }; + + const success = await testNotification(setting); + + return json({ + success, + message: success ? 'Test notification sent successfully' : 'Failed to send test notification' + }); + } catch (error: any) { + console.error('Error testing notification:', error); + return json({ + success: false, + error: error.message || 'Failed to test notification' + }, { status: 500 }); + } +}; diff --git a/routes/api/notifications/trigger-test/+server.ts b/routes/api/notifications/trigger-test/+server.ts new file mode 100644 index 0000000..50ed170 --- /dev/null +++ b/routes/api/notifications/trigger-test/+server.ts @@ -0,0 +1,130 @@ +import { json, type RequestHandler } from '@sveltejs/kit'; +import { sendEventNotification, sendEnvironmentNotification } from '$lib/server/notifications'; +import { NOTIFICATION_EVENT_TYPES, type NotificationEventType } from '$lib/server/db'; + +/** + * Test endpoint to trigger notifications for any event type. + * This is intended for development/testing purposes only. + * + * POST /api/notifications/trigger-test + * Body: { + * eventType: string, + * environmentId?: number, + * payload: { title: string, message: string, type?: string } + * } + */ +export const POST: RequestHandler = async ({ request }) => { + try { + const body = await request.json(); + const { eventType, environmentId, payload } = body; + + if (!eventType) { + return json({ error: 'eventType is required' }, { status: 400 }); + } + + if (!payload || !payload.title || !payload.message) { + return json({ error: 'payload with title and message is required' }, { status: 400 }); + } + + // Validate event type - NOTIFICATION_EVENT_TYPES is array of {id, label, ...} + const validEventIds = NOTIFICATION_EVENT_TYPES.map(e => e.id); + if (!validEventIds.includes(eventType)) { + return json({ + error: `Invalid event type: ${eventType}`, + validTypes: validEventIds + }, { status: 400 }); + } + + // Determine if this is a system event or environment event + const isSystemEvent = eventType === 'license_expiring'; + + let result; + + if (isSystemEvent) { + // System events don't have an environment + result = await sendEventNotification( + eventType as NotificationEventType, + { + title: payload.title, + message: payload.message, + type: payload.type || 'info' + } + ); + } else if (environmentId) { + // Environment-scoped events + result = await sendEventNotification( + eventType as NotificationEventType, + { + title: payload.title, + message: payload.message, + type: payload.type || 'info' + }, + environmentId + ); + } else { + return json({ + error: 'environmentId is required for non-system events' + }, { status: 400 }); + } + + return json({ + success: result.success, + sent: result.sent, + eventType, + environmentId: isSystemEvent ? null : environmentId + }); + } catch (error) { + console.error('[Notification Test] Error:', error); + return json({ + error: error instanceof Error ? error.message : 'Unknown error' + }, { status: 500 }); + } +}; + +/** + * GET endpoint to list all available event types + */ +export const GET: RequestHandler = async () => { + return json({ + eventTypes: NOTIFICATION_EVENT_TYPES, + categories: { + container: [ + 'container_started', + 'container_stopped', + 'container_restarted', + 'container_exited', + 'container_unhealthy', + 'container_oom', + 'container_updated', + 'image_pulled', + ], + autoUpdate: [ + 'auto_update_success', + 'auto_update_failed', + 'auto_update_blocked', + ], + gitStack: [ + 'git_sync_success', + 'git_sync_failed', + 'git_sync_skipped', + ], + stack: [ + 'stack_started', + 'stack_stopped', + 'stack_deployed', + 'stack_deploy_failed', + ], + security: [ + 'vulnerability_critical', + 'vulnerability_high', + 'vulnerability_any', + ], + system: [ + 'environment_offline', + 'environment_online', + 'disk_space_warning', + 'license_expiring', + ], + } + }); +}; diff --git a/routes/api/preferences/favorite-groups/+server.ts b/routes/api/preferences/favorite-groups/+server.ts new file mode 100644 index 0000000..b34e369 --- /dev/null +++ b/routes/api/preferences/favorite-groups/+server.ts @@ -0,0 +1,134 @@ +import { json, type RequestHandler } from '@sveltejs/kit'; +import { getUserPreference, setUserPreference } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; + +const LOGS_FAVORITE_GROUPS_KEY = 'logs_favorite_groups'; + +// Favorite groups are stored as an array of { name: string, containers: string[] } +// Per environment, so environmentId is required + +export interface FavoriteGroup { + name: string; + containers: string[]; // Container names (not IDs, since IDs change on recreate) +} + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + try { + const envId = url.searchParams.get('env'); + if (!envId) { + return json({ error: 'Environment ID is required' }, { status: 400 }); + } + + const environmentId = parseInt(envId); + if (isNaN(environmentId)) { + return json({ error: 'Invalid environment ID' }, { status: 400 }); + } + + // userId is null for free edition (shared prefs), set for enterprise + const userId = auth.user?.id ?? null; + + const groups = await getUserPreference({ + userId, + environmentId, + key: LOGS_FAVORITE_GROUPS_KEY + }); + + return json({ groups: groups ?? [] }); + } catch (error) { + console.error('Failed to get favorite groups:', error); + return json({ error: 'Failed to get favorite groups' }, { status: 500 }); + } +}; + +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + + try { + const body = await request.json(); + const { environmentId, action, name, containers, newName } = body; + + if (!environmentId || typeof environmentId !== 'number') { + return json({ error: 'Environment ID is required' }, { status: 400 }); + } + + if (!action || !['add', 'remove', 'update', 'reorder'].includes(action)) { + return json({ error: 'Action must be "add", "remove", "update", or "reorder"' }, { status: 400 }); + } + + // userId is null for free edition (shared prefs), set for enterprise + const userId = auth.user?.id ?? null; + + // Get current groups + const currentGroups = await getUserPreference({ + userId, + environmentId, + key: LOGS_FAVORITE_GROUPS_KEY + }) ?? []; + + let newGroups: FavoriteGroup[]; + + if (action === 'add') { + // Add a new group + if (!name || typeof name !== 'string') { + return json({ error: 'Group name is required' }, { status: 400 }); + } + if (!Array.isArray(containers) || containers.length === 0) { + return json({ error: 'Containers array is required and must not be empty' }, { status: 400 }); + } + + // Check for duplicate name + if (currentGroups.some(g => g.name === name)) { + return json({ error: 'A group with this name already exists' }, { status: 400 }); + } + + newGroups = [...currentGroups, { name, containers }]; + } else if (action === 'remove') { + // Remove a group by name + if (!name || typeof name !== 'string') { + return json({ error: 'Group name is required' }, { status: 400 }); + } + + newGroups = currentGroups.filter(g => g.name !== name); + } else if (action === 'update') { + // Update a group (rename or change containers) + if (!name || typeof name !== 'string') { + return json({ error: 'Group name is required' }, { status: 400 }); + } + + const groupIndex = currentGroups.findIndex(g => g.name === name); + if (groupIndex === -1) { + return json({ error: 'Group not found' }, { status: 404 }); + } + + // Check for duplicate name if renaming + if (newName && newName !== name && currentGroups.some(g => g.name === newName)) { + return json({ error: 'A group with this name already exists' }, { status: 400 }); + } + + newGroups = [...currentGroups]; + newGroups[groupIndex] = { + name: newName || name, + containers: Array.isArray(containers) ? containers : currentGroups[groupIndex].containers + }; + } else { + // Reorder: replace entire array + if (!Array.isArray(body.groups)) { + return json({ error: 'groups array is required for reorder action' }, { status: 400 }); + } + newGroups = body.groups; + } + + // Save updated groups + await setUserPreference( + { userId, environmentId, key: LOGS_FAVORITE_GROUPS_KEY }, + newGroups + ); + + return json({ groups: newGroups }); + } catch (error) { + console.error('Failed to update favorite groups:', error); + return json({ error: 'Failed to update favorite groups' }, { status: 500 }); + } +}; diff --git a/routes/api/preferences/favorites/+server.ts b/routes/api/preferences/favorites/+server.ts new file mode 100644 index 0000000..7b3f238 --- /dev/null +++ b/routes/api/preferences/favorites/+server.ts @@ -0,0 +1,103 @@ +import { json, type RequestHandler } from '@sveltejs/kit'; +import { getUserPreference, setUserPreference } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; + +const LOGS_FAVORITES_KEY = 'logs_favorites'; + +// Favorites are stored as an array of container names (strings) +// Per environment, so environmentId is required + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + try { + const envId = url.searchParams.get('env'); + if (!envId) { + return json({ error: 'Environment ID is required' }, { status: 400 }); + } + + const environmentId = parseInt(envId); + if (isNaN(environmentId)) { + return json({ error: 'Invalid environment ID' }, { status: 400 }); + } + + // userId is null for free edition (shared prefs), set for enterprise + const userId = auth.user?.id ?? null; + + const favorites = await getUserPreference({ + userId, + environmentId, + key: LOGS_FAVORITES_KEY + }); + + return json({ favorites: favorites ?? [] }); + } catch (error) { + console.error('Failed to get favorites:', error); + return json({ error: 'Failed to get favorites' }, { status: 500 }); + } +}; + +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + + try { + const body = await request.json(); + const { containerName, environmentId, action, favorites: newOrder } = body; + + if (!environmentId || typeof environmentId !== 'number') { + return json({ error: 'Environment ID is required' }, { status: 400 }); + } + + if (!action || (action !== 'add' && action !== 'remove' && action !== 'reorder')) { + return json({ error: 'Action must be "add", "remove", or "reorder"' }, { status: 400 }); + } + + // userId is null for free edition (shared prefs), set for enterprise + const userId = auth.user?.id ?? null; + + let newFavorites: string[]; + + if (action === 'reorder') { + // Reorder action: replace entire favorites array + if (!Array.isArray(newOrder)) { + return json({ error: 'favorites array is required for reorder action' }, { status: 400 }); + } + newFavorites = newOrder; + } else { + // Add/remove actions require containerName + if (!containerName || typeof containerName !== 'string') { + return json({ error: 'Container name is required' }, { status: 400 }); + } + + // Get current favorites + const currentFavorites = await getUserPreference({ + userId, + environmentId, + key: LOGS_FAVORITES_KEY + }) ?? []; + + if (action === 'add') { + // Add to favorites if not already present + if (!currentFavorites.includes(containerName)) { + newFavorites = [...currentFavorites, containerName]; + } else { + newFavorites = currentFavorites; + } + } else { + // Remove from favorites + newFavorites = currentFavorites.filter(name => name !== containerName); + } + } + + // Save updated favorites + await setUserPreference( + { userId, environmentId, key: LOGS_FAVORITES_KEY }, + newFavorites + ); + + return json({ favorites: newFavorites }); + } catch (error) { + console.error('Failed to update favorites:', error); + return json({ error: 'Failed to update favorites' }, { status: 500 }); + } +}; diff --git a/routes/api/preferences/grid/+server.ts b/routes/api/preferences/grid/+server.ts new file mode 100644 index 0000000..549d927 --- /dev/null +++ b/routes/api/preferences/grid/+server.ts @@ -0,0 +1,81 @@ +import { json, type RequestHandler } from '@sveltejs/kit'; +import { getGridPreferences, setGridPreferences, deleteGridPreferences, resetAllGridPreferences } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; +import type { GridColumnPreferences } from '$lib/types'; + +// GET - retrieve all grid preferences +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + + try { + // userId for per-user storage when auth is enabled + const userId = auth.authEnabled ? auth.user?.id : undefined; + const preferences = await getGridPreferences(userId); + + return json({ preferences }); + } catch (error) { + console.error('Failed to get grid preferences:', error); + return json({ error: 'Failed to get grid preferences' }, { status: 500 }); + } +}; + +// POST - update grid preferences for a specific grid +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + + try { + const body = await request.json(); + const { gridId, columns } = body; + + if (!gridId || typeof gridId !== 'string') { + return json({ error: 'gridId is required' }, { status: 400 }); + } + + if (!columns || !Array.isArray(columns)) { + return json({ error: 'columns array is required' }, { status: 400 }); + } + + // Validate column structure + for (const col of columns) { + if (typeof col.id !== 'string' || typeof col.visible !== 'boolean') { + return json({ error: 'Each column must have id (string) and visible (boolean)' }, { status: 400 }); + } + } + + const prefs: GridColumnPreferences = { columns }; + + // userId for per-user storage when auth is enabled + const userId = auth.authEnabled ? auth.user?.id : undefined; + await setGridPreferences(gridId, prefs, userId); + + // Return updated preferences + const preferences = await getGridPreferences(userId); + return json({ preferences }); + } catch (error) { + console.error('Failed to save grid preferences:', error); + return json({ error: 'Failed to save grid preferences' }, { status: 500 }); + } +}; + +// DELETE - reset grid preferences (single grid or all) +export const DELETE: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + try { + const gridId = url.searchParams.get('gridId'); + const userId = auth.authEnabled ? auth.user?.id : undefined; + + if (gridId) { + await deleteGridPreferences(gridId, userId); + } else { + // Reset all grids + await resetAllGridPreferences(userId); + } + + const preferences = await getGridPreferences(userId); + return json({ preferences }); + } catch (error) { + console.error('Failed to reset grid preferences:', error); + return json({ error: 'Failed to reset grid preferences' }, { status: 500 }); + } +}; diff --git a/routes/api/profile/+server.ts b/routes/api/profile/+server.ts new file mode 100644 index 0000000..cd30baf --- /dev/null +++ b/routes/api/profile/+server.ts @@ -0,0 +1,117 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { getUser, updateUser as dbUpdateUser, deleteUserSessions, userHasAdminRole } from '$lib/server/db'; +import { validateSession, hashPassword, isAuthEnabled } from '$lib/server/auth'; + +// GET /api/profile - Get current user's profile +export const GET: RequestHandler = async ({ cookies }) => { + if (!(await isAuthEnabled())) { + return json({ error: 'Authentication is not enabled' }, { status: 400 }); + } + + const currentUser = await validateSession(cookies); + if (!currentUser) { + return json({ error: 'Not authenticated' }, { status: 401 }); + } + + try { + const user = await getUser(currentUser.id); + if (!user) { + return json({ error: 'User not found' }, { status: 404 }); + } + + // Derive isAdmin from role assignment + const isAdmin = await userHasAdminRole(user.id); + + return json({ + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + avatar: user.avatar, + mfaEnabled: user.mfaEnabled, + isAdmin, + provider: currentUser.provider || 'local', + lastLogin: user.lastLogin, + createdAt: user.createdAt, + updatedAt: user.updatedAt + }); + } catch (error) { + console.error('Failed to get profile:', error); + return json({ error: 'Failed to get profile' }, { status: 500 }); + } +}; + +// PUT /api/profile - Update current user's profile +export const PUT: RequestHandler = async ({ request, cookies }) => { + if (!(await isAuthEnabled())) { + return json({ error: 'Authentication is not enabled' }, { status: 400 }); + } + + const currentUser = await validateSession(cookies); + if (!currentUser) { + return json({ error: 'Not authenticated' }, { status: 401 }); + } + + try { + const data = await request.json(); + const existingUser = await getUser(currentUser.id); + + if (!existingUser) { + return json({ error: 'User not found' }, { status: 404 }); + } + + // Build update object - users can only update certain fields + const updateData: any = {}; + + if (data.email !== undefined) updateData.email = data.email; + if (data.displayName !== undefined) updateData.displayName = data.displayName; + + // Handle password change - require current password + if (data.newPassword) { + if (!data.currentPassword) { + return json({ error: 'Current password is required' }, { status: 400 }); + } + + if (data.newPassword.length < 8) { + return json({ error: 'Password must be at least 8 characters' }, { status: 400 }); + } + + // Verify current password + const { verifyPassword } = await import('$lib/server/auth'); + const isValid = await verifyPassword(data.currentPassword, existingUser.passwordHash); + if (!isValid) { + return json({ error: 'Current password is incorrect' }, { status: 400 }); + } + + updateData.passwordHash = await hashPassword(data.newPassword); + // Invalidate other sessions on password change + deleteUserSessions(currentUser.id); + } + + const user = await dbUpdateUser(currentUser.id, updateData); + + if (!user) { + return json({ error: 'Failed to update profile' }, { status: 500 }); + } + + // Derive isAdmin from role assignment + const isAdmin = await userHasAdminRole(user.id); + + return json({ + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + avatar: user.avatar, + mfaEnabled: user.mfaEnabled, + isAdmin, + lastLogin: user.lastLogin, + createdAt: user.createdAt, + updatedAt: user.updatedAt + }); + } catch (error: any) { + console.error('Failed to update profile:', error); + return json({ error: 'Failed to update profile' }, { status: 500 }); + } +}; diff --git a/routes/api/profile/avatar/+server.ts b/routes/api/profile/avatar/+server.ts new file mode 100644 index 0000000..964b2f3 --- /dev/null +++ b/routes/api/profile/avatar/+server.ts @@ -0,0 +1,70 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { updateUser as dbUpdateUser, getUser } from '$lib/server/db'; +import { validateSession, isAuthEnabled } from '$lib/server/auth'; + +// POST /api/profile/avatar - Upload avatar (base64 data URL) +export const POST: RequestHandler = async ({ request, cookies }) => { + if (!(await isAuthEnabled())) { + return json({ error: 'Authentication is not enabled' }, { status: 400 }); + } + + const currentUser = await validateSession(cookies); + if (!currentUser) { + return json({ error: 'Not authenticated' }, { status: 401 }); + } + + try { + const data = await request.json(); + + if (!data.avatar) { + return json({ error: 'Avatar data is required' }, { status: 400 }); + } + + // Validate it's a valid base64 data URL + if (!data.avatar.startsWith('data:image/')) { + return json({ error: 'Invalid image format' }, { status: 400 }); + } + + // Check size (limit to ~500KB base64 which is roughly 375KB image) + if (data.avatar.length > 500000) { + return json({ error: 'Image too large. Maximum size is 500KB.' }, { status: 400 }); + } + + const user = await dbUpdateUser(currentUser.id, { avatar: data.avatar }); + + if (!user) { + return json({ error: 'Failed to update avatar' }, { status: 500 }); + } + + return json({ success: true, avatar: user.avatar }); + } catch (error) { + console.error('Failed to upload avatar:', error); + return json({ error: 'Failed to upload avatar' }, { status: 500 }); + } +}; + +// DELETE /api/profile/avatar - Remove avatar +export const DELETE: RequestHandler = async ({ cookies }) => { + if (!(await isAuthEnabled())) { + return json({ error: 'Authentication is not enabled' }, { status: 400 }); + } + + const currentUser = await validateSession(cookies); + if (!currentUser) { + return json({ error: 'Not authenticated' }, { status: 401 }); + } + + try { + const user = await dbUpdateUser(currentUser.id, { avatar: null }); + + if (!user) { + return json({ error: 'Failed to remove avatar' }, { status: 500 }); + } + + return json({ success: true }); + } catch (error) { + console.error('Failed to remove avatar:', error); + return json({ error: 'Failed to remove avatar' }, { status: 500 }); + } +}; diff --git a/routes/api/profile/preferences/+server.ts b/routes/api/profile/preferences/+server.ts new file mode 100644 index 0000000..c3af288 --- /dev/null +++ b/routes/api/profile/preferences/+server.ts @@ -0,0 +1,101 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { getUserThemePreferences, setUserThemePreferences } from '$lib/server/db'; +import { validateSession, isAuthEnabled } from '$lib/server/auth'; +import { lightThemes, darkThemes, fonts, monospaceFonts } from '$lib/themes'; + +// GET /api/profile/preferences - Get current user's theme preferences +export const GET: RequestHandler = async ({ cookies }) => { + if (!(await isAuthEnabled())) { + return json({ error: 'Authentication is not enabled' }, { status: 400 }); + } + + const currentUser = await validateSession(cookies); + if (!currentUser) { + return json({ error: 'Not authenticated' }, { status: 401 }); + } + + try { + const prefs = await getUserThemePreferences(currentUser.id); + return json(prefs); + } catch (error) { + console.error('Failed to get preferences:', error); + return json({ error: 'Failed to get preferences' }, { status: 500 }); + } +}; + +// PUT /api/profile/preferences - Update current user's theme preferences +export const PUT: RequestHandler = async ({ request, cookies }) => { + if (!(await isAuthEnabled())) { + return json({ error: 'Authentication is not enabled' }, { status: 400 }); + } + + const currentUser = await validateSession(cookies); + if (!currentUser) { + return json({ error: 'Not authenticated' }, { status: 401 }); + } + + try { + const data = await request.json(); + + // Validate theme values using imported theme lists + const validLightThemeIds = lightThemes.map(t => t.id); + const validDarkThemeIds = darkThemes.map(t => t.id); + const validFontIds = fonts.map(f => f.id); + const validTerminalFontIds = monospaceFonts.map(f => f.id); + const validFontSizes = ['xsmall', 'small', 'normal', 'medium', 'large', 'xlarge']; + + const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string } = {}; + + if (data.lightTheme !== undefined) { + if (!validLightThemeIds.includes(data.lightTheme)) { + return json({ error: 'Invalid light theme' }, { status: 400 }); + } + updates.lightTheme = data.lightTheme; + } + + if (data.darkTheme !== undefined) { + if (!validDarkThemeIds.includes(data.darkTheme)) { + return json({ error: 'Invalid dark theme' }, { status: 400 }); + } + updates.darkTheme = data.darkTheme; + } + + if (data.font !== undefined) { + if (!validFontIds.includes(data.font)) { + return json({ error: 'Invalid font' }, { status: 400 }); + } + updates.font = data.font; + } + + if (data.fontSize !== undefined) { + if (!validFontSizes.includes(data.fontSize)) { + return json({ error: 'Invalid font size' }, { status: 400 }); + } + updates.fontSize = data.fontSize; + } + + if (data.gridFontSize !== undefined) { + if (!validFontSizes.includes(data.gridFontSize)) { + return json({ error: 'Invalid grid font size' }, { status: 400 }); + } + updates.gridFontSize = data.gridFontSize; + } + + if (data.terminalFont !== undefined) { + if (!validTerminalFontIds.includes(data.terminalFont)) { + return json({ error: 'Invalid terminal font' }, { status: 400 }); + } + updates.terminalFont = data.terminalFont; + } + + await setUserThemePreferences(currentUser.id, updates); + + // Return updated preferences + const prefs = await getUserThemePreferences(currentUser.id); + return json(prefs); + } catch (error) { + console.error('Failed to update preferences:', error); + return json({ error: 'Failed to update preferences' }, { status: 500 }); + } +}; diff --git a/routes/api/prune/all/+server.ts b/routes/api/prune/all/+server.ts new file mode 100644 index 0000000..d7e91b0 --- /dev/null +++ b/routes/api/prune/all/+server.ts @@ -0,0 +1,24 @@ +import { json } from '@sveltejs/kit'; +import { pruneAll } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Prune all requires remove permission on all resource types (with environment context) + if (auth.authEnabled && (!await auth.can('containers', 'remove', envIdNum) || !await auth.can('images', 'remove', envIdNum) || !await auth.can('volumes', 'remove', envIdNum) || !await auth.can('networks', 'remove', envIdNum))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const result = await pruneAll(envIdNum); + return json({ success: true, result }); + } catch (error: any) { + console.error('Error pruning all:', error?.message || error, error?.stack); + return json({ error: 'Failed to prune system', details: error?.message }, { status: 500 }); + } +}; diff --git a/routes/api/prune/containers/+server.ts b/routes/api/prune/containers/+server.ts new file mode 100644 index 0000000..e1c353a --- /dev/null +++ b/routes/api/prune/containers/+server.ts @@ -0,0 +1,24 @@ +import { json } from '@sveltejs/kit'; +import { pruneContainers } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('containers', 'remove', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const result = await pruneContainers(envIdNum); + return json({ success: true, result }); + } catch (error) { + console.error('Error pruning containers:', error); + return json({ error: 'Failed to prune containers' }, { status: 500 }); + } +}; diff --git a/routes/api/prune/images/+server.ts b/routes/api/prune/images/+server.ts new file mode 100644 index 0000000..c6ab88e --- /dev/null +++ b/routes/api/prune/images/+server.ts @@ -0,0 +1,25 @@ +import { json } from '@sveltejs/kit'; +import { pruneImages } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + const danglingOnly = url.searchParams.get('dangling') !== 'false'; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('images', 'remove', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const result = await pruneImages(danglingOnly, envIdNum); + return json({ success: true, result }); + } catch (error) { + console.error('Error pruning images:', error); + return json({ error: 'Failed to prune images' }, { status: 500 }); + } +}; diff --git a/routes/api/prune/networks/+server.ts b/routes/api/prune/networks/+server.ts new file mode 100644 index 0000000..775f4dd --- /dev/null +++ b/routes/api/prune/networks/+server.ts @@ -0,0 +1,24 @@ +import { json } from '@sveltejs/kit'; +import { pruneNetworks } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('networks', 'remove', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const result = await pruneNetworks(envIdNum); + return json({ success: true, result }); + } catch (error) { + console.error('Error pruning networks:', error); + return json({ error: 'Failed to prune networks' }, { status: 500 }); + } +}; diff --git a/routes/api/prune/volumes/+server.ts b/routes/api/prune/volumes/+server.ts new file mode 100644 index 0000000..7a6e63e --- /dev/null +++ b/routes/api/prune/volumes/+server.ts @@ -0,0 +1,24 @@ +import { json } from '@sveltejs/kit'; +import { pruneVolumes } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('volumes', 'remove', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const result = await pruneVolumes(envIdNum); + return json({ success: true, result }); + } catch (error) { + console.error('Error pruning volumes:', error); + return json({ error: 'Failed to prune volumes' }, { status: 500 }); + } +}; diff --git a/routes/api/registries/+server.ts b/routes/api/registries/+server.ts new file mode 100644 index 0000000..fc25741 --- /dev/null +++ b/routes/api/registries/+server.ts @@ -0,0 +1,62 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getRegistries, createRegistry, setDefaultRegistry } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; + +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('registries', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const registries = await getRegistries(); + // Don't expose passwords in the response + const safeRegistries = registries.map(({ password, ...rest }) => ({ + ...rest, + hasCredentials: !!password + })); + return json(safeRegistries); + } catch (error) { + console.error('Error fetching registries:', error); + return json({ error: 'Failed to fetch registries' }, { status: 500 }); + } +}; + +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('registries', 'create')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const data = await request.json(); + + if (!data.name || !data.url) { + return json({ error: 'Name and URL are required' }, { status: 400 }); + } + + const registry = await createRegistry({ + name: data.name, + url: data.url, + username: data.username || undefined, + password: data.password || undefined, + isDefault: data.isDefault || false + }); + + // If this registry should be default, set it + if (data.isDefault) { + await setDefaultRegistry(registry.id); + } + + // Don't expose password in response + const { password, ...safeRegistry } = registry; + return json({ ...safeRegistry, hasCredentials: !!password }, { status: 201 }); + } catch (error: any) { + console.error('Error creating registry:', error); + if (error.message?.includes('UNIQUE constraint failed')) { + return json({ error: 'A registry with this name already exists' }, { status: 400 }); + } + return json({ error: 'Failed to create registry' }, { status: 500 }); + } +}; diff --git a/routes/api/registries/[id]/+server.ts b/routes/api/registries/[id]/+server.ts new file mode 100644 index 0000000..f640a3c --- /dev/null +++ b/routes/api/registries/[id]/+server.ts @@ -0,0 +1,96 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getRegistry, updateRegistry, deleteRegistry, setDefaultRegistry } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; + +export const GET: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('registries', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid registry ID' }, { status: 400 }); + } + + const registry = await getRegistry(id); + if (!registry) { + return json({ error: 'Registry not found' }, { status: 404 }); + } + + // Don't expose password + const { password, ...safeRegistry } = registry; + return json({ ...safeRegistry, hasCredentials: !!password }); + } catch (error) { + console.error('Error fetching registry:', error); + return json({ error: 'Failed to fetch registry' }, { status: 500 }); + } +}; + +export const PUT: RequestHandler = async ({ params, request, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('registries', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid registry ID' }, { status: 400 }); + } + + const data = await request.json(); + const registry = await updateRegistry(id, { + name: data.name, + url: data.url, + username: data.username, + password: data.password, + isDefault: data.isDefault + }); + + if (!registry) { + return json({ error: 'Registry not found' }, { status: 404 }); + } + + // If this registry should be default, set it + if (data.isDefault) { + await setDefaultRegistry(id); + } + + // Don't expose password + const { password, ...safeRegistry } = registry; + return json({ ...safeRegistry, hasCredentials: !!password }); + } catch (error: any) { + console.error('Error updating registry:', error); + if (error.message?.includes('UNIQUE constraint failed')) { + return json({ error: 'A registry with this name already exists' }, { status: 400 }); + } + return json({ error: 'Failed to update registry' }, { status: 500 }); + } +}; + +export const DELETE: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('registries', 'delete')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid registry ID' }, { status: 400 }); + } + + const deleted = await deleteRegistry(id); + if (!deleted) { + return json({ error: 'Registry not found or cannot be deleted' }, { status: 404 }); + } + + return json({ success: true }); + } catch (error) { + console.error('Error deleting registry:', error); + return json({ error: 'Failed to delete registry' }, { status: 500 }); + } +}; diff --git a/routes/api/registries/[id]/default/+server.ts b/routes/api/registries/[id]/default/+server.ts new file mode 100644 index 0000000..464f1c6 --- /dev/null +++ b/routes/api/registries/[id]/default/+server.ts @@ -0,0 +1,23 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { setDefaultRegistry, getRegistry } from '$lib/server/db'; + +export const POST: RequestHandler = async ({ params }) => { + try { + const id = parseInt(params.id); + if (isNaN(id)) { + return json({ error: 'Invalid registry ID' }, { status: 400 }); + } + + const registry = await getRegistry(id); + if (!registry) { + return json({ error: 'Registry not found' }, { status: 404 }); + } + + await setDefaultRegistry(id); + return json({ success: true }); + } catch (error) { + console.error('Error setting default registry:', error); + return json({ error: 'Failed to set default registry' }, { status: 500 }); + } +}; diff --git a/routes/api/registry/catalog/+server.ts b/routes/api/registry/catalog/+server.ts new file mode 100644 index 0000000..11fb6d0 --- /dev/null +++ b/routes/api/registry/catalog/+server.ts @@ -0,0 +1,90 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getRegistry } from '$lib/server/db'; + +export const GET: RequestHandler = async ({ url }) => { + try { + const registryId = url.searchParams.get('registry'); + + if (!registryId) { + return json({ error: 'Registry ID is required' }, { status: 400 }); + } + + const registry = await getRegistry(parseInt(registryId)); + if (!registry) { + return json({ error: 'Registry not found' }, { status: 404 }); + } + + // Docker Hub doesn't support catalog listing + if (registry.url.includes('docker.io') || registry.url.includes('hub.docker.com') || registry.url.includes('registry.hub.docker.com')) { + return json({ error: 'Docker Hub does not support catalog listing. Please use search instead.' }, { status: 400 }); + } + + // Build the catalog URL + let catalogUrl = registry.url; + if (!catalogUrl.endsWith('/')) { + catalogUrl += '/'; + } + catalogUrl += 'v2/_catalog'; + + // Prepare headers + const headers: HeadersInit = { + 'Accept': 'application/json' + }; + + // Add auth if credentials are present + if (registry.username && registry.password) { + const credentials = Buffer.from(`${registry.username}:${registry.password}`).toString('base64'); + headers['Authorization'] = `Basic ${credentials}`; + } + + const response = await fetch(catalogUrl, { + method: 'GET', + headers + }); + + if (!response.ok) { + if (response.status === 401) { + return json({ error: 'Authentication failed. Please check your credentials.' }, { status: 401 }); + } + if (response.status === 404) { + return json({ error: 'Registry does not support V2 catalog API' }, { status: 404 }); + } + return json({ error: `Registry returned error: ${response.status}` }, { status: response.status }); + } + + const data = await response.json(); + + // The V2 API returns { repositories: [...] } + const repositories = data.repositories || []; + + // For each repository, we could fetch tags, but that's expensive + // Just return the repository names for now + const results = repositories.map((name: string) => ({ + name, + description: '', + star_count: 0, + is_official: false, + is_automated: false + })); + + return json(results); + } catch (error: any) { + console.error('Error fetching registry catalog:', error); + + if (error.code === 'ECONNREFUSED') { + return json({ error: 'Could not connect to registry' }, { status: 503 }); + } + if (error.code === 'ENOTFOUND') { + return json({ error: 'Registry host not found' }, { status: 503 }); + } + if (error.cause?.code === 'ERR_SSL_PACKET_LENGTH_TOO_LONG') { + return json({ error: 'SSL error: Registry may be using HTTP, not HTTPS. Try changing the URL to http://' }, { status: 503 }); + } + if (error.cause?.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' || error.cause?.code === 'CERT_HAS_EXPIRED') { + return json({ error: 'SSL certificate error. Registry may have an invalid or self-signed certificate.' }, { status: 503 }); + } + + return json({ error: 'Failed to fetch catalog: ' + (error.message || 'Unknown error') }, { status: 500 }); + } +}; diff --git a/routes/api/registry/image/+server.ts b/routes/api/registry/image/+server.ts new file mode 100644 index 0000000..64ddbb5 --- /dev/null +++ b/routes/api/registry/image/+server.ts @@ -0,0 +1,109 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getRegistry } from '$lib/server/db'; + +function isDockerHub(url: string): boolean { + const lower = url.toLowerCase(); + return lower.includes('docker.io') || + lower.includes('hub.docker.com') || + lower.includes('registry.hub.docker.com'); +} + +export const DELETE: RequestHandler = async ({ url }) => { + try { + const registryId = url.searchParams.get('registry'); + const imageName = url.searchParams.get('image'); + const tag = url.searchParams.get('tag'); + + if (!registryId) { + return json({ error: 'Registry ID is required' }, { status: 400 }); + } + + if (!imageName) { + return json({ error: 'Image name is required' }, { status: 400 }); + } + + if (!tag) { + return json({ error: 'Tag is required' }, { status: 400 }); + } + + const registry = await getRegistry(parseInt(registryId)); + if (!registry) { + return json({ error: 'Registry not found' }, { status: 404 }); + } + + // Docker Hub doesn't support deletion via API + if (isDockerHub(registry.url)) { + return json({ error: 'Docker Hub does not support image deletion via API. Please use the Docker Hub web interface.' }, { status: 400 }); + } + + let baseUrl = registry.url; + if (!baseUrl.endsWith('/')) { + baseUrl += '/'; + } + + const headers: HeadersInit = { + 'Accept': 'application/vnd.docker.distribution.manifest.v2+json' + }; + + if (registry.username && registry.password) { + const credentials = Buffer.from(`${registry.username}:${registry.password}`).toString('base64'); + headers['Authorization'] = `Basic ${credentials}`; + } + + // Step 1: Get the manifest digest + const manifestUrl = `${baseUrl}v2/${imageName}/manifests/${tag}`; + const headResponse = await fetch(manifestUrl, { + method: 'HEAD', + headers + }); + + if (!headResponse.ok) { + if (headResponse.status === 401) { + return json({ error: 'Authentication failed' }, { status: 401 }); + } + if (headResponse.status === 404) { + return json({ error: 'Image or tag not found' }, { status: 404 }); + } + return json({ error: `Failed to get manifest: ${headResponse.status}` }, { status: headResponse.status }); + } + + const digest = headResponse.headers.get('Docker-Content-Digest'); + if (!digest) { + return json({ error: 'Could not get image digest. Registry may not support deletion.' }, { status: 400 }); + } + + // Step 2: Delete the manifest by digest + const deleteUrl = `${baseUrl}v2/${imageName}/manifests/${digest}`; + const deleteResponse = await fetch(deleteUrl, { + method: 'DELETE', + headers + }); + + if (!deleteResponse.ok) { + if (deleteResponse.status === 401) { + return json({ error: 'Authentication failed' }, { status: 401 }); + } + if (deleteResponse.status === 404) { + return json({ error: 'Manifest not found' }, { status: 404 }); + } + if (deleteResponse.status === 405) { + return json({ error: 'Registry does not allow deletion. Enable REGISTRY_STORAGE_DELETE_ENABLED=true on the registry.' }, { status: 405 }); + } + return json({ error: `Failed to delete image: ${deleteResponse.status}` }, { status: deleteResponse.status }); + } + + return json({ success: true, message: `Deleted ${imageName}:${tag}` }); + } catch (error: any) { + console.error('Error deleting image:', error); + + if (error.code === 'ECONNREFUSED') { + return json({ error: 'Could not connect to registry' }, { status: 503 }); + } + if (error.code === 'ENOTFOUND') { + return json({ error: 'Registry host not found' }, { status: 503 }); + } + + return json({ error: error.message || 'Failed to delete image' }, { status: 500 }); + } +}; diff --git a/routes/api/registry/search/+server.ts b/routes/api/registry/search/+server.ts new file mode 100644 index 0000000..219b655 --- /dev/null +++ b/routes/api/registry/search/+server.ts @@ -0,0 +1,136 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getRegistry } from '$lib/server/db'; + +interface SearchResult { + name: string; + description: string; + star_count: number; + is_official: boolean; + is_automated: boolean; +} + +function isDockerHub(url: string): boolean { + const lower = url.toLowerCase(); + return lower.includes('docker.io') || + lower.includes('hub.docker.com') || + lower.includes('registry.hub.docker.com'); +} + +async function searchDockerHub(term: string, limit: number): Promise { + // Use Docker Hub's search API directly + const url = `https://hub.docker.com/v2/search/repositories/?query=${encodeURIComponent(term)}&page_size=${limit}`; + + const response = await fetch(url, { + headers: { + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`Docker Hub search failed: ${response.status}`); + } + + const data = await response.json(); + const results = data.results || []; + + return results.map((item: any) => ({ + name: item.repo_name || item.name, + description: item.short_description || item.description || '', + star_count: item.star_count || 0, + is_official: item.is_official || false, + is_automated: item.is_automated || false + })); +} + +async function searchPrivateRegistry(registry: any, term: string, limit: number): Promise { + // Private registries use the V2 catalog API + let baseUrl = registry.url; + if (!baseUrl.endsWith('/')) { + baseUrl += '/'; + } + + const catalogUrl = `${baseUrl}v2/_catalog?n=1000`; + + const headers: HeadersInit = { + 'Accept': 'application/json' + }; + + if (registry.username && registry.password) { + const credentials = Buffer.from(`${registry.username}:${registry.password}`).toString('base64'); + headers['Authorization'] = `Basic ${credentials}`; + } + + const response = await fetch(catalogUrl, { + method: 'GET', + headers + }); + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Authentication failed'); + } + throw new Error(`Registry returned error: ${response.status}`); + } + + const data = await response.json(); + const repositories = data.repositories || []; + + // Filter repositories by search term (case-insensitive) + const termLower = term.toLowerCase(); + const filtered = repositories + .filter((name: string) => name.toLowerCase().includes(termLower)) + .slice(0, limit); + + // Return results in the same format as Docker Hub + return filtered.map((name: string) => ({ + name, + description: '', + star_count: 0, + is_official: false, + is_automated: false + })); +} + +export const GET: RequestHandler = async ({ url }) => { + const term = url.searchParams.get('term'); + const limit = parseInt(url.searchParams.get('limit') || '25', 10); + const registryId = url.searchParams.get('registry'); + + if (!term) { + return json({ error: 'Search term is required' }, { status: 400 }); + } + + try { + let results: SearchResult[]; + + if (!registryId) { + // No registry specified, search Docker Hub + results = await searchDockerHub(term, limit); + } else { + const registry = await getRegistry(parseInt(registryId)); + if (!registry) { + return json({ error: 'Registry not found' }, { status: 404 }); + } + + if (isDockerHub(registry.url)) { + results = await searchDockerHub(term, limit); + } else { + results = await searchPrivateRegistry(registry, term, limit); + } + } + + return json(results); + } catch (error: any) { + console.error('Failed to search images:', error); + + if (error.code === 'ECONNREFUSED') { + return json({ error: 'Could not connect to registry' }, { status: 503 }); + } + if (error.code === 'ENOTFOUND') { + return json({ error: 'Registry host not found' }, { status: 503 }); + } + + return json({ error: error.message || 'Failed to search images' }, { status: 500 }); + } +}; diff --git a/routes/api/registry/tags/+server.ts b/routes/api/registry/tags/+server.ts new file mode 100644 index 0000000..cba2360 --- /dev/null +++ b/routes/api/registry/tags/+server.ts @@ -0,0 +1,143 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getRegistry } from '$lib/server/db'; + +interface TagInfo { + name: string; + size?: number; + lastUpdated?: string; + digest?: string; +} + +function isDockerHub(url: string): boolean { + const lower = url.toLowerCase(); + return lower.includes('docker.io') || + lower.includes('hub.docker.com') || + lower.includes('registry.hub.docker.com'); +} + +async function fetchDockerHubTags(imageName: string): Promise { + // Docker Hub uses a different API + // For official images: https://hub.docker.com/v2/repositories/library//tags + // For user images: https://hub.docker.com/v2/repositories///tags + + let repoPath = imageName; + if (!imageName.includes('/')) { + // Official image (e.g., nginx -> library/nginx) + repoPath = `library/${imageName}`; + } + + const url = `https://hub.docker.com/v2/repositories/${repoPath}/tags?page_size=100&ordering=last_updated`; + + const response = await fetch(url, { + headers: { + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Image not found on Docker Hub'); + } + throw new Error(`Docker Hub returned error: ${response.status}`); + } + + const data = await response.json(); + const results = data.results || []; + + return results.map((tag: any) => ({ + name: tag.name, + size: tag.full_size || tag.images?.[0]?.size, + lastUpdated: tag.last_updated || tag.tag_last_pushed, + digest: tag.images?.[0]?.digest + })); +} + +async function fetchRegistryTags(registry: any, imageName: string): Promise { + // Standard V2 registry API + let baseUrl = registry.url; + if (!baseUrl.endsWith('/')) { + baseUrl += '/'; + } + + const tagsUrl = `${baseUrl}v2/${imageName}/tags/list`; + + const headers: HeadersInit = { + 'Accept': 'application/json' + }; + + if (registry.username && registry.password) { + const credentials = Buffer.from(`${registry.username}:${registry.password}`).toString('base64'); + headers['Authorization'] = `Basic ${credentials}`; + } + + const response = await fetch(tagsUrl, { + method: 'GET', + headers + }); + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Authentication failed'); + } + if (response.status === 404) { + throw new Error('Image not found in registry'); + } + throw new Error(`Registry returned error: ${response.status}`); + } + + const data = await response.json(); + const tags = data.tags || []; + + // For V2 registries, we only get tag names, not sizes or dates + // We could fetch manifests for each tag to get more info, but that's expensive + // Just return the basic info for now + return tags.map((name: string) => ({ + name, + size: undefined, + lastUpdated: undefined, + digest: undefined + })); +} + +export const GET: RequestHandler = async ({ url }) => { + try { + const registryId = url.searchParams.get('registry'); + const imageName = url.searchParams.get('image'); + + if (!imageName) { + return json({ error: 'Image name is required' }, { status: 400 }); + } + + let tags: TagInfo[]; + + if (!registryId) { + // No registry specified, assume Docker Hub + tags = await fetchDockerHubTags(imageName); + } else { + const registry = await getRegistry(parseInt(registryId)); + if (!registry) { + return json({ error: 'Registry not found' }, { status: 404 }); + } + + if (isDockerHub(registry.url)) { + tags = await fetchDockerHubTags(imageName); + } else { + tags = await fetchRegistryTags(registry, imageName); + } + } + + return json(tags); + } catch (error: any) { + console.error('Error fetching tags:', error); + + if (error.code === 'ECONNREFUSED') { + return json({ error: 'Could not connect to registry' }, { status: 503 }); + } + if (error.code === 'ENOTFOUND') { + return json({ error: 'Registry host not found' }, { status: 503 }); + } + + return json({ error: error.message || 'Failed to fetch tags' }, { status: 500 }); + } +}; diff --git a/routes/api/roles/+server.ts b/routes/api/roles/+server.ts new file mode 100644 index 0000000..663c9f7 --- /dev/null +++ b/routes/api/roles/+server.ts @@ -0,0 +1,65 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { + getRoles, + createRole as dbCreateRole +} from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; + +// GET /api/roles - List all roles +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + + // Allow viewing roles when auth is disabled (setup mode) or with enterprise license + // This lets users see built-in roles before activating auth/enterprise + if (auth.authEnabled && !auth.isEnterprise) { + return json({ error: 'Enterprise license required' }, { status: 403 }); + } + + try { + const roles = await getRoles(); + return json(roles); + } catch (error) { + console.error('Failed to get roles:', error); + return json({ error: 'Failed to get roles' }, { status: 500 }); + } +}; + +// POST /api/roles - Create a new role +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + + // Check enterprise license + if (!auth.isEnterprise) { + return json({ error: 'Enterprise license required' }, { status: 403 }); + } + + // When auth is disabled, allow all operations (setup mode) + // When auth is enabled, require admin access + if (auth.authEnabled && !auth.isAdmin) { + return json({ error: 'Admin access required' }, { status: 403 }); + } + + try { + const { name, description, permissions, environmentIds } = await request.json(); + + if (!name || !permissions) { + return json({ error: 'Name and permissions are required' }, { status: 400 }); + } + + const role = await dbCreateRole({ + name, + description, + permissions, + environmentIds: environmentIds ?? null + }); + + return json(role, { status: 201 }); + } catch (error: any) { + console.error('Failed to create role:', error); + if (error.message?.includes('UNIQUE constraint failed')) { + return json({ error: 'Role name already exists' }, { status: 409 }); + } + return json({ error: 'Failed to create role' }, { status: 500 }); + } +}; diff --git a/routes/api/roles/[id]/+server.ts b/routes/api/roles/[id]/+server.ts new file mode 100644 index 0000000..1e0343a --- /dev/null +++ b/routes/api/roles/[id]/+server.ts @@ -0,0 +1,126 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { + getRole, + updateRole as dbUpdateRole, + deleteRole as dbDeleteRole +} from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; + +// GET /api/roles/[id] - Get a specific role +export const GET: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + + // Allow viewing roles when auth is disabled (setup mode) or with enterprise license + if (auth.authEnabled && !auth.isEnterprise) { + return json({ error: 'Enterprise license required' }, { status: 403 }); + } + + if (!params.id) { + return json({ error: 'Role ID is required' }, { status: 400 }); + } + + try { + const id = parseInt(params.id); + const role = await getRole(id); + + if (!role) { + return json({ error: 'Role not found' }, { status: 404 }); + } + + return json(role); + } catch (error) { + console.error('Failed to get role:', error); + return json({ error: 'Failed to get role' }, { status: 500 }); + } +}; + +// PUT /api/roles/[id] - Update a role +export const PUT: RequestHandler = async ({ params, request, cookies }) => { + const auth = await authorize(cookies); + + // Check enterprise license + if (!auth.isEnterprise) { + return json({ error: 'Enterprise license required' }, { status: 403 }); + } + + // When auth is disabled, allow all operations (setup mode) + // When auth is enabled, require admin access + if (auth.authEnabled && !auth.isAdmin) { + return json({ error: 'Admin access required' }, { status: 403 }); + } + + if (!params.id) { + return json({ error: 'Role ID is required' }, { status: 400 }); + } + + try { + const id = parseInt(params.id); + const data = await request.json(); + + const existingRole = await getRole(id); + if (!existingRole) { + return json({ error: 'Role not found' }, { status: 404 }); + } + + if (existingRole.isSystem) { + return json({ error: 'Cannot modify system roles' }, { status: 400 }); + } + + const role = await dbUpdateRole(id, data); + if (!role) { + return json({ error: 'Failed to update role' }, { status: 500 }); + } + + return json(role); + } catch (error: any) { + console.error('Failed to update role:', error); + if (error.message?.includes('UNIQUE constraint failed')) { + return json({ error: 'Role name already exists' }, { status: 409 }); + } + return json({ error: 'Failed to update role' }, { status: 500 }); + } +}; + +// DELETE /api/roles/[id] - Delete a role +export const DELETE: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + + // Check enterprise license + if (!auth.isEnterprise) { + return json({ error: 'Enterprise license required' }, { status: 403 }); + } + + // When auth is disabled, allow all operations (setup mode) + // When auth is enabled, require admin access + if (auth.authEnabled && !auth.isAdmin) { + return json({ error: 'Admin access required' }, { status: 403 }); + } + + if (!params.id) { + return json({ error: 'Role ID is required' }, { status: 400 }); + } + + try { + const id = parseInt(params.id); + const role = await getRole(id); + + if (!role) { + return json({ error: 'Role not found' }, { status: 404 }); + } + + if (role.isSystem) { + return json({ error: 'Cannot delete system roles' }, { status: 400 }); + } + + const deleted = await dbDeleteRole(id); + if (!deleted) { + return json({ error: 'Failed to delete role' }, { status: 500 }); + } + + return json({ success: true }); + } catch (error) { + console.error('Failed to delete role:', error); + return json({ error: 'Failed to delete role' }, { status: 500 }); + } +}; diff --git a/routes/api/schedules/+server.ts b/routes/api/schedules/+server.ts new file mode 100644 index 0000000..6fe3d04 --- /dev/null +++ b/routes/api/schedules/+server.ts @@ -0,0 +1,207 @@ +/** + * Schedules API - List all active schedules + * + * GET /api/schedules - Returns all enabled schedules (container auto-updates, git stack syncs, and system jobs) + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { + getEnabledAutoUpdateSettings, + getEnabledAutoUpdateGitStacks, + getAllAutoUpdateSettings, + getAllAutoUpdateGitStacks, + getAllEnvUpdateCheckSettings, + getLastExecutionForSchedule, + getRecentExecutionsForSchedule, + getEnvironment, + getEnvironmentTimezone, + type ScheduleExecutionData, + type VulnerabilityCriteria +} from '$lib/server/db'; +import { getNextRun, getSystemSchedules } from '$lib/server/scheduler'; +import { getGlobalScannerDefaults, getScannerSettingsWithDefaults } from '$lib/server/scanner'; + +export interface ScheduleInfo { + id: number; + type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check'; + name: string; + entityName: string; + description?: string; + environmentId: number | null; + environmentName: string | null; + enabled: boolean; + scheduleType: string; + cronExpression: string | null; + nextRun: string | null; + lastExecution: ScheduleExecutionData | null; + recentExecutions: ScheduleExecutionData[]; + isSystem: boolean; + // Container update specific fields + envHasScanning?: boolean; + vulnerabilityCriteria?: VulnerabilityCriteria | null; +} + +export const GET: RequestHandler = async () => { + try { + const schedules: ScheduleInfo[] = []; + + // Pre-fetch global scanner defaults ONCE (CLI args are global, not per-environment) + const globalScannerDefaults = await getGlobalScannerDefaults(); + + // Get container auto-update schedules (get all, not just enabled, so we can show them in the grid) + const containerSettings = await getAllAutoUpdateSettings(); + const containerSchedules = await Promise.all( + containerSettings.map(async (setting) => { + const [env, lastExecution, recentExecutions, scannerSettings, timezone] = await Promise.all([ + setting.environmentId ? getEnvironment(setting.environmentId) : null, + getLastExecutionForSchedule('container_update', setting.id), + getRecentExecutionsForSchedule('container_update', setting.id, 5), + getScannerSettingsWithDefaults(setting.environmentId ?? undefined, globalScannerDefaults), + setting.environmentId ? getEnvironmentTimezone(setting.environmentId) : 'UTC' + ]); + const isEnabled = setting.enabled ?? false; + const nextRun = isEnabled && setting.cronExpression ? getNextRun(setting.cronExpression, timezone) : null; + // Check if env has scanning enabled + const envHasScanning = scannerSettings.scanner !== 'none'; + + return { + id: setting.id, + type: 'container_update' as const, + name: `Update container: ${setting.containerName}`, + entityName: setting.containerName, + environmentId: setting.environmentId ?? null, + environmentName: env?.name ?? null, + enabled: isEnabled, + scheduleType: setting.scheduleType ?? 'daily', + cronExpression: setting.cronExpression ?? null, + nextRun: nextRun?.toISOString() ?? null, + lastExecution: lastExecution ?? null, + recentExecutions, + isSystem: false, + envHasScanning, + vulnerabilityCriteria: setting.vulnerabilityCriteria ?? null + }; + }) + ); + schedules.push(...containerSchedules); + + // Get git stack auto-sync schedules + const gitStacks = await getAllAutoUpdateGitStacks(); + const gitSchedules = await Promise.all( + gitStacks.map(async (stack) => { + const [env, lastExecution, recentExecutions, timezone] = await Promise.all([ + stack.environmentId ? getEnvironment(stack.environmentId) : null, + getLastExecutionForSchedule('git_stack_sync', stack.id), + getRecentExecutionsForSchedule('git_stack_sync', stack.id, 5), + stack.environmentId ? getEnvironmentTimezone(stack.environmentId) : 'UTC' + ]); + const isEnabled = stack.autoUpdate ?? false; + const nextRun = isEnabled && stack.autoUpdateCron ? getNextRun(stack.autoUpdateCron, timezone) : null; + + return { + id: stack.id, + type: 'git_stack_sync' as const, + name: `Git sync: ${stack.stackName}`, + entityName: stack.stackName, + environmentId: stack.environmentId ?? null, + environmentName: env?.name ?? null, + enabled: isEnabled, + scheduleType: stack.autoUpdateSchedule ?? 'daily', + cronExpression: stack.autoUpdateCron ?? null, + nextRun: nextRun?.toISOString() ?? null, + lastExecution: lastExecution ?? null, + recentExecutions, + isSystem: false + }; + }) + ); + schedules.push(...gitSchedules); + + // Get environment update check schedules + const envUpdateCheckConfigs = await getAllEnvUpdateCheckSettings(); + const envUpdateCheckSchedules = await Promise.all( + envUpdateCheckConfigs.map(async ({ envId, settings }) => { + const [env, lastExecution, recentExecutions, scannerSettings, timezone] = await Promise.all([ + getEnvironment(envId), + getLastExecutionForSchedule('env_update_check', envId), + getRecentExecutionsForSchedule('env_update_check', envId, 5), + getScannerSettingsWithDefaults(envId, globalScannerDefaults), + getEnvironmentTimezone(envId) + ]); + const isEnabled = settings.enabled ?? false; + const nextRun = isEnabled && settings.cron ? getNextRun(settings.cron, timezone) : null; + const envHasScanning = scannerSettings.scanner !== 'none'; + + // Build description based on autoUpdate and scanning status + let description: string; + if (settings.autoUpdate) { + description = envHasScanning ? 'Check, scan & auto-update containers' : 'Check & auto-update containers'; + } else { + description = 'Check containers for updates (notify only)'; + } + + return { + id: envId, + type: 'env_update_check' as const, + name: `Update environment: ${env?.name || 'Unknown'}`, + entityName: env?.name || 'Unknown', + description, + environmentId: envId, + environmentName: env?.name ?? null, + enabled: isEnabled, + scheduleType: 'custom', + cronExpression: settings.cron ?? null, + nextRun: nextRun?.toISOString() ?? null, + lastExecution: lastExecution ?? null, + recentExecutions, + isSystem: false, + autoUpdate: settings.autoUpdate, + envHasScanning, + vulnerabilityCriteria: settings.autoUpdate ? (settings.vulnerabilityCriteria ?? null) : null + }; + }) + ); + schedules.push(...envUpdateCheckSchedules); + + // Get system schedules + const systemSchedules = await getSystemSchedules(); + const sysSchedules = await Promise.all( + systemSchedules.map(async (sys) => { + const [lastExecution, recentExecutions] = await Promise.all([ + getLastExecutionForSchedule(sys.type, sys.id), + getRecentExecutionsForSchedule(sys.type, sys.id, 5) + ]); + + return { + id: sys.id, + type: sys.type, + name: sys.name, + entityName: sys.name, + description: sys.description, + environmentId: null, + environmentName: null, + enabled: sys.enabled, + scheduleType: 'custom', + cronExpression: sys.cronExpression, + nextRun: sys.nextRun, + lastExecution: lastExecution ?? null, + recentExecutions, + isSystem: true + }; + }) + ); + schedules.push(...sysSchedules); + + // Sort: system jobs last, then by name + schedules.sort((a, b) => { + if (a.isSystem !== b.isSystem) return a.isSystem ? 1 : -1; + return a.name.localeCompare(b.name); + }); + + return json({ schedules }); + } catch (error: any) { + console.error('Failed to get schedules:', error); + return json({ error: error.message }, { status: 500 }); + } +}; diff --git a/routes/api/schedules/[type]/[id]/+server.ts b/routes/api/schedules/[type]/[id]/+server.ts new file mode 100644 index 0000000..1142c8b --- /dev/null +++ b/routes/api/schedules/[type]/[id]/+server.ts @@ -0,0 +1,62 @@ +/** + * Delete schedule + * DELETE /api/schedules/:type/:id + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { + getAutoUpdateSettingById, + deleteAutoUpdateSchedule, + updateGitStack, + deleteEnvUpdateCheckSettings +} from '$lib/server/db'; +import { unregisterSchedule } from '$lib/server/scheduler'; + +export const DELETE: RequestHandler = async ({ params }) => { + try { + const { type, id } = params; + const scheduleId = parseInt(id, 10); + + if (isNaN(scheduleId)) { + return json({ error: 'Invalid schedule ID' }, { status: 400 }); + } + + if (type === 'container_update') { + // Hard delete container schedule + const schedule = await getAutoUpdateSettingById(scheduleId); + if (schedule) { + await deleteAutoUpdateSchedule(schedule.containerName, schedule.environmentId ?? undefined); + // Unregister from croner + unregisterSchedule(scheduleId, 'container_update'); + } + return json({ success: true }); + + } else if (type === 'git_stack_sync') { + // Disable auto-update for git stack (don't delete the stack itself) + await updateGitStack(scheduleId, { + autoUpdate: false, + autoUpdateSchedule: null, + autoUpdateCron: null + }); + // Unregister from croner + unregisterSchedule(scheduleId, 'git_stack_sync'); + return json({ success: true }); + + } else if (type === 'env_update_check') { + // Delete env update check settings (scheduleId is environmentId) + await deleteEnvUpdateCheckSettings(scheduleId); + unregisterSchedule(scheduleId, 'env_update_check'); + return json({ success: true }); + + } else if (type === 'system_cleanup') { + return json({ error: 'System schedules cannot be removed' }, { status: 400 }); + + } else { + return json({ error: 'Invalid schedule type' }, { status: 400 }); + } + } catch (error) { + console.error('Failed to delete schedule:', error); + return json({ error: 'Failed to delete schedule' }, { status: 500 }); + } +}; diff --git a/routes/api/schedules/[type]/[id]/run/+server.ts b/routes/api/schedules/[type]/[id]/run/+server.ts new file mode 100644 index 0000000..ea8c1bb --- /dev/null +++ b/routes/api/schedules/[type]/[id]/run/+server.ts @@ -0,0 +1,52 @@ +/** + * Manual Schedule Trigger API - Manually run a schedule + * + * POST /api/schedules/[type]/[id]/run - Trigger a manual execution + * + * Path params: + * - type: 'container_update' | 'git_stack_sync' | 'system_cleanup' + * - id: schedule ID + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck } from '$lib/server/scheduler'; + +export const POST: RequestHandler = async ({ params }) => { + try { + const { type, id } = params; + const scheduleId = parseInt(id, 10); + + if (isNaN(scheduleId)) { + return json({ error: 'Invalid schedule ID' }, { status: 400 }); + } + + let result: { success: boolean; executionId?: number; error?: string }; + + switch (type) { + case 'container_update': + result = await triggerContainerUpdate(scheduleId); + break; + case 'git_stack_sync': + result = await triggerGitStackSync(scheduleId); + break; + case 'system_cleanup': + result = await triggerSystemJob(id); + break; + case 'env_update_check': + result = await triggerEnvUpdateCheck(scheduleId); + break; + default: + return json({ error: 'Invalid schedule type' }, { status: 400 }); + } + + if (!result.success) { + return json({ error: result.error }, { status: 400 }); + } + + return json({ success: true, message: 'Schedule triggered successfully' }); + } catch (error: any) { + console.error('Failed to trigger schedule:', error); + return json({ error: error.message }, { status: 500 }); + } +}; diff --git a/routes/api/schedules/[type]/[id]/toggle/+server.ts b/routes/api/schedules/[type]/[id]/toggle/+server.ts new file mode 100644 index 0000000..b81d3a8 --- /dev/null +++ b/routes/api/schedules/[type]/[id]/toggle/+server.ts @@ -0,0 +1,88 @@ +/** + * Toggle schedule enabled/disabled + * POST /api/schedules/:type/:id/toggle + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getAutoUpdateSettingById, updateAutoUpdateSettingById, getGitStack, updateGitStack, getEnvUpdateCheckSettings, setEnvUpdateCheckSettings } from '$lib/server/db'; +import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler'; + +export const POST: RequestHandler = async ({ params }) => { + try { + const { type, id } = params; + const scheduleId = parseInt(id, 10); + + if (isNaN(scheduleId)) { + return json({ error: 'Invalid schedule ID' }, { status: 400 }); + } + + if (type === 'container_update') { + const setting = await getAutoUpdateSettingById(scheduleId); + if (!setting) { + return json({ error: 'Schedule not found' }, { status: 404 }); + } + + const newEnabled = !setting.enabled; + await updateAutoUpdateSettingById(scheduleId, { + enabled: newEnabled + }); + + // Register or unregister schedule with croner + if (newEnabled && setting.cronExpression) { + await registerSchedule(scheduleId, 'container_update', setting.environmentId); + } else { + unregisterSchedule(scheduleId, 'container_update'); + } + + return json({ success: true, enabled: newEnabled }); + } else if (type === 'git_stack_sync') { + const stack = await getGitStack(scheduleId); + if (!stack) { + return json({ error: 'Schedule not found' }, { status: 404 }); + } + + const newEnabled = !stack.autoUpdate; + await updateGitStack(scheduleId, { + autoUpdate: newEnabled + }); + + // Register or unregister schedule with croner + if (newEnabled && stack.autoUpdateCron) { + await registerSchedule(scheduleId, 'git_stack_sync', stack.environmentId); + } else { + unregisterSchedule(scheduleId, 'git_stack_sync'); + } + + return json({ success: true, enabled: newEnabled }); + } else if (type === 'env_update_check') { + // scheduleId is environmentId for env update check + const config = await getEnvUpdateCheckSettings(scheduleId); + if (!config) { + return json({ error: 'Schedule not found' }, { status: 404 }); + } + + const newEnabled = !config.enabled; + await setEnvUpdateCheckSettings(scheduleId, { + ...config, + enabled: newEnabled + }); + + // Register or unregister schedule with croner + if (newEnabled && config.cron) { + await registerSchedule(scheduleId, 'env_update_check', scheduleId); + } else { + unregisterSchedule(scheduleId, 'env_update_check'); + } + + return json({ success: true, enabled: newEnabled }); + } else if (type === 'system_cleanup') { + return json({ error: 'System schedules cannot be paused' }, { status: 400 }); + } else { + return json({ error: 'Invalid schedule type' }, { status: 400 }); + } + } catch (error) { + console.error('Failed to toggle schedule:', error); + return json({ error: 'Failed to toggle schedule' }, { status: 500 }); + } +}; diff --git a/routes/api/schedules/executions/+server.ts b/routes/api/schedules/executions/+server.ts new file mode 100644 index 0000000..035d58c --- /dev/null +++ b/routes/api/schedules/executions/+server.ts @@ -0,0 +1,62 @@ +/** + * Schedule Executions API - List execution history + * + * GET /api/schedules/executions - Returns paginated execution history + * + * Query params: + * - scheduleType: 'container_update' | 'git_stack_sync' + * - scheduleId: number + * - environmentId: number + * - status: 'queued' | 'running' | 'success' | 'failed' | 'skipped' + * - triggeredBy: 'cron' | 'webhook' | 'manual' + * - fromDate: ISO date string + * - toDate: ISO date string + * - limit: number (default 50) + * - offset: number (default 0) + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { + getScheduleExecutions, + type ScheduleType, + type ScheduleTrigger, + type ScheduleStatus +} from '$lib/server/db'; + +export const GET: RequestHandler = async ({ url }) => { + try { + const scheduleType = url.searchParams.get('scheduleType') as ScheduleType | null; + const scheduleIdParam = url.searchParams.get('scheduleId'); + const environmentIdParam = url.searchParams.get('environmentId'); + const status = url.searchParams.get('status') as ScheduleStatus | null; + const statusesParam = url.searchParams.get('statuses'); + const triggeredBy = url.searchParams.get('triggeredBy') as ScheduleTrigger | null; + const fromDate = url.searchParams.get('fromDate'); + const toDate = url.searchParams.get('toDate'); + const limitParam = url.searchParams.get('limit'); + const offsetParam = url.searchParams.get('offset'); + + const filters: any = {}; + + if (scheduleType) filters.scheduleType = scheduleType; + if (scheduleIdParam) filters.scheduleId = parseInt(scheduleIdParam, 10); + if (environmentIdParam) { + filters.environmentId = environmentIdParam === 'null' ? null : parseInt(environmentIdParam, 10); + } + if (status) filters.status = status; + if (statusesParam) filters.statuses = statusesParam.split(',') as ScheduleStatus[]; + if (triggeredBy) filters.triggeredBy = triggeredBy; + if (fromDate) filters.fromDate = fromDate; + if (toDate) filters.toDate = toDate; + if (limitParam) filters.limit = parseInt(limitParam, 10); + if (offsetParam) filters.offset = parseInt(offsetParam, 10); + + const result = await getScheduleExecutions(filters); + + return json(result); + } catch (error: any) { + console.error('Failed to get schedule executions:', error); + return json({ error: error.message }, { status: 500 }); + } +}; diff --git a/routes/api/schedules/executions/[id]/+server.ts b/routes/api/schedules/executions/[id]/+server.ts new file mode 100644 index 0000000..6f12152 --- /dev/null +++ b/routes/api/schedules/executions/[id]/+server.ts @@ -0,0 +1,45 @@ +/** + * Schedule Execution Detail API + * + * GET /api/schedules/executions/[id] - Returns execution details including logs + * DELETE /api/schedules/executions/[id] - Delete a schedule execution + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getScheduleExecution, deleteScheduleExecution } from '$lib/server/db'; + +export const GET: RequestHandler = async ({ params }) => { + try { + const id = parseInt(params.id, 10); + if (isNaN(id)) { + return json({ error: 'Invalid execution ID' }, { status: 400 }); + } + + const execution = await getScheduleExecution(id); + if (!execution) { + return json({ error: 'Execution not found' }, { status: 404 }); + } + + return json(execution); + } catch (error: any) { + console.error('Failed to get schedule execution:', error); + return json({ error: error.message }, { status: 500 }); + } +}; + +export const DELETE: RequestHandler = async ({ params }) => { + try { + const id = parseInt(params.id, 10); + if (isNaN(id)) { + return json({ error: 'Invalid execution ID' }, { status: 400 }); + } + + await deleteScheduleExecution(id); + + return json({ success: true }); + } catch (error: any) { + console.error('Failed to delete schedule execution:', error); + return json({ error: error.message }, { status: 500 }); + } +}; diff --git a/routes/api/schedules/settings/+server.ts b/routes/api/schedules/settings/+server.ts new file mode 100644 index 0000000..ed21bc2 --- /dev/null +++ b/routes/api/schedules/settings/+server.ts @@ -0,0 +1,61 @@ +/** + * Schedule Settings API - Get/set schedule display preferences + * + * GET /api/schedules/settings - Get current display settings + * PUT /api/schedules/settings - Update display settings + * + * Note: Data retention settings are now managed in /api/settings/general + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getSetting, setSetting } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; + +// Setting key for hide system jobs preference +const getHideSystemJobsKey = (userId?: number) => + userId ? `user_${userId}_schedules_hide_system_jobs` : 'schedules_hide_system_jobs'; + +export const GET: RequestHandler = async ({ cookies }) => { + try { + const auth = await authorize(cookies); + const userId = auth.isAuthenticated ? auth.user?.id : undefined; + + // Get user-specific setting, fallback to global + let hideSystemJobs = await getSetting(getHideSystemJobsKey(userId)); + if (hideSystemJobs === null && userId) { + hideSystemJobs = await getSetting(getHideSystemJobsKey()); + } + if (hideSystemJobs === null) { + hideSystemJobs = false; // Default to visible + } + + return json({ hideSystemJobs }); + } catch (error: any) { + console.error('Failed to get schedule settings:', error); + return json({ error: error.message }, { status: 500 }); + } +}; + +export const PUT: RequestHandler = async ({ request, cookies }) => { + try { + const auth = await authorize(cookies); + const userId = auth.isAuthenticated ? auth.user?.id : undefined; + + const body = await request.json(); + const { hideSystemJobs } = body; + + if (hideSystemJobs !== undefined) { + if (typeof hideSystemJobs !== 'boolean') { + return json({ error: 'Invalid hideSystemJobs (must be boolean)' }, { status: 400 }); + } + // Save user-specific preference + await setSetting(getHideSystemJobsKey(userId), hideSystemJobs); + } + + return json({ success: true, hideSystemJobs }); + } catch (error: any) { + console.error('Failed to update schedule settings:', error); + return json({ error: error.message }, { status: 500 }); + } +}; diff --git a/routes/api/schedules/stream/+server.ts b/routes/api/schedules/stream/+server.ts new file mode 100644 index 0000000..5b7045f --- /dev/null +++ b/routes/api/schedules/stream/+server.ts @@ -0,0 +1,306 @@ +/** + * Schedules Stream API - Real-time schedule updates via SSE + * + * GET /api/schedules/stream - Server-Sent Events stream for schedule updates + */ + +import type { RequestHandler } from './$types'; +import { + getAllAutoUpdateSettings, + getAllAutoUpdateGitStacks, + getAllEnvUpdateCheckSettings, + getLastExecutionForSchedule, + getRecentExecutionsForSchedule, + getEnvironment, + getEnvironmentTimezone, + type VulnerabilityCriteria +} from '$lib/server/db'; +import { getNextRun, getSystemSchedules } from '$lib/server/scheduler'; +import { getGlobalScannerDefaults, getScannerSettingsWithDefaults } from '$lib/server/scanner'; +import { authorize } from '$lib/server/authorize'; +import type { ScheduleInfo } from '../+server'; + +async function getSchedulesData(): Promise { + const schedules: ScheduleInfo[] = []; + + // Pre-fetch global scanner defaults ONCE (CLI args are global, not per-environment) + const globalScannerDefaults = await getGlobalScannerDefaults(); + + // Get container auto-update schedules + const containerSettings = await getAllAutoUpdateSettings(); + const containerSchedules = await Promise.all( + containerSettings.map(async (setting) => { + const [env, lastExecution, recentExecutions, scannerSettings, timezone] = await Promise.all([ + setting.environmentId ? getEnvironment(setting.environmentId) : null, + getLastExecutionForSchedule('container_update', setting.id), + getRecentExecutionsForSchedule('container_update', setting.id, 5), + getScannerSettingsWithDefaults(setting.environmentId ?? undefined, globalScannerDefaults), + setting.environmentId ? getEnvironmentTimezone(setting.environmentId) : 'UTC' + ]); + const isEnabled = setting.enabled ?? false; + const nextRun = isEnabled && setting.cronExpression ? getNextRun(setting.cronExpression, timezone) : null; + const envHasScanning = scannerSettings.scanner !== 'none'; + + return { + id: setting.id, + type: 'container_update' as const, + name: `Update container: ${setting.containerName}`, + entityName: setting.containerName, + environmentId: setting.environmentId ?? null, + environmentName: env?.name ?? null, + enabled: isEnabled, + scheduleType: setting.scheduleType ?? 'daily', + cronExpression: setting.cronExpression ?? null, + nextRun: nextRun?.toISOString() ?? null, + lastExecution: lastExecution ?? null, + recentExecutions, + isSystem: false, + envHasScanning, + vulnerabilityCriteria: setting.vulnerabilityCriteria ?? null + }; + }) + ); + schedules.push(...containerSchedules); + + // Get git stack auto-sync schedules + const gitStacks = await getAllAutoUpdateGitStacks(); + const gitSchedules = await Promise.all( + gitStacks.map(async (stack) => { + const [env, lastExecution, recentExecutions, timezone] = await Promise.all([ + stack.environmentId ? getEnvironment(stack.environmentId) : null, + getLastExecutionForSchedule('git_stack_sync', stack.id), + getRecentExecutionsForSchedule('git_stack_sync', stack.id, 5), + stack.environmentId ? getEnvironmentTimezone(stack.environmentId) : 'UTC' + ]); + const isEnabled = stack.autoUpdate ?? false; + const nextRun = isEnabled && stack.autoUpdateCron ? getNextRun(stack.autoUpdateCron, timezone) : null; + + return { + id: stack.id, + type: 'git_stack_sync' as const, + name: `Git sync: ${stack.stackName}`, + entityName: stack.stackName, + environmentId: stack.environmentId ?? null, + environmentName: env?.name ?? null, + enabled: isEnabled, + scheduleType: stack.autoUpdateSchedule ?? 'daily', + cronExpression: stack.autoUpdateCron ?? null, + nextRun: nextRun?.toISOString() ?? null, + lastExecution: lastExecution ?? null, + recentExecutions, + isSystem: false + }; + }) + ); + schedules.push(...gitSchedules); + + // Get environment update check schedules + const envUpdateCheckConfigs = await getAllEnvUpdateCheckSettings(); + const envUpdateCheckSchedules = await Promise.all( + envUpdateCheckConfigs.map(async ({ envId, settings }) => { + const [env, lastExecution, recentExecutions, scannerSettings, timezone] = await Promise.all([ + getEnvironment(envId), + getLastExecutionForSchedule('env_update_check', envId), + getRecentExecutionsForSchedule('env_update_check', envId, 5), + getScannerSettingsWithDefaults(envId, globalScannerDefaults), + getEnvironmentTimezone(envId) + ]); + const isEnabled = settings.enabled ?? false; + const nextRun = isEnabled && settings.cron ? getNextRun(settings.cron, timezone) : null; + const envHasScanning = scannerSettings.scanner !== 'none'; + + // Build description based on autoUpdate and scanning status + let description: string; + if (settings.autoUpdate) { + description = envHasScanning ? 'Check, scan & auto-update containers' : 'Check & auto-update containers'; + } else { + description = 'Check containers for updates (notify only)'; + } + + return { + id: envId, + type: 'env_update_check' as const, + name: `Update environment: ${env?.name || 'Unknown'}`, + entityName: env?.name || 'Unknown', + description, + environmentId: envId, + environmentName: env?.name ?? null, + enabled: isEnabled, + scheduleType: 'custom', + cronExpression: settings.cron ?? null, + nextRun: nextRun?.toISOString() ?? null, + lastExecution: lastExecution ?? null, + recentExecutions, + isSystem: false, + autoUpdate: settings.autoUpdate, + envHasScanning, + vulnerabilityCriteria: settings.autoUpdate ? (settings.vulnerabilityCriteria ?? null) : null + }; + }) + ); + schedules.push(...envUpdateCheckSchedules); + + // Get system schedules + const systemSchedules = await getSystemSchedules(); + const sysSchedules = await Promise.all( + systemSchedules.map(async (sys) => { + const [lastExecution, recentExecutions] = await Promise.all([ + getLastExecutionForSchedule(sys.type, sys.id), + getRecentExecutionsForSchedule(sys.type, sys.id, 5) + ]); + + return { + id: sys.id, + type: sys.type, + name: sys.name, + entityName: sys.name, + description: sys.description, + environmentId: null, + environmentName: null, + enabled: sys.enabled, + scheduleType: 'custom', + cronExpression: sys.cronExpression, + nextRun: sys.nextRun, + lastExecution: lastExecution ?? null, + recentExecutions, + isSystem: true + }; + }) + ); + schedules.push(...sysSchedules); + + // Sort: system jobs last, then by name + schedules.sort((a, b) => { + if (a.isSystem !== b.isSystem) return a.isSystem ? 1 : -1; + return a.name.localeCompare(b.name); + }); + + return schedules; +} + +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('schedules', 'view')) { + return new Response(JSON.stringify({ error: 'Permission denied' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } + + let controllerClosed = false; + let intervalId: ReturnType | null = null; + let isPolling = false; // Guard against concurrent poll executions + let initialDataSent = false; // Track if initial data was successfully sent + + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + console.log('[Schedules Stream] New connection opened'); + + // Returns true if data was successfully enqueued + const safeEnqueue = (data: string): boolean => { + if (controllerClosed) { + return false; + } + try { + controller.enqueue(encoder.encode(data)); + return true; + } catch (err) { + console.log('[Schedules Stream] Controller closed during enqueue, cleaning up'); + controllerClosed = true; + if (intervalId) { + clearInterval(intervalId); + intervalId = null; + } + return false; + } + }; + + // Send immediate connection confirmation so client knows stream is alive + if (!safeEnqueue(`event: connected\ndata: {}\n\n`)) { + return; // Connection already closed, abort + } + + // Send initial data - this is critical, retry logic if needed + let retryCount = 0; + const maxRetries = 2; + + while (!initialDataSent && retryCount <= maxRetries && !controllerClosed) { + try { + const schedules = await getSchedulesData(); + + // Check if still connected before sending + if (controllerClosed) { + console.log('[Schedules Stream] Connection closed before initial data could be sent'); + return; + } + + if (safeEnqueue(`event: schedules\ndata: ${JSON.stringify({ schedules })}\n\n`)) { + initialDataSent = true; + console.log('[Schedules Stream] Initial data sent successfully'); + } else { + console.log('[Schedules Stream] Failed to enqueue initial data, connection closed'); + return; + } + } catch (error) { + console.error(`[Schedules Stream] Failed to get initial schedules (attempt ${retryCount + 1}):`, error); + retryCount++; + + if (retryCount > maxRetries) { + // Send error event to client so they can show an error state + safeEnqueue(`event: error\ndata: ${JSON.stringify({ error: String(error), fatal: true })}\n\n`); + return; + } + + // Brief delay before retry + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + + // Only start polling if initial data was sent successfully + if (!initialDataSent) { + console.log('[Schedules Stream] Initial data was never sent, not starting polling'); + return; + } + + // Poll every 2 seconds for updates (with guard against concurrent executions) + intervalId = setInterval(async () => { + // Skip if already polling or controller closed + if (isPolling || controllerClosed) { + if (controllerClosed && intervalId) { + clearInterval(intervalId); + intervalId = null; + } + return; + } + + isPolling = true; + try { + const schedules = await getSchedulesData(); + safeEnqueue(`event: schedules\ndata: ${JSON.stringify({ schedules })}\n\n`); + } catch (error) { + console.error('[Schedules Stream] Failed to get schedules during poll:', error); + // Don't send error event for poll failures, just log + } finally { + isPolling = false; + } + }, 2000); + }, + cancel() { + console.log('[Schedules Stream] Connection cancelled, cleaning up'); + controllerClosed = true; + if (intervalId) { + clearInterval(intervalId); + intervalId = null; + } + } + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + } + }); +}; diff --git a/routes/api/schedules/system/[id]/toggle/+server.ts b/routes/api/schedules/system/[id]/toggle/+server.ts new file mode 100644 index 0000000..0b0a06b --- /dev/null +++ b/routes/api/schedules/system/[id]/toggle/+server.ts @@ -0,0 +1,42 @@ +import { json, type RequestHandler } from '@sveltejs/kit'; +import { + setScheduleCleanupEnabled, + setEventCleanupEnabled, + getScheduleCleanupEnabled, + getEventCleanupEnabled +} from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; + +const SYSTEM_SCHEDULE_CLEANUP_ID = 1; +const SYSTEM_EVENT_CLEANUP_ID = 2; + +export const POST: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('settings', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const { id } = params; + const systemId = parseInt(id, 10); + + if (isNaN(systemId)) { + return json({ error: 'Invalid system schedule ID' }, { status: 400 }); + } + + if (systemId === SYSTEM_SCHEDULE_CLEANUP_ID) { + const currentEnabled = await getScheduleCleanupEnabled(); + await setScheduleCleanupEnabled(!currentEnabled); + return json({ success: true, enabled: !currentEnabled }); + } else if (systemId === SYSTEM_EVENT_CLEANUP_ID) { + const currentEnabled = await getEventCleanupEnabled(); + await setEventCleanupEnabled(!currentEnabled); + return json({ success: true, enabled: !currentEnabled }); + } else { + return json({ error: 'Unknown system schedule' }, { status: 400 }); + } + } catch (error: any) { + console.error('Failed to toggle system schedule:', error); + return json({ error: 'Failed to toggle system schedule' }, { status: 500 }); + } +}; diff --git a/routes/api/settings/general/+server.ts b/routes/api/settings/general/+server.ts new file mode 100644 index 0000000..6cd65ba --- /dev/null +++ b/routes/api/settings/general/+server.ts @@ -0,0 +1,329 @@ +import { json, type RequestHandler } from '@sveltejs/kit'; +import { + getSetting, + setSetting, + getScheduleRetentionDays, + setScheduleRetentionDays, + getEventRetentionDays, + setEventRetentionDays, + getScheduleCleanupCron, + setScheduleCleanupCron, + getEventCleanupCron, + setEventCleanupCron, + getScheduleCleanupEnabled, + setScheduleCleanupEnabled, + getEventCleanupEnabled, + setEventCleanupEnabled, + getDefaultTimezone, + setDefaultTimezone +} from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; +import { refreshSystemJobs } from '$lib/server/scheduler'; + +export type TimeFormat = '12h' | '24h'; +export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY'; +export type DownloadFormat = 'tar' | 'tar.gz'; + +export interface GeneralSettings { + confirmDestructive: boolean; + showStoppedContainers: boolean; + highlightUpdates: boolean; + timeFormat: TimeFormat; + dateFormat: DateFormat; + downloadFormat: DownloadFormat; + defaultGrypeArgs: string; + defaultTrivyArgs: string; + scheduleRetentionDays: number; + eventRetentionDays: number; + scheduleCleanupCron: string; + eventCleanupCron: string; + scheduleCleanupEnabled: boolean; + eventCleanupEnabled: boolean; + logBufferSizeKb: number; + defaultTimezone: string; + // Theme settings (for when auth is disabled) + lightTheme: string; + darkTheme: string; + font: string; + fontSize: string; + gridFontSize: string; + terminalFont: string; +} + +const DEFAULT_SETTINGS: Omit = { + confirmDestructive: true, + showStoppedContainers: true, + highlightUpdates: true, + timeFormat: '24h', + dateFormat: 'DD.MM.YYYY', + downloadFormat: 'tar', + defaultGrypeArgs: '-o json -v {image}', + defaultTrivyArgs: 'image --format json {image}', + logBufferSizeKb: 500, + defaultTimezone: 'UTC', + lightTheme: 'default', + darkTheme: 'default', + font: 'system', + fontSize: 'normal', + gridFontSize: 'normal', + terminalFont: 'system-mono' +}; + +const VALID_LIGHT_THEMES = ['default', 'catppuccin', 'rose-pine', 'nord', 'solarized', 'gruvbox', 'alucard', 'github', 'material', 'atom-one']; +const VALID_DARK_THEMES = ['default', 'catppuccin', 'dracula', 'rose-pine', 'rose-pine-moon', 'tokyo-night', 'nord', 'one-dark', 'gruvbox', 'solarized', 'everforest', 'kanagawa', 'monokai', 'monokai-pro', 'material', 'palenight', 'github']; +const VALID_FONTS = ['system', 'geist', 'inter', 'plus-jakarta', 'dm-sans', 'outfit', 'space-grotesk', 'sofia-sans', 'nunito', 'poppins', 'montserrat', 'raleway', 'manrope', 'roboto', 'open-sans', 'lato', 'source-sans', 'work-sans', 'fira-sans', 'jetbrains-mono', 'fira-code', 'quicksand', 'comfortaa']; +const VALID_FONT_SIZES = ['xsmall', 'small', 'normal', 'medium', 'large', 'xlarge']; +const VALID_TERMINAL_FONTS = ['system-mono', 'jetbrains-mono', 'fira-code', 'source-code-pro', 'cascadia-code', 'menlo', 'consolas', 'sf-mono']; + +const VALID_DATE_FORMATS: DateFormat[] = ['MM/DD/YYYY', 'DD/MM/YYYY', 'YYYY-MM-DD', 'DD.MM.YYYY']; + +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + // UI preferences (time format, date format) should be available to all authenticated users + // This doesn't expose sensitive data and is needed for proper UI rendering + if (auth.authEnabled && !auth.isAuthenticated) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + + try { + // Fetch all settings in parallel for better performance + const [ + confirmDestructive, + showStoppedContainers, + highlightUpdates, + timeFormat, + dateFormat, + downloadFormat, + defaultGrypeArgs, + defaultTrivyArgs, + scheduleRetentionDays, + eventRetentionDays, + scheduleCleanupCron, + eventCleanupCron, + scheduleCleanupEnabled, + eventCleanupEnabled, + logBufferSizeKb, + defaultTimezone, + lightTheme, + darkTheme, + font, + fontSize, + gridFontSize, + terminalFont + ] = await Promise.all([ + getSetting('confirm_destructive'), + getSetting('show_stopped_containers'), + getSetting('highlight_updates'), + getSetting('time_format'), + getSetting('date_format'), + getSetting('download_format'), + getSetting('default_grype_args'), + getSetting('default_trivy_args'), + getScheduleRetentionDays(), + getEventRetentionDays(), + getScheduleCleanupCron(), + getEventCleanupCron(), + getScheduleCleanupEnabled(), + getEventCleanupEnabled(), + getSetting('log_buffer_size_kb'), + getDefaultTimezone(), + getSetting('theme_light'), + getSetting('theme_dark'), + getSetting('theme_font'), + getSetting('theme_font_size'), + getSetting('theme_grid_font_size'), + getSetting('theme_terminal_font') + ]); + + const settings: GeneralSettings = { + confirmDestructive: confirmDestructive ?? DEFAULT_SETTINGS.confirmDestructive, + showStoppedContainers: showStoppedContainers ?? DEFAULT_SETTINGS.showStoppedContainers, + highlightUpdates: highlightUpdates ?? DEFAULT_SETTINGS.highlightUpdates, + timeFormat: timeFormat ?? DEFAULT_SETTINGS.timeFormat, + dateFormat: dateFormat ?? DEFAULT_SETTINGS.dateFormat, + downloadFormat: downloadFormat ?? DEFAULT_SETTINGS.downloadFormat, + defaultGrypeArgs: defaultGrypeArgs ?? DEFAULT_SETTINGS.defaultGrypeArgs, + defaultTrivyArgs: defaultTrivyArgs ?? DEFAULT_SETTINGS.defaultTrivyArgs, + scheduleRetentionDays, + eventRetentionDays, + scheduleCleanupCron, + eventCleanupCron, + scheduleCleanupEnabled, + eventCleanupEnabled, + logBufferSizeKb: logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb, + defaultTimezone: defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone, + lightTheme: lightTheme ?? DEFAULT_SETTINGS.lightTheme, + darkTheme: darkTheme ?? DEFAULT_SETTINGS.darkTheme, + font: font ?? DEFAULT_SETTINGS.font, + fontSize: fontSize ?? DEFAULT_SETTINGS.fontSize, + gridFontSize: gridFontSize ?? DEFAULT_SETTINGS.gridFontSize, + terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont + }; + + return json(settings); + } catch (error) { + console.error('Failed to get general settings:', error); + return json({ error: 'Failed to get general settings' }, { status: 500 }); + } +}; + +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('settings', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const body = await request.json(); + const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont } = body; + + if (confirmDestructive !== undefined) { + await setSetting('confirm_destructive', confirmDestructive); + } + if (showStoppedContainers !== undefined) { + await setSetting('show_stopped_containers', showStoppedContainers); + } + if (highlightUpdates !== undefined) { + await setSetting('highlight_updates', highlightUpdates); + } + if (timeFormat !== undefined && (timeFormat === '12h' || timeFormat === '24h')) { + await setSetting('time_format', timeFormat); + } + if (dateFormat !== undefined && VALID_DATE_FORMATS.includes(dateFormat)) { + await setSetting('date_format', dateFormat); + } + if (downloadFormat !== undefined && (downloadFormat === 'tar' || downloadFormat === 'tar.gz')) { + await setSetting('download_format', downloadFormat); + } + if (defaultGrypeArgs !== undefined && typeof defaultGrypeArgs === 'string') { + await setSetting('default_grype_args', defaultGrypeArgs); + } + if (defaultTrivyArgs !== undefined && typeof defaultTrivyArgs === 'string') { + await setSetting('default_trivy_args', defaultTrivyArgs); + } + if (scheduleRetentionDays !== undefined && typeof scheduleRetentionDays === 'number') { + await setScheduleRetentionDays(Math.max(1, Math.min(365, scheduleRetentionDays))); + } + if (eventRetentionDays !== undefined && typeof eventRetentionDays === 'number') { + await setEventRetentionDays(Math.max(1, Math.min(365, eventRetentionDays))); + } + if (scheduleCleanupCron !== undefined && typeof scheduleCleanupCron === 'string') { + await setScheduleCleanupCron(scheduleCleanupCron); + } + if (eventCleanupCron !== undefined && typeof eventCleanupCron === 'string') { + await setEventCleanupCron(eventCleanupCron); + } + if (scheduleCleanupEnabled !== undefined && typeof scheduleCleanupEnabled === 'boolean') { + await setScheduleCleanupEnabled(scheduleCleanupEnabled); + } + if (eventCleanupEnabled !== undefined && typeof eventCleanupEnabled === 'boolean') { + await setEventCleanupEnabled(eventCleanupEnabled); + } + if (logBufferSizeKb !== undefined && typeof logBufferSizeKb === 'number') { + // Clamp to reasonable range: 100KB - 5000KB (5MB) + await setSetting('log_buffer_size_kb', Math.max(100, Math.min(5000, logBufferSizeKb))); + } + if (defaultTimezone !== undefined && typeof defaultTimezone === 'string') { + await setDefaultTimezone(defaultTimezone); + // Refresh system jobs to use the new timezone + await refreshSystemJobs(); + } + if (lightTheme !== undefined && VALID_LIGHT_THEMES.includes(lightTheme)) { + await setSetting('theme_light', lightTheme); + } + if (darkTheme !== undefined && VALID_DARK_THEMES.includes(darkTheme)) { + await setSetting('theme_dark', darkTheme); + } + if (font !== undefined && VALID_FONTS.includes(font)) { + await setSetting('theme_font', font); + } + if (fontSize !== undefined && VALID_FONT_SIZES.includes(fontSize)) { + await setSetting('theme_font_size', fontSize); + } + if (gridFontSize !== undefined && VALID_FONT_SIZES.includes(gridFontSize)) { + await setSetting('theme_grid_font_size', gridFontSize); + } + if (terminalFont !== undefined && VALID_TERMINAL_FONTS.includes(terminalFont)) { + await setSetting('theme_terminal_font', terminalFont); + } + + // Fetch all settings in parallel for the response + const [ + confirmDestructiveVal, + showStoppedContainersVal, + highlightUpdatesVal, + timeFormatVal, + dateFormatVal, + downloadFormatVal, + defaultGrypeArgsVal, + defaultTrivyArgsVal, + scheduleRetentionDaysVal, + eventRetentionDaysVal, + scheduleCleanupCronVal, + eventCleanupCronVal, + scheduleCleanupEnabledVal, + eventCleanupEnabledVal, + logBufferSizeKbVal, + defaultTimezoneVal, + lightThemeVal, + darkThemeVal, + fontVal, + fontSizeVal, + gridFontSizeVal, + terminalFontVal + ] = await Promise.all([ + getSetting('confirm_destructive'), + getSetting('show_stopped_containers'), + getSetting('highlight_updates'), + getSetting('time_format'), + getSetting('date_format'), + getSetting('download_format'), + getSetting('default_grype_args'), + getSetting('default_trivy_args'), + getScheduleRetentionDays(), + getEventRetentionDays(), + getScheduleCleanupCron(), + getEventCleanupCron(), + getScheduleCleanupEnabled(), + getEventCleanupEnabled(), + getSetting('log_buffer_size_kb'), + getDefaultTimezone(), + getSetting('theme_light'), + getSetting('theme_dark'), + getSetting('theme_font'), + getSetting('theme_font_size'), + getSetting('theme_grid_font_size'), + getSetting('theme_terminal_font') + ]); + + const settings: GeneralSettings = { + confirmDestructive: confirmDestructiveVal ?? DEFAULT_SETTINGS.confirmDestructive, + showStoppedContainers: showStoppedContainersVal ?? DEFAULT_SETTINGS.showStoppedContainers, + highlightUpdates: highlightUpdatesVal ?? DEFAULT_SETTINGS.highlightUpdates, + timeFormat: timeFormatVal ?? DEFAULT_SETTINGS.timeFormat, + dateFormat: dateFormatVal ?? DEFAULT_SETTINGS.dateFormat, + downloadFormat: downloadFormatVal ?? DEFAULT_SETTINGS.downloadFormat, + defaultGrypeArgs: defaultGrypeArgsVal ?? DEFAULT_SETTINGS.defaultGrypeArgs, + defaultTrivyArgs: defaultTrivyArgsVal ?? DEFAULT_SETTINGS.defaultTrivyArgs, + scheduleRetentionDays: scheduleRetentionDaysVal, + eventRetentionDays: eventRetentionDaysVal, + scheduleCleanupCron: scheduleCleanupCronVal, + eventCleanupCron: eventCleanupCronVal, + scheduleCleanupEnabled: scheduleCleanupEnabledVal, + eventCleanupEnabled: eventCleanupEnabledVal, + logBufferSizeKb: logBufferSizeKbVal ?? DEFAULT_SETTINGS.logBufferSizeKb, + defaultTimezone: defaultTimezoneVal ?? DEFAULT_SETTINGS.defaultTimezone, + lightTheme: lightThemeVal ?? DEFAULT_SETTINGS.lightTheme, + darkTheme: darkThemeVal ?? DEFAULT_SETTINGS.darkTheme, + font: fontVal ?? DEFAULT_SETTINGS.font, + fontSize: fontSizeVal ?? DEFAULT_SETTINGS.fontSize, + gridFontSize: gridFontSizeVal ?? DEFAULT_SETTINGS.gridFontSize, + terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont + }; + + return json(settings); + } catch (error) { + console.error('Failed to save general settings:', error); + return json({ error: 'Failed to save general settings' }, { status: 500 }); + } +}; diff --git a/routes/api/settings/scanner/+server.ts b/routes/api/settings/scanner/+server.ts new file mode 100644 index 0000000..34d9dd1 --- /dev/null +++ b/routes/api/settings/scanner/+server.ts @@ -0,0 +1,195 @@ +import { json, type RequestHandler } from '@sveltejs/kit'; +import { getEnvSetting, setEnvSetting, getEnvironment } from '$lib/server/db'; +import { + checkScannerAvailability, + getScannerVersions, + checkScannerUpdates, + cleanupScannerVolumes, + getGlobalScannerDefaults, + type ScannerType +} from '$lib/server/scanner'; +import { removeImage, listImages } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; + +export interface ScannerSettings { + scanner: ScannerType; + grypeArgs: string; + trivyArgs: string; +} + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const parsedEnvId = envId ? parseInt(envId) : undefined; + const checkUpdates = url.searchParams.get('checkUpdates') === 'true'; + const settingsOnly = url.searchParams.get('settingsOnly') === 'true'; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('settings', 'view', parsedEnvId)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + + // Get global defaults from general settings (used for reset to defaults) + const globalDefaults = await getGlobalScannerDefaults(); + + // Get environment-specific settings (falls back to global defaults if not set) + const settings: ScannerSettings = { + scanner: await getEnvSetting('vulnerability_scanner', parsedEnvId) || 'none', + grypeArgs: await getEnvSetting('grype_cli_args', parsedEnvId) || globalDefaults.grypeArgs, + trivyArgs: await getEnvSetting('trivy_cli_args', parsedEnvId) || globalDefaults.trivyArgs + }; + + // Fast path: return just settings without Docker checks + if (settingsOnly) { + return json({ + settings, + defaults: globalDefaults + }); + } + + // Check scanner availability and versions in parallel + const [availability, versions] = await Promise.all([ + checkScannerAvailability(parsedEnvId), + getScannerVersions(parsedEnvId) + ]); + + // Optionally check for updates (slower operation) + let updates = undefined; + if (checkUpdates) { + updates = await checkScannerUpdates(parsedEnvId); + } + + return json({ + settings, + availability, + versions, + updates, + defaults: globalDefaults + }); + } catch (error) { + console.error('Failed to get scanner settings:', error); + return json({ error: 'Failed to get scanner settings' }, { status: 500 }); + } +}; + +export const POST: RequestHandler = async ({ request, url, cookies }) => { + const auth = await authorize(cookies); + + try { + const body = await request.json(); + const { scanner, grypeArgs, trivyArgs, envId } = body; + const parsedEnvId = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('settings', 'edit', parsedEnvId)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Validate scanner type + const validScanners: ScannerType[] = ['none', 'grype', 'trivy', 'both']; + if (scanner && !validScanners.includes(scanner)) { + return json({ error: 'Invalid scanner type' }, { status: 400 }); + } + + // Save environment-specific settings + if (scanner !== undefined) { + await setEnvSetting('vulnerability_scanner', scanner, parsedEnvId); + } + if (grypeArgs !== undefined) { + await setEnvSetting('grype_cli_args', grypeArgs, parsedEnvId); + } + if (trivyArgs !== undefined) { + await setEnvSetting('trivy_cli_args', trivyArgs, parsedEnvId); + } + + // Get global defaults for fallback + const globalDefaults = await getGlobalScannerDefaults(); + + return json({ + success: true, + settings: { + scanner: await getEnvSetting('vulnerability_scanner', parsedEnvId) || 'none', + grypeArgs: await getEnvSetting('grype_cli_args', parsedEnvId) || globalDefaults.grypeArgs, + trivyArgs: await getEnvSetting('trivy_cli_args', parsedEnvId) || globalDefaults.trivyArgs + } + }); + } catch (error) { + console.error('Failed to save scanner settings:', error); + return json({ error: 'Failed to save scanner settings' }, { status: 500 }); + } +}; + +export const DELETE: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + const removeImagesFlag = url.searchParams.get('removeImages') === 'true'; + const scanner = url.searchParams.get('scanner'); // 'grype', 'trivy', or null for both + const envId = url.searchParams.get('env'); + const parsedEnvId = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('settings', 'edit', parsedEnvId)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + + if (!removeImagesFlag) { + return json({ error: 'removeImages parameter required' }, { status: 400 }); + } + + if (!parsedEnvId) { + return json({ error: 'Environment ID required' }, { status: 400 }); + } + const env = await getEnvironment(parsedEnvId); + if (!env) { + return json({ error: 'Environment not found' }, { status: 404 }); + } + + const images = await listImages(parsedEnvId); + + const removed: string[] = []; + const errors: string[] = []; + + // Determine which images to remove + const scannersToRemove: ('grype' | 'trivy')[] = + scanner === 'grype' ? ['grype'] : + scanner === 'trivy' ? ['trivy'] : + ['grype', 'trivy']; + + for (const scannerType of scannersToRemove) { + const imageName = scannerType === 'grype' ? 'anchore/grype' : 'aquasec/trivy'; + + // Find the image + const image = images.find((img) => + img.tags?.some((tag: string) => tag.includes(imageName)) + ); + + if (image) { + try { + await removeImage(image.id, true, parsedEnvId); + removed.push(scannerType); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + console.error(`Failed to remove ${scannerType} image:`, err); + errors.push(`${scannerType}: ${errMsg}`); + } + } + } + + // Also cleanup scanner database volumes + await cleanupScannerVolumes(parsedEnvId); + + return json({ + success: true, + removed, + errors: errors.length > 0 ? errors : undefined + }); + } catch (error) { + console.error('Failed to remove scanner images:', error); + return json({ error: 'Failed to remove scanner images' }, { status: 500 }); + } +}; diff --git a/routes/api/stacks/+server.ts b/routes/api/stacks/+server.ts new file mode 100644 index 0000000..50b5a16 --- /dev/null +++ b/routes/api/stacks/+server.ts @@ -0,0 +1,131 @@ +import { json } from '@sveltejs/kit'; +import { listComposeStacks, deployStack, saveStackComposeFile } from '$lib/server/stacks'; +import { EnvironmentNotFoundError } from '$lib/server/docker'; +import { upsertStackSource, getStackSources } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !(await auth.can('stacks', 'view', envIdNum))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !(await auth.canAccessEnvironment(envIdNum))) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + // Early return if no environment specified + if (!envIdNum) { + return json([]); + } + + try { + const stacks = await listComposeStacks(envIdNum); + + // Add stacks from database that are internally managed but don't have containers yet + // (created with "Create" button, not "Create & Start") + const stackSources = await getStackSources(envIdNum); + const existingNames = new Set(stacks.map((s) => s.name)); + + for (const source of stackSources) { + // Only add internal/git stacks that aren't already in the list + if ( + !existingNames.has(source.stackName) && + (source.sourceType === 'internal' || source.sourceType === 'git') + ) { + stacks.push({ + name: source.stackName, + containers: [], + containerDetails: [], + status: 'created' as any + }); + } + } + + return json(stacks); + } catch (error) { + if (error instanceof EnvironmentNotFoundError) { + return json({ error: 'Environment not found' }, { status: 404 }); + } + console.error('Error listing compose stacks:', error); + // Return empty array instead of error to allow UI to load + return json([]); + } +}; + +export const POST: RequestHandler = async ({ request, url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !(await auth.can('stacks', 'create', envIdNum))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !(await auth.canAccessEnvironment(envIdNum))) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + const body = await request.json(); + const { name, compose, start } = body; + + if (!name || typeof name !== 'string') { + return json({ error: 'Stack name is required' }, { status: 400 }); + } + + if (!compose || typeof compose !== 'string') { + return json({ error: 'Compose file content is required' }, { status: 400 }); + } + + // If start is false, only create the compose file without deploying + if (start === false) { + const result = await saveStackComposeFile(name, compose, true); + if (!result.success) { + return json({ error: result.error }, { status: 400 }); + } + + // Record the stack as internally created + await upsertStackSource({ + stackName: name, + environmentId: envIdNum, + sourceType: 'internal' + }); + + return json({ success: true, started: false }); + } + + // Deploy and start the stack + const result = await deployStack({ + name, + compose, + envId: envIdNum + }); + + if (!result.success) { + return json({ error: result.error, output: result.output }, { status: 400 }); + } + + // Record the stack as internally created + await upsertStackSource({ + stackName: name, + environmentId: envIdNum, + sourceType: 'internal' + }); + + return json({ success: true, started: true, output: result.output }); + } catch (error: any) { + console.error('Error creating compose stack:', error); + return json({ error: error.message || 'Failed to create stack' }, { status: 500 }); + } +}; diff --git a/routes/api/stacks/[name]/+server.ts b/routes/api/stacks/[name]/+server.ts new file mode 100644 index 0000000..38098b5 --- /dev/null +++ b/routes/api/stacks/[name]/+server.ts @@ -0,0 +1,46 @@ +import { json } from '@sveltejs/kit'; +import { removeStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks'; +import { authorize } from '$lib/server/authorize'; +import { auditStack } from '$lib/server/audit'; +import type { RequestHandler } from './$types'; + +export const DELETE: RequestHandler = async (event) => { + const { params, url, cookies } = event; + const auth = await authorize(cookies); + + const force = url.searchParams.get('force') === 'true'; + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !(await auth.can('stacks', 'remove', envIdNum))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !(await auth.canAccessEnvironment(envIdNum))) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + const stackName = decodeURIComponent(params.name); + const result = await removeStack(stackName, envIdNum, force); + + // Audit log + await auditStack(event, 'delete', stackName, envIdNum, { force }); + + if (!result.success) { + return json({ success: false, error: result.error }, { status: 400 }); + } + return json({ success: true }); + } catch (error) { + if (error instanceof ExternalStackError) { + return json({ error: error.message }, { status: 400 }); + } + if (error instanceof ComposeFileNotFoundError) { + return json({ error: error.message }, { status: 404 }); + } + console.error('Error removing compose stack:', error); + return json({ error: 'Failed to remove compose stack' }, { status: 500 }); + } +}; diff --git a/routes/api/stacks/[name]/compose/+server.ts b/routes/api/stacks/[name]/compose/+server.ts new file mode 100644 index 0000000..08b5c11 --- /dev/null +++ b/routes/api/stacks/[name]/compose/+server.ts @@ -0,0 +1,72 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getStackComposeFile, deployStack, saveStackComposeFile } from '$lib/server/stacks'; +import { authorize } from '$lib/server/authorize'; + +// GET /api/stacks/[name]/compose - Get compose file content +export const GET: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !(await auth.can('stacks', 'view'))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + const { name } = params; + + try { + const result = await getStackComposeFile(name); + + if (!result.success) { + return json({ error: result.error }, { status: 404 }); + } + + return json({ content: result.content }); + } catch (error: any) { + console.error(`Error getting compose file for stack ${name}:`, error); + return json({ error: error.message || 'Failed to get compose file' }, { status: 500 }); + } +}; + +// PUT /api/stacks/[name]/compose - Update compose file content +export const PUT: RequestHandler = async ({ params, request, url, cookies }) => { + const auth = await authorize(cookies); + + const { name } = params; + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !(await auth.can('stacks', 'edit', envIdNum))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const body = await request.json(); + const { content, restart = false } = body; + + if (!content || typeof content !== 'string') { + return json({ error: 'Compose file content is required' }, { status: 400 }); + } + + let result; + if (restart) { + // Deploy with docker compose up -d (only recreates changed services) + result = await deployStack({ + name, + compose: content, + envId: envIdNum + }); + } else { + // Just save the file without restarting + result = await saveStackComposeFile(name, content); + } + + if (!result.success) { + return json({ error: result.error }, { status: 500 }); + } + + return json({ success: true }); + } catch (error: any) { + console.error(`Error updating compose file for stack ${name}:`, error); + return json({ error: error.message || 'Failed to update compose file' }, { status: 500 }); + } +}; diff --git a/routes/api/stacks/[name]/down/+server.ts b/routes/api/stacks/[name]/down/+server.ts new file mode 100644 index 0000000..1995f71 --- /dev/null +++ b/routes/api/stacks/[name]/down/+server.ts @@ -0,0 +1,54 @@ +import { json } from '@sveltejs/kit'; +import { downStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks'; +import { authorize } from '$lib/server/authorize'; +import { auditStack } from '$lib/server/audit'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async (event) => { + const { params, url, cookies, request } = event; + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !(await auth.can('stacks', 'stop', envIdNum))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !(await auth.canAccessEnvironment(envIdNum))) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + // Parse body for optional removeVolumes flag + let removeVolumes = false; + try { + const body = await request.json(); + removeVolumes = body.removeVolumes === true; + } catch { + // No body or invalid JSON - use defaults + } + + const stackName = decodeURIComponent(params.name); + const result = await downStack(stackName, envIdNum, removeVolumes); + + // Audit log + await auditStack(event, 'down', stackName, envIdNum, { removeVolumes }); + + if (!result.success) { + return json({ success: false, error: result.error }, { status: 400 }); + } + return json({ success: true, output: result.output }); + } catch (error) { + if (error instanceof ExternalStackError) { + return json({ error: error.message }, { status: 400 }); + } + if (error instanceof ComposeFileNotFoundError) { + return json({ error: error.message }, { status: 404 }); + } + console.error('Error downing compose stack:', error); + return json({ error: 'Failed to down compose stack' }, { status: 500 }); + } +}; diff --git a/routes/api/stacks/[name]/env/+server.ts b/routes/api/stacks/[name]/env/+server.ts new file mode 100644 index 0000000..23d7a4a --- /dev/null +++ b/routes/api/stacks/[name]/env/+server.ts @@ -0,0 +1,122 @@ +import { json } from '@sveltejs/kit'; +import { getStackEnvVars, setStackEnvVars } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +/** + * GET /api/stacks/[name]/env?env=X + * Get all environment variables for a stack. + * Secrets are masked with '***' in the response. + */ +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : null; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('stacks', 'view', envIdNum ?? undefined)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + const stackName = decodeURIComponent(params.name); + const variables = await getStackEnvVars(stackName, envIdNum, true); + + return json({ + variables: variables.map(v => ({ + key: v.key, + value: v.value, + isSecret: v.isSecret + })) + }); + } catch (error) { + console.error('Error getting stack env vars:', error); + return json({ error: 'Failed to get environment variables' }, { status: 500 }); + } +}; + +/** + * PUT /api/stacks/[name]/env?env=X + * Set/replace all environment variables for a stack. + * Body: { variables: [{ key, value, isSecret? }] } + * + * Note: For secrets, if the value is '***' (the masked placeholder), the original + * secret value from the database is preserved instead of overwriting with '***'. + */ +export const PUT: RequestHandler = async ({ params, url, cookies, request }) => { + const auth = await authorize(cookies); + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : null; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('stacks', 'edit', envIdNum ?? undefined)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + const stackName = decodeURIComponent(params.name); + const body = await request.json(); + + if (!body.variables || !Array.isArray(body.variables)) { + return json({ error: 'Invalid request body: variables array required' }, { status: 400 }); + } + + // Validate variables + for (const v of body.variables) { + if (!v.key || typeof v.key !== 'string') { + return json({ error: 'Invalid variable: key is required and must be a string' }, { status: 400 }); + } + if (typeof v.value !== 'string') { + return json({ error: `Invalid variable "${v.key}": value must be a string` }, { status: 400 }); + } + // Validate key format (env var naming convention) + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(v.key)) { + return json({ error: `Invalid variable name "${v.key}": must start with a letter or underscore and contain only alphanumeric characters and underscores` }, { status: 400 }); + } + } + + // Check if any secrets have the masked placeholder '***' + // If so, we need to preserve their original values from the database + const secretsWithMaskedValue = body.variables.filter( + (v: { key: string; value: string; isSecret?: boolean }) => + v.isSecret && v.value === '***' + ); + + let variablesToSave = body.variables; + + if (secretsWithMaskedValue.length > 0) { + // Get existing variables (unmasked) to preserve secret values + const existingVars = await getStackEnvVars(stackName, envIdNum, false); + const existingByKey = new Map(existingVars.map(v => [v.key, v])); + + // Replace masked secrets with their original values + variablesToSave = body.variables.map((v: { key: string; value: string; isSecret?: boolean }) => { + if (v.isSecret && v.value === '***') { + const existing = existingByKey.get(v.key); + if (existing && existing.isSecret) { + // Preserve the original secret value + return { ...v, value: existing.value }; + } + } + return v; + }); + } + + await setStackEnvVars(stackName, envIdNum, variablesToSave); + + return json({ success: true, count: variablesToSave.length }); + } catch (error) { + console.error('Error setting stack env vars:', error); + return json({ error: 'Failed to set environment variables' }, { status: 500 }); + } +}; diff --git a/routes/api/stacks/[name]/env/validate/+server.ts b/routes/api/stacks/[name]/env/validate/+server.ts new file mode 100644 index 0000000..85ad092 --- /dev/null +++ b/routes/api/stacks/[name]/env/validate/+server.ts @@ -0,0 +1,148 @@ +import { json } from '@sveltejs/kit'; +import { getStackEnvVars } from '$lib/server/db'; +import { getStackComposeFile } from '$lib/server/stacks'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +interface ValidationResult { + valid: boolean; + required: string[]; + optional: string[]; + defined: string[]; + missing: string[]; + unused: string[]; +} + +/** + * Extract environment variables from compose YAML content. + * Matches ${VAR_NAME} and ${VAR_NAME:-default} patterns. + * Returns { required: [...], optional: [...] } + */ +function extractComposeVars(yaml: string): { required: string[]; optional: string[] } { + const required: string[] = []; + const optional: string[] = []; + + // Match ${VAR_NAME} (required) and ${VAR_NAME:-default} or ${VAR_NAME-default} (optional) + const regex = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:-?)[^}]*)?\}/g; + let match; + + while ((match = regex.exec(yaml)) !== null) { + const varName = match[1]; + const hasDefault = match[2] !== undefined; + + if (hasDefault) { + if (!optional.includes(varName) && !required.includes(varName)) { + optional.push(varName); + } + } else { + // Move from optional to required if we find a non-default usage + const optIdx = optional.indexOf(varName); + if (optIdx !== -1) { + optional.splice(optIdx, 1); + } + if (!required.includes(varName)) { + required.push(varName); + } + } + } + + // Also match $VAR_NAME (simple variable substitution) + const simpleRegex = /\$([A-Za-z_][A-Za-z0-9_]*)(?![{A-Za-z0-9_])/g; + while ((match = simpleRegex.exec(yaml)) !== null) { + const varName = match[1]; + if (!required.includes(varName) && !optional.includes(varName)) { + required.push(varName); + } + } + + return { required: required.sort(), optional: optional.sort() }; +} + +/** + * POST /api/stacks/[name]/env/validate?env=X + * Validate stack environment variables against compose file requirements. + * Can use saved compose file or accept compose content in body. + * Body (optional): { compose: "yaml content..." } + */ +export const POST: RequestHandler = async ({ params, url, cookies, request }) => { + const auth = await authorize(cookies); + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : null; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('stacks', 'view', envIdNum ?? undefined)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + const stackName = decodeURIComponent(params.name); + let composeContent: string | null = null; + let providedVariables: string[] | null = null; + + // Check if compose content and/or variables are provided in body + const contentType = request.headers.get('content-type'); + if (contentType?.includes('application/json')) { + try { + const body = await request.json(); + if (body.compose && typeof body.compose === 'string') { + composeContent = body.compose; + } + // Accept variables from UI for validation (overrides DB lookup) + if (Array.isArray(body.variables)) { + providedVariables = body.variables.filter((v: unknown) => typeof v === 'string'); + } + } catch { + // Ignore JSON parse errors - will try to load from file + } + } + + // If no compose in body, try to load from saved file + if (!composeContent) { + const savedCompose = await getStackComposeFile(stackName); + if (savedCompose.success && savedCompose.content) { + composeContent = savedCompose.content; + } + } + + if (!composeContent) { + return json({ error: 'No compose content provided and no saved compose file found' }, { status: 400 }); + } + + // Extract variables from compose + const { required, optional } = extractComposeVars(composeContent); + + // Get defined variables - either from request body or database + let defined: string[]; + if (providedVariables !== null) { + // Use provided variables from UI + defined = providedVariables.sort(); + } else { + // Fall back to database + const envVars = await getStackEnvVars(stackName, envIdNum, false); + defined = envVars.map(v => v.key).sort(); + } + + // Calculate missing and unused + const missing = required.filter(v => !defined.includes(v)); + const unused = defined.filter(v => !required.includes(v) && !optional.includes(v)); + + const result: ValidationResult = { + valid: missing.length === 0, + required, + optional, + defined, + missing, + unused + }; + + return json(result); + } catch (error) { + console.error('Error validating stack env vars:', error); + return json({ error: 'Failed to validate environment variables' }, { status: 500 }); + } +}; diff --git a/routes/api/stacks/[name]/restart/+server.ts b/routes/api/stacks/[name]/restart/+server.ts new file mode 100644 index 0000000..29ecbf5 --- /dev/null +++ b/routes/api/stacks/[name]/restart/+server.ts @@ -0,0 +1,45 @@ +import { json } from '@sveltejs/kit'; +import { restartStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks'; +import { authorize } from '$lib/server/authorize'; +import { auditStack } from '$lib/server/audit'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async (event) => { + const { params, url, cookies } = event; + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !(await auth.can('stacks', 'restart', envIdNum))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !(await auth.canAccessEnvironment(envIdNum))) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + const stackName = decodeURIComponent(params.name); + const result = await restartStack(stackName, envIdNum); + + // Audit log + await auditStack(event, 'restart', stackName, envIdNum); + + if (!result.success) { + return json({ success: false, error: result.error }, { status: 400 }); + } + return json({ success: true, output: result.output }); + } catch (error) { + if (error instanceof ExternalStackError) { + return json({ error: error.message }, { status: 400 }); + } + if (error instanceof ComposeFileNotFoundError) { + return json({ error: error.message }, { status: 404 }); + } + console.error('Error restarting compose stack:', error); + return json({ error: 'Failed to restart compose stack' }, { status: 500 }); + } +}; diff --git a/routes/api/stacks/[name]/start/+server.ts b/routes/api/stacks/[name]/start/+server.ts new file mode 100644 index 0000000..7e5fc5f --- /dev/null +++ b/routes/api/stacks/[name]/start/+server.ts @@ -0,0 +1,45 @@ +import { json } from '@sveltejs/kit'; +import { startStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks'; +import { authorize } from '$lib/server/authorize'; +import { auditStack } from '$lib/server/audit'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async (event) => { + const { params, url, cookies } = event; + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !(await auth.can('stacks', 'start', envIdNum))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !(await auth.canAccessEnvironment(envIdNum))) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + const stackName = decodeURIComponent(params.name); + const result = await startStack(stackName, envIdNum); + + // Audit log + await auditStack(event, 'start', stackName, envIdNum); + + if (!result.success) { + return json({ success: false, error: result.error }, { status: 400 }); + } + return json({ success: true, output: result.output }); + } catch (error) { + if (error instanceof ExternalStackError) { + return json({ error: error.message }, { status: 400 }); + } + if (error instanceof ComposeFileNotFoundError) { + return json({ error: error.message }, { status: 404 }); + } + console.error('Error starting compose stack:', error); + return json({ error: 'Failed to start compose stack' }, { status: 500 }); + } +}; diff --git a/routes/api/stacks/[name]/stop/+server.ts b/routes/api/stacks/[name]/stop/+server.ts new file mode 100644 index 0000000..d57fa7b --- /dev/null +++ b/routes/api/stacks/[name]/stop/+server.ts @@ -0,0 +1,45 @@ +import { json } from '@sveltejs/kit'; +import { stopStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks'; +import { authorize } from '$lib/server/authorize'; +import { auditStack } from '$lib/server/audit'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async (event) => { + const { params, url, cookies } = event; + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !(await auth.can('stacks', 'stop', envIdNum))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !(await auth.canAccessEnvironment(envIdNum))) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + const stackName = decodeURIComponent(params.name); + const result = await stopStack(stackName, envIdNum); + + // Audit log + await auditStack(event, 'stop', stackName, envIdNum); + + if (!result.success) { + return json({ success: false, error: result.error }, { status: 400 }); + } + return json({ success: true, output: result.output }); + } catch (error) { + if (error instanceof ExternalStackError) { + return json({ error: error.message }, { status: 400 }); + } + if (error instanceof ComposeFileNotFoundError) { + return json({ error: error.message }, { status: 404 }); + } + console.error('Error stopping compose stack:', error); + return json({ error: 'Failed to stop compose stack' }, { status: 500 }); + } +}; diff --git a/routes/api/stacks/sources/+server.ts b/routes/api/stacks/sources/+server.ts new file mode 100644 index 0000000..d0688d9 --- /dev/null +++ b/routes/api/stacks/sources/+server.ts @@ -0,0 +1,34 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getStackSources } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('stacks', 'view', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const sources = await getStackSources(envIdNum); + + // Convert to a map for easier lookup in the frontend + const sourceMap: Record = {}; + for (const source of sources) { + sourceMap[source.stackName] = { + sourceType: source.sourceType, + repository: source.repository + }; + } + + return json(sourceMap); + } catch (error) { + console.error('Failed to get stack sources:', error); + return json({ error: 'Failed to get stack sources' }, { status: 500 }); + } +}; diff --git a/routes/api/system/+server.ts b/routes/api/system/+server.ts new file mode 100644 index 0000000..04c369a --- /dev/null +++ b/routes/api/system/+server.ts @@ -0,0 +1,218 @@ +import { json } from '@sveltejs/kit'; +import { + getDockerInfo, + getDockerVersion, + listContainers, + listImages, + listVolumes, + listNetworks, + getDockerConnectionInfo +} from '$lib/server/docker'; +import { listManagedStacks } from '$lib/server/stacks'; +import { isPostgres, isSqlite, getDatabaseSchemaVersion, getPostgresConnectionInfo } from '$lib/server/db/drizzle'; +import { hasEnvironments } from '$lib/server/db'; +import type { RequestHandler } from './$types'; +import { existsSync, readFileSync } from 'node:fs'; +import os from 'node:os'; +import { authorize } from '$lib/server/authorize'; + +// Detect if running inside a Docker container +function detectContainerRuntime(): { inContainer: boolean; runtime?: string; containerId?: string } { + // Check for .dockerenv file (Docker) + if (existsSync('/.dockerenv')) { + let containerId: string | undefined; + try { + // Try to get container ID from hostname (Docker sets it) + containerId = os.hostname(); + // Validate it looks like a container ID (12+ hex chars) + if (!/^[a-f0-9]{12,}$/i.test(containerId)) { + containerId = undefined; + } + } catch {} + return { inContainer: true, runtime: 'docker', containerId }; + } + + // Check cgroup for container indicators + try { + if (existsSync('/proc/1/cgroup')) { + const cgroup = readFileSync('/proc/1/cgroup', 'utf-8'); + if (cgroup.includes('docker') || cgroup.includes('containerd') || cgroup.includes('kubepods')) { + const runtime = cgroup.includes('kubepods') ? 'kubernetes' : + cgroup.includes('containerd') ? 'containerd' : 'docker'; + return { inContainer: true, runtime }; + } + } + } catch {} + + return { inContainer: false }; +} + +// Get Bun runtime info +function getBunInfo() { + const memUsage = process.memoryUsage(); + return { + version: typeof Bun !== 'undefined' ? Bun.version : null, + revision: typeof Bun !== 'undefined' ? Bun.revision?.slice(0, 7) : null, + memory: { + heapUsed: memUsage.heapUsed, + heapTotal: memUsage.heapTotal, + rss: memUsage.rss, + external: memUsage.external + } + }; +} + +// Get info about our own container if running in Docker +async function getOwnContainerInfo(containerId: string | undefined): Promise { + if (!containerId) return null; + + try { + // Try to inspect our own container via local socket + const socketPaths = [ + '/var/run/docker.sock', + process.env.DOCKER_SOCKET + ].filter(Boolean); + + for (const socketPath of socketPaths) { + if (!socketPath || !existsSync(socketPath)) continue; + + try { + const response = await fetch(`http://localhost/containers/${containerId}/json`, { + // @ts-ignore - Bun supports unix socket + unix: socketPath + }); + + if (response.ok) { + const info = await response.json(); + return { + id: info.Id?.slice(0, 12), + name: info.Name?.replace(/^\//, ''), + image: info.Config?.Image, + imageId: info.Image?.slice(7, 19), // Remove 'sha256:' prefix + created: info.Created, + status: info.State?.Status, + restartCount: info.RestartCount, + labels: { + version: info.Config?.Labels?.['org.opencontainers.image.version'], + revision: info.Config?.Labels?.['org.opencontainers.image.revision'] + } + }; + } + } catch {} + } + } catch {} + + return null; +} + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + // Check basic environment view permission + if (auth.authEnabled && !await auth.can('environments', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const envId = url.searchParams.get('env') ? parseInt(url.searchParams.get('env')!) : null; + + // Check environment access in enterprise mode + if (envId && auth.authEnabled && auth.isEnterprise && !await auth.canAccessEnvironment(envId)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + const schemaVersion = await getDatabaseSchemaVersion(); + + // Try to get Docker info, but don't fail if Docker isn't available + let dockerInfo = null; + let dockerVersion = null; + let connectionInfo = null; + let containers: any[] = []; + let images: any[] = []; + let volumes: any[] = []; + let networks: any[] = []; + + // Only try Docker connection if environment is specified + if (envId) { + try { + [dockerInfo, dockerVersion, containers, images, volumes, networks, connectionInfo] = await Promise.all([ + getDockerInfo(envId), + getDockerVersion(envId), + listContainers(true, envId), + listImages(envId), + listVolumes(envId), + listNetworks(envId), + getDockerConnectionInfo(envId) + ]); + } catch (dockerError) { + // Docker not available - continue with null values + console.log('Docker not available for system info:', dockerError instanceof Error ? dockerError.message : String(dockerError)); + } + } + + const stacks = listManagedStacks(); + const runningContainers = containers.filter(c => c.state === 'running').length; + const stoppedContainers = containers.length - runningContainers; + + const bunInfo = getBunInfo(); + const containerRuntime = detectContainerRuntime(); + const ownContainer = containerRuntime.inContainer + ? await getOwnContainerInfo(containerRuntime.containerId || os.hostname()) + : null; + + return json({ + docker: dockerInfo && dockerVersion ? { + version: dockerVersion.Version, + apiVersion: dockerVersion.ApiVersion, + os: dockerInfo.OperatingSystem, + arch: dockerInfo.Architecture, + kernelVersion: dockerInfo.KernelVersion, + serverVersion: dockerInfo.ServerVersion, + connection: connectionInfo ? { + type: connectionInfo.type, + socketPath: connectionInfo.socketPath, + host: connectionInfo.host, + port: connectionInfo.port + } : { type: 'socket' } + } : null, + host: dockerInfo ? { + name: dockerInfo.Name, + cpus: dockerInfo.NCPU, + memory: dockerInfo.MemTotal, + storageDriver: dockerInfo.Driver + } : null, + runtime: { + bun: bunInfo.version, + bunRevision: bunInfo.revision, + nodeVersion: process.version, + platform: os.platform(), + arch: os.arch(), + memory: bunInfo.memory, + container: containerRuntime, + ownContainer + }, + database: { + type: isPostgres ? 'PostgreSQL' : 'SQLite', + schemaVersion: schemaVersion.version, + schemaDate: schemaVersion.date, + ...(isPostgres && getPostgresConnectionInfo() ? { + host: getPostgresConnectionInfo()!.host, + port: getPostgresConnectionInfo()!.port + } : {}) + }, + stats: { + containers: { + total: containers.length, + running: runningContainers, + stopped: stoppedContainers + }, + images: images.length, + volumes: volumes.length, + networks: networks.length, + stacks: stacks.length + } + }); + } catch (error) { + console.error('Error fetching system info:', error); + return json({ error: 'Failed to fetch system info' }, { status: 500 }); + } +}; diff --git a/routes/api/system/disk/+server.ts b/routes/api/system/disk/+server.ts new file mode 100644 index 0000000..0f5eb2c --- /dev/null +++ b/routes/api/system/disk/+server.ts @@ -0,0 +1,40 @@ +import { json } from '@sveltejs/kit'; +import { getDiskUsage } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +const DISK_USAGE_TIMEOUT = 15000; // 15 second timeout + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + if (auth.authEnabled && !await auth.can('environments', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + const envId = url.searchParams.get('env') ? parseInt(url.searchParams.get('env')!) : null; + + if (!envId) { + return json({ error: 'Environment ID required' }, { status: 400 }); + } + + // Check environment access in enterprise mode + if (auth.authEnabled && auth.isEnterprise && !await auth.canAccessEnvironment(envId)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + // Fetch disk usage with timeout + const diskUsagePromise = getDiskUsage(envId); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Disk usage timeout')), DISK_USAGE_TIMEOUT) + ); + + const diskUsage = await Promise.race([diskUsagePromise, timeoutPromise]); + return json({ diskUsage }); + } catch (error) { + // Return null on timeout or error - UI will show loading/unavailable state + console.log(`Disk usage fetch failed for env ${envId}:`, error instanceof Error ? error.message : String(error)); + return json({ diskUsage: null }); + } +}; diff --git a/routes/api/users/+server.ts b/routes/api/users/+server.ts new file mode 100644 index 0000000..ea0f4ab --- /dev/null +++ b/routes/api/users/+server.ts @@ -0,0 +1,136 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { + getUsers, + createUser as dbCreateUser, + assignUserRole, + hasAdminUser, + getUserRoles, + userHasAdminRole, + getRoleByName +} from '$lib/server/db'; +import { hashPassword, createUserSession } from '$lib/server/auth'; +import { authorize } from '$lib/server/authorize'; + +// GET /api/users - List all users +// Free for all - local users are needed for basic auth +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + + // When auth is enabled, require valid session (no specific permission needed to view users list) + if (auth.authEnabled && !auth.isAuthenticated) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + // Any authenticated user can view the users list + // Admin permissions are only needed for create/edit/delete operations + + try { + const allUsers = await getUsers(); + const users = await Promise.all(allUsers.map(async user => { + // Derive isAdmin from role assignment + const isAdmin = await userHasAdminRole(user.id); + const isSso = !user.passwordHash; + const userData: any = { + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + mfaEnabled: user.mfaEnabled, + isAdmin, + isActive: user.isActive, + isSso, + authProvider: user.authProvider || 'local', + lastLogin: user.lastLogin, + createdAt: user.createdAt + }; + // Include roles for enterprise users + if (auth.isEnterprise) { + const userRoles = await getUserRoles(user.id); + userData.roles = userRoles.map(ur => ({ + id: ur.roleId, + name: ur.role?.name, + environmentId: ur.environmentId + })); + } + return userData; + })); + return json(users); + } catch (error) { + console.error('Failed to get users:', error); + return json({ error: 'Failed to get users' }, { status: 500 }); + } +}; + +// POST /api/users - Create a new user +// Free for all - local users are needed for basic auth +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + + // When auth is enabled and user is logged in, check they can manage users + // (allow if no user logged in for initial setup when no users exist) + if (auth.authEnabled && auth.isAuthenticated && !await auth.canManageUsers()) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const { username, email, password, displayName } = await request.json(); + + if (!username || !password) { + return json({ error: 'Username and password are required' }, { status: 400 }); + } + + // Validate password strength + if (password.length < 8) { + return json({ error: 'Password must be at least 8 characters' }, { status: 400 }); + } + + // Hash password + const passwordHash = await hashPassword(password); + + // Check if this is the first user + const isFirstUser = !(await hasAdminUser()); + + // Create user + const user = await dbCreateUser({ + username, + email, + passwordHash, + displayName + }); + + // Role assignment logic: + // - Enterprise: Roles are managed via syncUserRoles() from the modal (no auto-assignment here) + // - Free edition: All users get Admin role (no RBAC) + // - First user: Always gets Admin role (regardless of edition) + if (!auth.isEnterprise || isFirstUser) { + const adminRole = await getRoleByName('Admin'); + if (adminRole) { + await assignUserRole(user.id, adminRole.id, null); + } + } + + // Auto-login if this is the first user being created (and auth is enabled) + let autoLoggedIn = false; + if (isFirstUser && auth.authEnabled) { + await createUserSession(user.id, 'local', cookies); + autoLoggedIn = true; + } + + return json({ + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + isAdmin: !auth.isEnterprise || isFirstUser, + isActive: user.isActive, + createdAt: user.createdAt, + autoLoggedIn: autoLoggedIn + }, { status: 201 }); + } catch (error: any) { + console.error('Failed to create user:', error); + if (error.message?.includes('UNIQUE constraint failed')) { + return json({ error: 'Username already exists' }, { status: 409 }); + } + return json({ error: 'Failed to create user' }, { status: 500 }); + } +}; diff --git a/routes/api/users/[id]/+server.ts b/routes/api/users/[id]/+server.ts new file mode 100644 index 0000000..92bcd48 --- /dev/null +++ b/routes/api/users/[id]/+server.ts @@ -0,0 +1,299 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { + getUser, + updateUser as dbUpdateUser, + deleteUser as dbDeleteUser, + deleteUserSessions, + countAdminUsers, + updateAuthSettings, + userHasAdminRole, + getRoleByName, + assignUserRole, + removeUserRole +} from '$lib/server/db'; +import { hashPassword } from '$lib/server/auth'; +import { authorize } from '$lib/server/authorize'; + +// GET /api/users/[id] - Get a specific user +// Free for all - local users are needed for basic auth +export const GET: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + + // When auth is enabled, require authentication (any authenticated user can view) + if (auth.authEnabled && !auth.isAuthenticated) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + + if (!params.id) { + return json({ error: 'User ID is required' }, { status: 400 }); + } + + try { + const id = parseInt(params.id); + const user = await getUser(id); + + if (!user) { + return json({ error: 'User not found' }, { status: 404 }); + } + + // Derive isAdmin from role assignment + const isAdmin = await userHasAdminRole(id); + + return json({ + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + mfaEnabled: user.mfaEnabled, + isAdmin, + isActive: user.isActive, + lastLogin: user.lastLogin, + createdAt: user.createdAt, + updatedAt: user.updatedAt + }); + } catch (error) { + console.error('Failed to get user:', error); + return json({ error: 'Failed to get user' }, { status: 500 }); + } +}; + +// PUT /api/users/[id] - Update a user +// Free for all - local users are needed for basic auth +export const PUT: RequestHandler = async ({ params, request, cookies }) => { + const auth = await authorize(cookies); + + if (!params.id) { + return json({ error: 'User ID is required' }, { status: 400 }); + } + + const userId = parseInt(params.id); + + // Allow users to edit their own profile, otherwise check permission + // (free edition allows all, enterprise checks RBAC) + if (auth.user && auth.user.id !== userId && !await auth.can('users', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const data = await request.json(); + const existingUser = await getUser(userId); + + if (!existingUser) { + return json({ error: 'User not found' }, { status: 404 }); + } + + // Check if user is currently an admin (via role) + const existingUserIsAdmin = await userHasAdminRole(userId); + + // Build update object + const updateData: any = {}; + + if (data.username !== undefined) updateData.username = data.username; + if (data.email !== undefined) updateData.email = data.email; + if (data.displayName !== undefined) updateData.displayName = data.displayName; + + // Track if we need to change admin role + let shouldPromote = false; + let shouldDemote = false; + + // Only admins can change admin status or active status + if (auth.isAdmin) { + // Check if trying to demote or deactivate the last admin + if (existingUserIsAdmin) { + const adminCount = await countAdminUsers(); + const isDemoting = data.isAdmin === false; + const isDeactivating = data.isActive === false; + + if (adminCount <= 1 && (isDemoting || isDeactivating)) { + const confirmDisableAuth = data.confirmDisableAuth === true; + if (!confirmDisableAuth) { + return json({ + error: 'This is the last admin user', + isLastAdmin: true, + message: isDemoting + ? 'Removing admin privileges from this user will disable authentication.' + : 'Deactivating this user will disable authentication.' + }, { status: 409 }); + } + + // User confirmed - proceed and disable auth + if (isDemoting) shouldDemote = true; + if (isDeactivating) { + updateData.isActive = false; + await deleteUserSessions(userId); + } + + // Disable authentication + await updateAuthSettings({ authEnabled: false }); + + // Update user first + const user = await dbUpdateUser(userId, updateData); + if (!user) { + return json({ error: 'Failed to update user' }, { status: 500 }); + } + + // Remove Admin role if demoting + if (shouldDemote) { + const adminRole = await getRoleByName('Admin'); + if (adminRole) { + await removeUserRole(userId, adminRole.id, null); + } + } + + return json({ + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + mfaEnabled: user.mfaEnabled, + isAdmin: !shouldDemote && existingUserIsAdmin, + isActive: user.isActive, + lastLogin: user.lastLogin, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + authDisabled: true + }); + } + } + + // Handle isAdmin change via Admin role assignment + if (data.isAdmin !== undefined) { + if (data.isAdmin && !existingUserIsAdmin) { + shouldPromote = true; + } else if (!data.isAdmin && existingUserIsAdmin) { + shouldDemote = true; + } + } + if (data.isActive !== undefined) { + updateData.isActive = data.isActive; + // If deactivating, invalidate all sessions + if (!data.isActive) { + await deleteUserSessions(userId); + } + } + } + + // Handle password change + if (data.password) { + if (data.password.length < 8) { + return json({ error: 'Password must be at least 8 characters' }, { status: 400 }); + } + updateData.passwordHash = await hashPassword(data.password); + // Invalidate all sessions on password change (except current) + await deleteUserSessions(userId); + } + + const user = await dbUpdateUser(userId, updateData); + + if (!user) { + return json({ error: 'Failed to update user' }, { status: 500 }); + } + + // Handle Admin role assignment/removal + const adminRole = await getRoleByName('Admin'); + if (adminRole) { + if (shouldPromote) { + await assignUserRole(userId, adminRole.id, null); + } else if (shouldDemote) { + await removeUserRole(userId, adminRole.id, null); + } + } + + // Compute final isAdmin status + const finalIsAdmin = shouldPromote || (existingUserIsAdmin && !shouldDemote); + + return json({ + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + mfaEnabled: user.mfaEnabled, + isAdmin: finalIsAdmin, + isActive: user.isActive, + lastLogin: user.lastLogin, + createdAt: user.createdAt, + updatedAt: user.updatedAt + }); + } catch (error: any) { + console.error('Failed to update user:', error); + if (error.message?.includes('UNIQUE constraint failed')) { + return json({ error: 'Username already exists' }, { status: 409 }); + } + return json({ error: 'Failed to update user' }, { status: 500 }); + } +}; + +// DELETE /api/users/[id] - Delete a user +// Free for all - local users are needed for basic auth +export const DELETE: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + + // When auth is enabled, check permission (free edition allows all, enterprise checks RBAC) + if (auth.authEnabled && auth.isAuthenticated && !await auth.can('users', 'remove')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + if (!params.id) { + return json({ error: 'User ID is required' }, { status: 400 }); + } + + const confirmDisableAuth = url.searchParams.get('confirmDisableAuth') === 'true'; + + try { + const id = parseInt(params.id); + const isSelfDeletion = auth.user && auth.user.id === id; + + const user = await getUser(id); + if (!user) { + return json({ error: 'User not found' }, { status: 404 }); + } + + // Check if user is admin via role + const userIsAdmin = await userHasAdminRole(id); + + // Check if this is the last admin user AND auth is enabled + // Only warn if auth is currently ON (deleting last admin will turn it off) + if (auth.authEnabled && userIsAdmin) { + const adminCount = await countAdminUsers(); + if (adminCount <= 1) { + // This is the last admin - require confirmation (whether self or other) + if (!confirmDisableAuth) { + return json({ + error: 'This is the last admin user', + isLastAdmin: true, + isSelf: isSelfDeletion, + message: isSelfDeletion + ? 'This is the only admin account. Deleting it will disable authentication and allow anyone to access Dockhand.' + : 'This is the only admin account. Deleting it will disable authentication and allow anyone to access Dockhand.' + }, { status: 409 }); + } + + // User confirmed - proceed with deletion and disable auth + await deleteUserSessions(id); + const deleted = await dbDeleteUser(id); + if (!deleted) { + return json({ error: 'Failed to delete user' }, { status: 500 }); + } + + // Disable authentication + await updateAuthSettings({ authEnabled: false }); + + return json({ success: true, authDisabled: true }); + } + } + + // Delete all sessions first + await deleteUserSessions(id); + + const deleted = await dbDeleteUser(id); + if (!deleted) { + return json({ error: 'Failed to delete user' }, { status: 500 }); + } + + return json({ success: true }); + } catch (error) { + console.error('Failed to delete user:', error); + return json({ error: 'Failed to delete user' }, { status: 500 }); + } +}; diff --git a/routes/api/users/[id]/mfa/+server.ts b/routes/api/users/[id]/mfa/+server.ts new file mode 100644 index 0000000..4fac9c4 --- /dev/null +++ b/routes/api/users/[id]/mfa/+server.ts @@ -0,0 +1,96 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { isEnterprise } from '$lib/server/license'; +import { + validateSession, + checkPermission, + generateMfaSetup, + verifyAndEnableMfa, + disableMfa +} from '$lib/server/auth'; + +// POST /api/users/[id]/mfa - Setup MFA (generate QR code) +export const POST: RequestHandler = async ({ params, request, cookies }) => { + // Check enterprise license + if (!(await isEnterprise())) { + return json({ error: 'Enterprise license required' }, { status: 403 }); + } + + const currentUser = await validateSession(cookies); + + if (!params.id) { + return json({ error: 'User ID is required' }, { status: 400 }); + } + + const userId = parseInt(params.id); + + // Users can only setup MFA for themselves, or admins can do it for others + if (!currentUser || (currentUser.id !== userId && !currentUser.isAdmin)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const body = await request.json().catch(() => ({})); + + // Check if this is a verification request + if (body.action === 'verify') { + if (!body.token) { + return json({ error: 'MFA token is required' }, { status: 400 }); + } + + const success = await verifyAndEnableMfa(userId, body.token); + if (!success) { + return json({ error: 'Invalid MFA code' }, { status: 400 }); + } + + return json({ success: true, message: 'MFA enabled successfully' }); + } + + // Generate new MFA setup + const setup = await generateMfaSetup(userId); + if (!setup) { + return json({ error: 'User not found' }, { status: 404 }); + } + + return json({ + secret: setup.secret, + qrDataUrl: setup.qrDataUrl + }); + } catch (error) { + console.error('MFA setup error:', error); + return json({ error: 'Failed to setup MFA' }, { status: 500 }); + } +}; + +// DELETE /api/users/[id]/mfa - Disable MFA +export const DELETE: RequestHandler = async ({ params, cookies }) => { + // Check enterprise license + if (!(await isEnterprise())) { + return json({ error: 'Enterprise license required' }, { status: 403 }); + } + + const currentUser = await validateSession(cookies); + + if (!params.id) { + return json({ error: 'User ID is required' }, { status: 400 }); + } + + const userId = parseInt(params.id); + + // Users can only disable their own MFA, or admins can do it for others + if (!currentUser || (currentUser.id !== userId && !currentUser.isAdmin)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const success = await disableMfa(userId); + if (!success) { + return json({ error: 'User not found' }, { status: 404 }); + } + + return json({ success: true, message: 'MFA disabled successfully' }); + } catch (error) { + console.error('MFA disable error:', error); + return json({ error: 'Failed to disable MFA' }, { status: 500 }); + } +}; diff --git a/routes/api/users/[id]/roles/+server.ts b/routes/api/users/[id]/roles/+server.ts new file mode 100644 index 0000000..324d276 --- /dev/null +++ b/routes/api/users/[id]/roles/+server.ts @@ -0,0 +1,110 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { isEnterprise } from '$lib/server/license'; +import { validateSession } from '$lib/server/auth'; +import { + getUserRoles, + assignUserRole, + removeUserRole, + getUser +} from '$lib/server/db'; + +// GET /api/users/[id]/roles - Get roles assigned to a user +export const GET: RequestHandler = async ({ params, cookies }) => { + // Check enterprise license + if (!(await isEnterprise())) { + return json({ error: 'Enterprise license required' }, { status: 403 }); + } + + if (!params.id) { + return json({ error: 'User ID is required' }, { status: 400 }); + } + + try { + const userId = parseInt(params.id); + const user = await getUser(userId); + + if (!user) { + return json({ error: 'User not found' }, { status: 404 }); + } + + const userRoles = await getUserRoles(userId); + return json(userRoles); + } catch (error) { + console.error('Failed to get user roles:', error); + return json({ error: 'Failed to get user roles' }, { status: 500 }); + } +}; + +// POST /api/users/[id]/roles - Assign a role to a user +export const POST: RequestHandler = async ({ params, request, cookies }) => { + // Check enterprise license + if (!(await isEnterprise())) { + return json({ error: 'Enterprise license required' }, { status: 403 }); + } + + const currentUser = await validateSession(cookies); + if (!currentUser || !currentUser.isAdmin) { + return json({ error: 'Admin access required' }, { status: 403 }); + } + + if (!params.id) { + return json({ error: 'User ID is required' }, { status: 400 }); + } + + try { + const userId = parseInt(params.id); + const { roleId, environmentId } = await request.json(); + + if (!roleId) { + return json({ error: 'Role ID is required' }, { status: 400 }); + } + + const user = await getUser(userId); + if (!user) { + return json({ error: 'User not found' }, { status: 404 }); + } + + const userRole = await assignUserRole(userId, roleId, environmentId); + return json(userRole, { status: 201 }); + } catch (error) { + console.error('Failed to assign role:', error); + return json({ error: 'Failed to assign role' }, { status: 500 }); + } +}; + +// DELETE /api/users/[id]/roles - Remove a role from a user +export const DELETE: RequestHandler = async ({ params, request, cookies }) => { + // Check enterprise license + if (!(await isEnterprise())) { + return json({ error: 'Enterprise license required' }, { status: 403 }); + } + + const currentUser = await validateSession(cookies); + if (!currentUser || !currentUser.isAdmin) { + return json({ error: 'Admin access required' }, { status: 403 }); + } + + if (!params.id) { + return json({ error: 'User ID is required' }, { status: 400 }); + } + + try { + const userId = parseInt(params.id); + const { roleId, environmentId } = await request.json(); + + if (!roleId) { + return json({ error: 'Role ID is required' }, { status: 400 }); + } + + const deleted = await removeUserRole(userId, roleId, environmentId); + if (!deleted) { + return json({ error: 'Role assignment not found' }, { status: 404 }); + } + + return json({ success: true }); + } catch (error) { + console.error('Failed to remove role:', error); + return json({ error: 'Failed to remove role' }, { status: 500 }); + } +}; diff --git a/routes/api/volumes/+server.ts b/routes/api/volumes/+server.ts new file mode 100644 index 0000000..12366fd --- /dev/null +++ b/routes/api/volumes/+server.ts @@ -0,0 +1,86 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listVolumes, createVolume, EnvironmentNotFoundError, type CreateVolumeOptions } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import { auditVolume } from '$lib/server/audit'; +import { hasEnvironments } from '$lib/server/db'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('volumes', 'view', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + // Early return if no environment specified + if (!envIdNum) { + return json([]); + } + + try { + const volumes = await listVolumes(envIdNum); + return json(volumes); + } catch (error: any) { + if (error instanceof EnvironmentNotFoundError) { + return json({ error: 'Environment not found' }, { status: 404 }); + } + console.error('Failed to list volumes:', error); + return json({ error: 'Failed to list volumes' }, { status: 500 }); + } +}; + +export const POST: RequestHandler = async (event) => { + const { url, request, cookies } = event; + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('volumes', 'create', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + const body = await request.json(); + + // Validate required fields + if (!body.name) { + return json({ error: 'Volume name is required' }, { status: 400 }); + } + + const options: CreateVolumeOptions = { + name: body.name, + driver: body.driver || 'local', + driverOpts: body.driverOpts || {}, + labels: body.labels || {} + }; + + const volume = await createVolume(options, envIdNum); + + // Audit log + await auditVolume(event, 'create', volume.Name, body.name, envIdNum, { driver: options.driver }); + + return json({ success: true, name: volume.Name }); + } catch (error: any) { + console.error('Failed to create volume:', error); + return json({ + error: 'Failed to create volume', + details: error.message || String(error) + }, { status: 500 }); + } +}; diff --git a/routes/api/volumes/[name]/+server.ts b/routes/api/volumes/[name]/+server.ts new file mode 100644 index 0000000..2f0c643 --- /dev/null +++ b/routes/api/volumes/[name]/+server.ts @@ -0,0 +1,63 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { removeVolume, inspectVolume } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import { auditVolume } from '$lib/server/audit'; + +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('volumes', 'inspect', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + + const volume = await inspectVolume(params.name, envIdNum); + return json(volume); + } catch (error) { + console.error('Failed to inspect volume:', error); + return json({ error: 'Failed to inspect volume' }, { status: 500 }); + } +}; + +export const DELETE: RequestHandler = async (event) => { + const { params, url, cookies } = event; + const auth = await authorize(cookies); + + const force = url.searchParams.get('force') === 'true'; + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('volumes', 'remove', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Environment access check (enterprise only) + if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + try { + + await removeVolume(params.name, force, envIdNum); + + // Audit log + await auditVolume(event, 'delete', params.name, params.name, envIdNum, { force }); + + return json({ success: true }); + } catch (error: any) { + console.error('Failed to remove volume:', error); + return json({ error: 'Failed to remove volume', details: error.message }, { status: 500 }); + } +}; diff --git a/routes/api/volumes/[name]/browse/+server.ts b/routes/api/volumes/[name]/browse/+server.ts new file mode 100644 index 0000000..7309610 --- /dev/null +++ b/routes/api/volumes/[name]/browse/+server.ts @@ -0,0 +1,52 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listVolumeDirectory, getVolumeUsage } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; + +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + const path = url.searchParams.get('path') || '/'; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('volumes', 'inspect', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + // Check if volume is in use by any containers + const usage = await getVolumeUsage(params.name, envIdNum); + const isInUse = usage.length > 0; + + // Mount read-only if in use, otherwise writable + const result = await listVolumeDirectory(params.name, path, envIdNum, isInUse); + + // Note: Helper container is cached and reused for subsequent requests. + // Cache TTL handles cleanup automatically. + + return json({ + path: result.path, + entries: result.entries, + usage, + isInUse, + // Expose helper container ID so frontend can use container file endpoints + helperId: result.containerId + }); + } catch (error: any) { + console.error('Failed to browse volume:', error); + + if (error.message?.includes('No such file or directory')) { + return json({ error: 'Directory not found', path: url.searchParams.get('path') || '/' }, { status: 404 }); + } + if (error.message?.includes('Permission denied')) { + return json({ error: 'Permission denied to access this path' }, { status: 403 }); + } + + return json({ + error: 'Failed to browse volume', + details: error.message || String(error) + }, { status: 500 }); + } +}; diff --git a/routes/api/volumes/[name]/browse/content/+server.ts b/routes/api/volumes/[name]/browse/content/+server.ts new file mode 100644 index 0000000..0a8cd92 --- /dev/null +++ b/routes/api/volumes/[name]/browse/content/+server.ts @@ -0,0 +1,53 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { readVolumeFile } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; + +// Max file size for reading (1MB) +const MAX_FILE_SIZE = 1024 * 1024; + +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + + const path = url.searchParams.get('path'); + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('volumes', 'inspect', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + if (!path) { + return json({ error: 'Path is required' }, { status: 400 }); + } + + const content = await readVolumeFile( + params.name, + path, + envIdNum + ); + + // Check if content is too large + if (content.length > MAX_FILE_SIZE) { + return json({ error: 'File is too large to view (max 1MB)' }, { status: 413 }); + } + + return json({ content, path }); + } catch (error: any) { + console.error('Error reading volume file:', error); + + if (error.message?.includes('No such file or directory')) { + return json({ error: 'File not found' }, { status: 404 }); + } + if (error.message?.includes('Permission denied')) { + return json({ error: 'Permission denied to read this file' }, { status: 403 }); + } + if (error.message?.includes('Is a directory')) { + return json({ error: 'Cannot read a directory' }, { status: 400 }); + } + + return json({ error: 'Failed to read file' }, { status: 500 }); + } +}; diff --git a/routes/api/volumes/[name]/browse/release/+server.ts b/routes/api/volumes/[name]/browse/release/+server.ts new file mode 100644 index 0000000..2cb96f0 --- /dev/null +++ b/routes/api/volumes/[name]/browse/release/+server.ts @@ -0,0 +1,33 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { releaseVolumeHelperContainer } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; + +/** + * Release the cached volume helper container when done browsing. + * This is called when the volume browser modal is closed. + */ +export const POST: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('volumes', 'inspect', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + + await releaseVolumeHelperContainer(params.name, envIdNum); + + return json({ success: true }); + } catch (error: any) { + console.error('Failed to release volume helper:', error); + return json({ + error: 'Failed to release volume helper', + details: error.message || String(error) + }, { status: 500 }); + } +}; diff --git a/routes/api/volumes/[name]/clone/+server.ts b/routes/api/volumes/[name]/clone/+server.ts new file mode 100644 index 0000000..2dd926e --- /dev/null +++ b/routes/api/volumes/[name]/clone/+server.ts @@ -0,0 +1,55 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { inspectVolume, createVolume, type CreateVolumeOptions } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; +import { auditVolume } from '$lib/server/audit'; + +export const POST: RequestHandler = async (event) => { + const { params, url, request, cookies } = event; + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('volumes', 'create', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + + const body = await request.json(); + const newName = body.name; + + if (!newName) { + return json({ error: 'New volume name is required' }, { status: 400 }); + } + + // Get source volume info + const sourceVolume = await inspectVolume(params.name, envIdNum); + + // Create new volume with same driver and options + const options: CreateVolumeOptions = { + name: newName, + driver: sourceVolume.Driver || 'local', + driverOpts: sourceVolume.Options || {}, + labels: { ...sourceVolume.Labels, 'dockhand.cloned.from': params.name } + }; + + const newVolume = await createVolume(options, envIdNum); + + // Audit log + await auditVolume(event, 'clone', newVolume.Name, `${params.name} → ${newName}`, envIdNum, { + source: params.name, + driver: options.driver + }); + + return json({ success: true, name: newVolume.Name }); + } catch (error: any) { + console.error('Failed to clone volume:', error); + return json({ + error: 'Failed to clone volume', + details: error.message || String(error) + }, { status: 500 }); + } +}; diff --git a/routes/api/volumes/[name]/export/+server.ts b/routes/api/volumes/[name]/export/+server.ts new file mode 100644 index 0000000..978edcb --- /dev/null +++ b/routes/api/volumes/[name]/export/+server.ts @@ -0,0 +1,73 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getVolumeArchive } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; + +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + const path = url.searchParams.get('path') || '/'; + const format = url.searchParams.get('format') || 'tar'; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('volumes', 'inspect', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + + const { response } = await getVolumeArchive(params.name, path, envIdNum); + + // Determine filename + const volumeName = params.name.replace(/[:/]/g, '_'); + const pathPart = path === '/' ? '' : `-${path.replace(/^\//, '').replace(/\//g, '-')}`; + let filename = `${volumeName}${pathPart}`; + let contentType = 'application/x-tar'; + let extension = '.tar'; + + // Prepare response based on format + let body: ReadableStream | Uint8Array = response.body!; + + if (format === 'tar.gz') { + // Compress with gzip using Bun's native implementation + const tarData = new Uint8Array(await response.arrayBuffer()); + body = Bun.gzipSync(tarData); + contentType = 'application/gzip'; + extension = '.tar.gz'; + } + + // Note: Helper container is cached and reused for subsequent requests. + // Cache TTL handles cleanup automatically. + + const headers: Record = { + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename="${filename}${extension}"` + }; + + // Set content length for compressed data + if (body instanceof Uint8Array) { + headers['Content-Length'] = body.length.toString(); + } else { + // Pass through content length for streaming tar + const contentLength = response.headers.get('Content-Length'); + if (contentLength) { + headers['Content-Length'] = contentLength; + } + } + + return new Response(body, { headers }); + } catch (error: any) { + console.error('Failed to export volume:', error); + + if (error.message?.includes('No such file or directory')) { + return json({ error: 'Path not found' }, { status: 404 }); + } + + return json({ + error: 'Failed to export volume', + details: error.message || String(error) + }, { status: 500 }); + } +}; diff --git a/routes/api/volumes/[name]/inspect/+server.ts b/routes/api/volumes/[name]/inspect/+server.ts new file mode 100644 index 0000000..f11c47f --- /dev/null +++ b/routes/api/volumes/[name]/inspect/+server.ts @@ -0,0 +1,24 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { inspectVolume } from '$lib/server/docker'; +import { authorize } from '$lib/server/authorize'; + +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + // Permission check with environment context + if (auth.authEnabled && !await auth.can('volumes', 'inspect', envIdNum)) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const volumeData = await inspectVolume(params.name, envIdNum); + return json(volumeData); + } catch (error) { + console.error('Failed to inspect volume:', error); + return json({ error: 'Failed to inspect volume' }, { status: 500 }); + } +}; diff --git a/routes/audit/+page.svelte b/routes/audit/+page.svelte new file mode 100644 index 0000000..762a453 --- /dev/null +++ b/routes/audit/+page.svelte @@ -0,0 +1,1142 @@ + + + + Audit log - Dockhand + + +
    + +
    + + {#if $licenseStore.isEnterprise && total > 0} + + Showing {visibleStart}-{visibleEnd} of {total} + + {/if} + + {#if $licenseStore.isEnterprise} +
    + + + + {$auditSseConnected ? 'Live' : 'Connecting'} + + +
    + + {#if showExportMenu} +
    + + + +
    + {/if} +
    +
    + {/if} +
    + + {#if $licenseStore.loading} + +
    + +

    Loading...

    +
    + {:else if !$licenseStore.isEnterprise} + +
    +
    + +
    +

    Enterprise feature

    +

    + Audit logging is an enterprise feature that tracks all user actions for compliance and security monitoring. +

    + +
    + {:else} + +
    +
    +
    + + Filters +
    + + + + + + {#if filterUsernames.length === 0} + All users + {:else if filterUsernames.length === 1} + {filterUsernames[0]} + {:else} + {filterUsernames.length} users + {/if} + + + + {#if filterUsernames.length > 0} + + {/if} + {#each users as user} + + + {user} + + {/each} + + + + + + + + + {#if filterEntityTypes.length === 0} + All entities + {:else if filterEntityTypes.length === 1} + {entityTypes.find(e => e.value === filterEntityTypes[0])?.label || filterEntityTypes[0]} + {:else} + {filterEntityTypes.length} entities + {/if} + + + + {#if filterEntityTypes.length > 0} + + {/if} + {#each entityTypes as type} + + + {type.label} + + {/each} + + + + + + + + + {#if filterActions.length === 0} + All actions + {:else if filterActions.length === 1} + {actionTypes.find(a => a.value === filterActions[0])?.label || filterActions[0]} + {:else} + {filterActions.length} actions + {/if} + + + + {#if filterActions.length > 0} + + {/if} + {#each actionTypes as action} + + + {action.label} + + {/each} + + + + + {#if environments.length > 0} + {@const selectedEnv = environments.find(e => e.id === filterEnvironmentId)} + {@const SelectedEnvIcon = selectedEnv ? getIconComponent(selectedEnv.icon || 'globe') : Server} + filterEnvironmentId = v ? parseInt(v) : null} + > + + + + {#if filterEnvironmentId === null} + All environments + {:else} + {selectedEnv?.name || 'Environment'} + {/if} + + + + + + All environments + + {#each environments as env} + {@const EnvIcon = getIconComponent(env.icon || 'globe')} + + + {env.name} + + {/each} + + + {/if} + + + { + selectedDatePreset = v || ''; + if (v !== 'custom') { + applyDatePreset(v || ''); + } + }} + > + + + + {#if selectedDatePreset === 'custom'} + Custom + {:else if selectedDatePreset} + {datePresets.find(d => d.value === selectedDatePreset)?.label || 'All time'} + {:else} + All time + {/if} + + + + All time + {#each datePresets as preset} + {preset.label} + {/each} + Custom range... + + + + + {#if selectedDatePreset === 'custom'} + + + {/if} + + + {#if filterUsernames.length > 0 || filterEntityTypes.length > 0 || filterActions.length > 0 || filterEnvironmentId !== null || selectedDatePreset} + + {/if} +
    +
    + + +
    + +
    + +
    +
    Timestamp
    +
    Environment
    +
    User
    +
    Action
    +
    Entity
    +
    Name
    +
    IP address
    +
    +
    +
    + + +
    + {#if loading || !initialized} +
    + + Loading... +
    + {:else if logs.length === 0} +
    + +

    No audit log entries found

    +
    + {:else} + +
    +
    + {#each visibleLogs as log (log.id)} +
    showDetails(log)} + role="button" + tabindex="0" + onkeydown={(e) => e.key === 'Enter' && showDetails(log)} + > +
    + {formatTimestamp(log.timestamp)} +
    +
    + {#if log.environment_name} + {@const LogEnvIcon = getIconComponent(log.environment_icon || 'globe')} +
    + + {log.environment_name} +
    + {:else} + - + {/if} +
    +
    +
    + + {log.username} +
    +
    +
    + + + +
    +
    +
    + + {log.entity_type} +
    +
    +
    + + {log.entity_name || log.entity_id || '-'} + +
    +
    + {log.ip_address || '-'} +
    +
    + +
    +
    + {/each} +
    +
    + + + {#if loadingMore} +
    + + Loading more... +
    + {/if} + + + {#if !hasMore && logs.length > 0} +
    + End of results ({total.toLocaleString()} entries) +
    + {/if} + {/if} +
    +
    + {/if} +
    + + + + + + Audit log details + + {#if selectedLog} +
    +
    +
    + +

    {formatTimestamp(selectedLog.timestamp)}

    +
    +
    + +

    + + {selectedLog.username} +

    +
    +
    + +

    + + + {selectedLog.action} + +

    +
    +
    + +

    + + {selectedLog.entity_type} +

    +
    + {#if selectedLog.entity_name} +
    + +

    {selectedLog.entity_name}

    +
    + {/if} + {#if selectedLog.entity_id} +
    + +

    {selectedLog.entity_id}

    +
    + {/if} + {#if selectedLog.environment_id} +
    + +

    {selectedLog.environment_id}

    +
    + {/if} + {#if selectedLog.ip_address} +
    + +

    {selectedLog.ip_address}

    +
    + {/if} +
    + + {#if selectedLog.description} +
    + +

    {selectedLog.description}

    +
    + {/if} + + {#if selectedLog.user_agent} +
    + +

    {selectedLog.user_agent}

    +
    + {/if} + + {#if selectedLog.details} +
    + +
    {JSON.stringify(selectedLog.details, null, 2)}
    +
    + {/if} +
    + {/if} + + + +
    +
    + + +{#if showExportMenu} + +{/if} diff --git a/routes/audit/+server.ts b/routes/audit/+server.ts new file mode 100644 index 0000000..09a6b6f --- /dev/null +++ b/routes/audit/+server.ts @@ -0,0 +1,53 @@ +import { json } from '@sveltejs/kit'; +import { authorize, enterpriseRequired } from '$lib/server/authorize'; +import { getAuditLogs, getAuditLogUsers, type AuditLogFilters, type AuditEntityType, type AuditAction } from '$lib/server/db'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + + // Audit log is Enterprise-only + if (!auth.isEnterprise) { + return json(enterpriseRequired(), { status: 403 }); + } + + // Check permission + if (!await auth.canViewAuditLog()) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + // Parse query parameters + const filters: AuditLogFilters = {}; + + const username = url.searchParams.get('username'); + if (username) filters.username = username; + + const entityType = url.searchParams.get('entity_type'); + if (entityType) filters.entityType = entityType as AuditEntityType; + + const action = url.searchParams.get('action'); + if (action) filters.action = action as AuditAction; + + const envId = url.searchParams.get('environment_id'); + if (envId) filters.environmentId = parseInt(envId); + + const fromDate = url.searchParams.get('from_date'); + if (fromDate) filters.fromDate = fromDate; + + const toDate = url.searchParams.get('to_date'); + if (toDate) filters.toDate = toDate; + + const limit = url.searchParams.get('limit'); + if (limit) filters.limit = parseInt(limit); + + const offset = url.searchParams.get('offset'); + if (offset) filters.offset = parseInt(offset); + + const result = await getAuditLogs(filters); + return json(result); + } catch (error) { + console.error('Error fetching audit logs:', error); + return json({ error: 'Failed to fetch audit logs' }, { status: 500 }); + } +}; diff --git a/routes/audit/users/+server.ts b/routes/audit/users/+server.ts new file mode 100644 index 0000000..e881583 --- /dev/null +++ b/routes/audit/users/+server.ts @@ -0,0 +1,26 @@ +import { json } from '@sveltejs/kit'; +import { authorize, enterpriseRequired } from '$lib/server/authorize'; +import { getAuditLogUsers } from '$lib/server/db'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + + // Audit log is Enterprise-only + if (!auth.isEnterprise) { + return json(enterpriseRequired(), { status: 403 }); + } + + // Check permission + if (!await auth.canViewAuditLog()) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const users = await getAuditLogUsers(); + return json(users); + } catch (error) { + console.error('Error fetching audit log users:', error); + return json({ error: 'Failed to fetch users' }, { status: 500 }); + } +}; diff --git a/routes/containers/+page.svelte b/routes/containers/+page.svelte new file mode 100644 index 0000000..8fa394a --- /dev/null +++ b/routes/containers/+page.svelte @@ -0,0 +1,2173 @@ + + +
    +
    + +
    +
    + + e.key === 'Escape' && (searchQuery = '')} + class="pl-8 h-8 w-48 text-sm" + /> +
    + + +
    + {#if $canAccess('containers', 'create')} + + {/if} + + {#if batchUpdateContainerIds.length > 0} + + {/if} + {#if $canAccess('containers', 'remove')} + confirmPrune = open} + unstyled + > + {#snippet children({ open })} + + {/snippet} + + {/if} + + +
    +
    +
    + + + {#if selectedContainers.size > 0} +
    + {selectedInFilter.length} selected + + {#if selectedStopped.length > 0 && $canAccess('containers', 'start')} + confirmBulkStart = open} + > + {#snippet children({ open })} + + + Start + + {/snippet} + + {/if} + {#if selectedRunning.length > 0 && $canAccess('containers', 'stop')} + confirmBulkStop = open} + > + {#snippet children({ open })} + + + Stop + + {/snippet} + + confirmBulkPause = open} + > + {#snippet children({ open })} + + + Pause + + {/snippet} + + {/if} + {#if selectedPaused.length > 0 && $canAccess('containers', 'start')} + confirmBulkUnpause = open} + > + {#snippet children({ open })} + + + Unpause + + {/snippet} + + {/if} + {#if $canAccess('containers', 'restart')} + confirmBulkRestart = open} + > + {#snippet children({ open })} + + + Restart + + {/snippet} + + {/if} + {#if $canAccess('containers', 'remove')} + confirmBulkRemove = open} + > + {#snippet children({ open })} + + + Remove + + {/snippet} + + {/if} + {#if selectedHaveUpdates} + + {/if} + {#if bulkActionInProgress} + + {/if} +
    + {/if} + + {#if $environments.length === 0 || !$currentEnvironment} + + {:else if !loading && containers.length === 0} + + {:else} + +
    + + { sortField = state.field as SortField; sortDirection = state.direction; }} + highlightedKey={highlightedRowId} + rowClass={(container) => { + let classes = ''; + if (currentLogsContainerId === container.id) classes += 'bg-blue-500/10 hover:bg-blue-500/15 '; + if (currentTerminalContainerId === container.id) classes += 'bg-green-500/10 hover:bg-green-500/15 '; + if ($appSettings.highlightUpdates && containersWithUpdatesSet.has(container.id)) classes += 'has-update '; + return classes; + }} + onRowClick={(container, e) => { + if (activeLogs.length > 0 || activeTerminals.length > 0) { + selectContainer(container); + } + highlightedRowId = highlightedRowId === container.id ? null : container.id; + }} + > + {#snippet cell(column, container, rowState)} + {@const ports = formatPorts(container.ports)} + {@const stack = getComposeProject(container.labels)} + {#if column.id === 'name'} + {container.name} + {:else if column.id === 'image'} +
    + {#if containersWithUpdatesSet.has(container.id)} + + + + {/if} + {container.image} +
    + {:else if column.id === 'state'} + {@const StateIcon = getStatusIcon(container.state)} + + + {container.state} + + {:else if column.id === 'health'} + {#if container.health} +
    + {#if container.health === 'healthy'} + + {:else if container.health === 'unhealthy'} + + {:else} + + {/if} +
    + {:else} +
    + - +
    + {/if} + {:else if column.id === 'uptime'} + {formatUptime(container.status)} + {:else if column.id === 'restartCount'} + {#if container.restartCount > 0} + {container.restartCount} + {:else} + - + {/if} + {:else if column.id === 'cpu'} +
    + {#if containerStats.get(container.id)} + {@const stats = containerStats.get(container.id)} + {stats.cpuPercent.toFixed(1)}% + {:else if container.state === 'running'} + ... + {:else} + - + {/if} +
    + {:else if column.id === 'memory'} +
    + {#if containerStats.get(container.id)} + {@const stats = containerStats.get(container.id)} + {formatBytes(stats.memoryUsage)} + {:else if container.state === 'running'} + ... + {:else} + - + {/if} +
    + {:else if column.id === 'networkIO'} +
    + {#if containerStats.get(container.id)} + {@const stats = containerStats.get(container.id)} + + ↓{formatBytes(stats.networkRx, 0)} ↑{formatBytes(stats.networkTx, 0)} + + {:else if container.state === 'running'} + ... + {:else} + - + {/if} +
    + {:else if column.id === 'diskIO'} +
    + {#if containerStats.get(container.id)} + {@const stats = containerStats.get(container.id)} + + r{formatBytes(stats.blockRead, 0)} w{formatBytes(stats.blockWrite, 0)} + + {:else if container.state === 'running'} + ... + {:else} + - + {/if} +
    + {:else if column.id === 'ip'} + {getContainerIp(container.networks)} + {:else if column.id === 'ports'} + {#if ports.length > 0} +
    + {#each ports.slice(0, 2) as port} + {@const url = currentEnvDetails ? getPortUrl(port.publicPort) : null} + {#if url} + e.stopPropagation()} + class="inline-flex items-center gap-0.5 text-xs bg-muted hover:bg-blue-500/20 hover:text-blue-500 px-1 py-0.5 rounded transition-colors" + title="Open {url} in new tab" + > + {port.display} + + + {:else} + {port.display} + {/if} + {/each} + {#if ports.length > 2} + +{ports.length - 2} + {/if} +
    + {:else} + - + {/if} + {:else if column.id === 'autoUpdate'} + {#if autoUpdateSettings.get(container.name)?.enabled} + {@const settings = autoUpdateSettings.get(container.name)} +
    + {settings?.label} + {#if envHasScanning} + {@const criteria = settings?.vulnerabilityCriteria || 'never'} + {@const icon = vulnerabilityCriteriaIcons[criteria]} + {#if icon} + {@const IconComponent = icon.component} + + + + {/if} + {/if} +
    + {:else} + - + {/if} + {:else if column.id === 'stack'} + {#if stack} + {stack} + {:else} + - + {/if} + {:else if column.id === 'actions'} +
    + {#if containersWithUpdatesSet.has(container.id)} + + {/if} + {#if container.state === 'running' || container.state === 'restarting'} + {#if $canAccess('containers', 'stop')} + stopContainer(container.id)} + onOpenChange={(open) => confirmStopId = open ? container.id : null} + > + {#snippet children({ open })} + + {/snippet} + + {#if container.state === 'running'} + + {/if} + {/if} + {:else if container.state === 'paused'} + {#if $canAccess('containers', 'start')} + + {/if} + {:else} + {#if $canAccess('containers', 'start')} + + {/if} + {/if} + {#if $canAccess('containers', 'restart')} + restartContainer(container.id)} + onOpenChange={(open) => confirmRestartId = open ? container.id : null} + > + {#snippet children({ open })} + + {/snippet} + + {/if} + + {#if container.state === 'running' && $canAccess('containers', 'exec')} + + {/if} + {#if $canAccess('containers', 'create')} + + {/if} + {#if $canAccess('containers', 'logs')} + {#if hasActiveLogs(container.id)} + + {:else} + + {/if} + {/if} + {#if container.state === 'running' && $canAccess('containers', 'exec')} + {#if hasActiveTerminal(container.id)} + + {:else} + { terminalPopoverStates[container.id] = open; }}> + e.stopPropagation()} + class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" + > + + + +
    +
    + + {container.name} +
    +
    +
    +
    + + + + + {shellOptions.find(o => o.value === terminalShell)?.label || 'Select'} + + + {#each shellOptions as option} + + + {option.label} + + {/each} + + +
    +
    + + + + + {userOptions.find(o => o.value === terminalUser)?.label || 'Select'} + + + {#each userOptions as option} + + + {option.label} + + {/each} + + +
    + +
    +
    +
    + {/if} + {/if} + {#if $canAccess('containers', 'remove')} + removeContainer(container.id)} + onOpenChange={(open) => confirmDeleteId = open ? container.id : null} + > + {#snippet children({ open })} + + {/snippet} + + {/if} + {#if operationError?.id === container.id} +
    + + {operationError.message} + +
    + {/if} +
    + {/if} + {/snippet} +
    + + + {#if layoutMode === 'vertical' && (currentLogsContainerId || currentTerminalContainerId)} + + + +
    + + {#if currentLogsContainerId} + {@const activeLog = activeLogs.find(l => l.containerId === currentLogsContainerId)} + {#if activeLog} +
    + closeLogs(activeLog.containerId)} + /> +
    + {/if} + {/if} + + + {#if currentTerminalContainerId} + {@const activeTerminal = activeTerminals.find(t => t.containerId === currentTerminalContainerId)} + {#if activeTerminal} +
    + closeTerminal(activeTerminal.containerId)} + /> +
    + {/if} + {/if} +
    + {/if} +
    + + + {#if layoutMode === 'horizontal'} + + {#if currentLogsContainerId} + {@const activeLog = activeLogs.find(l => l.containerId === currentLogsContainerId)} + {#if activeLog} + closeLogs(activeLog.containerId)} + /> + {/if} + {/if} + + + {#if currentTerminalContainerId} + {@const activeTerminal = activeTerminals.find(t => t.containerId === currentTerminalContainerId)} + {#if activeTerminal} + closeTerminal(activeTerminal.containerId)} + /> + {/if} + {/if} + {/if} + {/if} +
    + + (showCreateModal = false)} + onSuccess={fetchContainers} +/> + + (showEditModal = false)} + onSuccess={fetchContainers} +/> + + { + // Update the container name in the local state + inspectContainerName = newName; + // Refresh the container list + fetchContainers(); + }} +/> + + showFileBrowserModal = false} +/> + + + + + + + diff --git a/routes/containers/AutoUpdateSettings.svelte b/routes/containers/AutoUpdateSettings.svelte new file mode 100644 index 0000000..731a4cc --- /dev/null +++ b/routes/containers/AutoUpdateSettings.svelte @@ -0,0 +1,81 @@ + + +
    +
    + + onenablechange?.(value)} + /> +
    + + {#if enabled} + { + cronExpression = cron; + oncronchange?.(cron); + }} + /> + + {#if envHasScanning} +
    + + oncriteriachange?.(v)} + /> +

    + Block auto-updates if new image has vulnerabilities matching this criteria +

    +
    + {/if} + {/if} +
    diff --git a/routes/containers/BatchUpdateModal.svelte b/routes/containers/BatchUpdateModal.svelte new file mode 100644 index 0000000..d192303 --- /dev/null +++ b/routes/containers/BatchUpdateModal.svelte @@ -0,0 +1,604 @@ + + + + { if (status === 'updating') e.preventDefault(); }}> + + + + Updating containers + {#if vulnerabilityCriteria !== 'never'} + + + + {/if} + + + {#if status === 'updating'} + {@const activeContainer = progress.find(p => p.step !== 'done' && p.step !== 'failed' && p.step !== 'blocked')} + {#if activeContainer} + + {getStepLabel(activeContainer.step)} {activeContainer.containerName}... + + ({currentIndex}/{totalCount}) + {:else} + Processing {currentIndex} of {totalCount} containers... + {/if} + {:else if status === 'complete'} + Update complete + {:else if status === 'error'} + Update failed + {:else} + Preparing to update {containerIds.length} container{containerIds.length > 1 ? 's' : ''}... + {/if} + + + +
    + +
    +
    + Progress + {currentIndex}/{totalCount} +
    + +
    + + + {#if progress.length > 0} +
    + {#each progress as item (item.containerId)} + {@const StepIcon = getStepIcon(item.step)} + {@const isActive = item.step !== 'done' && item.step !== 'failed' && item.step !== 'blocked'} + {@const hasLogs = item.pullLogs.length > 0 || item.scanLogs.length > 0} +
    + +
    + +
    +
    {item.containerName}
    + {#if item.error} +
    {item.error}
    + {:else if item.blockReason} +
    {item.blockReason}
    + {:else} +
    {getStepLabel(item.step)}
    + {/if} +
    + + + {#if item.scannerResults && item.scannerResults.length > 0} + + {:else if item.scanResult} +
    + {#if item.scanResult.critical > 0} + + + C:{item.scanResult.critical} + + +

    {item.scanResult.critical} Critical vulnerabilities

    +
    +
    + {/if} + {#if item.scanResult.high > 0} + + + H:{item.scanResult.high} + + +

    {item.scanResult.high} High severity vulnerabilities

    +
    +
    + {/if} + {#if item.scanResult.medium > 0} + + + M:{item.scanResult.medium} + + +

    {item.scanResult.medium} Medium severity vulnerabilities

    +
    +
    + {/if} + {#if item.scanResult.low > 0} + + + L:{item.scanResult.low} + + +

    {item.scanResult.low} Low severity vulnerabilities

    +
    +
    + {/if} +
    + {/if} + + {#if item.success === true} + + {:else if item.step === 'failed'} + + {:else if item.step === 'blocked'} + {#if forceUpdating.has(item.containerId)} + + {:else} + + {/if} + {/if} + {#if hasLogs} + + {/if} +
    + + + {#if item.showLogs && hasLogs} +
    + {#each item.pullLogs as log} +
    + {formatPullLog(log)} +
    + {/each} + {#if item.scanLogs.length > 0} + {#if item.pullLogs.length > 0} +
    + {/if} + {#each item.scanLogs as log} +
    + {formatScanLog(log)} +
    + {/each} + {/if} +
    + {/if} +
    + {/each} +
    + {/if} + + + {#if summary} +
    + +
    + {/if} + + + {#if errorMessage} +
    + + {errorMessage} +
    + {/if} +
    + + + {#if status === 'updating'} + + {:else} + + {/if} + +
    +
    diff --git a/routes/containers/ContainerInspectModal.svelte b/routes/containers/ContainerInspectModal.svelte new file mode 100644 index 0000000..8d97585 --- /dev/null +++ b/routes/containers/ContainerInspectModal.svelte @@ -0,0 +1,1230 @@ + + + + + + + + Container details: + {#if isEditing} + { + if (e.key === 'Enter') saveRename(); + if (e.key === 'Escape') cancelEditing(); + }} + disabled={renaming} + /> + + + {:else} + {displayName || containerId.slice(0, 12)} + + {/if} + {#if containerData?.State?.Running && !loading} + + + {isLiveConnected ? 'Live' : 'Offline'} + + {/if} + {#if containerData && !loading} + + {/if} + + + +
    + {#if loading} +
    + +
    + {:else if error} +
    + {error} +
    + {:else if containerData} + + + showLogs = false}>Overview + showLogs = true}>Logs + showLogs = false}>Layers + { showLogs = false; if (processesAutoRefresh) startProcessesCollection(); else fetchProcesses(); }}>Processes + showLogs = false}>Network + showLogs = false}>Mounts + showLogs = false}>Files + showLogs = false}>Environment + showLogs = false}>Security + showLogs = false}>Resources + showLogs = false}>Health + + + + + + {#if containerData.State?.Running} +
    + +
    +
    + + CPU + {currentStats?.cpuPercent?.toFixed(1) ?? '—'}% +
    + {#if cpuHistory.length >= 2} + + + + + {:else} +
    Loading...
    + {/if} +
    + +
    +
    + + Memory + {currentStats?.memoryPercent?.toFixed(1) ?? '—'}% +
    + {#if memoryHistory.length >= 2} + + + + + {:else} +
    Loading...
    + {/if} +
    + {formatBytes(currentStats?.memoryUsage ?? 0)} / {formatBytes(currentStats?.memoryLimit ?? 0)} +
    +
    + +
    +
    + + Network I/O +
    +
    +
    + RX: + {formatBytes(currentStats?.networkRx ?? 0)} +
    +
    + TX: + {formatBytes(currentStats?.networkTx ?? 0)} +
    +
    +
    + +
    +
    + + Disk I/O +
    +
    +
    + Read: + {formatBytes(currentStats?.blockRead ?? 0)} +
    +
    + Write: + {formatBytes(currentStats?.blockWrite ?? 0)} +
    +
    +
    +
    + {/if} + + +
    + +
    +

    + + Status +

    +
    +
    +

    State

    + + {containerData.State?.Status || 'unknown'} + +
    +
    +

    Restart Policy

    + {containerData.HostConfig?.RestartPolicy?.Name || 'no'} +
    +
    +

    Exit Code

    + {containerData.State?.ExitCode ?? 'N/A'} +
    +
    +

    Restart Count

    + {containerData.RestartCount ?? 0} +
    +
    +
    + + +
    +

    Basic information

    +
    +
    +

    ID

    + {containerData.Id?.slice(0, 12)} +
    +
    +

    Platform

    +

    {containerData.Platform || 'N/A'}

    +
    +
    +

    Created

    +

    {formatDate(containerData.Created)}

    +
    +
    +

    Started

    +

    {formatDate(containerData.State?.StartedAt)}

    +
    +
    +
    +
    + + +
    +

    Image

    +
    + {containerData.Config?.Image || 'N/A'} +
    +
    + + + {#if containerData.Path || containerData.Args} +
    +

    Command

    +
    + + {containerData.Path || ''} {containerData.Args?.join(' ') || ''} + +
    +
    + {/if} + + + {#if containerData.Config?.Labels && Object.keys(containerData.Config.Labels).length > 0} +
    + + Labels ({Object.keys(containerData.Config.Labels).length}) + +
    + {#each Object.entries(containerData.Config.Labels) as [key, value]} +
    + {key} + = + {value} +
    + {/each} +
    +
    + {/if} +
    + + + + {#if !containerData.State?.Running} +
    + + Container is not running +
    + {:else if processesLoading} +
    + +
    + {:else if processesError} +
    + {processesError} +
    + {:else if processesData && processesData.Processes?.length > 0} +
    + + + + + {#each processesData.Titles as title} + + {/each} + + + + {#each processesData.Processes as process, i} + + + {#each process as cell} + + {/each} + + {/each} + +
    #{title}
    {i + 1}{cell}
    +
    +
    + {processesData.Processes.length} process(es) +
    + {:else} +

    No processes found

    + {/if} +
    + + + + showLogs = false} + /> + + + + + {#if containerData?.Image} + + {:else} +

    No image information available

    + {/if} +
    + + + + +
    +

    Network mode

    + {containerData.HostConfig?.NetworkMode || 'default'} +
    + + + {#if containerData.HostConfig?.Dns?.length > 0 || containerData.HostConfig?.DnsSearch?.length > 0 || containerData.HostConfig?.DnsOptions?.length > 0} +
    +

    DNS configuration

    +
    + {#if containerData.HostConfig?.Dns?.length > 0} +
    +

    DNS Servers

    + {#each containerData.HostConfig.Dns as dns} + {dns} + {/each} +
    + {/if} + {#if containerData.HostConfig?.DnsSearch?.length > 0} +
    +

    DNS Search

    + {#each containerData.HostConfig.DnsSearch as search} + {search} + {/each} +
    + {/if} + {#if containerData.HostConfig?.DnsOptions?.length > 0} +
    +

    DNS Options

    + {#each containerData.HostConfig.DnsOptions as opt} + {opt} + {/each} +
    + {/if} +
    +
    + {/if} + + + {#if containerData.HostConfig?.ExtraHosts?.length > 0} +
    +

    Extra hosts

    +
    + {#each containerData.HostConfig.ExtraHosts as host} +
    + {host} +
    + {/each} +
    +
    + {/if} + + + {#if containerData.NetworkSettings?.Networks && Object.keys(containerData.NetworkSettings.Networks).length > 0} +
    +

    Connected networks

    +
    + {#each Object.entries(containerData.NetworkSettings.Networks) as [networkName, networkData]} +
    +
    + {networkName} + {networkData.NetworkID?.slice(0, 12)} +
    +
    + {#if networkData.IPAddress} +
    +

    IPv4

    + {networkData.IPAddress} +
    + {/if} + {#if networkData.GlobalIPv6Address} +
    +

    IPv6

    + {networkData.GlobalIPv6Address} +
    + {/if} + {#if networkData.MacAddress} +
    +

    MAC

    + {networkData.MacAddress} +
    + {/if} + {#if networkData.Gateway} +
    +

    Gateway

    + {networkData.Gateway} +
    + {/if} + {#if networkData.Aliases?.length > 0} +
    +

    Aliases

    + {networkData.Aliases.join(', ')} +
    + {/if} +
    +
    + {/each} +
    +
    + {/if} + + + {#if containerData.NetworkSettings?.Ports && Object.keys(containerData.NetworkSettings.Ports).length > 0} +
    +

    Port mappings

    +
    + {#each Object.entries(containerData.NetworkSettings.Ports) as [containerPort, hostBindings]} + {#if hostBindings && hostBindings.length > 0} + {#each hostBindings as binding} +
    + {binding.HostIp || '0.0.0.0'}:{binding.HostPort} + → + {containerPort} +
    + {/each} + {:else} +
    + exposed + {containerPort} +
    + {/if} + {/each} +
    +
    + {/if} +
    + + + + {#if containerData.Mounts && containerData.Mounts.length > 0} +
    + {#each containerData.Mounts as mount} +
    +
    + {mount.Type} + + {mount.RW ? 'Read/Write' : 'Read-Only'} + +
    +
    +
    +

    Source

    + {mount.Source || mount.Name || 'N/A'} +
    +
    +

    Destination

    + {mount.Destination} +
    + {#if mount.Driver} +
    +

    Driver

    + {mount.Driver} +
    + {/if} + {#if mount.Propagation} +
    +

    Propagation

    + {mount.Propagation} +
    + {/if} +
    +
    + {/each} +
    + {:else} +

    No mounts configured

    + {/if} +
    + + + + {#if containerData.State?.Running && !containerData.State?.Paused} + + {:else if containerData.State?.Paused} +
    + + Container is paused +
    + {:else} +
    + + Container is not running +
    + {/if} +
    + + + + {#if containerData.Config?.Env && containerData.Config.Env.length > 0} +
    + {#each containerData.Config.Env as envVar} + {@const [key, ...valueParts] = envVar.split('=')} + {@const value = valueParts.join('=')} +
    + {key} + = + {value} +
    + {/each} +
    + {:else} +

    No environment variables

    + {/if} +
    + + + + +
    +
    +

    Privileged

    + + {containerData.HostConfig?.Privileged ? 'Yes' : 'No'} + +
    +
    +

    Read-only Root

    + + {containerData.HostConfig?.ReadonlyRootfs ? 'Yes' : 'No'} + +
    +
    +

    User

    + {containerData.Config?.User || 'root'} +
    +
    +

    User Namespace

    + {containerData.HostConfig?.UsernsMode || 'host'} +
    +
    + + + {#if containerData.HostConfig?.SecurityOpt?.length > 0} +
    +

    Security options

    +
    + {#each containerData.HostConfig.SecurityOpt as opt} +
    + {opt} +
    + {/each} +
    +
    + {/if} + + +
    + {#if containerData.AppArmorProfile !== undefined} +
    +

    AppArmor Profile

    + {containerData.AppArmorProfile || 'unconfined'} +
    + {/if} + {#if containerData.HostConfig?.SecurityOpt?.some((o: string) => o.startsWith('seccomp'))} +
    +

    Seccomp

    + + {containerData.HostConfig.SecurityOpt.find((o: string) => o.startsWith('seccomp'))?.split('=')[1] || 'default'} + +
    + {/if} +
    + + +
    + {#if containerData.HostConfig?.CapAdd?.length > 0} +
    +

    Added capabilities

    +
    + {#each containerData.HostConfig.CapAdd as cap} + {cap} + {/each} +
    +
    + {/if} + {#if containerData.HostConfig?.CapDrop?.length > 0} +
    +

    Dropped capabilities

    +
    + {#each containerData.HostConfig.CapDrop as cap} + {cap} + {/each} +
    +
    + {/if} +
    + + {#if !containerData.HostConfig?.CapAdd?.length && !containerData.HostConfig?.CapDrop?.length && !containerData.HostConfig?.SecurityOpt?.length} +

    Default security settings

    + {/if} +
    + + + + +
    +

    + + Resource limits +

    +
    +
    +

    CPU Shares

    + {containerData.HostConfig?.CpuShares || 'default'} +
    +
    +

    CPUs

    + {containerData.HostConfig?.NanoCpus ? (containerData.HostConfig.NanoCpus / 1e9).toFixed(2) : 'unlimited'} +
    +
    +

    Memory

    + {formatMemory(containerData.HostConfig?.Memory)} +
    +
    +

    Memory Swap

    + {formatMemory(containerData.HostConfig?.MemorySwap)} +
    +
    +

    Memory Reservation

    + {formatMemory(containerData.HostConfig?.MemoryReservation)} +
    +
    +

    PIDs Limit

    + {containerData.HostConfig?.PidsLimit ?? 'unlimited'} +
    +
    +

    OOM Kill

    + + {containerData.HostConfig?.OomKillDisable ? 'Disabled' : 'Enabled'} + +
    +
    +

    CPU Period/Quota

    + + {containerData.HostConfig?.CpuPeriod || 0}/{containerData.HostConfig?.CpuQuota || 0} + +
    +
    +
    + + + {#if containerData.HostConfig?.Ulimits?.length > 0} +
    +

    Ulimits

    +
    + {#each containerData.HostConfig.Ulimits as ulimit} +
    + {ulimit.Name} + soft={ulimit.Soft} hard={ulimit.Hard} +
    + {/each} +
    +
    + {/if} + + + {#if containerData.HostConfig?.Devices?.length > 0} +
    +

    Devices

    +
    + {#each containerData.HostConfig.Devices as device} +
    + {device.PathOnHost} + → + {device.PathInContainer} + {#if device.CgroupPermissions} + {device.CgroupPermissions} + {/if} +
    + {/each} +
    +
    + {/if} + + +
    +

    Cgroup settings

    +
    +
    +

    Cgroup

    + {containerData.HostConfig?.Cgroup || 'default'} +
    +
    +

    Cgroup Parent

    + {containerData.HostConfig?.CgroupParent || 'default'} +
    +
    +

    Cgroupns Mode

    + {containerData.HostConfig?.CgroupnsMode || 'host'} +
    +
    +
    +
    + + + + {#if containerData.State?.Health} +
    +
    +
    +

    Status

    + + {containerData.State.Health.Status} + +
    +
    +

    Failing Streak

    + {containerData.State.Health.FailingStreak || 0} +
    +
    + + {#if containerData.State.Health.Log && containerData.State.Health.Log.length > 0} +
    +

    Health check log

    +
    + {#each containerData.State.Health.Log.slice(-5) as log} +
    +
    + + Exit: {log.ExitCode} + + {formatDate(log.End)} +
    + {#if log.Output} + {log.Output.trim()} + {/if} +
    + {/each} +
    +
    + {/if} +
    + {:else} +

    No health check configured

    + {/if} +
    +
    + {/if} +
    + + + + +
    +
    + + + + + + + + Raw JSON + + + +
    +
    + + + {#each jsonLines as line, i} + + + + + {/each} + +
    {i + 1}{@html line || ' '}
    +
    +
    + + + +
    +
    + diff --git a/routes/containers/ContainerTerminal.svelte b/routes/containers/ContainerTerminal.svelte new file mode 100644 index 0000000..37f83ed --- /dev/null +++ b/routes/containers/ContainerTerminal.svelte @@ -0,0 +1,346 @@ + + + !isOpen && handleClose()}> + + +
    +
    + + Terminal - {containerName} + {#if connected} + + + Connected + + {/if} +
    + +
    +
    + + {#if showConfig} +
    +
    +
    + +

    Open terminal session

    +

    + Configure the shell and user for this session +

    +
    + +
    +
    + + + + + {shellOptions.find(o => o.value === selectedShell)?.label || 'Select shell'} + + + {#each shellOptions as option} + + + {option.label} + + {/each} + + +
    + +
    + + + + + {userOptions.find(o => o.value === selectedUser)?.label || 'Select user'} + + + {#each userOptions as option} + + + {option.label} + + {/each} + + +
    +
    + +
    + + +
    +
    +
    + {:else} +
    +
    +
    + {/if} +
    +
    + + diff --git a/routes/containers/ContainerTile.svelte b/routes/containers/ContainerTile.svelte new file mode 100644 index 0000000..ced20db --- /dev/null +++ b/routes/containers/ContainerTile.svelte @@ -0,0 +1,46 @@ + + + diff --git a/routes/containers/CreateContainerModal.svelte b/routes/containers/CreateContainerModal.svelte new file mode 100644 index 0000000..6796704 --- /dev/null +++ b/routes/containers/CreateContainerModal.svelte @@ -0,0 +1,1657 @@ + + + isOpen && focusFirstInput()}> + + + Create new container + + + + + {#if !skipPullTab} +
    + + + {#if envHasScanning} + + + + {/if} + + + +
    + {/if} + + + +
    + image = newImage} + /> +
    + + +
    + {#if envHasScanning} + + {:else} + +
    +
    + +

    Vulnerability scanning is disabled for this environment.

    +

    Enable it in Settings → Environments to scan images.

    +
    +
    + {/if} +
    + + +
    + +
    +
    + +
    +

    Image: {image || 'Not set'}

    + {#if isPulling || isScanning} +

    + + {isScanning ? 'Scanning...' : 'Pulling...'} +

    + {:else if imageReady} +

    + + Image pulled and ready + {#if scanResults.length > 0} + • {totalVulnerabilities} vulnerabilities + {/if} +

    + {:else if !image && !skipPullTab} +

    + + Go to "Pull" tab to set the image +

    + {/if} +
    +
    +
    + + + {#if configSets.length > 0} +
    +
    + +

    Config set

    +
    +
    +
    + + + {selectedConfigSetId ? configSets.find(c => c.id === parseInt(selectedConfigSetId))?.name : 'Select a config set to pre-fill values...'} + + + {#each configSets as configSet} + +
    + {configSet.name} + {#if configSet.description} + {configSet.description} + {/if} +
    +
    + {/each} +
    +
    +
    +
    +
    + {/if} + + +
    +
    +

    Basic settings

    +
    + +
    + + errors.name = undefined} + /> + {#if errors.name} +

    {errors.name}

    + {/if} +
    + +
    + + +
    + +
    +
    + + + + + {#if restartPolicy === 'no'} + + {:else if restartPolicy === 'always'} + + {:else if restartPolicy === 'on-failure'} + + {:else} + + {/if} + {restartPolicy === 'no' ? 'No' : restartPolicy === 'always' ? 'Always' : restartPolicy === 'on-failure' ? 'On failure' : 'Unless stopped'} + + + + + {#snippet children()} + + No + {/snippet} + + + {#snippet children()} + + Always + {/snippet} + + + {#snippet children()} + + On failure + {/snippet} + + + {#snippet children()} + + Unless stopped + {/snippet} + + + +
    + +
    + + + + + {#if networkMode === 'bridge'} + + {:else if networkMode === 'host'} + + {:else} + + {/if} + {networkMode === 'bridge' ? 'Bridge' : networkMode === 'host' ? 'Host' : 'None'} + + + + + {#snippet children()} + + Bridge + {/snippet} + + + {#snippet children()} + + Host + {/snippet} + + + {#snippet children()} + + None + {/snippet} + + + +
    +
    + +
    + + +
    +
    + + + {#if availableNetworks.length > 0} +
    +
    +
    + +

    Networks

    +
    +
    + +
    + + + Select network to add... + + + {#each availableNetworks.filter(n => !selectedNetworks.includes(n.name) && !['bridge', 'host', 'none'].includes(n.name)) as network} + + {#snippet children()} +
    + {network.name} + {network.driver} +
    + {/snippet} +
    + {/each} +
    +
    + + {#if selectedNetworks.length > 0} +
    + {#each selectedNetworks as networkName} + {@const network = availableNetworks.find(n => n.name === networkName)} + + {networkName} + {#if network} + {network.driver} + {/if} + + + {/each} +
    + {/if} +
    +
    + {/if} + + +
    +
    +

    Port mappings

    + +
    + +
    + {#each portMappings as mapping, index} +
    +
    + Host + +
    +
    + Container + +
    + { portMappings[index].protocol = v; }} + /> + +
    + {/each} +
    +
    + + +
    +
    +

    Volume mappings

    + +
    + +
    + {#each volumeMappings as mapping, index} +
    +
    + Host path + +
    +
    + Container path + +
    + { volumeMappings[index].mode = v; }} + /> + +
    + {/each} +
    +
    + + +
    +
    +

    Environment variables

    + +
    + +
    + {#each envVars as envVar, index} +
    +
    + Key + +
    +
    + Value + +
    + +
    + {/each} +
    +
    + + +
    +
    +

    Labels

    + +
    + +
    + {#each labels as label, index} +
    +
    + Key + +
    +
    + Value + +
    + +
    + {/each} +
    +
    + + +
    +

    Advanced container options (click to expand)

    +
    + + +
    + + {#if showResources} +
    +

    Configure memory and CPU limits for this container

    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    + {/if} +
    + + +
    + + {#if showSecurity} +
    +
    +
    + + +
    +
    +
    + + +
    +
    +
    + +
    + + { addCapability('add', v); }}> + + Select capability to add... + + + {#each commonCapabilities.filter(c => !capAdd.includes(c)) as cap} + + {/each} + + + {#if capAdd.length > 0} +
    + {#each capAdd as cap} + + +{cap} + + + {/each} +
    + {/if} +
    + +
    + + { addCapability('drop', v); }}> + + Select capability to drop... + + + {#each commonCapabilities.filter(c => !capDrop.includes(c)) as cap} + + {/each} + + + {#if capDrop.length > 0} +
    + {#each capDrop as cap} + + -{cap} + + + {/each} +
    + {/if} +
    +
    + {/if} +
    + + +
    + + {#if showHealth} +
    +
    + + +
    + {#if healthcheckEnabled} +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + {/if} +
    + {/if} +
    + + +
    + + {#if showDns} +
    +
    + +
    + { if (e.key === 'Enter') { e.preventDefault(); addDnsServer(); } }} + /> + +
    + {#if dnsServers.length > 0} +
    + {#each dnsServers as server} + + {server} + + + {/each} +
    + {/if} +
    +
    + {/if} +
    + + +
    + + {#if showDevices} +
    +
    + +
    + {#each deviceMappings as mapping, index} +
    + + + +
    + {/each} +
    + {/if} +
    + + +
    + + {#if showUlimits} +
    +
    + +
    + {#each ulimits as ulimit, index} +
    + + + {ulimit.name} + + + {#each commonUlimits as name} + + {/each} + + + + + +
    + {/each} +
    + {/if} +
    + + +
    +
    + +

    Auto-update

    +
    + +
    +
    + +
    +
    + {#if activeTab === 'container' && hasCriticalOrHigh} +
    + + Critical/high vulnerabilities found in image +
    + {/if} +
    +
    + + +
    +
    +
    +
    diff --git a/routes/containers/EditContainerModal.svelte b/routes/containers/EditContainerModal.svelte new file mode 100644 index 0000000..f0f41f3 --- /dev/null +++ b/routes/containers/EditContainerModal.svelte @@ -0,0 +1,1299 @@ + + + isOpen && focusFirstInput()}> + + + + Edit container + {#if isEditingTitle} + - + { + if (e.key === 'Enter') saveEditingTitle(); + if (e.key === 'Escape') cancelEditingTitle(); + }} + /> + + + {:else if name} + - {name} + + {/if} + + + + {#if loadingData} +
    + + Loading container data... +
    + {:else} +
    + + {#if configSets.length > 0} +
    +
    + +

    Apply config set

    +
    +
    +
    + + + {selectedConfigSetId ? configSets.find(c => c.id === parseInt(selectedConfigSetId))?.name : 'Select a config set to merge values...'} + + + {#each configSets as configSet} + +
    + {configSet.name} + {#if configSet.description} + {configSet.description} + {/if} +
    +
    + {/each} +
    +
    +
    +
    + {#if selectedConfigSetId} + {@const selectedSet = configSets.find(c => c.id === parseInt(selectedConfigSetId))} + {#if selectedSet?.description} +

    {selectedSet.description}

    + {/if} + {/if} +

    Note: Values from the config set will be merged with existing settings. Existing keys won't be overwritten.

    +
    + {/if} + + + {#if showComposeConfigWarning} +
    + +
    +

    + This container belongs to stack "{composeStackName}" +

    +

    + Modifying settings will remove this container from the stack. The container will be recreated and lose its stack association. To avoid this, edit the stack's compose file instead. +

    +
    +
    + {:else if showComposeRenameWarning} +
    + +
    +

    + Renaming container from stack "{composeStackName}" +

    +

    + The container will stay in the stack, but the compose file will be out of sync. Running docker compose up may recreate it with the original name. +

    +
    +
    + {:else if isComposeContainer} +
    + +
    +

    + Stack container: {composeStackName} +

    +

    + This container is managed by a Docker Compose stack. +

    +
    +
    + {/if} + + +
    +
    +

    Basic settings

    +
    + +
    +
    + + errors.name = undefined} + /> + {#if errors.name} +

    {errors.name}

    + {/if} +
    +
    + + errors.image = undefined} + /> + {#if errors.image} +

    {errors.image}

    + {/if} +
    +
    + +
    + + +
    + +
    +
    + + + + + {#if restartPolicy === 'no'} + + {:else if restartPolicy === 'always'} + + {:else if restartPolicy === 'on-failure'} + + {:else} + + {/if} + {restartPolicy === 'no' ? 'No' : restartPolicy === 'always' ? 'Always' : restartPolicy === 'on-failure' ? 'On failure' : 'Unless stopped'} + + + + + {#snippet children()} + + No + {/snippet} + + + {#snippet children()} + + Always + {/snippet} + + + {#snippet children()} + + On failure + {/snippet} + + + {#snippet children()} + + Unless stopped + {/snippet} + + + +
    + +
    + + + + + {#if networkMode === 'bridge'} + + {:else if networkMode === 'host'} + + {:else} + + {/if} + {networkMode === 'bridge' ? 'Bridge' : networkMode === 'host' ? 'Host' : 'None'} + + + + + {#snippet children()} + + Bridge + {/snippet} + + + {#snippet children()} + + Host + {/snippet} + + + {#snippet children()} + + None + {/snippet} + + + +
    +
    + +
    + + +
    +
    + + + {#if availableNetworks.length > 0} +
    +
    +
    + +

    Networks

    +
    +
    + +
    + + + Select network to add... + + + {#each availableNetworks.filter(n => !selectedNetworks.includes(n.name) && !['bridge', 'host', 'none'].includes(n.name)) as network} + + {#snippet children()} +
    + {network.name} + {network.driver} +
    + {/snippet} +
    + {/each} +
    +
    + + {#if selectedNetworks.length > 0} +
    + {#each selectedNetworks as networkName} + {@const network = availableNetworks.find(n => n.name === networkName)} + + {networkName} + {#if network} + {network.driver} + {/if} + + + {/each} +
    + {/if} +

    Container will be connected to selected networks in addition to the network mode above

    +
    +
    + {/if} + + +
    +
    +

    Port mappings

    + +
    + +
    + {#each portMappings as mapping, index} +
    +
    + Host + +
    +
    + Container + +
    +
    + Protocol + + + {mapping.protocol.toUpperCase()} + + + + + + +
    + +
    + {/each} +
    +
    + + +
    +
    +

    Volume mappings

    + +
    + +
    + {#each volumeMappings as mapping, index} +
    +
    + Host path + +
    +
    + Container path + +
    +
    + Mode + + + {mapping.mode.toUpperCase()} + + + + + + +
    + +
    + {/each} +
    +
    + + +
    +
    +

    Environment variables

    + +
    + +
    + {#each envVars as envVar, index} +
    +
    + Key + +
    +
    + Value + +
    + +
    + {/each} +
    +
    + + +
    +
    +

    Labels

    + +
    + +
    + {#each labels as label, index} +
    +
    + Key + +
    +
    + Value + +
    + +
    + {/each} +
    +
    + + +
    +
    + +

    Auto-update

    +
    + + +
    + + {#if statusMessage} +
    + {statusMessage} +
    + {/if} + + {#if error} +
    + {error} +
    + {/if} + +
    +
    + + +
    + {/if} +
    +
    diff --git a/routes/containers/FileBrowserModal.svelte b/routes/containers/FileBrowserModal.svelte new file mode 100644 index 0000000..bb0253d --- /dev/null +++ b/routes/containers/FileBrowserModal.svelte @@ -0,0 +1,39 @@ + + + + + + + + Browse files - {containerName} + + + Browse, upload, and download files from the container filesystem. + + +
    + +
    +
    +
    diff --git a/routes/containers/FileBrowserPanel.svelte b/routes/containers/FileBrowserPanel.svelte new file mode 100644 index 0000000..935deba --- /dev/null +++ b/routes/containers/FileBrowserPanel.svelte @@ -0,0 +1,1283 @@ + + +
    + +
    + + + + +
    + + {#each pathSegments() as segment, i} + + + {/each} +
    + + + {#if effectiveCanEdit} + + + + + {/if} + + +
    + + +
    + {#if loading} +
    + + Loading... +
    + {/if} + {#if error} +
    +
    + +

    Unable to browse files

    +

    {error}

    + +
    +
    + {:else if !loading && displayEntries().length === 0} +
    + {showHiddenFiles ? 'Directory is empty' : 'No visible files (hidden files are hidden)'} +
    + {:else if displayEntries().length > 0} + + + + + + + + + + + Permissions + + + + + Actions + + + + {#each displayEntries() as entry (entry.name)} + {@const Icon = getIcon(entry)} + {@const isClickable = entry.type === 'directory'} + + + + + + {entry.type === 'directory' ? '-' : formatSize(entry.size)} + + + {permissionsToOctal(entry.permissions)} + {entry.permissions} + + + {formatDate(entry.modified)} + + +
    + {#if isViewable(entry)} + + {/if} + {#if effectiveCanEdit && isEditable(entry)} + + {/if} + {#if effectiveCanEdit} + + + handleDelete(entry)} + onOpenChange={(open) => confirmDeleteEntry = open ? entry.name : null} + > + {#snippet children({ open })} + {#if deleting === entry.name} + + {:else} + + {/if} + {/snippet} + + {/if} + +
    +
    +
    + {/each} +
    +
    + {/if} +
    + + + {#if editingFile} +
    +
    +
    + + {editingFile.name} + {editingFile.path} +
    +
    + + + +
    +
    +
    + editorContent = v} + /> +
    +
    + {/if} + + + {#if viewingFile} +
    +
    +
    + + {viewingFile.name} + {viewingFile.path} + read-only +
    +
    + + +
    +
    +
    + +
    +
    + {/if} +
    + + + + + + Create {createType === 'file' ? 'File' : 'Directory'} + +
    +
    + + { if (e.key === 'Enter') handleCreate(); }} + /> +
    +

    + Will be created in: {currentPath} +

    +
    + + + + +
    +
    + + + + + + Rename + +
    +
    + + { if (e.key === 'Enter') handleRename(); }} + /> +
    +
    + + + + +
    +
    + + + + + + Change permissions + +
    + {#if chmodEntry} +

    {chmodEntry.name}

    +

    Current: {chmodEntry.permissions}

    + {/if} + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ReadWriteExecute
    Owner
    Group
    Others
    +
    + + +
    +
    + Octal: + {chmodMode} +
    +
    + Symbolic: + {checkboxesToSymbolic()} +
    +
    + + +
    + + octalToCheckboxes(chmodMode)} + onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter') handleChmod(); }} + /> +
    + + {#if chmodEntry?.type === 'directory'} + + {/if} +
    + + + + +
    +
    diff --git a/routes/dashboard/DraggableGrid.svelte b/routes/dashboard/DraggableGrid.svelte new file mode 100644 index 0000000..ada299e --- /dev/null +++ b/routes/dashboard/DraggableGrid.svelte @@ -0,0 +1,575 @@ + + + + +
    + {#each items as item (item.id)} + {@const isDragTarget = dragItem?.id === item.id} + {@const isDragging = isDragTarget && dragActuallyMoved} + {@const isResizing = resizeItem?.id === item.id} + {@const isActive = isDragging || isResizing} + {@const itemWidth = item.w * colWidth + (item.w - 1) * gap} + {@const itemHeight = item.h * rowHeight + (item.h - 1) * gap} + + + {#if isDragging || isResizing} +
    + {/if} + + + {@const baseWidth = item.w * colWidth + (item.w - 1) * gap} + {@const baseHeight = item.h * rowHeight + (item.h - 1) * gap} + {@const minPixelW = minW * colWidth + (minW - 1) * gap} + {@const maxPixelW = Math.min(maxW, cols - item.x) * colWidth + (Math.min(maxW, cols - item.x) - 1) * gap} + {@const minPixelH = minH * rowHeight + (minH - 1) * gap} + {@const maxPixelH = maxH * rowHeight + (maxH - 1) * gap} + {@const currentWidth = isResizing ? Math.max(minPixelW, Math.min(maxPixelW, baseWidth + resizePixelDeltaX)) : baseWidth} + {@const currentHeight = isResizing ? Math.max(minPixelH, Math.min(maxPixelH, baseHeight + resizePixelDeltaY)) : baseHeight} +
    { + // Check if clicking on resize handle area (bottom-right 28x28 corner) + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const isInResizeArea = x > rect.width - 28 && y > rect.height - 28; + + if (isInResizeArea) { + handleResizeStart(e, item); + } else { + handleDragStart(e, item); + } + }} + > + +
    + {@render children({ item, width: itemWidth, height: itemHeight })} +
    + + +
    + + + +
    +
    + {/each} +
    + + diff --git a/routes/dashboard/EnvironmentTile.svelte b/routes/dashboard/EnvironmentTile.svelte new file mode 100644 index 0000000..bcaf811 --- /dev/null +++ b/routes/dashboard/EnvironmentTile.svelte @@ -0,0 +1,707 @@ + + + + + {#if is1x1} + + +
    + +
    +
    + +
    + {#if stats.connectionType === 'socket' || !stats.connectionType} + + + + {:else if stats.connectionType === 'direct'} + + + + {:else if stats.connectionType === 'hawser-standard'} + + + + {:else if stats.connectionType === 'hawser-edge'} + + + + {/if} +
    +
    + {stats.name} + {#if !showOffline} + + {:else} + + {/if} +
    + + {stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') : + stats.connectionType === 'hawser-edge' ? 'Edge connection' : + (stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')} + +
    +
    + +
    + {#if stats.updateCheckEnabled} + + {#if stats.updateCheckAutoUpdate} + + {:else} + + {/if} + + {/if} + {#if stats.scannerEnabled} + + + + {/if} + {#if stats.collectActivity} + + + + {/if} + {#if stats.collectMetrics} + + + + {/if} + {#if $canAccess('environments', 'edit')} + + {/if} +
    +
    +
    + + + {#if !showOffline} +
    + + +
    + {:else} + + {/if} +
    + + + {:else if is2x1} + + +
    + +
    +
    + +
    + {#if stats.connectionType === 'socket' || !stats.connectionType} + + + + {:else if stats.connectionType === 'direct'} + + + + {:else if stats.connectionType === 'hawser-standard'} + + + + {:else if stats.connectionType === 'hawser-edge'} + + + + {/if} +
    +
    + {stats.name} + {#if !showOffline} + + {:else} + + {/if} +
    + + {stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') : + stats.connectionType === 'hawser-edge' ? 'Edge connection' : + (stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')} + +
    +
    + +
    + {#if stats.updateCheckEnabled} + + {#if stats.updateCheckAutoUpdate} + + {:else} + + {/if} + + {/if} + {#if stats.scannerEnabled} + + + + {/if} + {#if stats.collectActivity} + + + + {/if} + {#if stats.collectMetrics} + + + + {/if} + {#if $canAccess('environments', 'edit')} + + {/if} +
    +
    +
    + + + {#if !showOffline} +
    +
    + + +
    + {#if stats.recentEvents} +
    + +
    + {/if} +
    + {:else} + + {/if} +
    + + + {:else if is1x2} + + +
    + +
    +
    + +
    + {#if stats.connectionType === 'socket' || !stats.connectionType} + + + + {:else if stats.connectionType === 'direct'} + + + + {:else if stats.connectionType === 'hawser-standard'} + + + + {:else if stats.connectionType === 'hawser-edge'} + + + + {/if} +
    +
    + {stats.name} + {#if !showOffline} + + {:else} + + {/if} +
    + + {stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') : + stats.connectionType === 'hawser-edge' ? 'Edge connection' : + (stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')} + +
    +
    + +
    + {#if stats.updateCheckEnabled} + + {#if stats.updateCheckAutoUpdate} + + {:else} + + {/if} + + {/if} + {#if stats.scannerEnabled} + + + + {/if} + {#if stats.collectActivity} + + + + {/if} + {#if stats.collectMetrics} + + + + {/if} + {#if $canAccess('environments', 'edit')} + + {/if} +
    +
    +
    + + + {#if !showOffline} +
    + + + {#if stats.collectMetrics && stats.metrics} + + {/if} + + +
    + {:else} + + {/if} +
    + + + {:else if is1x3} + + +
    + +
    +
    + +
    + {#if stats.connectionType === 'socket' || !stats.connectionType} + + + + {:else if stats.connectionType === 'direct'} + + + + {:else if stats.connectionType === 'hawser-standard'} + + + + {:else if stats.connectionType === 'hawser-edge'} + + + + {/if} +
    +
    + {stats.name} + {#if !showOffline} + + {:else} + + {/if} +
    + + {stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') : + stats.connectionType === 'hawser-edge' ? 'Edge connection' : + (stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')} + +
    +
    + +
    + {#if stats.updateCheckEnabled} + + {#if stats.updateCheckAutoUpdate} + + {:else} + + {/if} + + {/if} + {#if stats.scannerEnabled} + + + + {/if} + {#if stats.collectActivity} + + + + {/if} + {#if stats.collectMetrics} + + + + {/if} + {#if $canAccess('environments', 'edit')} + + {/if} +
    +
    +
    + + + {#if !showOffline} +
    + + + {#if stats.collectMetrics && stats.metrics} + + {/if} + + + {#if stats.recentEvents} + + {/if} +
    + {:else} + + {/if} +
    + + + {:else if is1x4} + + +
    + +
    +
    + +
    + {#if stats.connectionType === 'socket' || !stats.connectionType} + + + + {:else if stats.connectionType === 'direct'} + + + + {:else if stats.connectionType === 'hawser-standard'} + + + + {:else if stats.connectionType === 'hawser-edge'} + + + + {/if} +
    +
    + {stats.name} + {#if !showOffline} + + {:else} + + {/if} +
    + + {stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') : + stats.connectionType === 'hawser-edge' ? 'Edge connection' : + (stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')} + +
    +
    + +
    + {#if stats.updateCheckEnabled} + + {#if stats.updateCheckAutoUpdate} + + {:else} + + {/if} + + {/if} + {#if stats.scannerEnabled} + + + + {/if} + {#if stats.collectActivity} + + + + {/if} + {#if stats.collectMetrics} + + + + {/if} + {#if $canAccess('environments', 'edit')} + + {/if} +
    +
    +
    + + + {#if !showOffline} +
    + + + {#if stats.collectMetrics && stats.metrics} + + {/if} + + + {#if stats.recentEvents} + + {/if} + +
    + {:else} + + {/if} +
    + + + {:else if is2x2} + + + + + + {#if !showOffline} +
    + +
    + + + {#if stats.metrics} + + {/if} + + +
    + +
    + +
    +
    + {:else} + + {/if} +
    + + + {:else if is2x3} + + + + + + {#if !showOffline} +
    + +
    + + + {#if stats.metrics} + + {/if} + + + {#if stats.recentEvents} + + {/if} +
    + +
    + + {#if stats.collectMetrics && stats.metrics && stats.metricsHistory} + + {/if} +
    +
    + {:else} + + {/if} +
    + + + {:else if is2x4} + + + + + + {#if !showOffline} +
    + +
    + + + {#if stats.metrics} + + {/if} + + + {#if stats.recentEvents} + + {/if} + +
    + +
    + {#if stats.collectMetrics && stats.metrics && stats.metricsHistory} + + {/if} + +
    +
    + {:else} + + {/if} +
    + {/if} +
    diff --git a/routes/dashboard/EnvironmentTileSkeleton.svelte b/routes/dashboard/EnvironmentTileSkeleton.svelte new file mode 100644 index 0000000..4872a8e --- /dev/null +++ b/routes/dashboard/EnvironmentTileSkeleton.svelte @@ -0,0 +1,639 @@ + + + + + + + + {#if is1x1} +
    +
    + +
    +
    +
    + {#if name} +
    {name}
    + {:else} +
    + {/if} +
    +
    + +
    + {#each [1, 2, 3, 4, 5] as _} +
    + {/each} +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + {:else if is2x1} +
    +
    + +
    +
    +
    + {#if name} +
    {name}
    + {:else} +
    + {/if} +
    +
    + +
    + {#each [1, 2, 3, 4, 5] as _} +
    + {/each} +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + {:else if is1x2} + + +
    +
    +
    + {#if name} +
    {name}
    +
    {host || 'Connecting...'}
    + {:else} +
    +
    + {/if} +
    +
    +
    + + +
    + {#each [1, 2, 3, 4, 5, 6] as _} +
    + {/each} +
    + +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + {#each [1, 2, 3, 4] as _} +
    +
    +
    +
    + {/each} +
    + +
    +
    +
    +
    +
    + + + {:else if is1x3} + +
    +
    +
    + {#if name} +
    {name}
    +
    {host || 'Connecting...'}
    + {:else} +
    +
    + {/if} +
    +
    +
    + + +
    + {#each [1, 2, 3, 4, 5, 6] as _} +
    + {/each} +
    + +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + {#each [1, 2, 3, 4] as _} +
    +
    +
    +
    + {/each} +
    + +
    +
    +
    +
    + +
    +
    +
    + {#each [1, 2, 3, 4, 5, 6, 7, 8] as _} +
    +
    +
    +
    +
    + {/each} +
    +
    +
    + + + {:else if is1x4} + +
    +
    +
    + {#if name} +
    {name}
    +
    {host || 'Connecting...'}
    + {:else} +
    +
    + {/if} +
    +
    +
    + + +
    + {#each [1, 2, 3, 4, 5, 6] as _} +
    + {/each} +
    + +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + {#each [1, 2, 3, 4] as _} +
    +
    +
    +
    + {/each} +
    + +
    +
    +
    +
    + +
    +
    +
    + {#each [1, 2, 3, 4, 5, 6, 7, 8] as _} +
    +
    +
    +
    +
    + {/each} +
    +
    + +
    +
    +
    + {#each [1, 2, 3, 4, 5, 6, 7, 8] as _} +
    +
    +
    +
    +
    + {/each} +
    +
    +
    + + + {:else if is2x2} + +
    +
    +
    + {#if name} +
    {name}
    +
    {host || 'Connecting...'}
    + {:else} +
    +
    + {/if} +
    +
    +
    + +
    + +
    + +
    + {#each [1, 2, 3, 4, 5, 6] as _} +
    + {/each} +
    + +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + {#each [1, 2, 3, 4] as _} +
    +
    +
    +
    + {/each} +
    + +
    +
    +
    +
    +
    + +
    + +
    +
    +
    + {#each [1, 2, 3, 4, 5, 6, 7, 8] as _} +
    +
    +
    +
    +
    + {/each} +
    +
    +
    +
    +
    + + + {:else if is2x3} + +
    +
    +
    + {#if name} +
    {name}
    +
    {host || 'Connecting...'}
    + {:else} +
    +
    + {/if} +
    +
    +
    + +
    + +
    + +
    + {#each [1, 2, 3, 4, 5, 6] as _} +
    + {/each} +
    + +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + {#each [1, 2, 3, 4] as _} +
    +
    +
    +
    + {/each} +
    + +
    +
    +
    +
    + +
    +
    +
    + {#each [1, 2, 3, 4, 5] as _} +
    +
    +
    +
    +
    + {/each} +
    +
    +
    + +
    + +
    +
    +
    + {#each [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as _} +
    +
    +
    +
    +
    + {/each} +
    +
    + +
    +
    +
    +
    +
    +
    +
    + + + {:else if is2x4} + +
    +
    +
    + {#if name} +
    {name}
    +
    {host || 'Connecting...'}
    + {:else} +
    +
    + {/if} +
    +
    +
    + +
    + +
    + +
    + {#each [1, 2, 3, 4, 5, 6] as _} +
    + {/each} +
    + +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + {#each [1, 2, 3, 4] as _} +
    +
    +
    +
    + {/each} +
    + +
    +
    +
    +
    + +
    +
    +
    + {#each [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as _} +
    +
    +
    +
    +
    + {/each} +
    +
    + +
    +
    +
    + {#each [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as _} +
    +
    +
    +
    +
    + {/each} +
    +
    +
    + +
    + +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    + {#each [1, 2, 3, 4] as _} +
    +
    +
    +
    +
    + {/each} +
    +
    +
    +
    +
    +
    + {/if} +
    diff --git a/routes/dashboard/dashboard-container-stats.svelte b/routes/dashboard/dashboard-container-stats.svelte new file mode 100644 index 0000000..c536c81 --- /dev/null +++ b/routes/dashboard/dashboard-container-stats.svelte @@ -0,0 +1,150 @@ + + +{#if showSkeleton && compact} + +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    +{:else if showSkeleton} + +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + Total +
    +
    +
    +{:else if compact} + +
    +
    + + {containers.running} +
    +
    + + {containers.stopped} +
    +
    + + {containers.paused} +
    +
    + + {containers.restarting} +
    +
    + + {containers.unhealthy} +
    +
    +{:else} + +
    +
    + + {containers.running} +
    +
    + + {containers.stopped} +
    +
    + + {containers.paused} +
    +
    + + {containers.restarting} +
    +
    + + {containers.unhealthy} +
    +
    + Total + {containers.total} +
    +
    +{/if} + + diff --git a/routes/dashboard/dashboard-cpu-memory-bars.svelte b/routes/dashboard/dashboard-cpu-memory-bars.svelte new file mode 100644 index 0000000..315fa12 --- /dev/null +++ b/routes/dashboard/dashboard-cpu-memory-bars.svelte @@ -0,0 +1,176 @@ + + +{#if showSkeleton} + + {#if compact} +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + {:else} +
    +
    +
    + + CPU + +
    +
    +
    +
    +
    +
    +
    +
    + + Memory + +
    +
    +
    +
    +
    +
    +
    + {/if} +{:else if !collectMetrics} + +
    + Metrics collection disabled +
    +{:else if !hasMetrics} + +
    + No metrics available +
    +{:else if compact} + +
    +
    + +
    +
    +
    + {cpuPercent.toFixed(0)}% +
    +
    + +
    +
    +
    + {memoryPercent.toFixed(0)}% +
    +
    +{:else} + +
    +
    +
    + + CPU + + {cpuPercent.toFixed(1)}% +
    +
    +
    +
    +
    +
    +
    + + Memory + + + {memoryPercent.toFixed(1)}% + {#if showMemoryUsed} + ({formatBytes(memoryUsed)}) + {/if} + +
    +
    +
    +
    +
    +
    +{/if} + + diff --git a/routes/dashboard/dashboard-cpu-memory-charts.svelte b/routes/dashboard/dashboard-cpu-memory-charts.svelte new file mode 100644 index 0000000..944de8d --- /dev/null +++ b/routes/dashboard/dashboard-cpu-memory-charts.svelte @@ -0,0 +1,142 @@ + + +{#if !hasMetrics} + +
    +
    + No metrics available +
    +
    +{:else} +
    +
    + + CPU & Memory history +
    + + +
    +
    + CPU + {cpuPercent.toFixed(1)}% +
    + {#if hasHistory} +
    + + + + + +
    + {:else} +
    +
    +
    + {/if} +
    + + +
    +
    + Memory + {memoryPercent.toFixed(1)}% ({formatBytes(memoryUsed)}) +
    + {#if hasHistory} +
    + + + + + +
    + {:else} +
    +
    +
    + {/if} +
    +
    +{/if} diff --git a/routes/dashboard/dashboard-disk-usage.svelte b/routes/dashboard/dashboard-disk-usage.svelte new file mode 100644 index 0000000..ca5cd9e --- /dev/null +++ b/routes/dashboard/dashboard-disk-usage.svelte @@ -0,0 +1,213 @@ + + +{#if showSkeleton} +
    +
    + + Disk usage + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + Images +
    +
    +
    +
    + + Volumes +
    +
    +
    +
    +{:else if totalSize > 0} +
    +
    + + Disk usage + {formatBytes(totalSize)} +
    + + {#if showPieChart && pieData.length > 0} + +
    +
    + + + + {#snippet children({ arcs })} + {#each arcs as arc} + + {/each} + {/snippet} + + + +
    + + +
    + {#each pieData as item} +
    +
    + {item.label} + {formatBytes(item.value)} +
    + {/each} +
    +
    + {:else} + +
    + {#if imagesSize > 0} +
    + {/if} + {#if containersSize > 0} +
    + {/if} + {#if volumesSize > 0} +
    + {/if} + {#if buildCacheSize > 0} +
    + {/if} +
    + + +
    + {#if imagesSize > 0} +
    +
    + + Images + {formatBytes(imagesSize)} +
    + {/if} + {#if containersSize > 0} +
    +
    + + Containers + {formatBytes(containersSize)} +
    + {/if} + {#if volumesSize > 0} +
    +
    + + Volumes + {formatBytes(volumesSize)} +
    + {/if} + {#if buildCacheSize > 0} +
    +
    + + Build cache + {formatBytes(buildCacheSize)} +
    + {/if} +
    + {/if} +
    +{/if} + + diff --git a/routes/dashboard/dashboard-events-summary.svelte b/routes/dashboard/dashboard-events-summary.svelte new file mode 100644 index 0000000..d502a3c --- /dev/null +++ b/routes/dashboard/dashboard-events-summary.svelte @@ -0,0 +1,21 @@ + + +{#if total > 0} +
    + + Events + + + {today} today / {total} total + +
    +{/if} diff --git a/routes/dashboard/dashboard-header.svelte b/routes/dashboard/dashboard-header.svelte new file mode 100644 index 0000000..c0ef83a --- /dev/null +++ b/routes/dashboard/dashboard-header.svelte @@ -0,0 +1,174 @@ + + +{#if compact} + +
    +
    + +
    +
    +
    + {name} + {#if online} + + {:else} + + {/if} +
    + {hostDisplay} +
    +
    +{:else} + +
    +
    +
    + +
    + {#if connectionType === 'socket' || !connectionType} + + + + {:else if connectionType === 'direct'} + + + + {:else if connectionType === 'hawser-standard'} + + + + {:else if connectionType === 'hawser-edge'} + + + + {/if} +
    +
    + {name} + {#if online} + + {:else} + + {/if} +
    + {hostDisplay} +
    +
    + +
    + {#if updateCheckEnabled} + + {#if updateCheckAutoUpdate} + + {:else} + + {/if} + + {/if} + {#if scannerEnabled} + + + + {/if} + {#if collectActivity} + + + + {/if} + {#if collectMetrics} + + + + {/if} + {#if canEdit} + + {/if} +
    +
    +{/if} diff --git a/routes/dashboard/dashboard-health-banner.svelte b/routes/dashboard/dashboard-health-banner.svelte new file mode 100644 index 0000000..aa228e8 --- /dev/null +++ b/routes/dashboard/dashboard-health-banner.svelte @@ -0,0 +1,27 @@ + + +
    + {#if unhealthy > 0} + + {unhealthy} unhealthy + {:else if restarting > 0} + + {restarting} restarting + {:else} + + All containers healthy + {/if} +
    diff --git a/routes/dashboard/dashboard-labels.svelte b/routes/dashboard/dashboard-labels.svelte new file mode 100644 index 0000000..7d387f9 --- /dev/null +++ b/routes/dashboard/dashboard-labels.svelte @@ -0,0 +1,45 @@ + + +{#if labels && labels.length > 0} + {#if compact} +
    + {#each labels as label} + {@const colors = getLabelColors(label)} + + {label} + + {/each} +
    + {:else if unified} +
    + {#each labels as label} + {@const colors = getLabelColors(label)} + + {label} + + {/each} +
    + {:else} +
    + {#each labels as label} + {@const colors = getLabelColors(label)} + + {label} + + {/each} +
    + {/if} +{/if} diff --git a/routes/dashboard/dashboard-offline-state.svelte b/routes/dashboard/dashboard-offline-state.svelte new file mode 100644 index 0000000..9ddd632 --- /dev/null +++ b/routes/dashboard/dashboard-offline-state.svelte @@ -0,0 +1,25 @@ + + +{#if compact} +
    + + Offline +
    +{:else} +
    + + Environment offline + {#if error} + {error} + {/if} +
    +{/if} diff --git a/routes/dashboard/dashboard-recent-events.svelte b/routes/dashboard/dashboard-recent-events.svelte new file mode 100644 index 0000000..e4dc4ab --- /dev/null +++ b/routes/dashboard/dashboard-recent-events.svelte @@ -0,0 +1,133 @@ + + +{#if events && events.length > 0} + + +
    { + if (onclick) { + e.stopPropagation(); + onclick(); + } + }} + onpointerdown={(e) => { + if (onclick) { + e.stopPropagation(); + } + }} + > +
    + + Recent events +
    + +
    + {#each events.slice(0, limit) as event} + + + {formatTime(event.timestamp)} + + +
    + +
    + + + {event.container_name} + + {/each} +
    +
    +{/if} diff --git a/routes/dashboard/dashboard-resource-stats.svelte b/routes/dashboard/dashboard-resource-stats.svelte new file mode 100644 index 0000000..5dd3457 --- /dev/null +++ b/routes/dashboard/dashboard-resource-stats.svelte @@ -0,0 +1,87 @@ + + +
    +
    + + Images + + {#if showImagesSkeleton} +
    + {:else} + {images.total} + {/if} +
    +
    + + Stacks + + {#if showStacksSkeleton} +
    + {:else} + + {stacks.total} + {#if stacks.total > 0} + {stacks.running}/{stacks.partial}/{stacks.stopped} + {/if} + + {/if} +
    +
    + + Volumes + + {#if showVolumesSkeleton} +
    + {:else} + {volumes.total} + {/if} +
    +
    + + Networks + + {#if showNetworksSkeleton} +
    + {:else} + {networks.total} + {/if} +
    +
    + + diff --git a/routes/dashboard/dashboard-status-icons.svelte b/routes/dashboard/dashboard-status-icons.svelte new file mode 100644 index 0000000..7b7a409 --- /dev/null +++ b/routes/dashboard/dashboard-status-icons.svelte @@ -0,0 +1,47 @@ + + +
    + {#if updateCheckEnabled} + + {#if updateCheckAutoUpdate} + + {:else} + + {/if} + + {/if} + {#if scannerEnabled} + + + + {/if} + {#if collectActivity} + + + + {/if} + {#if !online && compact} + + + + {/if} +
    diff --git a/routes/dashboard/dashboard-top-containers.svelte b/routes/dashboard/dashboard-top-containers.svelte new file mode 100644 index 0000000..aa3e218 --- /dev/null +++ b/routes/dashboard/dashboard-top-containers.svelte @@ -0,0 +1,86 @@ + + +{#if showSkeleton} +
    +
    + + Top containers by CPU + +
    + +
    + {#each Array(Math.min(limit, 5)) as _, i} +
    +
    + +
    +
    +
    + +
    +
    + {/each} +
    +
    +{:else if containers && containers.length > 0} +
    +
    + + Top containers by CPU +
    + +
    + {#each containers.slice(0, limit) as container} + + + {container.name} + + + + + {container.cpuPercent.toFixed(1)}% + + + + + {container.memoryPercent.toFixed(1)}% + + {/each} +
    +
    +{:else} +
    + No running containers +
    +{/if} + + diff --git a/routes/dashboard/index.ts b/routes/dashboard/index.ts new file mode 100644 index 0000000..f49ca3d --- /dev/null +++ b/routes/dashboard/index.ts @@ -0,0 +1,14 @@ +// Dashboard tile section components +export { default as DashboardHeader } from './dashboard-header.svelte'; +export { default as DashboardLabels } from './dashboard-labels.svelte'; +export { default as DashboardContainerStats } from './dashboard-container-stats.svelte'; +export { default as DashboardHealthBanner } from './dashboard-health-banner.svelte'; +export { default as DashboardCpuMemoryBars } from './dashboard-cpu-memory-bars.svelte'; +export { default as DashboardResourceStats } from './dashboard-resource-stats.svelte'; +export { default as DashboardEventsSummary } from './dashboard-events-summary.svelte'; +export { default as DashboardRecentEvents } from './dashboard-recent-events.svelte'; +export { default as DashboardTopContainers } from './dashboard-top-containers.svelte'; +export { default as DashboardDiskUsage } from './dashboard-disk-usage.svelte'; +export { default as DashboardCpuMemoryCharts } from './dashboard-cpu-memory-charts.svelte'; +export { default as DashboardOfflineState } from './dashboard-offline-state.svelte'; +export { default as DashboardStatusIcons } from './dashboard-status-icons.svelte'; diff --git a/routes/environments/+page.svelte b/routes/environments/+page.svelte new file mode 100644 index 0000000..7c677c3 --- /dev/null +++ b/routes/environments/+page.svelte @@ -0,0 +1,592 @@ + + +
    +
    + + {environments.length} total + +
    + + +
    +
    + + {#if loading && environments.length === 0} +

    Loading environments...

    + {:else if environments.length === 0} +

    No environments found

    + {:else} +
    + {#each environments as env (env.id)} + {@const testResult = testResults[env.id]} + + +
    +
    + + {env.name} +
    +
    +
    + +
    + {#if env.connectionType === 'socket' || !env.connectionType} + {env.socketPath || '/var/run/docker.sock'} + {:else if env.connectionType === 'hawser-edge'} + Edge connection (outbound) + {:else} + {env.protocol || 'http'}://{env.host}:{env.port || 2375} + {/if} +
    + + {#if testResult} +
    + {#if testResult === 'testing'} +
    + + Testing connection... +
    + {:else if testResult.success} +
    + + Connected +
    + {#if testResult.info} +
    +
    Host: {testResult.info.name}
    +
    Docker: {testResult.info.serverVersion}
    +
    Containers: {testResult.info.containers} | Images: {testResult.info.images}
    +
    + {/if} + {:else} +
    + + Failed +
    + {#if testResult.error} +
    {testResult.error}
    + {/if} + {/if} +
    + {/if} + +
    + + + +
    +
    +
    + {/each} +
    + {/if} +
    + + + + + + Add environment + +
    + {#if formError} +
    {formError}
    + {/if} + + +
    + +
    + + +
    +

    + Paste a full Docker URL to auto-fill the fields below +

    +
    + +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + + + {#if formProtocol === 'https'} + + {:else} + + {/if} + {formProtocol === 'https' ? 'HTTPS (TLS)' : 'HTTP'} + + + + + HTTP + + + + HTTPS (TLS) + + + +
    +
    + {#if formProtocol === 'https'} +
    +

    TLS certificates (optional)

    +
    + + +
    +
    + + +
    +
    + + +
    +
    + {/if} +
    + + + + +
    +
    + + + + + + Edit environment + +
    + {#if formError} +
    {formError}
    + {/if} + + +
    + +
    + + +
    +

    + Paste a full Docker URL to auto-fill the fields below +

    +
    + +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + + + {#if formProtocol === 'https'} + + {:else} + + {/if} + {formProtocol === 'https' ? 'HTTPS (TLS)' : 'HTTP'} + + + + + HTTP + + + + HTTPS (TLS) + + + +
    +
    + {#if formProtocol === 'https'} +
    +

    TLS certificates (optional)

    +
    + + +
    +
    + + +
    +
    + + +
    +
    + {/if} +
    + + + + +
    +
    diff --git a/routes/images/+page.server.ts b/routes/images/+page.server.ts new file mode 100644 index 0000000..7f66ca6 --- /dev/null +++ b/routes/images/+page.server.ts @@ -0,0 +1,9 @@ +import type { PageServerLoad } from './$types'; +import { getScannerSettings } from '$lib/server/scanner'; + +export const load: PageServerLoad = async () => { + const { scanner } = await getScannerSettings(); + return { + scannerEnabled: scanner !== 'none' + }; +}; diff --git a/routes/images/+page.svelte b/routes/images/+page.svelte new file mode 100644 index 0000000..fd428c6 --- /dev/null +++ b/routes/images/+page.svelte @@ -0,0 +1,1044 @@ + + +
    +
    + +
    +
    + + e.key === 'Escape' && (searchQuery = '')} + class="pl-8 h-8 w-48 text-sm" + /> +
    + {#if $canAccess('images', 'remove')} + confirmPrune = open} + > + {#snippet children({ open })} + + {#if pruneStatus === 'pruning'} + + {:else if pruneStatus === 'success'} + + {:else if pruneStatus === 'error'} + + {:else} + + {/if} + Prune + + {/snippet} + + {/if} + +
    +
    + + + {#if selectedImages.size > 0} +
    + {selectedInFilter.length} selected + + {#if $canAccess('images', 'remove')} + + {/if} +
    + {/if} + + {#if !loading && ($environments.length === 0 || !$currentEnvironment)} + + {:else if !loading && images.length === 0} + + {:else} + { + sortField = state.field as SortField; + sortDirection = state.direction; + }} + onRowClick={(group) => toggleRepo(group.repoName)} + rowClass={(group) => { + const isExp = expandedRepos.has(group.repoName); + return isExp ? 'bg-muted/40' : ''; + }} + > + {#snippet headerCell(column, sortState)} + {#if column.id === 'select'} + {@const allImageIds = sortedGroups.flatMap(g => Array.from(g.imageIds))} + {@const allSelected = allImageIds.length > 0 && allImageIds.every(id => selectedImages.has(id))} + {@const someSelected = allImageIds.some(id => selectedImages.has(id)) && !allSelected} + + {/if} + {/snippet} + {#snippet cell(column, group, rowState)} + {#if column.id === 'select'} + + + {:else if column.id === 'expand'} + {@const hasMultipleTags = group.tags.length > 1} + {#if hasMultipleTags} + {#if rowState.isExpanded} + + {:else} + + {/if} + {/if} + {:else if column.id === 'image'} +
    + + {group.repoName === '' ? '' : group.repoName} + + {#if group.tags.length === 1} + + {group.tags[0].tag} + + {/if} +
    + {:else if column.id === 'tags'} + + {group.tags.length} + + {:else if column.id === 'size'} + {formatSize(group.totalSize)} + {:else if column.id === 'updated'} + {formatImageDate(group.latestCreated)} + {:else if column.id === 'actions'} + + {#if !rowState.isExpanded && group.tags.length > 0} + {@const firstTag = group.tags[0]} + +
    e.stopPropagation()}> + {#if $canAccess('containers', 'create')} + + {/if} + {#if scannerEnabled && $canAccess('images', 'inspect')} + + {/if} + {#if $canAccess('images', 'push')} + + {/if} +
    + {/if} + {/if} + {/snippet} + {#snippet expandedRow(group, rowState)} +
    + + {#snippet cell(column, tagInfo, rowState)} + {#if column.id === 'tag'} +
    + + {tagInfo.tag} +
    + {:else if column.id === 'id'} + + {:else if column.id === 'size'} + {formatSize(tagInfo.size)} + {:else if column.id === 'created'} + {formatImageDate(tagInfo.created)} + {:else if column.id === 'actions'} +
    + {#if $canAccess('images', 'inspect')} + + {/if} + {#if $canAccess('containers', 'create')} + + {/if} + {#if scannerEnabled && $canAccess('images', 'inspect')} + + {/if} + {#if $canAccess('images', 'push')} + + {/if} + {#if $canAccess('images', 'inspect')} + + {/if} + {#if $canAccess('images', 'build')} + + {/if} + {#if $canAccess('images', 'remove')} +
    + removeImage(tagInfo.fullRef, tagInfo.fullRef)} + onOpenChange={(open) => confirmDeleteId = open ? tagInfo.fullRef : null} + > + {#snippet children({ open })} + + {/snippet} + +
    + {/if} +
    + {/if} + {/snippet} +
    +
    + {/snippet} +
    + {/if} +
    + + +{#if pushingImage} + +{/if} + + + + + + showRunModal = false} + onSuccess={() => showRunModal = false} + {prefilledImage} + skipPullTab={true} +/> + + + + + + showBatchOpModal = false} + onComplete={handleBatchComplete} +/> + + + + + + + + Tag image + + + Add a new tag to {tagImageCurrentName} + + +
    +
    + + +
    +
    + + { + if (e.key === 'Enter' && !tagging && tagNewRepo.trim()) { + tagImage(); + } + }} + /> +
    +
    + + + + +
    +
    diff --git a/routes/images/ImageHistoryModal.svelte b/routes/images/ImageHistoryModal.svelte new file mode 100644 index 0000000..778977c --- /dev/null +++ b/routes/images/ImageHistoryModal.svelte @@ -0,0 +1,37 @@ + + + + + + + + Image layers: {imageName || imageId.slice(7, 19)} + + + +
    + +
    + + + + +
    +
    diff --git a/routes/images/ImageLayersView.svelte b/routes/images/ImageLayersView.svelte new file mode 100644 index 0000000..3d49e75 --- /dev/null +++ b/routes/images/ImageLayersView.svelte @@ -0,0 +1,288 @@ + + +
    + {#if loading && history.length === 0} +
    + +
    + {:else if error} +
    + {error} +
    + {:else if history.length === 0} +

    No layer history found

    + {:else} + +
    +
    +

    Total layers: {history.length}

    +

    Total size: {formatSize(totalSize)}

    +
    + + {imageId.startsWith('sha256:') ? imageId.slice(7, 19) : imageName || imageId} + +
    + + +
    +

    + + Layer stack (bottom to top) - click to expand +

    +
    + {#each history.slice().reverse() as layer, index} + {@const layerNum = history.length - index} + {@const barWidth = getBarWidth(layer.Size)} + {@const barColor = getBarColor(history.length - index - 1)} + {@const isExpanded = expandedLayers.has(layerNum)} +
    + + + + + {#if isExpanded} +
    +
    +
    +

    Created

    +

    {formatDate(layer.Created)}

    +
    +
    +

    Size

    +

    {formatSize(layer.Size)}

    +
    +
    + + {#if layer.CreatedBy} +
    +

    Command

    + + {@html highlightCommand(layer.CreatedBy)} + +
    + {/if} + + {#if layer.Comment} +
    +

    Comment

    +

    {layer.Comment}

    +
    + {/if} + + {#if layer.Tags && layer.Tags.length > 0} +
    +

    Tags

    +
    + {#each layer.Tags as tag} + {tag} + {/each} +
    +
    + {/if} +
    + {/if} +
    + {/each} +
    +
    + {/if} +
    diff --git a/routes/images/ImagePullProgressPopover.svelte b/routes/images/ImagePullProgressPopover.svelte new file mode 100644 index 0000000..c3cb4aa --- /dev/null +++ b/routes/images/ImagePullProgressPopover.svelte @@ -0,0 +1,369 @@ + + + + + {@render children()} + + + +
    +
    + + {displayImageName} +
    + + +
    +
    + {#if overallStatus === 'idle'} + + Initializing... + {:else if overallStatus === 'pulling'} + + Pulling... + {:else if overallStatus === 'complete'} + + Complete! + {:else if overallStatus === 'error'} + + Failed + {/if} +
    + {#if totalLayers > 0} + + {completedLayers}/{totalLayers} + + {/if} +
    + + {#if statusMessage && (overallStatus === 'pulling' || overallStatus === 'complete')} +

    {statusMessage}

    + {/if} + + {#if totalLayers > 0} + + {/if} + + {#if errorMessage} +
    + + {errorMessage} +
    + {/if} +
    + + + {#if sortedLayers.length > 0} +
    +
    + {#each sortedLayers as layer (layer.id)} + {@const StatusIcon = getStatusIcon(layer.status)} + {@const percentage = getProgressPercentage(layer)} + {@const statusLower = layer.status.toLowerCase()} + {@const isDownloading = statusLower.includes('downloading')} + {@const isExtracting = statusLower.includes('extracting')} +
    + + {layer.id.slice(0, 12)} +
    + {#if (isDownloading || isExtracting) && layer.current && layer.total} +
    +
    +
    +
    + {percentage}% +
    + {:else} + + {layer.status} + + {/if} +
    +
    + {/each} +
    +
    + {:else if overallStatus === 'complete'} +
    +

    Image is up to date

    +
    + {/if} + + + {#if overallStatus === 'complete' || overallStatus === 'error'} +
    + +
    + {/if} +
    +
    + + + diff --git a/routes/images/ImageScanModal.svelte b/routes/images/ImageScanModal.svelte new file mode 100644 index 0000000..60f4f3c --- /dev/null +++ b/routes/images/ImageScanModal.svelte @@ -0,0 +1,285 @@ + + + + + + + {#if scanStatus === 'complete' && scanResults.length > 0} + {#if hasCriticalOrHigh} + + {:else if totalVulnerabilities > 0} + + {:else} + + {/if} + {:else if scanStatus === 'complete'} + + {:else if scanStatus === 'error'} + + {:else} + + {/if} + Vulnerability scan + {imageName} + + + +
    + +
    + + +
    + {#if scanStatus === 'error'} + + {/if} + {#if scanStatus === 'complete' && scanResults.length > 0 && totalVulnerabilities > 0} + + + {#snippet child({ props })} + + {/snippet} + + + + + Markdown report (.md) + + + + CSV spreadsheet (.csv) + + + + JSON data (.json) + + + + {/if} +
    + +
    +
    +
    diff --git a/routes/images/PushToRegistryModal.svelte b/routes/images/PushToRegistryModal.svelte new file mode 100644 index 0000000..9d4b287 --- /dev/null +++ b/routes/images/PushToRegistryModal.svelte @@ -0,0 +1,292 @@ + + + + + + + {#if pushStatus === 'complete'} + + {:else if pushStatus === 'error'} + + {:else} + + {/if} + Push to registry + {imageName} + + + + +
    + + + +
    + +
    + +
    +
    + +
    + {imageName} +
    +
    + +
    + + targetRegistryId = Number(v)}> + + {#if targetRegistry} + {#if isDockerHub(targetRegistry)} + + {:else} + + {/if} + {targetRegistry.name}{targetRegistry.hasCredentials ? ' (auth)' : ''} + {:else} + Select registry + {/if} + + + {#each pushableRegistries as registry} + + {#if isDockerHub(registry)} + + {:else} + + {/if} + {registry.name} + {#if registry.hasCredentials} + auth + {/if} + + {/each} + + + {#if pushableRegistries.length === 0} +

    No target registries available. Add a private registry in Settings.

    + {/if} +
    + +
    + + +

    + Will be pushed as: {targetImageName()} +

    +
    +
    + + +
    + +
    +
    + + +
    + {#if currentStep === 'push' && pushStatus === 'error'} + + {/if} +
    +
    + + {#if currentStep === 'configure'} + + {/if} +
    +
    +
    +
    diff --git a/routes/images/ScanResultsView.svelte b/routes/images/ScanResultsView.svelte new file mode 100644 index 0000000..9c87eca --- /dev/null +++ b/routes/images/ScanResultsView.svelte @@ -0,0 +1,214 @@ + + +{#if results.length === 0} +
    No scan results available
    +{:else} +
    + + {#if results.length > 1} +
    + {#each results as r} + + {/each} +
    + {/if} + + {#if activeResult} + +
    + {#if activeResult.summary.critical > 0} + + {activeResult.summary.critical} Critical + + {/if} + {#if activeResult.summary.high > 0} + + {activeResult.summary.high} High + + {/if} + {#if activeResult.summary.medium > 0} + + {activeResult.summary.medium} Medium + + {/if} + {#if activeResult.summary.low > 0} + + {activeResult.summary.low} Low + + {/if} + {#if activeResult.summary.negligible > 0} + + {activeResult.summary.negligible} Negligible + + {/if} + {#if activeResult.summary.unknown > 0} + + {activeResult.summary.unknown} Unknown + + {/if} + {#if activeResult.vulnerabilities.length === 0} + + + No vulnerabilities + + {/if} + + {activeResult.scanner === 'grype' ? 'Grype' : 'Trivy'} • {activeResult.vulnerabilities.length} total + {#if activeResult.scanDuration}• {formatDuration(activeResult.scanDuration)}{/if} + +
    + + + {#if activeResult.vulnerabilities.length > 0 && !compact} +
    + + + + + + + + + + + + {#each activeResult.vulnerabilities as vuln, i} + toggleVulnDetails(vuln.id + i)} + > + + + + + + + {#if expandedVulns.has(vuln.id + i) && vuln.description} + + + + {/if} + {/each} + +
    CVE IDSeverityPackageInstalledFixed in
    + + + + {vuln.severity} + + + {vuln.package} + + {vuln.version} + + {#if vuln.fixedVersion} + {vuln.fixedVersion} + {:else} + - + {/if} +
    +

    {vuln.description}

    +
    +
    + {/if} + {/if} +
    +{/if} diff --git a/routes/images/VulnerabilityScanModal.svelte b/routes/images/VulnerabilityScanModal.svelte new file mode 100644 index 0000000..8406ecf --- /dev/null +++ b/routes/images/VulnerabilityScanModal.svelte @@ -0,0 +1,756 @@ + + + + + + + {#if stage === 'complete' && activeResult} + {#if activeResult.summary.critical > 0 || activeResult.summary.high > 0} + + {:else if activeResult.summary.medium > 0} + + {:else} + + {/if} + {:else if stage === 'error'} + + {:else} + + {/if} + Vulnerability scan + + +
    Scanning {imageName}
    + {#if activeResult?.imageId} +
    SHA: {activeResult.imageId.replace('sha256:', '')}
    + {/if} +
    +
    + +
    + {#if stage !== 'complete' && stage !== 'error'} + +
    +
    + + {message} +
    +
    +
    +
    + {#if scanner} +

    + Using {scanner === 'grype' ? 'Grype (Anchore)' : 'Trivy (Aqua Security)'} scanner +

    + {/if} + + +
    +
    +
    + + Scanner output +
    + +
    +
    + {#each activeOutputLines as line} +
    + {#if line.startsWith('[grype]')} + grype + {line.slice(8)} + {:else if line.startsWith('[trivy]')} + trivy + {line.slice(8)} + {:else if line.startsWith('[dockhand]')} + dockhand + {line.slice(11)} + {:else} + {line} + {/if} +
    + {/each} +
    +
    +
    + {:else if stage === 'error'} + +
    + + {#if Object.keys(scannerErrors).length > 0} +
    + {#each Object.entries(scannerErrors) as [scannerName, scannerError]} +
    +
    + +
    +

    {scannerName === 'grype' ? 'Grype' : 'Trivy'} failed

    +

    {scannerError}

    +
    +
    +
    + {/each} +
    + {:else} +
    +
    + +
    +

    Scan failed

    +

    {error}

    +
    +
    +
    + {/if} + + + + +
    +
    +
    + + Scanner output +
    + +
    +
    + {#each activeOutputLines as line} +
    + {#if line.startsWith('[grype]')} + grype + {line.slice(8)} + {:else if line.startsWith('[trivy]')} + trivy + {line.slice(8)} + {:else if line.startsWith('[dockhand]')} + dockhand + {line.slice(11)} + {:else} + {line} + {/if} +
    + {/each} +
    +
    +
    + {:else if stage === 'complete' && activeResult} + +
    + + {#if results.length > 1} +
    + {#each results as r} + + {/each} +
    + {/if} + + + {#if Object.keys(scannerErrors).length > 0} +
    + {#each Object.entries(scannerErrors) as [scannerName, scannerError]} +
    +
    + +
    + {scannerName === 'grype' ? 'Grype' : 'Trivy'} failed: + {scannerError} +
    +
    +
    + {/each} +
    + {/if} + + +
    + {#if activeResult.summary.critical > 0} + + {activeResult.summary.critical} Critical + + {/if} + {#if activeResult.summary.high > 0} + + {activeResult.summary.high} High + + {/if} + {#if activeResult.summary.medium > 0} + + {activeResult.summary.medium} Medium + + {/if} + {#if activeResult.summary.low > 0} + + {activeResult.summary.low} Low + + {/if} + {#if activeResult.summary.negligible > 0} + + {activeResult.summary.negligible} Negligible + + {/if} + {#if activeResult.summary.unknown > 0} + + {activeResult.summary.unknown} Unknown + + {/if} + {#if activeResult.vulnerabilities.length === 0} + + + No vulnerabilities found + + {/if} +
    + + +
    + Scanner: {activeResult.scanner === 'grype' ? 'Grype' : 'Trivy'} + Duration: {formatDuration(activeResult.scanDuration)} + Total: {activeResult.vulnerabilities.length} vulnerabilities +
    + + + {#if activeResult.vulnerabilities.length > 0} +
    + + + + + + + + + + + + {#each activeResult.vulnerabilities.slice(0, 100) as vuln, i} + toggleVulnDetails(vuln.id + i)} + > + + + + + + + {#if expandedVulns.has(vuln.id + i) && vuln.description} + + + + {/if} + {/each} + +
    CVE IDSeverityPackageInstalledFixed in
    + + + + {vuln.severity} + + + {vuln.package} + + {vuln.version} + + {#if vuln.fixedVersion} + {vuln.fixedVersion} + {:else} + No fix available + {/if} +
    +

    {vuln.description}

    +
    + {#if activeResult.vulnerabilities.length > 100} +
    + Showing 100 of {activeResult.vulnerabilities.length} vulnerabilities +
    + {/if} +
    + {/if} + + +
    +
    +
    + + Scanner output ({activeOutputLines.length} lines) +
    + +
    +
    + {#each activeOutputLines as line} +
    + {#if line.startsWith('[grype]')} + grype + {line.slice(8)} + {:else if line.startsWith('[trivy]')} + trivy + {line.slice(8)} + {:else if line.startsWith('[dockhand]')} + dockhand + {line.slice(11)} + {:else} + {line} + {/if} +
    + {/each} +
    +
    +
    + {/if} +
    + + + {#if stage === 'complete'} +
    + + {#if activeResult && activeResult.vulnerabilities.length > 0} + + + {#snippet child({ props })} + + {/snippet} + + + + + Markdown report (.md) + + + + CSV spreadsheet (.csv) + + + + JSON data (.json) + + + + {/if} +
    + {:else} +
    + {/if} + +
    +
    +
    diff --git a/routes/login/+page.svelte b/routes/login/+page.svelte new file mode 100644 index 0000000..039aafb --- /dev/null +++ b/routes/login/+page.svelte @@ -0,0 +1,320 @@ + + + + Login - Dockhand + + +
    + + +
    + Dockhand Logo + +
    + Welcome back + + {#if requiresMfa} + Enter your two-factor authentication code + {:else} + Sign in to your Dockhand account + {/if} + +
    + + + {#if error} + + + {error} + + {/if} + + + {#if hasOidcProviders && !requiresMfa} +
    + {#each oidcProviders as provider} + + {/each} +
    + + {#if hasCredentialProviders} +
    +
    + +
    +
    + or continue with +
    +
    + {/if} + {/if} + + {#if hasCredentialProviders} +
    + {#if !requiresMfa} + {#if credentialProviders.length > 1} +
    + +
    + {#each credentialProviders as provider} + {@const Icon = getProviderIcon(provider.type)} + + {/each} +
    +
    + {/if} + +
    + + +
    + +
    + + +
    + {:else} +
    +
    + + Two-factor authentication required +
    + + +

    + Enter the code from your authenticator app +

    +
    + {/if} + + + + {#if requiresMfa} + + {/if} +
    + {/if} +
    + + +

    Dockhand Docker Management

    +
    +
    +
    diff --git a/routes/logs/+page.svelte b/routes/logs/+page.svelte new file mode 100644 index 0000000..bdfe0c6 --- /dev/null +++ b/routes/logs/+page.svelte @@ -0,0 +1,2190 @@ + + +{#if $environments.length === 0 || !$currentEnvironment} +
    + + +
    +{:else} +
    + +
    + + + + {#if layoutMode === 'single'} +
    + +
    + + + +
    + + + {#if dropdownOpen} +
    + {#if filteredContainers().length === 0} +
    + {containers.length === 0 ? 'No containers' : 'No matches found'} +
    + {:else} + {#each filteredContainers() as container} + {@const isCurrentSelection = selectedContainer?.id === container.id} + + {/each} + {/if} +
    + {/if} +
    + {:else if layoutMode === 'multi'} + +
    + {:else} + +
    + {/if} +
    + + +
    + + {#if layoutMode === 'multi' || layoutMode === 'grouped'} +
    +
    +
    + + +
    +
    + {#if layoutMode === 'grouped'} + +
    + + | + +
    + {/if} +
    + {#if layoutMode === 'grouped'} + {@const validFavoriteGroups = favoriteGroups.filter(g => g?.name && g?.containers)} + {#if validFavoriteGroups.length > 0} + +
    +
    + + Saved groups +
    + {#each validFavoriteGroups as savedGroup, idx (savedGroup.name || `group-${idx}`)} +
    loadFavoriteGroup(savedGroup)} + onkeydown={(e) => e.key === 'Enter' && loadFavoriteGroup(savedGroup)} + role="button" + tabindex="0" + > + +
    +
    {savedGroup.name}
    +
    {savedGroup.containers.length} container{savedGroup.containers.length !== 1 ? 's' : ''}
    +
    + +
    + {/each} +
    + {/if} + {/if} + {#if filteredContainers().length === 0} +
    + {containers.length === 0 ? 'No containers' : 'No matches found'} +
    + {:else} + + {#if layoutMode === 'multi' && favoriteContainers().length > 0} +
    +
    + + Favorites +
    + {#each favoriteContainers() as container} + {@const isMultiSelected = multiModeSelections.has(container.id)} +
    selectContainer(container)} + onkeydown={(e) => e.key === 'Enter' && selectContainer(container)} + role="button" + tabindex="0" + draggable="true" + ondragstart={(e) => handleDragStart(e, container.name)} + ondragover={(e) => handleDragOver(e, container.name)} + ondragleave={handleDragLeave} + ondragend={handleDragEnd} + ondrop={(e) => handleDrop(e, container.name)} + > + + + + +
    +
    {container.name}
    +
    {container.image}
    +
    + +
    + {/each} +
    + {/if} + + {#if layoutMode === 'grouped' && favoriteContainers().length > 0} +
    +
    + + Favorites +
    + {#each favoriteContainers() as container} + {@const isSelected = selectedContainerIds.has(container.id)} + {@const containerColor = groupedContainerInfo.get(container.id)?.color} +
    toggleContainerSelection(container.id)} + onkeydown={(e) => e.key === 'Enter' && toggleContainerSelection(container.id)} + role="button" + tabindex="0" + > +
    + {#if isSelected} +
    + +
    + {:else} +
    + {/if} +
    + +
    +
    {container.name}
    +
    {container.image}
    +
    + +
    + {/each} +
    + {/if} + + {#if layoutMode === 'multi' ? nonFavoriteContainers().length > 0 : (layoutMode === 'grouped' ? nonFavoriteContainers().length > 0 : filteredContainers().length > 0)} + {#if (layoutMode === 'multi' || layoutMode === 'grouped') && favoriteContainers().length > 0} +
    + All containers +
    + {/if} + {#each layoutMode === 'multi' || layoutMode === 'grouped' ? nonFavoriteContainers() : filteredContainers() as container} + {@const isSelected = layoutMode === 'grouped' ? selectedContainerIds.has(container.id) : selectedContainer?.id === container.id} + {@const isMultiSelected = multiModeSelections.has(container.id)} + {@const containerColor = groupedContainerInfo.get(container.id)?.color} +
    layoutMode === 'grouped' ? toggleContainerSelection(container.id) : selectContainer(container)} + onkeydown={(e) => e.key === 'Enter' && (layoutMode === 'grouped' ? toggleContainerSelection(container.id) : selectContainer(container))} + role="button" + tabindex="0" + > + {#if layoutMode === 'grouped'} +
    + {#if isSelected} +
    + +
    + {:else} +
    + {/if} +
    + {:else if layoutMode === 'multi'} + + + {/if} + +
    +
    {container.name}
    +
    {container.image}
    +
    + {#if layoutMode === 'multi'} + + {/if} +
    + {/each} + {/if} + {/if} +
    + {#if layoutMode === 'grouped'} + +
    +
    + {selectedContainerIds.size} selected + {containers.length} total +
    + {#if selectedContainerIds.size > 0} + + {#if showSaveGroupInput} +
    + e.key === 'Enter' && saveCurrentGroup()} + use:focusOnMount + class="h-6 text-xs flex-1 px-2 rounded border border-input bg-background focus:outline-none focus:ring-1 focus:ring-ring" + /> + + +
    + {:else} + + {/if} + {/if} +
    + {:else} + +
    +
    + {#if multiModeSelections.size > 0} + {multiModeSelections.size} selected + + {:else} + {containers.length} container{containers.length !== 1 ? 's' : ''} + {/if} +
    + {#if multiModeSelections.size >= 2} + + {/if} +
    + {/if} +
    + {/if} + + +
    + {#if layoutMode === 'grouped'} + {#if selectedContainerIds.size === 0} +
    + Select containers from the list to view merged logs +
    + {:else} + +
    +
    + {#if streamingEnabled} + {#if isConnected} +
    + + Live +
    + {:else if loading} +
    + + Connecting... +
    + {:else if connectionError} + + {/if} + {:else} +
    + + Paused +
    + {/if} + +
    + {#if stackName} + {stackName} + {:else if groupedContainerInfo.size === 1} + {@const singleContainer = Array.from(groupedContainerInfo.values())[0]} +
    +
    + {singleContainer.name} +
    + {/if} + {#if stackName || groupedContainerInfo.size > 1} + {#each Array.from(groupedContainerInfo.entries()) as [id, info]} +
    +
    +
    + {/each} + {/if} +
    +
    +
    + + + { fontSize = Number(v); saveState(); }}> + + {fontSize}px + + + {#each fontSizeOptions as size} + {size}px + {/each} + + + + + {#if logSearchActive} +
    + + + {#if matchCount > 0} + {currentMatchIndex + 1}/{matchCount} + {:else if logSearchQuery} + 0/0 + {/if} + + + +
    + {:else} + + {/if} + + +
    +
    +
    + {#if loading && mergedLogs.length === 0} +
    + +
    + {:else if loading} +
    + +
    + {/if} +
    + {#each formattedMergedLogs() as log} +
    + [{log.containerName}] + {@html log.formattedText} +
    + {/each} +
    +
    + {/if} + {:else if !selectedContainer} +
    + {layoutMode === 'multi' ? 'Select a container from the list' : 'Select a container to view logs'} +
    + {:else} + +
    +
    + + {#if streamingEnabled} + {#if isConnected} +
    + + Live +
    + {:else if loading} +
    + + Connecting... +
    + {:else if connectionError} + + {:else} +
    + + Offline +
    + {/if} + {:else} +
    + + Paused +
    + {/if} + + {#if selectedContainer} +
    + {selectedContainer.name} +
    + {/if} +
    +
    + + + + { fontSize = Number(v); saveState(); }}> + + {fontSize}px + + + {#each fontSizeOptions as size} + {size}px + {/each} + + + + + + {#if logSearchActive} +
    + + + {#if matchCount > 0} + {currentMatchIndex + 1}/{matchCount} + {:else if logSearchQuery} + 0/0 + {/if} + + + +
    + {:else} + + {/if} + + + +
    +
    + {#if loading && !logs} +
    + + Loading logs... +
    + {:else} +
    +
    {@html highlightedLogs()}
    +
    + {/if} + {/if} +
    +
    +
    +{/if} + + diff --git a/routes/logs/LogViewer.svelte b/routes/logs/LogViewer.svelte new file mode 100644 index 0000000..fa06f3f --- /dev/null +++ b/routes/logs/LogViewer.svelte @@ -0,0 +1,321 @@ + + +
    + +
    +
    + {#if loading} + + {/if} +
    +
    + + + + + + fontSize = Number(v)}> + + + {fontSize}px + + + {#each fontSizeOptions as size} + + + {size}px + + {/each} + + + + + + {#if logSearchActive} +
    + + + {#if matchCount > 0} + {currentMatchIndex + 1}/{matchCount} + {:else if logSearchQuery} + 0/0 + {/if} + + + +
    + {:else} + + {/if} + + + + + + +
    +
    + + +
    + {#if logs} +
    {@html highlightedLogs()}
    + {:else if loading} +
    + + Loading logs... +
    + {:else} +

    No logs available

    + {/if} +
    +
    + + diff --git a/routes/logs/LogsPanel.svelte b/routes/logs/LogsPanel.svelte new file mode 100644 index 0000000..7dd16a6 --- /dev/null +++ b/routes/logs/LogsPanel.svelte @@ -0,0 +1,926 @@ + + + +
    + + + + +
    +
    + + {#if streamingEnabled} + {#if isConnected} +
    + + Live +
    + {:else if loading} +
    + + Connecting... +
    + {:else if connectionError} + + {:else} + + {/if} + {:else} +
    + + Paused +
    + {/if} + | + {containerName} +
    +
    + + + + + + updateFontSize(Number(v))}> + + {fontSize}px + + + {#each fontSizeOptions as size} + {size}px + {/each} + + + + + + + + {#if logSearchActive} +
    + + + {#if matchCount > 0} + {currentMatchIndex + 1}/{matchCount} + {:else if logSearchQuery} + 0/0 + {/if} + + + +
    + {:else} + + {/if} + + + + + + + + {#if showCloseButton} + + {/if} +
    +
    + + +
    + {#if logs} +
    {@html highlightedLogs()}
    + {:else if loading} +

    Connecting to log stream...

    + {:else} +

    No logs available

    + {/if} +
    +
    + + diff --git a/routes/networks/+page.svelte b/routes/networks/+page.svelte new file mode 100644 index 0000000..608488d --- /dev/null +++ b/routes/networks/+page.svelte @@ -0,0 +1,721 @@ + + +
    +
    + +
    +
    + + e.key === 'Escape' && (searchInput = '')} + class="pl-8 h-8 w-48 text-sm" + /> +
    + + + + + {#if $canAccess('networks', 'remove')} + confirmPrune = open} + > + {#snippet children({ open })} + + {#if pruneStatus === 'pruning'} + + {:else if pruneStatus === 'success'} + + {:else if pruneStatus === 'error'} + + {:else} + + {/if} + Prune + + {/snippet} + + {/if} + + {#if $canAccess('networks', 'create')} + + {/if} +
    +
    + + + {#if selectedNetworks.size > 0} +
    + {selectedInFilter.length} selected + + {#if $canAccess('networks', 'remove')} + confirmBulkRemove = open} + > + {#snippet children({ open })} + + + Delete + + {/snippet} + + {/if} +
    + {/if} + + {#if !loading && ($environments.length === 0 || !$currentEnvironment)} + + {:else if !loading && networks.length === 0} + + {:else} + !protectedNetworks.includes(n.name)} + sortState={{ field: sortField, direction: sortDirection }} + onSortChange={(state) => { sortField = state.field as SortField; sortDirection = state.direction; }} + highlightedKey={highlightedRowId} + onRowClick={(network) => { highlightedRowId = highlightedRowId === network.id ? null : network.id; }} + > + {#snippet cell(column, network, rowState)} + {@const containerCount = Object.keys(network.containers || {}).length} + {@const isProtected = protectedNetworks.includes(network.name)} + {#if column.id === 'name'} +
    + {network.name} + {#if isProtected} + built-in + {/if} + {#if network.internal} + internal + {/if} +
    + {:else if column.id === 'driver'} + {network.driver} + {:else if column.id === 'scope'} + {network.scope} + {:else if column.id === 'subnet'} + {getSubnet(network)} + {:else if column.id === 'gateway'} + {getGateway(network)} + {:else if column.id === 'containers'} + {containerCount} + {:else if column.id === 'actions'} +
    + {#if deleteError?.id === network.id} +
    + + {deleteError.message} + +
    + {/if} + {#if $canAccess('networks', 'inspect')} + + {/if} + {#if !isProtected && $canAccess('networks', 'connect')} + + {/if} + + {#if !isProtected && $canAccess('networks', 'create')} + + {/if} + {#if !isProtected && $canAccess('networks', 'remove')} + removeNetwork(network.id, network.name)} + onOpenChange={(open) => confirmDeleteId = open ? network.id : null} + > + {#snippet children({ open })} + + {/snippet} + + {/if} +
    + {/if} + {/snippet} +
    + {/if} +
    + + showCreateModal = false} + onSuccess={fetchNetworks} +/> + + + + + + showBatchOpModal = false} + onComplete={handleBatchComplete} +/> diff --git a/routes/networks/ConnectContainerModal.svelte b/routes/networks/ConnectContainerModal.svelte new file mode 100644 index 0000000..9827ca6 --- /dev/null +++ b/routes/networks/ConnectContainerModal.svelte @@ -0,0 +1,172 @@ + + + isOpen && focusFirstInput()}> + + + + + Connect container to {network?.name} + + + Select a container to connect to this network. + + + +
    + {#if loading} +
    + +
    + {:else if availableContainers.length === 0} +
    + +

    No containers available to connect.

    +

    All containers are already connected to this network.

    +
    + {:else} +
    + + + + {#if selectedContainerInfo} + + + {selectedContainerInfo.name} + + {:else} + Select a container... + {/if} + + + {#each availableContainers as container} + + + + {container.name} + {container.state} + + + {/each} + + +
    + {/if} +
    + + + + + +
    +
    diff --git a/routes/networks/CreateNetworkModal.svelte b/routes/networks/CreateNetworkModal.svelte new file mode 100644 index 0000000..5a108b5 --- /dev/null +++ b/routes/networks/CreateNetworkModal.svelte @@ -0,0 +1,633 @@ + + + + + { if (isOpen) focusFirstInput(); else handleClose(); }}> + + + + + Create network + + Configure a new Docker network with custom settings. + + + + + + Basic + + + IPAM + + + Options + + + Labels + + + +
    + + +
    + + errors.name = undefined} + /> + {#if errors.name} +

    {errors.name}

    + {/if} +
    + +
    + + + + + {#if driver === 'bridge'} + + {:else if driver === 'host'} + + {:else if driver === 'overlay'} + + {:else if driver === 'macvlan'} + + {:else if driver === 'ipvlan'} + + {:else} + + {/if} + {NETWORK_DRIVERS.find(d => d.value === driver)?.label || 'Select driver'} + + + + {#each NETWORK_DRIVERS as d} + + {#snippet children()} +
    + {#if d.value === 'bridge'} + + {:else if d.value === 'host'} + + {:else if d.value === 'overlay'} + + {:else if d.value === 'macvlan'} + + {:else if d.value === 'ipvlan'} + + {:else} + + {/if} +
    + {d.label} + {d.description} +
    +
    + {/snippet} +
    + {/each} +
    +
    +
    + + {#if needsParentConfig} +
    +

    + {driver === 'macvlan' ? 'Macvlan' : 'IPvlan'} configuration (required) +

    +
    +
    + + errors.parentInterface = undefined} + /> + {#if errors.parentInterface} +

    {errors.parentInterface}

    + {/if} +
    + {#if driver === 'macvlan'} +
    + + + + + {macvlanMode === 'bridge' ? 'Bridge (default)' : macvlanMode === 'private' ? 'Private' : macvlanMode === 'vepa' ? 'VEPA' : 'Passthru'} + + + + Bridge (default) + + + Private + + + VEPA + + + Passthru + + + +
    + {:else} +
    + + + + + {ipvlanMode === 'l2' ? 'L2 (default)' : ipvlanMode === 'l3' ? 'L3' : 'L3S'} + + + + L2 (default) + + + L3 + + + L3S + + + +
    + {/if} +
    +
    +
    + + errors.subnet = undefined} + /> + {#if errors.subnet} +

    {errors.subnet}

    + {/if} +
    +
    + + +
    +
    +
    + {/if} + +
    +
    + +
    + Internal network + Restrict external access to this network +
    +
    + +
    + +
    + Attachable + Allow manual container attachment (overlay networks) +
    +
    + +
    + +
    + Enable IPv6 + Enable IPv6 networking +
    +
    +
    +
    + + + +
    + + +

    IP Address Management driver (default: default)

    +
    + +
    +
    + + +
    +
    + + +
    +
    + +
    + + +

    Allocate container IPs from a sub-range of the subnet

    +
    + + +
    +
    + + +
    +

    Reserve IP addresses for network devices (e.g., host=192.168.1.1)

    + {#each auxAddresses as aux, i} +
    +
    + Hostname + +
    +
    + IP + +
    + +
    + {/each} +
    + + +
    +
    + + +
    + {#each ipamOptions as opt, i} +
    +
    + Key + +
    +
    + Value + +
    + +
    + {/each} +
    +
    + + + +
    +
    + + +
    +

    Set driver-specific options (-o key=value)

    + + {#if COMMON_DRIVER_OPTIONS[driver]?.length > 0} +
    +

    Common options for {driver} driver:

    + {#each COMMON_DRIVER_OPTIONS[driver] as opt} +

    {opt.key} - {opt.description}

    + {/each} +
    + {/if} + + {#each driverOptions as opt, i} +
    +
    + Key + +
    +
    + Value + +
    + +
    + {/each} +
    +
    + + + +
    +
    + + +
    +

    Set metadata labels on the network

    + + {#each labels as label, i} +
    +
    + Key + +
    +
    + Value + +
    + +
    + {/each} + {#if labels.length === 0} +

    No labels configured

    + {/if} +
    +
    +
    +
    + + {#if error} + + + {error} + + {/if} + + {#if errors.name || errors.parentInterface || errors.subnet} + + + Please fix the validation errors above + + {/if} + + + + + +
    +
    diff --git a/routes/networks/NetworkInspectModal.svelte b/routes/networks/NetworkInspectModal.svelte new file mode 100644 index 0000000..5c9eb9c --- /dev/null +++ b/routes/networks/NetworkInspectModal.svelte @@ -0,0 +1,220 @@ + + + + + + + + Network details: {networkName || networkId.slice(0, 12)} + + + +
    + {#if loading} +
    + +
    + {:else if error} +
    + {error} +
    + {:else if networkData} + +
    +

    Basic information

    +
    +
    +

    Name

    +

    {networkData.Name}

    +
    +
    +

    ID

    + {networkData.Id?.slice(0, 12)} +
    +
    +

    Driver

    + {networkData.Driver} +
    +
    +

    Scope

    + {networkData.Scope} +
    +
    +

    Created

    +

    {formatDate(networkData.Created)}

    +
    +
    +

    Internal

    + + {networkData.Internal ? 'Yes' : 'No'} + +
    +
    +
    + + + {#if networkData.IPAM} +
    +

    IPAM configuration

    +
    +
    +

    Driver

    +

    {networkData.IPAM.Driver || 'default'}

    +
    + {#if networkData.IPAM.Config && networkData.IPAM.Config.length > 0} +
    +

    Subnets

    + {#each networkData.IPAM.Config as config} +
    + {#if config.Subnet} +
    + Subnet: + {config.Subnet} +
    + {/if} + {#if config.Gateway} +
    + Gateway: + {config.Gateway} +
    + {/if} + {#if config.IPRange} +
    + IP Range: + {config.IPRange} +
    + {/if} +
    + {/each} +
    + {/if} +
    +
    + {/if} + + + {#if networkData.Containers && Object.keys(networkData.Containers).length > 0} +
    +

    Connected containers ({Object.keys(networkData.Containers).length})

    +
    + {#each Object.entries(networkData.Containers) as [id, container]} + openContainerInspect(id, container.Name)} + /> + {/each} +
    +
    + {:else} +
    + No containers connected to this network +
    + {/if} + + + {#if networkData.Options && Object.keys(networkData.Options).length > 0} +
    +

    Driver options

    +
    + {#each Object.entries(networkData.Options) as [key, value]} +
    + {key} + {value} +
    + {/each} +
    +
    + {/if} + + + {#if networkData.Labels && Object.keys(networkData.Labels).length > 0} +
    +

    Labels

    +
    + {#each Object.entries(networkData.Labels) as [key, value]} +
    + {key} + {value} +
    + {/each} +
    +
    + {/if} + {/if} +
    + + + + +
    +
    + + diff --git a/routes/profile/+page.svelte b/routes/profile/+page.svelte new file mode 100644 index 0000000..1ab3b21 --- /dev/null +++ b/routes/profile/+page.svelte @@ -0,0 +1,641 @@ + + + + Profile - Dockhand + + +
    +
    + +

    Manage your account settings

    +
    +
    + + {#if loading} +
    + +
    + {:else if error} + + +
    + + {error} +
    +
    +
    + {:else if profile} +
    + + {#if formSuccess} +
    + + {formSuccess} +
    + {/if} + + + + + + + Account information + + + +
    + + + + +
    +
    + + + {#if profile.avatar} + + {/if} +
    +
    + + +
    +
    +
    + +

    {profile.username}

    +
    +
    + +
    + {#if profile.isAdmin} + + + Admin + + {:else} + User + {/if} +
    +
    +
    + +
    +
    + +

    + + {formatDate(profile.createdAt)} +

    +
    +
    + +

    + + {formatDate(profile.lastLogin)} +

    +
    +
    +
    +
    +
    +
    + + + + + + + Profile details + + + + {#if formError} + + + {formError} + + {/if} + +
    +
    + + +
    +
    + + +
    +
    + +
    + +
    +
    +
    + + + + + + + Security + + + + + {#if profile.provider === 'local'} +
    +
    + +
    +

    Password

    +

    Change your password

    +
    +
    + +
    + {:else} +
    +
    + +
    +

    Password

    +

    Managed by your SSO provider

    +
    +
    + + + SSO + +
    + {/if} + + + {#if profile.provider === 'local'} +
    +
    + +
    +
    +

    Two-factor authentication

    + {#if profile.mfaEnabled} + + + Enabled + + {:else} + Disabled + {/if} +
    +

    + {#if profile.mfaEnabled} + MFA is enabled for your account + {:else} + Add an extra layer of security + {/if} +

    +
    +
    + {#if $licenseStore.isEnterprise} + {#if profile.mfaEnabled} + + {:else} + + {/if} + {:else} + + + Enterprise + + {/if} +
    + {:else} +
    +
    + +
    +

    Two-factor authentication

    +

    Managed by your SSO provider

    +
    +
    + + + SSO + +
    + {/if} + + {#if mfaError && !showMfaSetupModal} + + + {mfaError} + + {/if} +
    +
    + + + + + + + Appearance + + Customize the look of the application + + + + + +
    + {/if} +
    + + + showPasswordModal = false} + onSuccess={showSuccessMessage} +/> + + +{#if profile} + showMfaSetupModal = false} + onSuccess={handleMfaEnabled} + /> + + showDisableMfaModal = false} + onSuccess={handleMfaDisabled} + /> +{/if} + + + diff --git a/routes/profile/ChangePasswordModal.svelte b/routes/profile/ChangePasswordModal.svelte new file mode 100644 index 0000000..05101df --- /dev/null +++ b/routes/profile/ChangePasswordModal.svelte @@ -0,0 +1,133 @@ + + + { if (o) { resetForm(); focusFirstInput(); } else onClose(); }}> + + + + + Change password + + +
    + {#if error} + + + {error} + + {/if} +
    + + +
    +
    + + + +
    +
    + + +
    +
    + + + + +
    +
    diff --git a/routes/profile/DisableMfaModal.svelte b/routes/profile/DisableMfaModal.svelte new file mode 100644 index 0000000..4e61092 --- /dev/null +++ b/routes/profile/DisableMfaModal.svelte @@ -0,0 +1,63 @@ + + + { if (o) focusFirstInput(); else onClose(); }}> + + + + + Disable two-factor authentication + + +
    +

    + Are you sure you want to disable two-factor authentication? This will make your account less secure. +

    +
    + + + + +
    +
    diff --git a/routes/profile/MfaSetupModal.svelte b/routes/profile/MfaSetupModal.svelte new file mode 100644 index 0000000..c41370d --- /dev/null +++ b/routes/profile/MfaSetupModal.svelte @@ -0,0 +1,117 @@ + + + { if (o) { resetForm(); focusFirstInput(); } else onClose(); }}> + + + + + Setup two-factor authentication + + +
    + {#if error} + + + {error} + + {/if} + +

    + Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.) +

    + + {#if qrCode} +
    + MFA QR Code +
    + {/if} + +
    + + {secret} +
    + +
    + + +

    + Enter the code from your authenticator app to verify setup +

    +
    +
    + + + + +
    +
    diff --git a/routes/registry/+page.svelte b/routes/registry/+page.svelte new file mode 100644 index 0000000..65b7add --- /dev/null +++ b/routes/registry/+page.svelte @@ -0,0 +1,633 @@ + + +
    +
    + + {#if $canAccess('registries', 'edit')} + + + Manage registries + + {/if} +
    + + +
    + { selectedRegistryId = Number(v); handleRegistryChange(); }}> + + {@const selected = registries.find(r => r.id === selectedRegistryId)} + {#if selected && isDockerHub(selected)} + + {:else} + + {/if} + {selected ? `${selected.name}${selected.hasCredentials ? ' (auth)' : ''}` : 'Select registry'} + + + {#each registries as registry} + + {#if isDockerHub(registry)} + + {:else} + + {/if} + {registry.name} + {#if registry.hasCredentials} + auth + {/if} + + {/each} + + +
    + + +
    + + {#if supportsBrowsing()} + + {/if} +
    + + + {#if loading || browsing} +

    {browsing ? 'Loading catalog...' : 'Searching...'}

    + {:else if errorMessage} +

    {errorMessage}

    + {:else if searched && results.length === 0} +

    + {browseMode ? 'No images found in this registry' : `No images found for "${searchTerm}"`} +

    + {:else if results.length > 0} +
    + + + + + {#if !browseMode} + + + + {/if} + + + + {#each results as result (result.name)} + {@const isExpanded = !!expandedImages[result.name]} + {@const expandState = expandedImages[result.name]} + + toggleImageExpansion(result.name)} + > + + {#if !browseMode} + + + + {/if} + + + {#if isExpanded} + + + + {/if} + {/each} + +
    NameDescriptionStarsType
    +
    + {#if isExpanded} + + {:else} + + {/if} + {result.name} +
    +
    + + {result.description || '-'} + + +
    + + {result.star_count.toLocaleString()} +
    +
    + {#if result.is_official} + Official + {:else if result.is_automated} + Auto + {:else} + - + {/if} +
    + {#if expandState?.loading} +
    + + Loading tags... +
    + {:else if expandState?.error} +
    + {expandState.error} +
    + {:else if expandState?.tags && expandState.tags.length > 0} +
    + + + + + + + + + + + {#each expandState.tags as tag} + + + + + + + {/each} + +
    TagSizeModifiedActions
    +
    + + {tag.name} +
    +
    + {formatBytes(tag.size)} + + {formatDate(tag.lastUpdated)} + +
    + + + {#if pushableRegistries.length > 0} + + {/if} + {#if supportsBrowsing()} + {@const deleteKey = `${result.name}:${tag.name}`} + deleteTag(result.name, tag.name)} + onOpenChange={(open) => confirmDeleteKey = open ? deleteKey : null} + > + + + {/if} +
    +
    +
    + {:else} +
    + No tags found +
    + {/if} +
    +
    + {:else} +
    + +

    + {#if supportsBrowsing()} + Search or browse {selectedRegistry?.name || 'a registry'} to find images + {:else} + Search {selectedRegistry?.name || 'a registry'} to find and pull images + {/if} +

    +
    + {/if} +
    + + + + + + + + + diff --git a/routes/registry/CopyToRegistryModal.svelte b/routes/registry/CopyToRegistryModal.svelte new file mode 100644 index 0000000..131e59f --- /dev/null +++ b/routes/registry/CopyToRegistryModal.svelte @@ -0,0 +1,497 @@ + + + + + + + {#if pushStatus === 'complete'} + + {:else if pullStatus === 'error' || pushStatus === 'error'} + + {:else} + + {/if} + Copy to registry + {imageName} + + + + +
    + + + + {#if envHasScanning} + + + {/if} + + +
    + +
    + +
    +
    + +
    + {imageName}:{sourceTag} +
    +
    + +
    + + targetRegistryId = Number(v)}> + + {#if targetRegistry} + {#if isDockerHub(targetRegistry)} + + {:else} + + {/if} + {targetRegistry.name}{targetRegistry.hasCredentials ? ' (auth)' : ''} + {:else} + Select registry + {/if} + + + {#each pushableRegistries as registry} + + {#if isDockerHub(registry)} + + {:else} + + {/if} + {registry.name} + {#if registry.hasCredentials} + auth + {/if} + + {/each} + + + {#if pushableRegistries.length === 0} +

    No target registries available. Add a private registry in Settings.

    + {/if} +
    + +
    + + +

    + Will be pushed as: + {targetImageName()} + +

    +
    +
    + + +
    + +
    + + + {#if envHasScanning} +
    + +
    + {/if} + + +
    + +
    +
    + + +
    + {#if currentStep === 'pull' && pullStatus === 'error'} + + {:else if currentStep === 'scan' && scanStatus === 'error'} + + {:else if currentStep === 'push' && pushStatus === 'error'} + + {/if} +
    +
    + + {#if currentStep === 'configure'} + + {:else if currentStep === 'scan' && scanStatus === 'complete'} + {#if hasCriticalOrHigh} +
    + + Critical/high vulnerabilities found +
    + {:else if totalVulnerabilities > 0} +
    + + {totalVulnerabilities} vulnerabilities found +
    + {/if} + + {/if} +
    +
    +
    +
    diff --git a/routes/registry/ImagePullModal.svelte b/routes/registry/ImagePullModal.svelte new file mode 100644 index 0000000..0429b9f --- /dev/null +++ b/routes/registry/ImagePullModal.svelte @@ -0,0 +1,230 @@ + + + + + + + {#if scanStatus === 'complete' && scanResults.length > 0} + {#if hasCriticalOrHigh} + + {:else if totalVulnerabilities > 0} + + {:else} + + {/if} + {:else if pullStatus === 'complete' && !envHasScanning} + + {:else if pullStatus === 'error' || scanStatus === 'error'} + + {:else} + + {/if} + {title} + {imageName} + + + + + {#if envHasScanning} +
    + + + +
    + {/if} + +
    + +
    + +
    + + + {#if envHasScanning} +
    + +
    + {/if} +
    + + + + +
    +
    diff --git a/routes/schedules/+page.svelte b/routes/schedules/+page.svelte new file mode 100644 index 0000000..4420138 --- /dev/null +++ b/routes/schedules/+page.svelte @@ -0,0 +1,1632 @@ + + + + Schedules - Dockhand + + +
    + +
    + +
    +
    + + e.key === 'Escape' && (searchQuery = '')} + /> +
    + + + + + + {#if filterTypes.length === 0} + All types + {:else if filterTypes.length === 1} + {#if filterTypes[0] === 'container_update'} + Container updates + {:else if filterTypes[0] === 'git_stack_sync'} + Git stack syncs + {:else if filterTypes[0] === 'env_update_check'} + Env update checks + {:else} + System jobs + {/if} + {:else} + {filterTypes.length} types + {/if} + + + + {#if filterTypes.length > 0} + + {/if} + + + Container updates + + + + Git stack syncs + + + + Env update checks + + {#if !hideSystemJobs} + + + System jobs + + {/if} + + + + + + + + + {#if filterEnvironments.length === 0} + All envs + {:else if filterEnvironments.length === 1} + {environments.find(e => String(e.id) === filterEnvironments[0])?.name || 'Environment'} + {:else} + {filterEnvironments.length} envs + {/if} + + + + {#if filterEnvironments.length > 0} + + {/if} + {#each environments as env} + {@const EnvIcon = getIconComponent(env.icon)} + + + {env.name} + + {/each} + + + + + + + + {#if filterStatuses.length === 0} + All statuses + {:else if filterStatuses.length === 1} + {#if filterStatuses[0] === 'success'} + Success + {:else if filterStatuses[0] === 'failed'} + Failed + {:else if filterStatuses[0] === 'skipped'} + Up-to-date + {:else if filterStatuses[0] === 'running'} + Running + {:else} + {filterStatuses[0]} + {/if} + {:else} + {filterStatuses.length} statuses + {/if} + + + + {#if filterStatuses.length > 0} + + {/if} + + + Success + + + + Failed + + + + Up-to-date + + + + Running + + + + + + {#if systemJobCount > 0} + + {/if} + + + + + +
    +
    + + + { + if (schedule.lastExecution !== null) { + toggleScheduleExpansion(schedule); + } + }} + class="border-none" + wrapperClass="border rounded-lg" + > + {#snippet cell(column, schedule, rowState)} + {#if column.id === 'expand'} + {#if schedule.lastExecution !== null} + + {/if} + {:else if column.id === 'schedule'} +
    + {#if schedule.type === 'container_update'} + + {:else if schedule.type === 'git_stack_sync'} + + {:else if schedule.type === 'env_update_check'} + {#if schedule.autoUpdate} + + {:else} + + {/if} + {:else} + + {/if} +
    +
    + {schedule.name} + {#if schedule.isSystem} + System + {/if} +
    +
    + {#if schedule.type === 'container_update'} + {#if schedule.envHasScanning} + {@const criteria = (schedule.vulnerabilityCriteria || 'never') as VulnerabilityCriteria} + {@const icon = vulnerabilityCriteriaIcons[criteria]} + {@const IconComponent = icon.component} + + + + Check, scan & auto-update + {:else} + Check & auto-update + {/if} + {:else if schedule.type === 'git_stack_sync'} + Git sync + {:else if schedule.type === 'env_update_check'} + {#if schedule.autoUpdate && schedule.envHasScanning && schedule.vulnerabilityCriteria} + {@const criteria = schedule.vulnerabilityCriteria as VulnerabilityCriteria} + {@const icon = vulnerabilityCriteriaIcons[criteria]} + {@const IconComponent = icon.component} + + + + {/if} + {schedule.description || 'Env update check'} + {:else} + {schedule.description || 'System job'} + {/if} +
    +
    +
    + {:else if column.id === 'environment'} + {#if schedule.environmentName} +
    + + {schedule.environmentName} +
    + {:else} + - + {/if} + {:else if column.id === 'cron'} +
    + + + {#if schedule.cronExpression} + {(() => { + try { + const is12Hour = $appSettings.timeFormat === '12h'; + return cronstrue.toString(schedule.cronExpression, { + use24HourTimeFormat: !is12Hour, + throwExceptionOnParseError: true, + locale: 'en' + }); + } catch { + return schedule.cronExpression; + } + })()} + {:else} + {schedule.scheduleType} + {/if} + +
    + {:else if column.id === 'lastRun'} + {#if schedule.lastExecution} +
    {formatTimestamp(schedule.lastExecution.triggeredAt)}
    + {#if schedule.lastExecution.duration} +
    + + {formatDuration(schedule.lastExecution.duration)} +
    + {/if} + {:else} + Never + {/if} + {:else if column.id === 'nextRun'} + {formatNextRun(schedule.nextRun)} + {:else if column.id === 'status'} + {#if schedule.lastExecution} + {@const badge = getStatusBadge(schedule.lastExecution.status)} + {@const envUpdateStatus = getEnvUpdateStatus(schedule.lastExecution)} + {@const isBlockedByVuln = schedule.lastExecution.details?.reason === 'vulnerabilities_found'} + + + {#if envUpdateStatus} + {@const EnvUpdateIcon = envUpdateStatus.icon} + + + + {:else if isBlockedByVuln} + + + + {:else} + {@const BadgeIcon = badge.icon} + + + + {/if} + + +

    + {#if envUpdateStatus} + {envUpdateStatus.label} + {:else if isBlockedByVuln} + Update blocked due to vulnerabilities + {:else if schedule.lastExecution.status === 'skipped'} + Up-to-date + {:else} + {schedule.lastExecution.status} + {/if} +

    +
    +
    + {:else} + + + + + + + +

    No runs

    +
    +
    + {/if} + {:else if column.id === 'actions'} +
    + {#if schedule.lastExecution} + + {/if} + + + {#if !schedule.isSystem} + {@const scheduleKey = getScheduleKey(schedule)} + deleteSchedule(schedule.type, schedule.id, schedule.entityName)} + onOpenChange={(open) => confirmDeleteId = open ? scheduleKey : null} + > + {#snippet children({ open })} + + {/snippet} + + {/if} +
    + {/if} + {/snippet} + + {#snippet expandedRow(schedule, rowState)} + {@const scheduleKey = getScheduleKey(schedule)} + {@const executions = expandedExecutions.get(scheduleKey) || []} + {@const isLoading = loadingMoreExecutions.has(scheduleKey)} + {@const canLoadMore = hasMoreExecutions.get(scheduleKey) ?? false} +
    +
    +

    Execution history

    + {#if executions.length > 0} + + {/if} +
    + {#if executions.length > 0} +
    + + + + + + + + + + + + + {#each executions as exec} + {@const badge = getStatusBadge(exec.status)} + {@const trigger = getTriggerBadge(exec.triggeredBy)} + + + + + + + + + {/each} + +
    TriggeredTriggerDurationStatusError
    {formatTimestamp(exec.triggeredAt)} + + + {@const TriggerIcon = trigger.icon} + + + + + +

    {trigger.label}

    +
    +
    +
    {formatDuration(exec.duration)}
    + + + {#if exec.details?.reason === 'vulnerabilities_found'} + + + + {:else} + {@const ExecBadgeIcon = badge.icon} + + + + {/if} + + +

    {exec.details?.reason === 'vulnerabilities_found' ? 'Update blocked due to vulnerabilities' : (exec.status === 'skipped' ? 'Up-to-date' : exec.status)}

    +
    +
    +
    + {exec.errorMessage || ''} + +
    + + +
    +
    + {#if canLoadMore} +
    + +
    + {/if} +
    + {:else if isLoading} +
    + +
    + {:else} +

    No executions found

    + {/if} +
    + {/snippet} + + {#snippet emptyState()} +
    + +

    No schedules found

    +

    Enable auto-update on containers or auto-sync on git stacks to see them here

    +
    + {/snippet} +
    +
    + + + + + + + {#if selectedExecution?.scheduleType === 'container_update'} + + {:else if selectedExecution?.scheduleType === 'git_stack_sync'} + + {:else if selectedExecution?.scheduleType === 'env_update_check'} + {#if selectedExecution?.details?.autoUpdate} + + {:else} + + {/if} + {:else} + + {/if} + Execution details + {#if selectedExecution} + + ({#if selectedExecution.scheduleType === 'container_update'}Container update{:else if selectedExecution.scheduleType === 'env_update_check'}Environment update{:else if selectedExecution.scheduleType === 'git_stack_sync'}Git stack sync{:else}System job{/if}) + + {/if} + + {#if selectedExecution} + + {formatTimestamp(selectedExecution.triggeredAt)} · {formatDuration(selectedExecution.duration)} + + {/if} + + + {#if loadingExecutionDetail} +
    + +
    + {:else if selectedExecution} +
    + + {#if selectedExecution.scheduleType === 'env_update_check' && selectedExecution.details?.autoUpdate} +
    + {/if} + + + {#if selectedExecution.details?.blockedContainers?.length > 0} +
    +
    Blocked containers
    +
    +
    + {#each selectedExecution.details.blockedContainers as bc} +
    +
    + + {bc.name} + - {bc.reason} +
    + {#if bc.scannerResults} + + {/if} +
    + {/each} +
    +
    +
    + {/if} + + +
    +
    + Status + {#if selectedExecution.status} + {@const badge = getStatusBadge(selectedExecution.status)} + {@const envUpdateStatus = getEnvUpdateStatus(selectedExecution)} + {@const isBlockedByVuln = selectedExecution.details?.reason === 'vulnerabilities_found'} + {#if envUpdateStatus} + {@const StatusIcon = envUpdateStatus.icon} + + + {envUpdateStatus.label} + + {:else if isBlockedByVuln} + + + Blocked + + {:else} + {@const SelBadgeIcon = badge.icon} + + + {selectedExecution.status === 'skipped' ? 'Up-to-date' : selectedExecution.status} + + {/if} + {/if} +
    +
    + Trigger + {#if selectedExecution.triggeredBy} + {@const trigger = getTriggerBadge(selectedExecution.triggeredBy)} + {@const SelTriggerIcon = trigger.icon} + + + {trigger.label} + + {/if} +
    + {#if selectedExecution.details?.vulnerabilityCriteria} +
    + Update block criteria + +
    + {/if} +
    + + + {#if selectedExecution.details?.reason === 'vulnerabilities_found'} +
    +
    Block reason
    +
    + + {selectedExecution.details.blockReason || 'Update blocked due to vulnerabilities'} +
    +
    + {/if} + + + {#if selectedExecution.details?.scanResult?.summary} + {@const summary = selectedExecution.details.scanResult.summary} + {@const scannerResults = selectedExecution.details.scanResult.scannerResults} +
    +
    Vulnerability scan results
    +
    +
    + +
    +
    + Scanned with {selectedExecution.details.scanResult.scanners?.join(', ') || 'scanner'} + {#if selectedExecution.details.scanResult.scannedAt} + at {formatDateTime(selectedExecution.details.scanResult.scannedAt)} + {/if} +
    +
    +
    + {/if} + + + {#if selectedExecution.errorMessage} +
    +
    Error
    +
    + {selectedExecution.errorMessage} +
    +
    + {/if} + + +
    + +
    + +
    + {/if} + + + +
    +
    diff --git a/routes/settings/+page.svelte b/routes/settings/+page.svelte new file mode 100644 index 0000000..f0595c5 --- /dev/null +++ b/routes/settings/+page.svelte @@ -0,0 +1,129 @@ + + +
    +
    + +
    + + + + + + General + + + + Environments + + + + Registries + + + + Git + + + + Config sets + + + + Notifications + + + + Authentication + + + + License + + + + About + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    diff --git a/routes/settings/about/AboutTab.svelte b/routes/settings/about/AboutTab.svelte new file mode 100644 index 0000000..802e389 --- /dev/null +++ b/routes/settings/about/AboutTab.svelte @@ -0,0 +1,869 @@ + + +
    + +
    + + + +
    + + + +
    + Dockhand Logo + + + ✦ + ✦ + ✦ + ✦ + ✦ + ✦ + ✦ + ✦ + + {#if showEasterEgg} +
    + Stop already, will you? Coming from r/selfhosted? +
    + {/if} +
    + + +
    + Version {currentVersion} +
    + + +
    + {#if BUILD_BRANCH} +
    + + {BUILD_BRANCH} +
    + {/if} + {#if BUILD_COMMIT} +
    + + {BUILD_COMMIT.slice(0, 7)}{#if BUILD_DATE} ({formatBuildDate(BUILD_DATE)}){/if} +
    + {/if} + {#if serverUptime !== null} +
    + + Uptime {formatUptime(serverUptime)} +
    + {/if} +
    + + +

    + {TAGLINES[taglineIndex]} +

    + + + + https://dockhand.pro + +
    +
    +
    + + + + + System information + + + {#if loading} +
    +
    Loading...
    +
    + {:else if error} +
    +
    {error}
    +
    + {:else if systemInfo} +
    + {#if systemInfo.docker} + +
    +
    + + Docker +
    +
    +
    + Version + {systemInfo.docker.version} + | + API + {systemInfo.docker.apiVersion} + | + OS/Arch + {systemInfo.docker.os}/{systemInfo.docker.arch} +
    +
    +
    + + +
    +
    + + Connection +
    +
    +
    + {#if systemInfo.docker.connection.type === 'socket'} + + Unix Socket + + {systemInfo.docker.connection.socketPath} + {:else if systemInfo.docker.connection.type === 'https'} + + HTTPS (TLS) + + {systemInfo.docker.connection.host}:{systemInfo.docker.connection.port} + {:else} + + HTTP + + {systemInfo.docker.connection.host}:{systemInfo.docker.connection.port} + {/if} +
    +
    +
    + + +
    +
    + + Host +
    +
    +
    + Name + {systemInfo.host.name} + | + CPUs + {systemInfo.host.cpus} + | + Memory + {formatBytes(systemInfo.host.memory)} +
    +
    +
    + {/if} + + +
    +
    + + Runtime +
    +
    +
    + {#if systemInfo.runtime.bun} + + Bun {systemInfo.runtime.bun} + + {/if} + | + Platform + {systemInfo.runtime.platform}/{systemInfo.runtime.arch} + | + Memory + {formatBytes(systemInfo.runtime.memory.rss)} +
    + {#if systemInfo.runtime.container.inContainer} +
    + + {systemInfo.runtime.container.runtime || 'Container'} + + {#if systemInfo.runtime.ownContainer} + {systemInfo.runtime.ownContainer.image} + {/if} + {#if systemInfo.docker} + | + Docker + {systemInfo.docker.version} + {/if} +
    + {/if} +
    +
    + + +
    +
    + + Database +
    +
    +
    + {#if systemInfo.database.type === 'PostgreSQL'} + + PostgreSQL + + {#if systemInfo.database.host} + | + {systemInfo.database.host}:{systemInfo.database.port} + {/if} + {:else} + + SQLite + + {/if} +
    + {#if systemInfo.database.schemaVersion} +
    + Schema + {systemInfo.database.schemaVersion} + {#if systemInfo.database.schemaDate} + ({systemInfo.database.schemaDate}) + {/if} +
    + {/if} +
    +
    + + +
    +
    + {#if $licenseStore.licenseType === 'enterprise'} + + {:else if $licenseStore.licenseType === 'smb'} + + {:else} + + {/if} + License +
    +
    +
    + Edition + {#if $licenseStore.licenseType === 'enterprise'} + + + Enterprise + + {:else if $licenseStore.licenseType === 'smb'} + + + SMB + + {:else} + Community + {/if} + {#if $licenseStore.isLicensed && $licenseStore.licensedTo} + | + Licensed to + {$licenseStore.licensedTo} + {/if} + | + + | + +
    +
    + + + Submit issue or idea + + {#if !$licenseStore.isLicensed} + | + + + Buy me a coffee + + {/if} +
    + {#if !$licenseStore.isLicensed} +

    Dockhand Community Edition is free and always will be. No strings attached. Like it? Fuel the dev with caffeine.

    + {/if} +
    +
    + + {#if systemInfo.docker} + +
    +
    +
    + +
    +
    {systemInfo.stats.containers.total}
    +
    Containers
    +
    +
    +
    + +
    +
    {systemInfo.stats.stacks}
    +
    Stacks
    +
    +
    +
    + +
    +
    {systemInfo.stats.images}
    +
    Images
    +
    +
    +
    + +
    +
    {systemInfo.stats.volumes}
    +
    Volumes
    +
    +
    +
    + +
    +
    {systemInfo.stats.networks}
    +
    Networks
    +
    +
    + {/if} +
    + {/if} +
    +
    +
    + + + + +
    + + + + Release notes + {changelog.length} + + + + Dependencies + {dependencies.length} + + +
    + + + {#if loadingChangelog} +
    +
    Loading releases...
    +
    + {:else if changelogError} +
    +
    {changelogError}
    +
    + {:else} +
    + {#each changelog as release, index} + {@const isExpanded = expandedReleases.has(index)} +
    + + +
    toggleRelease(index)} + > +
    + {#if isExpanded} + + {:else} + + {/if} + + v{release.version} + {#if index === 0} + Latest + {/if} + {release.changes.length} changes +
    + {formatChangelogDate(release.date)} +
    + {#if isExpanded} +
    +
      + {#each release.changes as change} +
    • + {#if change.type === 'feature'} + + + New + + {:else if change.type === 'fix'} + + + Fix + + {/if} + {change.text} +
    • + {/each} +
    +
    + {release.imageTag} +
    +
    + {/if} +
    + {/each} +
    + {/if} +
    + + +
    +
    + + +
    +
    + {#if loadingDeps} +
    +
    Loading dependencies...
    +
    + {:else if depsError} +
    +
    {depsError}
    +
    + {:else} +
    +
    +
    Package
    +
    Version
    +
    License
    +
    +
    +
    + {#each filteredDeps as dep} +
    +
    {dep.name}
    +
    + {dep.version} +
    +
    + {dep.license} +
    +
    + {#if dep.repository} + + + + {/if} +
    +
    + {/each} +
    +
    + {/if} +
    +
    +
    + +
    + + + diff --git a/routes/settings/about/LicenseModal.svelte b/routes/settings/about/LicenseModal.svelte new file mode 100644 index 0000000..5f5fccf --- /dev/null +++ b/routes/settings/about/LicenseModal.svelte @@ -0,0 +1,66 @@ + + + + + + + + License terms and conditions + + + +
    + {#if loading} +
    +
    Loading...
    +
    + {:else if error} +
    +
    {error}
    +
    + {:else} +
    {content}
    + {/if} +
    + + + + +
    +
    diff --git a/routes/settings/about/PrivacyModal.svelte b/routes/settings/about/PrivacyModal.svelte new file mode 100644 index 0000000..d20d52d --- /dev/null +++ b/routes/settings/about/PrivacyModal.svelte @@ -0,0 +1,66 @@ + + + + + + + + Privacy policy + + + +
    + {#if loading} +
    +
    Loading...
    +
    + {:else if error} +
    +
    {error}
    +
    + {:else} +
    {content}
    + {/if} +
    + + + + +
    +
    diff --git a/routes/settings/auth/AuthTab.svelte b/routes/settings/auth/AuthTab.svelte new file mode 100644 index 0000000..a981e5a --- /dev/null +++ b/routes/settings/auth/AuthTab.svelte @@ -0,0 +1,326 @@ + + +
    + +
    + +
    +
    +

    Authentication

    + handleAuthEnabledToggle(checked)} + disabled={authLoading || authSaving || !$canAccess('settings', 'edit')} + /> +
    +

    + {authEnabled + ? 'Users must log in to access the application' + : 'Authentication is disabled - open access'} +

    +

    + + {#if $licenseStore.isEnterprise} + {authEnabled + ? 'Audit logging is active - all actions are recorded' + : 'Enable authentication to activate audit logging'} + {:else} + Enable authentication to activate audit logging + {/if} +

    +
    +
    + + +
    + + + + + +
    + + +
    + +{#if authSubTab === 'general'} +
    + {#if authEnabled} + + + + + Session settings + + + +
    + +

    + How long until inactive sessions expire +

    +
    + (sessionTimeout = parseInt(e.currentTarget.value))} + class="w-32" + disabled={!$canAccess('settings', 'edit')} + /> + seconds + + ({Math.floor(sessionTimeout / 3600)} hours) + +
    +
    + {#if $canAccess('settings', 'edit')} + + {/if} +
    +
    + {:else} +
    + +

    Enable authentication to configure session settings

    +
    + {/if} +
    +{/if} + + +{#if authSubTab === 'local'} +
    + +
    +{/if} + + +{#if authSubTab === 'ldap'} +
    + +
    +{/if} + + +{#if authSubTab === 'sso'} +
    + +
    +{/if} + + +{#if authSubTab === 'roles'} +
    + +
    +{/if} +
    +
    diff --git a/routes/settings/auth/ldap/LdapModal.svelte b/routes/settings/auth/ldap/LdapModal.svelte new file mode 100644 index 0000000..d9fab59 --- /dev/null +++ b/routes/settings/auth/ldap/LdapModal.svelte @@ -0,0 +1,508 @@ + + + { if (o) { formError = ''; formErrors = {}; formModalTab = 'connection'; focusFirstInput(); } }}> + + + + {#if isEditing} + + Edit LDAP configuration + {:else} + + Add LDAP configuration + {/if} + + +
    + {#if formError} + + + {formError} + + {/if} + + + + Connection + Group settings + + + + +
    +

    Basic settings

    +
    +
    + + formErrors.name = undefined} + /> + {#if formErrors.name} +

    {formErrors.name}

    + {/if} +
    +
    + + formErrors.serverUrl = undefined} + /> + {#if formErrors.serverUrl} +

    {formErrors.serverUrl}

    + {/if} +
    +
    +
    + formEnabled = checked === true} + /> + +
    +
    + + +
    +

    Bind credentials (optional)

    +

    Service account used to search for users. Leave empty for anonymous bind.

    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +

    User search settings

    +
    + + formErrors.baseDn = undefined} + /> + {#if formErrors.baseDn} +

    {formErrors.baseDn}

    + {:else} +

    The base DN to search for users.

    + {/if} +
    +
    + + +

    + LDAP filter to find users. Use {`{{username}}`} as placeholder.
    + OpenLDAP: (uid={`{{username}}`}) • AD: (sAMAccountName={`{{username}}`}) +

    +
    +
    + + +
    +

    Attribute mapping

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +

    TLS settings

    +
    + formTlsEnabled = checked === true} + /> + +
    + {#if formTlsEnabled} +
    + + +
    + {/if} +
    +
    + + + +
    +

    Group settings

    +

    Configure group-based access control. These settings are optional.

    +
    +
    + + +

    The base DN to search for groups.

    +
    +
    + + +

    Members of this group will be admins.

    +
    +
    +
    + + +

    + Filter to find groups the user belongs to. Use {'{{user_dn}}'} as placeholder. +

    +
    +
    + + + {#if isEnterprise} +
    +
    +

    Group to role mappings

    + +
    +

    Map LDAP groups to Dockhand roles. Users in these groups will be assigned the corresponding role.

    + + {#if formRoleMappings.length > 0} +
    + {#each formRoleMappings as mapping, index} +
    + updateRoleMappingGroupDn(index, e.currentTarget.value)} + /> + updateRoleMappingRole(index, parseInt(value))} + > + + {#if mapping.roleId} + {@const role = roles.find(r => r.id === mapping.roleId)} + {role?.name || 'Select role'} + {:else} + Select role + {/if} + + + {#each roles.filter(r => !r.isSystem || r.name !== 'Admin') as role} + {role.name} + {/each} + + + +
    + {/each} +
    + {/if} + + +
    + {/if} +
    +
    +
    + + + + +
    +
    diff --git a/routes/settings/auth/ldap/LdapSubTab.svelte b/routes/settings/auth/ldap/LdapSubTab.svelte new file mode 100644 index 0000000..ea1aacb --- /dev/null +++ b/routes/settings/auth/ldap/LdapSubTab.svelte @@ -0,0 +1,320 @@ + + +{#if $licenseStore.loading} + + +
    + +
    +
    +
    +{:else if !$licenseStore.isEnterprise} + + +
    +

    + + Enterprise feature +

    +

    + LDAP / Active Directory integration is available with an enterprise license. Connect to your organization's directory services for centralized authentication. +

    + +
    +
    +
    +{:else} +
    + + +
    +
    + + + LDAP configurations + +

    Connect to LDAP or Active Directory servers for centralized user authentication.

    +
    + {#if $canAccess('settings', 'edit')} + + {/if} +
    +
    + + {#if ldapLoading} +
    + +
    + {:else if ldapConfigs.length === 0} + + {:else} +
    + {#each ldapConfigs as config} +
    +
    + +
    +
    + {config.name} + {#if config.enabled} + Enabled + {:else} + Disabled + {/if} +
    +

    {config.serverUrl}

    +
    +
    +
    + + {#if $canAccess('settings', 'edit')} + + + deleteLdapConfig(config.id)} + onOpenChange={(open) => confirmDeleteLdapId = open ? config.id : null} + > + {#snippet children({ open })} + + {/snippet} + + {/if} +
    +
    + {/each} +
    + {#if ldapTestResult} +
    + {#if ldapTestResult.success} +

    Connection successful! Found {ldapTestResult.userCount} users.

    + {:else} +

    Connection failed: {ldapTestResult.error}

    + {/if} +
    + {/if} + {/if} +
    +
    +
    +{/if} + + diff --git a/routes/settings/auth/oidc/OidcModal.svelte b/routes/settings/auth/oidc/OidcModal.svelte new file mode 100644 index 0000000..6f67304 --- /dev/null +++ b/routes/settings/auth/oidc/OidcModal.svelte @@ -0,0 +1,511 @@ + + + { if (o) { formError = ''; formErrors = {}; focusFirstInput(); } }}> + + + + {#if isEditing} + + Edit OIDC provider + {:else} + + Add OIDC provider + {/if} + + + + + + General + + + Role mapping + + + + + {#if formError} + + + {formError} + + {/if} + + +
    +

    Basic settings

    +
    +
    + + formErrors.name = undefined} + /> + {#if formErrors.name} +

    {formErrors.name}

    + {/if} +
    +
    + + formErrors.issuerUrl = undefined} + /> + {#if formErrors.issuerUrl} +

    {formErrors.issuerUrl}

    + {/if} +
    +
    +
    + formEnabled = checked === true} + /> + +
    +
    + + +
    +

    Client credentials

    +

    Get these from your identity provider's application settings.

    +
    +
    + + formErrors.clientId = undefined} + /> + {#if formErrors.clientId} +

    {formErrors.clientId}

    + {/if} +
    +
    + + formErrors.clientSecret = undefined} + /> + {#if formErrors.clientSecret} +

    {formErrors.clientSecret}

    + {/if} +
    +
    +
    + + +
    +

    Redirect settings

    +
    + + formErrors.redirectUri = undefined} + /> + {#if formErrors.redirectUri} +

    {formErrors.redirectUri}

    + {:else} +

    Add this URI to your identity provider's allowed callback URLs.

    + {/if} +
    +
    + + +
    +
    + + +
    +

    Claim mapping

    +

    Map OIDC claims to user attributes.

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + + {#if !isEnterprise} + +
    +
    +

    + + Enterprise feature +

    +

    + Role mapping allows you to automatically assign Dockhand roles based on your identity provider's groups or claims. This feature requires an enterprise license. +

    + {#if onNavigateToLicense} + + {/if} +
    +
    + {:else} + +
    +

    Groups/roles claim

    +

    Grant admin access based on claim values from your identity provider.

    +
    +
    + + +

    Name of the claim containing roles/groups

    +
    +
    + + +

    Comma-separated values that grant Admin role

    +
    +
    +
    + + +
    +
    +
    +

    Claim to role mappings

    +

    Map claim values from your identity provider to Dockhand roles.

    +
    + +
    + + {#if formRoleMappings.length === 0} +
    + No role mappings configured. Click "Add mapping" to create one. +
    + {:else} +
    + {#each formRoleMappings as mapping, index} +
    +
    +
    + + +
    +
    + + { + if (value) { + updateRoleMappingRole(index, parseInt(value)); + } + }} + > + + {#if mapping.role_id} + {roles.find(r => r.id === mapping.role_id)?.name || 'Select role...'} + {:else} + Select role... + {/if} + + + {#each roles as role} + +
    + + {role.name} +
    +
    + {/each} +
    +
    +
    +
    + +
    + {/each} +
    + {/if} +
    + {/if} +
    +
    + + + + + +
    +
    diff --git a/routes/settings/auth/oidc/SsoSubTab.svelte b/routes/settings/auth/oidc/SsoSubTab.svelte new file mode 100644 index 0000000..ad45c77 --- /dev/null +++ b/routes/settings/auth/oidc/SsoSubTab.svelte @@ -0,0 +1,289 @@ + + +
    + + +
    +
    + + + SSO providers + +

    Enable SSO using OpenID Connect providers like Okta, Auth0, Azure AD, or Google Workspace.

    +
    + {#if $canAccess('settings', 'edit')} + + {/if} +
    +
    + + {#if oidcLoading} +
    + +
    + {:else if oidcConfigs.length === 0} + + {:else} +
    + {#each oidcConfigs as config} +
    +
    +
    + {config.name} + {#if config.enabled} + Enabled + {:else} + Disabled + {/if} +
    + {config.issuerUrl} +
    +
    + + {#if $canAccess('settings', 'edit')} + + + deleteOidcConfig(config.id)} + onOpenChange={(open) => confirmDeleteOidcId = open ? config.id : null} + > + {#snippet children({ open })} + + {/snippet} + + {/if} +
    +
    + {/each} +
    + {/if} + + {#if oidcTestResult} +
    + {#if oidcTestResult.success} +
    + +

    Connection successful

    +
    + {#if oidcTestResult.issuer} +

    Issuer: {oidcTestResult.issuer}

    + {/if} + {:else} +
    + +

    Connection failed: {oidcTestResult.error}

    +
    + {/if} +
    + {/if} +
    +
    +
    + + diff --git a/routes/settings/auth/roles/RoleModal.svelte b/routes/settings/auth/roles/RoleModal.svelte new file mode 100644 index 0000000..779eda4 --- /dev/null +++ b/routes/settings/auth/roles/RoleModal.svelte @@ -0,0 +1,632 @@ + + + { if (o) { formError = ''; formErrors = {}; focusFirstInput(); } }}> + + + + {#if isEditing} + + Edit role + {:else if isCopying} + + Copy role + {:else} + + Create role + {/if} + + + {#if isEditing} + Update role permissions + {:else if isCopying} + Create a new role based on "{copyFrom?.name}" + {:else} + Define a new role with specific permissions + {/if} + + + {#if formError} + + + {formError} + + {/if} +
    +
    + + formErrors.name = undefined} + /> + {#if formErrors.name} +

    {formErrors.name}

    + {/if} +
    +
    + + +
    +
    + + +
    + +
    +
    +
    + + System permissions + (always global) +
    +
    +
    + {#each Object.entries(systemPermissions) as [category, permissions]} + {@const IconComponent = categoryIcons[category]} +
    + +
    + + {category} +
    + +
    + + | + +
    +
    + {#each permissions as permission} + {@const PermIcon = permissionIcons[permission.key]} + + {/each} +
    +
    + {/each} +
    +
    + + +
    +
    +
    +
    + + Environment permissions +
    + {#if environments.length > 0} +
    + All environments (incl. new) + +
    + {/if} +
    + + {#if environments.length > 0} + {#if !formAllEnvironments} +
    + {#each environments as env} + {@const EnvIcon = getIconComponent(env.icon || 'globe')} + + {/each} +
    + {#if formEnvironmentIds.length === 0} +

    Select at least one environment for these permissions to be effective.

    + {/if} + {:else} +

    Permissions apply to all environments, including future ones.

    + {/if} + {:else} +

    Permissions apply to all environments.

    + {/if} +
    +
    + {#each Object.entries(environmentPermissions) as [category, permissions]} + {@const IconComponent = categoryIcons[category]} +
    + +
    + + {category} +
    + +
    + + | + +
    +
    + {#each permissions as permission} + {@const PermIcon = permissionIcons[permission.key]} + + {/each} +
    +
    + {/each} +
    +
    +
    + + + + + +
    +
    diff --git a/routes/settings/auth/roles/RolesSubTab.svelte b/routes/settings/auth/roles/RolesSubTab.svelte new file mode 100644 index 0000000..1eedc6a --- /dev/null +++ b/routes/settings/auth/roles/RolesSubTab.svelte @@ -0,0 +1,444 @@ + + +{#if $licenseStore.loading} + + +
    + +
    +
    +
    +{:else if !$licenseStore.isEnterprise} + + +
    +

    + + Enterprise feature +

    +

    + Role-based access control (RBAC) is available with an enterprise license. Define custom + roles with granular permissions and assign them to users. +

    + +
    +
    +
    +{:else} +
    + + +
    +
    + + + Roles + +

    + Define roles with granular permissions and assign them to users for access control. +

    +
    + {#if $canAccess('settings', 'edit')} + + {/if} +
    +
    + + {#if rolesLoading} +
    + +
    + {:else if roles.length === 0} +
    + +

    No roles configured

    +

    Create a role to define custom permissions

    +
    + {:else} +
    + {#each roles as role} + {@const pills = getRolePermissionPills(role.permissions)} +
    +
    +
    + {role.name} + {#if role.isSystem} + System + {/if} +
    + {#if role.description} +

    {role.description}

    + {/if} + + {#if pills.system.length > 0} +
    + System: + {#each pills.system as { category, perms }} + {@const CategoryIcon = categoryIcons[category]} + + {#if CategoryIcon} + + {/if} + {category} + + {#each perms as perm} + {@const PermIcon = permissionIcons[perm]} + {#if PermIcon} + + + + {/if} + {/each} + + + {/each} +
    + {/if} + + {#if pills.env.length > 0} +
    + Env + {#if role.environmentIds === null || role.environmentIds === undefined} + + + All + + {:else if role.environmentIds.length > 0} + {@const envs = role.environmentIds + .map(id => environments.find(e => e.id === id)) + .filter(Boolean)} + {#each envs as env} + {@const EnvIcon = getIconComponent(env.icon || 'globe')} + + + {env.name} + + {/each} + {/if} + : + {#each pills.env as { category, perms }} + {@const CategoryIcon = categoryIcons[category]} + + {#if CategoryIcon} + + {/if} + {category} + + {#each perms as perm} + {@const PermIcon = permissionIcons[perm]} + {#if PermIcon} + + + + {/if} + {/each} + + + {/each} +
    + {/if} +
    + {#if $canAccess('settings', 'edit')} +
    + {#if role.isSystem} + + + {:else} + + + + deleteRole(role.id)} + onOpenChange={(open) => (confirmDeleteRoleId = open ? role.id : null)} + > + {#snippet children({ open })} + + {/snippet} + + {/if} +
    + {/if} +
    + {/each} +
    + {/if} +
    +
    +
    +{/if} + + diff --git a/routes/settings/auth/users/UserModal.svelte b/routes/settings/auth/users/UserModal.svelte new file mode 100644 index 0000000..3868419 --- /dev/null +++ b/routes/settings/auth/users/UserModal.svelte @@ -0,0 +1,598 @@ + + + { if (o) { formError = ''; formErrors = {}; focusFirstInput(); } }}> + + + + {#if isEditing} + + Edit user + {:else} + + Add user + {/if} + + +
    +
    + {#if formError} + + + {formError} + + {/if} + {#if user?.isSso} +
    + +

    + SSO user - profile synced from identity provider +

    +
    + {/if} + + +
    +

    + + User details +

    +
    +
    + + formErrors.username = undefined} + /> + {#if formErrors.username} +

    {formErrors.username}

    + {/if} +
    +
    + + +
    +
    +
    + + +
    +
    + + + {#if !user?.isSso} +
    +

    + + Password +

    +
    +
    + {#if isEditing} + + {:else} + + {/if} + formErrors.password = undefined} + /> + + {#if formErrors.password} +

    {formErrors.password}

    + {/if} +
    +
    + {#if isEditing} + + {:else} + + {/if} + formErrors.passwordRepeat = undefined} + /> + {#if formErrors.passwordRepeat} +

    {formErrors.passwordRepeat}

    + {/if} +
    +
    +
    + {/if} + + + {#if isEnterprise && isEditing && user && !user.isSso} +
    +

    + + Two-factor authentication +

    +
    +
    +

    MFA status

    +

    + {#if user.mfaEnabled} + User has MFA configured + {:else} + User has not configured MFA + {/if} +

    +
    + +
    +
    + {/if} + + + {#if isEnterprise} + {@const systemRoles = roles.filter(r => r.isSystem)} + {@const customRoles = roles.filter(r => !r.isSystem)} +
    +
    + +

    Assign roles to this user. Environment scope is configured on the role itself.

    +
    + +
    + + {#if systemRoles.length > 0} +
    +

    + + System roles +

    +
    + {#each systemRoles as role} + {@const isAssigned = formRoleAssignments.some(a => a.roleId === role.id)} + {@const RoleIcon = getRoleIcon(role.name)} + + {/each} +
    +
    + {/if} + + + {#if customRoles.length > 0} +
    +

    + + Custom roles +

    +
    + {#each customRoles as role} + {@const isAssigned = formRoleAssignments.some(a => a.roleId === role.id)} + {@const envCount = role.environmentIds?.length ?? 0} + {@const isGlobal = role.environmentIds === null} + {@const RoleIcon = getRoleIcon(role.name)} + + {/each} +
    +
    + {/if} + + {#if roles.length === 0} +
    + No roles defined yet +
    + {/if} +
    +
    + {:else} +
    +

    + All users have full access to all environments. +

    +

    + + Upgrade to Enterprise for role-based access control. +

    +
    + {/if} +
    + + {#if isEditing} + + + {:else} + + + {/if} + +
    +
    +
    diff --git a/routes/settings/auth/users/UsersSubTab.svelte b/routes/settings/auth/users/UsersSubTab.svelte new file mode 100644 index 0000000..095c775 --- /dev/null +++ b/routes/settings/auth/users/UsersSubTab.svelte @@ -0,0 +1,490 @@ + + +
    + + +
    +
    + + + Users + +

    Manage user accounts for local authentication, SSO, and LDAP.

    +
    + {#if $canAccess('users', 'create')} + + {/if} +
    +
    + + {#if usersLoading} +
    + +
    + {:else if localUsers.length === 0} + + {:else} + +
    +
    + + +
    +
    + {filteredAndSortedUsers.length} of {localUsers.length} users +
    +
    + +
    + + + + + + + {#if $licenseStore.isEnterprise} + + {/if} + + + + + + {#each filteredAndSortedUsers as user} + {@const provider = getProviderInfo(user)} + {@const ProviderIcon = provider.icon} + {@const visibleRoles = user.roles?.slice(0, MAX_VISIBLE_ROLES) || []} + {@const hiddenRolesCount = (user.roles?.length || 0) - MAX_VISIBLE_ROLES} + + + + + + + + + {#if $licenseStore.isEnterprise} + + {/if} + + + + + + {:else} + + + + {/each} + +
    + + + + MFARoles + +
    +
    +
    + +
    +
    + {user.username} + {#if !user.isActive} + Disabled + {/if} +
    +
    +
    + {user.email || '—'} + + {#if user.mfaEnabled} + + + Enabled + + {:else} + — + {/if} + + {#if user.roles && user.roles.length > 0} +
    + {#each visibleRoles as role} + {@const RoleIcon = getRoleIcon(role.name)} + + + {role.name} + + {/each} + {#if hiddenRolesCount > 0} + +{hiddenRolesCount} more + {/if} +
    + {:else} + — + {/if} +
    + + + {provider.label} + + +
    + {#if $canAccess('users', 'edit')} + + {/if} + {#if $canAccess('users', 'delete')} + deleteLocalUser(user.id)} + onOpenChange={(open) => { if (!open) confirmDeleteUserId = null; else confirmDeleteUserId = user.id; }} + > + + + + + {/if} +
    +
    + +

    No users found matching "{searchQuery}"

    +
    +
    + {/if} +
    +
    +
    + + + + + + + + + + Delete last admin? + + + This is the only admin account. Deleting it will disable authentication and allow anyone to access Dockhand without logging in. + + + + + + + + diff --git a/routes/settings/config-sets/ConfigSetModal.svelte b/routes/settings/config-sets/ConfigSetModal.svelte new file mode 100644 index 0000000..a2e0447 --- /dev/null +++ b/routes/settings/config-sets/ConfigSetModal.svelte @@ -0,0 +1,387 @@ + + + { if (o) { formError = ''; formErrors = {}; focusFirstInput(); } }}> + + + {isEditing ? 'Edit' : 'Add'} config set + +
    + {#if formError} +
    {formError}
    + {/if} + +
    +
    + + formErrors.name = undefined} + /> + {#if formErrors.name} +

    {formErrors.name}

    + {/if} +
    +
    + + +
    +
    + +
    +
    + + formNetworkMode = v}> + + {formNetworkMode === 'bridge' ? 'Bridge' : formNetworkMode === 'host' ? 'Host' : 'None'} + + + + + + + +
    +
    + + formRestartPolicy = v}> + + {formRestartPolicy === 'no' ? 'No' : formRestartPolicy === 'always' ? 'Always' : formRestartPolicy === 'on-failure' ? 'On failure' : 'Unless stopped'} + + + + + + + + +
    +
    + + +
    +
    + + +
    + {#each formEnvVars as envVar, i} +
    + + + +
    + {/each} +
    + + +
    +
    + + +
    + {#each formLabels as label, i} +
    + + + +
    + {/each} +
    + + +
    +
    + + +
    + {#each formPorts as port, i} +
    +
    + validatePort(i, 'host')} + /> + {#if hasPortError(i, 'host')} +

    Invalid port (1-65535)

    + {/if} +
    +
    + validatePort(i, 'container')} + /> + {#if hasPortError(i, 'container')} +

    Invalid port (1-65535)

    + {/if} +
    + { formPorts[i].protocol = v; formPorts = formPorts; }} + /> + +
    + {/each} +
    + + +
    +
    + + +
    + {#each formVolumes as vol, i} +
    + + + { formVolumes[i].mode = v; formVolumes = formVolumes; }} + /> + +
    + {/each} +
    +
    + + + + +
    +
    diff --git a/routes/settings/config-sets/ConfigSetsTab.svelte b/routes/settings/config-sets/ConfigSetsTab.svelte new file mode 100644 index 0000000..9569e83 --- /dev/null +++ b/routes/settings/config-sets/ConfigSetsTab.svelte @@ -0,0 +1,195 @@ + + +
    + + +
    + +
    +

    What are config sets?

    +

    + Config sets are reusable templates for container configuration. Define common environment variables, labels, ports, and volumes once, then apply them when creating or editing containers. Values from config sets can be overwritten during container creation. +

    +
    +
    +
    +
    + +
    +
    + {configSets.length} total +
    +
    + {#if $canAccess('configsets', 'create')} + + {/if} + +
    +
    + + {#if cfgLoading && configSets.length === 0} +

    Loading config sets...

    + {:else if configSets.length === 0} + + {:else} +
    + {#each configSets as cfg (cfg.id)} +
    + + +
    +
    + + {cfg.name} +
    +
    +
    + + {#if cfg.description} +

    {cfg.description}

    + {/if} + +
    + {#if cfg.envVars && cfg.envVars.length > 0} + {cfg.envVars.length} env vars + {/if} + {#if cfg.labels && cfg.labels.length > 0} + {cfg.labels.length} labels + {/if} + {#if cfg.ports && cfg.ports.length > 0} + {cfg.ports.length} ports + {/if} + {#if cfg.volumes && cfg.volumes.length > 0} + {cfg.volumes.length} volumes + {/if} +
    + +
    + Network: {cfg.networkMode} + | + Restart: {cfg.restartPolicy} +
    + +
    + {#if $canAccess('configsets', 'edit')} + + {/if} + {#if $canAccess('configsets', 'delete')} + deleteConfigSet(cfg.id)} + onOpenChange={(open) => confirmDeleteConfigSetId = open ? cfg.id : null} + > + {#snippet children({ open })} + + {/snippet} + + {/if} +
    +
    +
    +
    + {/each} +
    + {/if} +
    + + { showCfgModal = false; editingCfg = null; }} + onSaved={fetchConfigSets} +/> diff --git a/routes/settings/environments/EnvironmentModal.svelte b/routes/settings/environments/EnvironmentModal.svelte new file mode 100644 index 0000000..b04f2f7 --- /dev/null +++ b/routes/settings/environments/EnvironmentModal.svelte @@ -0,0 +1,2530 @@ + + + { if (o) focusFirstInput(); else onClose(); }}> + + + + {#if !isEditing} + Add environment + {:else} + Edit environment + {/if} + {#if environment} + {environment.name} + {/if} + + + + {#if formError} +
    {formError}
    + {/if} + + + + + + General + + + + Updates + + + + Activity + + + + Security + + + + Notifications + + + +
    + + + +
    + +
    + formIcon = icon} /> + formErrors.name = undefined} + /> +
    + {#if formErrors.name} +

    {formErrors.name}

    + {/if} +
    + + +
    +
    + + ({formLabels.length}/{MAX_LABELS}) +
    + {#if formLabels.length > 0} +
    + {#each formLabels as label} + + {label} + + + {/each} +
    + {/if} + {#if formLabels.length < MAX_LABELS} +
    +
    + showLabelDropdown = true} + onblur={() => setTimeout(() => showLabelDropdown = false, 150)} + onkeydown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + const trimmed = newLabelInput.trim().toLowerCase(); + if (trimmed && !formLabels.includes(trimmed)) { + formLabels = [...formLabels, trimmed]; + newLabelInput = ''; + } + } else if (e.key === 'Escape') { + showLabelDropdown = false; + } + }} + /> + {#if showLabelDropdown && filteredLabelSuggestions.length > 0} +
    + {#each filteredLabelSuggestions as suggestion} + {@const colors = { color: getLabelColor(suggestion), bgColor: getLabelBgColor(suggestion) }} + + {/each} +
    + {/if} +
    + +
    + {:else} +

    Maximum labels reached

    + {/if} +
    + + +
    +
    + + + + + + +
    +
    + +
    +

    Unix socket

    +

    Connect via Docker socket on the same machine. Default path: /var/run/docker.sock. Also works with Docker Desktop and OrbStack.

    +
    +
    +
    + +
    +

    Direct connection

    +

    Connect directly to Docker Engine API. Requires Docker to expose its API on a TCP port (default 2375/2376). Best for LAN environments.

    +
    +
    +
    + +
    +

    Hawser standard

    +

    Hawser agent listens on a port and Dockhand connects to it. Good for LAN with static IPs.

    +
    +
    +
    + +
    +

    Hawser edge

    +

    Hawser agent initiates outbound WebSocket to Dockhand. No port forwarding needed. Perfect for VPS, NAT, or dynamic IPs.

    +
    +
    + + + Learn more about Hawser + +
    +
    +
    +
    + { + formConnectionType = v as ConnectionType; + // Set default port based on connection type + if (v === 'direct') { + formPort = 2375; + } else if (v === 'hawser-standard') { + formPort = 2376; + } + }}> + + + {#if formConnectionType === 'socket'} + + Unix socket + {:else if formConnectionType === 'direct'} + + Direct connection + {:else if formConnectionType === 'hawser-standard'} + + Hawser agent (standard) + {:else} + + Hawser agent (edge) + {/if} + + + + + + + Unix socket + + + + + + Direct connection + + + + + + Hawser agent (standard) + + + + + + Hawser agent (edge) + + + + + +

    + {#if formConnectionType === 'socket'} + Connect via Unix socket on the same machine. + {:else if formConnectionType === 'direct'} + Connect directly to Docker Engine API on TCP port. + {:else if formConnectionType === 'hawser-standard'} + Hawser agent listens, Dockhand connects. + {:else} + Hawser agent connects out to Dockhand. No port forwarding needed. + {/if} +

    +
    + + + {#if formConnectionType === 'socket'} +
    + +
    +
    + + +
    + {#if showSocketDropdown && detectedSockets.length > 1} +
    +
    + {#each detectedSockets as socket} + + {/each} +
    +
    + {/if} +
    +

    + Click to auto-detect available Docker sockets +

    +
    + {/if} + + + {#if formConnectionType === 'direct'} +
    +
    + + { formErrors.host = undefined; handleHostInput(); }} + onblur={() => handleHostInput(true)} + /> + {#if formErrors.host} +

    {formErrors.host}

    + {/if} +
    +
    + + +
    +
    +
    + + formProtocol = v}> + + + {#if formProtocol === 'https'} + + HTTPS (TLS) + {:else} + + HTTP + {/if} + + + + + + + HTTP + + + + + + HTTPS (TLS) + + + + +
    + {#if formProtocol === 'https'} +
    +

    TLS certificates for mTLS authentication (RSA or ECDSA)

    +
    + + +
    +
    + + +
    +
    + + +
    +
    + {/if} + {/if} + + + {#if formConnectionType === 'hawser-standard'} +
    +
    + + { formErrors.host = undefined; handleHostInput(); }} + onblur={() => handleHostInput(true)} + /> + {#if formErrors.host} +

    {formErrors.host}

    + {/if} +
    +
    + + +
    +
    +
    + + formProtocol = v}> + + + {#if formProtocol === 'https'} + + HTTPS (TLS) + {:else} + + HTTP + {/if} + + + + + + + HTTP + + + + + + HTTPS (TLS) + + + + +
    + {#if formProtocol === 'https'} +
    + + +

    Paste the CA certificate if agent uses self-signed TLS (RSA or ECDSA).

    +
    +
    +
    + +

    Disable certificate validation (insecure)

    +
    + +
    + {/if} +
    + + +

    If the Hawser agent is configured with TOKEN, enter it here.

    +
    +
    + + Run Hawser agent on the target host: hawser --port {formPort} +
    + {/if} + + + {#if formConnectionType === 'hawser-edge'} +
    + + {#if isEditing && environment} +
    + + {#if environment.hawserAgentId} + + + Connected + + {:else} + + + Waiting for agent + + {/if} +
    + + + {#if environment.hawserAgentId} +
    +

    Agent: {environment.hawserAgentName || environment.hawserAgentId}

    + {#if environment.hawserVersion} +

    Version: {environment.hawserVersion}

    + {/if} + {#if environment.hawserLastSeen} +

    Last seen: {new Date(environment.hawserLastSeen).toLocaleString()}

    + {/if} +
    + {/if} + {/if} + + +
    +
    + + {#if isEditing && hawserToken} + + {:else if isEditing && !hawserToken && !hawserTokenLoading} + + {/if} +
    + + + {#if !isEditing} + {#if !pendingToken} + +

    + Generate a token now. It will be saved when you add the environment. +

    + {:else} + +
    +

    + + Copy this token now - you'll need it for the Hawser agent! +

    +
    + + +
    +
    + Run on your host: +
    + DOCKHAND_SERVER_URL={getConnectionUrl()} TOKEN={pendingToken} hawser + +
    +
    + +
    + {/if} + {/if} + + + {#if isEditing} + {#if hawserTokenLoading} +
    + +
    + {:else if generatedToken} + +
    +

    + + Save this token now - it won't be shown again! +

    +
    + + +
    +
    + Run on your host: +
    + DOCKHAND_SERVER_URL={getConnectionUrl()} TOKEN={generatedToken} hawser + +
    +
    +
    + {:else if hawserToken} + +
    + + {hawserToken.tokenPrefix}... + {#if hawserToken.lastUsed} + + + Last used: {new Date(hawserToken.lastUsed).toLocaleDateString()} + + {/if} +
    + {:else} +

    No token generated yet. Click Generate above.

    + {/if} + {/if} +
    +
    + {/if} + + + {#if formConnectionType !== 'hawser-edge'} +
    +
    + + + + + + +

    IP address or hostname where container ports are accessible from your browser. For local Docker, use the server's LAN IP.

    +
    +
    +
    + +

    + Used for clickable port links on the containers page +

    +
    + {/if} +
    + + + +
    +
    + Scheduled update check +
    +

    + Periodically check all containers in this environment for available image updates. +

    + + {#if updateCheckLoading} +
    + +
    + {:else} +
    + +
    + +

    Automatically check for container updates on a schedule

    +
    + +
    + + {#if updateCheckEnabled} +
    +
    +
    + + updateCheckCron = cron} /> +
    +
    + +
    + +
    + +

    + When enabled, containers will be updated automatically when new images are found. + When disabled, only sends notifications about available updates. +

    +
    + +
    + + {#if updateCheckAutoUpdate && scannerEnabled} +
    +
    +
    + +

    + Block auto-updates if the new image has vulnerabilities exceeding this criteria +

    +
    + +
    + {/if} + +
    + + {#if updateCheckAutoUpdate} + {#if scannerEnabled && updateCheckVulnerabilityCriteria !== 'never'} + New images are pulled to a temporary tag, scanned, then deployed if they pass the vulnerability check. Blocked images are deleted automatically. + {:else} + Containers will be updated automatically when new images are available. + {/if} + {:else} + You'll receive notifications when updates are available. Containers won't be modified. + {/if} +
    + {/if} + {/if} +
    + + +
    + + +

    + Used for scheduling auto-updates and git syncs +

    +
    +
    + + + +
    +
    + +

    Track container events (start, stop, restart, etc.) from this environment in real-time

    +
    + +
    +
    +
    + +

    Collect CPU and memory usage statistics from this environment

    +
    + +
    +
    +
    + +

    Show amber glow when container values change in the containers list

    +
    + +
    +
    + + + +
    +
    + + Vulnerability scanning +
    + + {#if !isEditing} + +
    +
    + +

    Scan images for known security vulnerabilities

    +
    + +
    + + {#if formEnableScanner} +
    +
    + +

    Choose vulnerability scanner

    +
    + { formScannerType = v as ScannerType; }} + /> +
    + +
    + + Scanner images will be pulled automatically on first scan. Vulnerability databases are cached in Docker volumes for faster subsequent scans. +
    + {/if} + {:else if scannerLoading} +
    + +
    + {:else} +
    +
    + +

    Scan images for known security vulnerabilities

    +
    + +
    + + {#if scannerEnabled} +
    +
    + +

    Choose vulnerability scanner

    +
    + { selectedScanner = v as ScannerType; }} + /> +
    + +
    + + {#if selectedScanner === 'grype' || selectedScanner === 'both'} +
    +
    + Grype + {#if loadingScannerVersions} + + + + {:else if scannerAvailability.grype && scannerVersions.grype} + v{scannerVersions.grype} + {:else if scannerAvailability.grype} + Ready + {:else} + Not installed + {/if} + {#if !loadingScannerVersions} + {#if !scannerAvailability.grype} + loadScannerSettings(environment?.id)}> + + + {:else} + + {#if grypeUpdateStatus === 'up-to-date'} + + + Latest + + {:else if grypeUpdateStatus === 'update-available' || pullingGrype} + + {:else} + + {/if} + {/if} + {/if} +
    +
    + {/if} + + + {#if selectedScanner === 'trivy' || selectedScanner === 'both'} +
    +
    + Trivy + {#if loadingScannerVersions} + + + + {:else if scannerAvailability.trivy && scannerVersions.trivy} + v{scannerVersions.trivy} + {:else if scannerAvailability.trivy} + Ready + {:else} + Not installed + {/if} + {#if !loadingScannerVersions} + {#if !scannerAvailability.trivy} + loadScannerSettings(environment?.id)}> + + + {:else} + + {#if trivyUpdateStatus === 'up-to-date'} + + + Latest + + {:else if trivyUpdateStatus === 'update-available' || pullingTrivy} + + {:else} + + {/if} + {/if} + {/if} +
    +
    + {/if} + + + {#if ((selectedScanner === 'grype' || selectedScanner === 'both') && !scannerAvailability.grype) || ((selectedScanner === 'trivy' || selectedScanner === 'both') && !scannerAvailability.trivy)} +
    + + Scanner images will be pulled automatically on first scan. Vulnerability databases are cached in Docker volumes for faster subsequent scans. +
    + {/if} +
    + {/if} + {/if} +
    +
    + + + +
    + + Notification channels +
    + + {#if !isEditing} + +

    + Select which notification channels should send alerts for events from this environment. +

    + + {#if notifications.length === 0} +
    + +

    No notification channels configured yet.

    +

    Create notification channels in the Notifications settings tab first.

    +
    + {:else} +
    + {#each notifications as channel (channel.id)} + {@const isSelected = formSelectedNotifications.some(n => n.id === channel.id)} + {@const selectedNotif = formSelectedNotifications.find(n => n.id === channel.id)} +
    +
    +
    + {#if channel.type === 'smtp'} + + {:else} + + {/if} + {channel.name} ({channel.type}) +
    +
    + { + if (isSelected) { + formSelectedNotifications = formSelectedNotifications.filter(n => n.id !== channel.id); + } else { + formSelectedNotifications = [...formSelectedNotifications, { id: channel.id, eventTypes: NOTIFICATION_EVENT_TYPES.map(e => e.id) }]; + } + }} + /> +
    +
    + {#if !channel.enabled} +

    + + Channel disabled globally +

    + {/if} + + {#if isSelected && selectedNotif} + {@const isCollapsed = collapsedChannels.has(channel.id)} +
    +
    toggleChannelCollapse(channel.id)} + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleChannelCollapse(channel.id); } }} + > + {#if isCollapsed} + + {:else} + + {/if} + Event types ({selectedNotif.eventTypes.length}) +
    + {#if !isCollapsed} + { + formSelectedNotifications = formSelectedNotifications.map(n => + n.id === channel.id ? { ...n, eventTypes: newTypes } : n + ); + }} + /> + {/if} +
    + {/if} +
    + {/each} +
    + {/if} + {:else} +

    + Configure which notification channels should send alerts for events from this environment. + {#if environment && !environment.collectActivity} + Activity collection will be enabled automatically when you add a channel. + {/if} +

    + + {#if envNotifLoading} +
    + +
    + {:else} + + {#if envNotifications.length > 0} +
    + {#each envNotifications as notif (notif.id)} +
    + +
    toggleChannelCollapse(notif.notificationId)} + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleChannelCollapse(notif.notificationId); } }} + > +
    + {#if collapsedChannels.has(notif.notificationId)} + + {:else} + + {/if} + {#if notif.channelType === 'smtp'} + + {:else} + + {/if} + {notif.channelName} + ({notif.eventTypes.length} events) +
    +
    e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}> + environment && updateEnvNotification(environment.id, notif.notificationId, { enabled: !notif.enabled, eventTypes: notif.eventTypes })} + /> + +
    +
    + {#if !notif.channelEnabled} +
    +

    + + Channel disabled globally +

    +
    + {/if} + + {#if !collapsedChannels.has(notif.notificationId)} +
    + { + environment && updateEnvNotification(environment.id, notif.notificationId, { enabled: notif.enabled, eventTypes: newTypes }); + }} + /> +
    + {/if} +
    + {/each} +
    + {:else} +
    + +

    No notification channels configured

    +

    Add a channel below to receive alerts for this environment

    +
    + {/if} + + + {@const availableChannels = notifications.filter(n => !envNotifications.some(en => en.notificationId === n.id))} + {#if availableChannels.length > 0} +
    + +
    + {#each availableChannels as channel} + + {/each} +
    +
    + {:else if notifications.length === 0} +
    + + {#if !$licenseStore.isEnterprise || $canAccess('notifications', 'create')} + No notification channels have been created yet. Go to Settings → Notifications to add channels first. + {:else} + No notification channels have been created yet. Contact your administrator to configure notification channels. + {/if} +
    + {/if} + {/if} + {/if} +
    +
    +
    + + +
    + + + + {#if !isEditing} + + + + {:else} + + + + {/if} +
    +
    +
    +
    diff --git a/routes/settings/environments/EnvironmentsTab.svelte b/routes/settings/environments/EnvironmentsTab.svelte new file mode 100644 index 0000000..6a5b886 --- /dev/null +++ b/routes/settings/environments/EnvironmentsTab.svelte @@ -0,0 +1,641 @@ + + + +
    +
    +
    + {environments.length} total +
    +
    + {#if $canAccess('environments', 'create')} + + {/if} + + +
    +
    + + {#if envLoading && environments.length === 0} +

    Loading environments...

    + {:else if environments.length === 0} +

    No environments found

    + {:else} +
    + + + + Name + Connection + Labels + Timezone + Features + Status + Docker + Hawser + Actions + + + + {#each environments as env (env.id)} + {@const testResult = testResults[env.id]} + {@const isTesting = testingEnvs.has(env.id)} + {@const hasScannerEnabled = envScannerStatus[env.id]} + {@const EnvIcon = getIconComponent(env.icon || 'globe')} + + + +
    + + {#if env.connectionType === 'socket' || !env.connectionType} + + + + {:else if env.connectionType === 'direct'} + + + + {:else if env.connectionType === 'hawser-standard'} + + + + {:else if env.connectionType === 'hawser-edge'} + + + + {/if} + {env.name} +
    +
    + + + + + {#if env.connectionType === 'socket' || !env.connectionType} + {env.socketPath || '/var/run/docker.sock'} + {:else if env.connectionType === 'hawser-edge'} + Edge connection (outbound) + {:else} + {env.protocol || 'http'}://{env.host}:{env.port || 2375} + {/if} + + + + + + {#if env.labels && env.labels.length > 0} +
    + {#each env.labels as label} + {@const colors = getLabelColors(label)} + + {label} + + {/each} +
    + {:else} + — + {/if} +
    + + + + {#if env.timezone} +
    + + {env.timezone} +
    + {:else} + — + {/if} +
    + + + +
    + {#if env.updateCheckEnabled} + + {#if env.updateCheckAutoUpdate} + + {:else} + + {/if} + + {/if} + {#if hasScannerEnabled} + + + + {/if} + {#if env.collectActivity} + + + + {/if} + {#if env.collectMetrics} + + + + {/if} + {#if !env.updateCheckEnabled && !hasScannerEnabled && !env.collectActivity && !env.collectMetrics} + — + {/if} +
    +
    + + + + {#if testResult} + {#if testResult.success} +
    + {#if isTesting} + + {:else} + + {/if} + Connected +
    + {:else} +
    + {#if isTesting} + + {:else} + + {/if} + Failed +
    + {/if} + {:else if isTesting} +
    + + Testing... +
    + {:else} + Not tested + {/if} +
    + + + + {#if testResult?.info?.serverVersion} + {testResult.info.serverVersion} + {:else} + — + {/if} + + + + + {#if testResult?.hawser?.hawserVersion} + {testResult.hawser.hawserVersion} + {:else if env.hawserVersion} + {env.hawserVersion} + {:else} + — + {/if} + + + + +
    + + {#if $canAccess('environments', 'edit')} + + {/if} + {#if $canAccess('containers', 'remove') && $canAccess('images', 'remove') && $canAccess('volumes', 'remove') && $canAccess('networks', 'remove')} + pruneSystem(env.id)} + onOpenChange={(open) => confirmPruneEnvId = open ? env.id : null} + > + {#snippet children({ open })} + + {/snippet} + + {/if} + {#if $canAccess('environments', 'delete')} + deleteEnvironment(env.id)} + onOpenChange={(open) => confirmDeleteEnvId = open ? env.id : null} + > + {#snippet children({ open })} + + {/snippet} + + {/if} +
    +
    +
    + {/each} +
    +
    +
    + {/if} +
    + + diff --git a/routes/settings/environments/EventTypesEditor.svelte b/routes/settings/environments/EventTypesEditor.svelte new file mode 100644 index 0000000..5283763 --- /dev/null +++ b/routes/settings/environments/EventTypesEditor.svelte @@ -0,0 +1,210 @@ + + +
    + {#each NOTIFICATION_EVENT_GROUPS as group (group.id)} + {@const isCollapsed = collapsedGroups.has(group.id)} + {@const selectedCount = getGroupSelectedCount(group)} + {@const allSelected = selectedCount === group.events.length} + {@const someSelected = selectedCount > 0 && selectedCount < group.events.length} + {@const GroupIcon = group.icon} + +
    + +
    toggleGroup(group.id)} + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleGroup(group.id); } }} + > +
    + {#if isCollapsed} + + {:else} + + {/if} + + {group.label} + + ({selectedCount}/{group.events.length}) + +
    + +
    + + + {#if !isCollapsed} +
    + {#each group.events as event (event.id)} + {@const isSelected = selectedEventTypes.includes(event.id)} +
    +
    +
    {event.label}
    +
    {event.description}
    +
    + toggleEvent(event.id)} + {disabled} + /> +
    + {/each} +
    + {/if} +
    + {/each} +
    diff --git a/routes/settings/general/GeneralTab.svelte b/routes/settings/general/GeneralTab.svelte new file mode 100644 index 0000000..8a794e8 --- /dev/null +++ b/routes/settings/general/GeneralTab.svelte @@ -0,0 +1,441 @@ + + +
    +
    + +
    + + + + + Appearance + {#if !$authStore.authEnabled} + + + + + + + + Theme and font settings are global when authentication is disabled. When auth is enabled, users can customize their appearance in their profile. + + + + + {/if} + + + +
    + +
    +
    +
    + + { + appSettings.setShowStoppedContainers(!showStoppedContainers); + toast.success(showStoppedContainers ? 'Stopped containers hidden' : 'Stopped containers shown'); + }} + disabled={!$canAccess('settings', 'edit')} + /> +
    +

    Display stopped and exited containers in lists

    +
    +
    +
    + + { + appSettings.setHighlightUpdates(!highlightUpdates); + toast.success(highlightUpdates ? 'Update highlighting disabled' : 'Update highlighting enabled'); + }} + disabled={!$canAccess('settings', 'edit')} + /> +
    +

    Highlight container rows in amber when updates are available

    +
    +
    +
    + + { + appSettings.setTimeFormat(newFormat as '12h' | '24h'); + toast.success(`Time format set to ${newFormat === '12h' ? '12-hour (AM/PM)' : '24-hour'}`); + }} + disabled={!$canAccess('settings', 'edit')} + /> +
    +

    Display timestamps in 12-hour (AM/PM) or 24-hour format

    +
    +
    +
    + + { + if (value) { + appSettings.setDateFormat(value as DateFormat); + toast.success(`Date format set to ${value}`); + } + }} + disabled={!$canAccess('settings', 'edit')} + > + + + {dateFormat} + + + {#each dateFormatOptions as option} + +
    + {option.label} + {option.example} +
    +
    + {/each} +
    +
    +
    +

    How dates are displayed throughout the app

    +
    +
    + + {#if !$authStore.authEnabled} +
    + +
    + {:else} +
    + +
    +

    Appearance settings (theme, fonts) are personal when auth is enabled.

    + Configure in your profile +
    +
    + {/if} +
    +
    +
    + + + + + + Scheduling + + + +
    + + { + appSettings.setDefaultTimezone(value); + toast.success(`Default timezone set to ${value}`); + }} + class="w-[320px]" + /> +

    Default timezone for new environments. Used for scheduled tasks like auto-updates.

    +
    +
    +
    + + + + + + Confirmations + + + +
    +
    + + { + appSettings.setConfirmDestructive(!confirmDestructive); + toast.success(confirmDestructive ? 'Confirmations disabled' : 'Confirmations enabled'); + }} + disabled={!$canAccess('settings', 'edit')} + /> +
    +

    Show confirmation dialogs before deleting resources

    +
    +
    +
    + + + + + + Logs & files + + + +
    + +
    + + KB +
    +

    Maximum log buffer per container panel. Older logs are truncated when exceeded.

    + {#if logBufferSizeKb > 1000} +
    + +

    High values may degrade browser performance with verbose containers. Recommended: 250-1000 KB.

    +
    + {/if} +
    +
    +
    + + { + appSettings.setDownloadFormat(newFormat as DownloadFormat); + toast.success(`Download format set to ${newFormat}`); + }} + disabled={!$canAccess('settings', 'edit')} + /> +
    +

    Archive format when downloading files from containers

    +
    +
    +
    + +
    + + +
    + + + + + Vulnerability scanners + + + +
    + + +

    Use {'{image}'} as placeholder for the image name

    +
    +
    + + +

    Use {'{image}'} as placeholder for the image name

    +
    +
    +
    + + + + + + System jobs + + + +
    +
    + + +
    +

    Delete executions older than specified days

    +
    + + days +
    + +
    +
    +
    +
    +
    + + +
    +

    Delete events older than specified days

    +
    + + days +
    + +
    +
    +
    +
    +
    + + Always enabled +
    +

    + Automatically removes temporary containers used for browsing volume contents. + Runs every 30 minutes and on startup. +

    +
    +
    +
    +
    +
    +
    diff --git a/routes/settings/git/GitCredentialModal.svelte b/routes/settings/git/GitCredentialModal.svelte new file mode 100644 index 0000000..9218afa --- /dev/null +++ b/routes/settings/git/GitCredentialModal.svelte @@ -0,0 +1,251 @@ + + + { if (o) focusFirstInput(); else onClose(); }}> + + + + + {isEditing ? 'Edit' : 'Add'} Git credential + + + {isEditing ? 'Update credential settings' : 'Create a new credential for accessing Git repositories'} + + + +
    { e.preventDefault(); saveCredential(); }} class="space-y-4"> +
    + + errors.name = undefined} + /> + {#if errors.name} +

    {errors.name}

    + {:else if !isEditing} +

    A friendly name to identify this credential

    + {/if} +
    + +
    + + formAuthType = val as 'password' | 'ssh'} + /> +
    + + +
    + {#if formAuthType === 'password'} +
    + + +
    +
    + + errors.password = undefined} + /> + {#if errors.password} +

    {errors.password}

    + {:else if isEditing && credential?.hasPassword} +

    Current password is set. Leave empty to keep it.

    + {/if} +
    + {:else if formAuthType === 'ssh'} +
    + + + {#if errors.sshKey} +

    {errors.sshKey}

    + {:else if isEditing && credential?.hasSshKey} +

    Current SSH key is set. Leave empty to keep it.

    + {/if} +
    +
    + + +
    + {/if} +
    + + {#if formError} +

    {formError}

    + {/if} + + + + + +
    +
    +
    diff --git a/routes/settings/git/GitCredentialsTab.svelte b/routes/settings/git/GitCredentialsTab.svelte new file mode 100644 index 0000000..b4b7157 --- /dev/null +++ b/routes/settings/git/GitCredentialsTab.svelte @@ -0,0 +1,176 @@ + + +
    +
    +
    +

    Git credentials

    +

    Manage credentials for accessing Git repositories

    +
    + {#if $canAccess('settings', 'edit')} + + {/if} +
    + + {#if loading} +

    Loading credentials...

    + {:else if credentials.length === 0} + + + + + + {:else} +
    + {#each credentials as cred (cred.id)} + + +
    +
    + {#if cred.authType === 'ssh'} + + {:else if cred.authType === 'password'} + + {:else} + + {/if} +
    +
    +
    {cred.name}
    +
    + {#if cred.username} + Username: {cred.username} + {:else} + No username + {/if} +
    +
    + + {getAuthTypeBadge(cred.authType).label} + +
    + {#if $canAccess('settings', 'edit')} +
    + + deleteCredential(cred.id)} + onOpenChange={(open) => confirmDeleteId = open ? cred.id : null} + > + {#snippet children({ open })} + + {/snippet} + +
    + {/if} +
    +
    + {/each} +
    + {/if} +
    + + diff --git a/routes/settings/git/GitRepositoriesTab.svelte b/routes/settings/git/GitRepositoriesTab.svelte new file mode 100644 index 0000000..57a4619 --- /dev/null +++ b/routes/settings/git/GitRepositoriesTab.svelte @@ -0,0 +1,244 @@ + + +
    +
    +
    +

    Git repositories

    +

    Manage Git repositories that can be used to deploy stacks

    +
    + {#if $canAccess('settings', 'edit')} + + {/if} +
    + + {#if loading} +

    Loading repositories...

    + {:else if repositories.length === 0} + + + + + + {:else} +
    + {#each repositories as repo (repo.id)} +
    +
    + {#if repo.url.includes('github.com')} + + {:else} + + {/if} + {repo.name} + +
    +
    + {#if testResult?.id === repo.id} + + {#if testResult.success} + + {:else} + + {/if} + + + {/if} + {#if repo.credentialName} + + + + + {:else} + + + + + {/if} + + + {repo.branch} + + + {#if $canAccess('settings', 'edit')} + + deleteRepository(repo.id)} + onOpenChange={(open) => confirmDeleteId = open ? repo.id : null} + > + {#snippet children({ open })} + + {/snippet} + + {/if} +
    +
    + {/each} +
    + {/if} +
    + + diff --git a/routes/settings/git/GitRepositoryModal.svelte b/routes/settings/git/GitRepositoryModal.svelte new file mode 100644 index 0000000..ed6f3da --- /dev/null +++ b/routes/settings/git/GitRepositoryModal.svelte @@ -0,0 +1,330 @@ + + + { if (o) focusFirstInput(); else onClose(); }}> + + + + + {isEditing ? 'Edit' : 'Add'} Git repository + + + {isEditing ? 'Update repository settings' : 'Add a Git repository that can be used to deploy stacks'} + + + +
    { e.preventDefault(); saveRepository(); }} class="space-y-4"> +
    + + formErrors.name = undefined} + /> + {#if formErrors.name} +

    {formErrors.name}

    + {:else if !isEditing} +

    A friendly name to identify this repository

    + {/if} +
    + +
    + + { formErrors.url = undefined; testResult = null; }} + /> + {#if formErrors.url} +

    {formErrors.url}

    + {/if} +
    + +
    + + testResult = null} /> +
    + +
    + + { formCredentialId = v === 'none' ? null : parseInt(v); testResult = null; }} + > + + {@const selectedCred = credentials.find(c => c.id === formCredentialId)} + {#if selectedCred} + {@const Icon = getAuthIcon(selectedCred.authType)} + + + {selectedCred.name} ({getAuthLabel(selectedCred.authType)}) + + {:else} + + + None (public repository) + + {/if} + + + + + + None (public repository) + + + {#each credentials as cred} + + + {#if cred.authType === 'ssh'} + + {:else if cred.authType === 'password'} + + {:else} + + {/if} + {cred.name} ({getAuthLabel(cred.authType)}) + + + {/each} + + + {#if credentials.length === 0 && !isEditing} +

    + Add credentials for private repositories +

    + {/if} +
    + + {#if formError} +

    {formError}

    + {/if} + + + + + + +
    +
    +
    diff --git a/routes/settings/git/GitTab.svelte b/routes/settings/git/GitTab.svelte new file mode 100644 index 0000000..698e2ca --- /dev/null +++ b/routes/settings/git/GitTab.svelte @@ -0,0 +1,36 @@ + + +
    + + + + {#if gitSubTab === 'repositories'} + + {:else} + + {/if} +
    diff --git a/routes/settings/license/LicenseTab.svelte b/routes/settings/license/LicenseTab.svelte new file mode 100644 index 0000000..cc094b4 --- /dev/null +++ b/routes/settings/license/LicenseTab.svelte @@ -0,0 +1,257 @@ + + +
    + + +
    + +
    +

    License management

    +

    + Activate your license to validate commercial use. Enterprise licenses unlock premium features including RBAC, LDAP and audit logs. +

    +
    +
    +
    +
    + + {#if licenseLoading} + + + +

    Loading license information...

    +
    +
    + {:else if licenseInfo?.valid && licenseInfo?.active} + + {@const isEnterprise = licenseInfo.payload?.type === 'enterprise'} + + + + {#if isEnterprise} + + Active Enterprise license + {:else} + + Active SMB license + {/if} + + + +
    +
    +

    Licensed to

    +

    {licenseInfo.payload?.name}

    +
    +
    +

    License type

    +

    + {#if isEnterprise} + + Enterprise + {:else} + + SMB + {/if} +

    +
    +
    +

    Licensed host

    +

    {licenseInfo.payload?.host}

    +
    +
    +

    Issued

    +

    {new Date(licenseInfo.payload?.issued || '').toLocaleDateString()}

    +
    +
    +

    Expires

    +

    {licenseInfo.payload?.expires ? new Date(licenseInfo.payload.expires).toLocaleDateString() : 'Never (Perpetual)'}

    +
    +
    +
    +

    Current hostname

    + {licenseInfo.hostname} +
    + {#if $canAccess('settings', 'edit')} +
    + +
    + {/if} +
    +
    + {:else} + + + + + + Activate license + + + + {#if licenseFormError} +
    + {licenseFormError} +
    + {/if} + + {#if licenseInfo?.error && !licenseFormError} +
    + {licenseInfo.error} +
    + {/if} + +
    + + +

    Enter the name exactly as provided with your license

    +
    + +
    + + +
    + +
    +

    Current hostname (for license validation)

    + {licenseInfo?.hostname || 'Unknown'} +
    + + {#if $canAccess('settings', 'edit')} +
    + +
    + {/if} +
    +
    + {/if} +
    diff --git a/routes/settings/notifications/NotificationModal.svelte b/routes/settings/notifications/NotificationModal.svelte new file mode 100644 index 0000000..a619064 --- /dev/null +++ b/routes/settings/notifications/NotificationModal.svelte @@ -0,0 +1,479 @@ + + + { if (o) { formError = ''; focusFirstInput(); } }}> + + + {isEditing ? 'Edit' : 'Add'} notification channel + +
    + {#if formError} +
    {formError}
    + {/if} + +
    +
    + + +
    +
    + + {#if isEditing} + + {formType === 'smtp' ? 'SMTP (Email)' : 'Apprise (Webhooks)'} + + {:else} + formType = v as 'smtp' | 'apprise'} + > + + + {#if formType === 'smtp'} + SMTP (Email) + {:else} + Apprise (Webhooks) + {/if} + + + + + SMTP (Email) + + + Apprise (Webhooks) + + + + {/if} +
    +
    + +
    + + +
    + + {#if formType === 'smtp'} +
    +

    SMTP configuration

    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + {:else} +
    +

    Apprise configuration

    +
    + + +

    + Supports Gotify (gotify:// or gotifys:// for HTTPS), Discord, Slack, Telegram, ntfy, Pushover, and generic JSON webhooks. +

    +
    +
    + {/if} + + +
    + + {#if showSystemEvents} +
    +

    + These events are not tied to specific environments and are configured globally here. +

    + {#each SYSTEM_EVENTS as event} + + {/each} +
    + {/if} +
    + + +
    +
    + + Environment-specific events (containers, stacks, auto-updates) are configured in each environment's settings. +
    +
    +
    + + +
    + + +
    +
    +
    +
    diff --git a/routes/settings/notifications/NotificationsTab.svelte b/routes/settings/notifications/NotificationsTab.svelte new file mode 100644 index 0000000..b59e229 --- /dev/null +++ b/routes/settings/notifications/NotificationsTab.svelte @@ -0,0 +1,278 @@ + + +
    + + +
    + +
    +

    Notification channels

    +

    + Configure notification channels to receive alerts about Docker events. Supports SMTP email and Apprise URLs (Discord, Slack, Telegram, ntfy, and more). +

    +

    + + Detailed notification settings (event types, enable/disable) are configured per environment in Environment settings. +

    +
    +
    +
    +
    + +
    +
    + {notifications.length} channels +
    +
    + {#if $canAccess('notifications', 'create')} + + {/if} + +
    +
    + + {#if notifLoading && notifications.length === 0} +

    Loading notification channels...

    + {:else if notifications.length === 0} + + {:else} +
    + {#each notifications as notif (notif.id)} +
    + + +
    +
    + {#if notif.type === 'smtp'} + + {:else} + + {/if} + {notif.name} +
    + {#if $canAccess('notifications', 'edit')} + toggleNotification(notif)} + /> + {:else} + + {notif.enabled ? 'Enabled' : 'Disabled'} + + {/if} +
    +
    + +
    + {#if notif.type === 'smtp'} + SMTP: {notif.config.host}:{notif.config.port} + {:else} + Apprise: {notif.config.urls?.length || 0} URLs + {/if} +
    + + {#if testingNotif === notif.id} +
    + + Sending test... +
    + {:else if testResult && testedNotifId === notif.id} +
    + {#if testResult.success} + + Test sent successfully + {:else} + + {testResult.error || 'Test failed'} + {/if} +
    + {/if} + +
    + + {#if $canAccess('notifications', 'edit')} + + {/if} + {#if $canAccess('notifications', 'delete')} + deleteNotification(notif.id)} + onOpenChange={(open) => confirmDeleteNotificationId = open ? notif.id : null} + > + {#snippet children({ open })} + + {/snippet} + + {/if} +
    +
    +
    +
    + {/each} +
    + {/if} +
    + + { showNotifModal = false; editingNotif = null; }} + onSaved={fetchNotifications} +/> diff --git a/routes/settings/registries/RegistriesTab.svelte b/routes/settings/registries/RegistriesTab.svelte new file mode 100644 index 0000000..d611265 --- /dev/null +++ b/routes/settings/registries/RegistriesTab.svelte @@ -0,0 +1,213 @@ + + +
    +
    +
    + {registries.length} total +
    +
    + {#if $canAccess('registries', 'create')} + + {/if} + +
    +
    + + {#if regLoading && registries.length === 0} +

    Loading registries...

    + {:else if registries.length === 0} + + {:else} +
    + {#each registries as registry (registry.id)} +
    + + +
    +
    + {#if isDockerHub(registry)} + + {:else} + + {/if} + {registry.name} +
    +
    + {#if registry.isDefault} + Default + {/if} + {#if registry.hasCredentials} + Auth + {/if} +
    +
    +
    + +
    + {registry.url} +
    + + +
    + {#if registry.username} + + {registry.username} + {/if} +
    + +
    + {#if !registry.isDefault && $canAccess('registries', 'edit')} + + {/if} + {#if $canAccess('registries', 'edit')} + + {/if} + {#if $canAccess('registries', 'delete')} + deleteRegistry(registry.id)} + onOpenChange={(open) => confirmDeleteRegistryId = open ? registry.id : null} + > + {#snippet children({ open })} + + {/snippet} + + {/if} +
    +
    +
    +
    + {/each} +
    + {/if} +
    + + { showRegModal = false; editingReg = null; }} + onSaved={fetchRegistries} +/> diff --git a/routes/settings/registries/RegistryModal.svelte b/routes/settings/registries/RegistryModal.svelte new file mode 100644 index 0000000..8fe566a --- /dev/null +++ b/routes/settings/registries/RegistryModal.svelte @@ -0,0 +1,153 @@ + + + { if (o) { formError = ''; focusFirstInput(); } }}> + + + {isEditing ? 'Edit' : 'Add'} registry + +
    + {#if formError} +
    {formError}
    + {/if} +
    + + +
    +
    + + +
    +
    +

    Credentials {isEditing ? '(leave password blank to keep existing)' : '(optional)'}

    +
    + + +
    +
    + + +
    +
    +
    + + + + +
    +
    diff --git a/routes/stacks/+page.svelte b/routes/stacks/+page.svelte new file mode 100644 index 0000000..d2b939d --- /dev/null +++ b/routes/stacks/+page.svelte @@ -0,0 +1,1934 @@ + + +
    +
    + + {#if stacks.length > 0} + + {/if} + +
    +
    + + e.key === 'Escape' && (searchInput = '')} + class="pl-8 h-8 w-48 text-sm" + /> +
    + + + {#if $canAccess('stacks', 'create')} + + + {/if} +
    +
    + + + {#if selectedStacks.size > 0} +
    + {selectedInFilter.length} selected + + {#if selectedStopped.length > 0 && $canAccess('stacks', 'start')} + confirmBulkStart = open} + > + {#snippet children({ open })} + + + Start + + {/snippet} + + {/if} + {#if selectedRunning.length > 0 && $canAccess('stacks', 'restart')} + confirmBulkRestart = open} + > + {#snippet children({ open })} + + + Restart + + {/snippet} + + {/if} + {#if selectedRunning.length > 0 && $canAccess('stacks', 'stop')} + confirmBulkStop = open} + > + {#snippet children({ open })} + + + Stop + + {/snippet} + + {/if} + {#if selectedRunning.length > 0 && $canAccess('stacks', 'stop')} + confirmBulkDown = open} + > + {#snippet children({ open })} + + + Down + + {/snippet} + + {/if} + {#if $canAccess('stacks', 'remove')} + confirmBulkRemove = open} + > + {#snippet children({ open })} + + + Remove + + {/snippet} + + {/if} +
    + {/if} + + {#if !loading && ($environments.length === 0 || !$currentEnvironment)} + + {:else if !loading && stacks.length === 0} + + {:else} + saveExpandedState()} + sortState={{ field: sortField, direction: sortDirection }} + onSortChange={(state) => { + sortField = state.field as SortField; + sortDirection = state.direction; + }} + onRowClick={(stack, e) => { + const hasContainers = stack.containers && stack.containers.length > 0; + if (hasContainers) { + toggleExpand(stack.name); + } + }} + rowClass={(stack) => { + const isExp = expandedStacks.has(stack.name); + const isSel = selectedStacks.has(stack.name); + return `${isExp ? 'bg-muted/40' : ''} ${isSel ? 'bg-muted/30' : ''}`; + }} + > + {#snippet cell(column, stack, rowState)} + {@const source = getStackSource(stack.name)} + {#if column.id === 'name'} + {stack.name} + {#if stackEnvVarCounts[stack.name]} + + + + + {stackEnvVarCounts[stack.name]} + + + + {stackEnvVarCounts[stack.name]} environment variable{stackEnvVarCounts[stack.name] !== 1 ? 's' : ''} configured + + + {/if} + {:else if column.id === 'source'} + {#if source.sourceType === 'git'} + + + + + Git + + + + {#if source.repository} + {source.repository.url} ({source.repository.branch}) + {:else} + Deployed from Git repository + {/if} + + + {:else if source.sourceType === 'internal'} + + + + + Internal + + + Created in Dockhand + + {:else} + + + + + External + + + Created outside Dockhand + + {/if} + {:else if column.id === 'containers'} +
    + {#if getContainerStateCounts(stack).running} + + + {getContainerStateCounts(stack).running} + + {/if} + {#if getContainerStateCounts(stack).exited} + + + {getContainerStateCounts(stack).exited} + + {/if} + {#if getContainerStateCounts(stack).paused} + + + {getContainerStateCounts(stack).paused} + + {/if} + {#if getContainerStateCounts(stack).restarting} + + + {getContainerStateCounts(stack).restarting} + + {/if} + {#if getContainerStateCounts(stack).created} + + + {getContainerStateCounts(stack).created} + + {/if} + {#if getContainerStateCounts(stack).dead} + + + {getContainerStateCounts(stack).dead} + + {/if} + {#if stack.containers.length === 0} + - + {/if} +
    + {:else if column.id === 'cpu'} + {@const stats = getStackStats(stack)} +
    + {#if stats} + {stats.cpuPercent.toFixed(1)}% + {:else if stack.status === 'running' || stack.status === 'partial'} + ... + {:else} + - + {/if} +
    + {:else if column.id === 'memory'} + {@const stats = getStackStats(stack)} +
    + {#if stats} + {formatBytes(stats.memoryUsage)} + {:else if stack.status === 'running' || stack.status === 'partial'} + ... + {:else} + - + {/if} +
    + {:else if column.id === 'networkIO'} + {@const stats = getStackStats(stack)} +
    + {#if stats} + + ↓{formatBytes(stats.networkRx, 0)} ↑{formatBytes(stats.networkTx, 0)} + + {:else if stack.status === 'running' || stack.status === 'partial'} + ... + {:else} + - + {/if} +
    + {:else if column.id === 'diskIO'} + {@const stats = getStackStats(stack)} +
    + {#if stats} + + r{formatBytes(stats.blockRead, 0)} w{formatBytes(stats.blockWrite, 0)} + + {:else if stack.status === 'running' || stack.status === 'partial'} + ... + {:else} + - + {/if} +
    + {:else if column.id === 'networks'} + + {getStackNetworkCount(stack) || '-'} + + {:else if column.id === 'volumes'} + + {getStackVolumeCount(stack) || '-'} + + {:else if column.id === 'status'} + {@const StatusIcon = getStackStatusIcon(stack.status)} + + + {stack.status} + + {:else if column.id === 'actions'} +
    + {#if operationError?.id === stack.name && operationError?.message} +
    + + {operationError.message} + +
    + {/if} + {#if stack.status === 'not deployed' && source.gitStack} + + + {#snippet children()} + + {/snippet} + + {:else} + {#if source.sourceType === 'git' && source.gitStack} + + {#snippet children()} + + {/snippet} + + {/if} + {#if $canAccess('stacks', 'edit')} + {#if source.sourceType === 'internal'} + + {:else if source.sourceType === 'git' && source.gitStack} + + {/if} + {/if} + {#if stack.containers && stack.containers.length > 0} + + {/if} + {#if stackActionLoading === stack.name} +
    + +
    + {:else if stack.status === 'running' || stack.status === 'partial'} + {#if $canAccess('stacks', 'restart')} + restartStack(stack.name)} + onOpenChange={() => {}} + > + {#snippet children({ open })} + + {/snippet} + + {/if} + {#if $canAccess('stacks', 'stop')} + stopStack(stack.name)} + onOpenChange={(open) => confirmStopName = open ? stack.name : null} + > + {#snippet children({ open })} + + {/snippet} + + {/if} + {#if $canAccess('stacks', 'stop')} + downStack(stack.name)} + onOpenChange={(open) => confirmDownName = open ? stack.name : null} + > + {#snippet children({ open })} + + {/snippet} + + {/if} + {:else} + {#if $canAccess('stacks', 'start')} + + {/if} + {/if} + {/if} + {#if $canAccess('stacks', 'remove')} + removeStack(stack.name)} + onOpenChange={(open) => confirmDeleteName = open ? stack.name : null} + > + {#snippet children({ open })} + + {/snippet} + + {/if} +
    + {/if} + {/snippet} + + {#snippet expandedRow(stack, rowState)} + {#if stack.containerDetails?.length > 0} +
    +
    + {#each stack.containerDetails as container (container.id)} + {@const isLoading = containerActionLoading === container.id} +
    +
    + + {container.service} + {#if container.health} + + {#if container.health === 'healthy'} + + {:else if container.health === 'unhealthy'} + + {:else} + + {/if} + + {/if} + {container.state} +
    +
    +
    {container.image}
    +
    + + + {formatUptime(container.status)} + + {#if container.restartCount > 0} + + + {container.restartCount} + + {/if} +
    +
    + + {#if container.state === 'running'} + {@const stats = containerStats.get(container.id)} + {@const history = containerStatsHistory.get(container.id)} + {#key statsUpdateCount} +
    + +
    +
    + CPU + {stats?.cpuPercent?.toFixed(0) ?? '-'}% +
    + {#if history?.cpu && history.cpu.length >= 2} + + + + + {:else} +
    + {/if} +
    + +
    +
    + Mem + {stats ? formatBytes(stats.memoryUsage) : '-'} +
    + {#if history?.mem && history.mem.length >= 2} + + + + + {:else} +
    + {/if} +
    + +
    +
    + Net + {stats ? formatBytes(stats.networkRx + stats.networkTx) : '-'} +
    + {#if history?.netRx && history.netRx.length >= 2} + + rx + (history.netTx[i] || 0)), 60, 16)} fill="rgba(34, 197, 94, 0.15)" /> + rx + (history.netTx[i] || 0)), 60, 16)} fill="none" stroke="rgb(34, 197, 94)" stroke-width="1" /> + + {:else} +
    + {/if} +
    + +
    +
    + Disk + {stats ? formatBytes(stats.blockRead + stats.blockWrite) : '-'} +
    + {#if history?.diskR && history.diskR.length >= 2} + + r + (history.diskW[i] || 0)), 60, 16)} fill="rgba(251, 146, 60, 0.15)" /> + r + (history.diskW[i] || 0)), 60, 16)} fill="none" stroke="rgb(251, 146, 60)" stroke-width="1" /> + + {:else} +
    + {/if} +
    +
    + {/key} + {/if} +
    + + {#if container.ports.length > 0} + {@const uniquePorts = container.ports.filter((p, i, arr) => p.publicPort && arr.findIndex(x => x.publicPort === p.publicPort) === i)} + {#each uniquePorts.slice(0, 2) as port} + {@const url = getPortUrl(port.publicPort)} + {#if url} + e.stopPropagation()} + class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors" + title="Open {url} in new tab" + > + :{port.publicPort} + + + {:else} + + :{port.publicPort} + + {/if} + {/each} + {#if uniquePorts.length > 2} + +{uniquePorts.length - 2} + {/if} + {/if} + + {#if container.networks.length > 0} + {@const ip = getContainerIp(container.networks)} + + + + + {ip !== '-' ? ip : container.networks.length} + + + + {#each container.networks as net} +
    {net.name}: {net.ipAddress || 'no IP'}
    + {/each} +
    +
    + {/if} + + {#if container.volumeCount > 0} + + + {container.volumeCount} + + {/if} +
    +
    +
    + + {#if container.state === 'running' && $canAccess('containers', 'exec')} + + {/if} + {#if container.state === 'running' && $canAccess('containers', 'files')} + + {/if} + +
    +
    + {#if operationError?.id === container.id && operationError?.message} +
    + + {operationError.message} + +
    + {/if} + {#if isLoading} + + {:else} + {#if container.state === 'running'} + {#if $canAccess('containers', 'restart')} + restartContainer(container.id)} + onOpenChange={(open) => confirmRestartContainerId = open ? container.id : null} + > + {#snippet children({ open })} + + {/snippet} + + {/if} + {#if $canAccess('containers', 'pause')} + pauseContainer(container.id)} + onOpenChange={(open) => confirmPauseContainerId = open ? container.id : null} + > + {#snippet children({ open })} + + {/snippet} + + {/if} + {#if $canAccess('containers', 'stop')} + stopContainer(container.id)} + onOpenChange={(open) => confirmStopContainerId = open ? container.id : null} + > + {#snippet children({ open })} + + {/snippet} + + {/if} + {:else if container.state === 'paused'} + {#if $canAccess('containers', 'unpause')} + + {/if} + {:else} + {#if $canAccess('containers', 'start')} + + {/if} + {/if} + {/if} +
    +
    +
    + {/each} +
    +
    + {/if} + {/snippet} +
    + {/if} +
    + + + showCreateModal = false} + onSuccess={fetchStacks} +/> + + + { + showEditModal = false; + editingStackName = ''; + }} + onSuccess={fetchStacks} +/> + + { + showGitModal = false; + editingGitStack = null; + }} + onSaved={fetchStacks} +/> + + + + showFileBrowserModal = false} +/> + + showBatchOpModal = false} + onComplete={handleBatchComplete} +/> diff --git a/routes/stacks/ComposeGraphViewer.svelte b/routes/stacks/ComposeGraphViewer.svelte new file mode 100644 index 0000000..328bcd8 --- /dev/null +++ b/routes/stacks/ComposeGraphViewer.svelte @@ -0,0 +1,3018 @@ + + +
    + +
    +
    + +
    + + + {#if showAddMenu} +
    + + + + + +
    + {/if} +
    + + + + + + + + + {#if connectionMode || mountMode} +
    + + {#if connectionMode} + {#if connectionSource} + Click target service + {:else} + Click source service + {/if} + {:else if mountMode} + {#if mountSource} + Click target service + {:else} + Click volume/network/config/secret + {/if} + {/if} +
    + {/if} +
    + + +
    + +
    + + {#if showLayoutMenu} + +
    showLayoutMenu = false} + > + + + + + +
    + {/if} +
    +
    + + +
    + + + + +
    +
    + +
    + +
    +
    + {#if parseError} +
    +
    +

    YAML Parse Error

    +

    {parseError}

    +
    +
    + {/if} + +
    +
    +
    +
    + Service +
    +
    +
    + Network +
    +
    +
    + Volume +
    +
    +
    + Config +
    +
    +
    + Secret +
    +
    +
    + + {#if selectedNode || selectedEdge} +
    + + {#if selectedNode} + {@const NodeIcon = getNodeIcon(selectedNode.type)} +
    +
    +
    +
    + +
    +
    +

    {selectedNode.label}

    +

    {selectedNode.type}

    +
    +
    +
    + {#if (selectedNode.type === 'service' && serviceEditDirty) || + (selectedNode.type === 'network' && networkEditDirty) || + (selectedNode.type === 'volume' && volumeEditDirty) || + (selectedNode.type === 'config' && configEditDirty) || + (selectedNode.type === 'secret' && secretEditDirty)} + + {/if} + + +
    +
    +
    + {:else if selectedEdge} + +
    +
    +
    +

    {selectedEdge.type.replace('-', ' ')}

    +

    + {selectedEdge.source.replace(/^(service|network|volume|config|secret)-/, '')} + → + {selectedEdge.target.replace(/^(service|network|volume|config|secret)-/, '')} +

    +
    +
    + + +
    +
    +
    + {/if} + +
    + {#if selectedNode} + {#if selectedNode.type === 'service'} +
    + +
    +
    + Image +
    + +
    + + +
    + Command + +
    + + +
    + Restart policy + { serviceEditDirty = true; }}> + + {editServiceRestart === 'no' ? 'No' : editServiceRestart === 'always' ? 'Always' : editServiceRestart === 'on-failure' ? 'On failure' : 'Unless stopped'} + + + + + + + + +
    + + +
    +
    + Port mappings + +
    +
    + {#each editServicePorts as port, index} +
    +
    + Host + +
    +
    + Container + +
    + +
    + {/each} +
    +
    + + +
    +
    + Volumes + +
    +
    + {#each editServiceVolumes as vol, index} +
    +
    + Host + +
    +
    + Container + +
    + +
    + {/each} +
    +
    + + +
    +
    + Environment + +
    +
    + {#each editServiceEnvVars as env, index} +
    +
    + Key + +
    +
    + Value + +
    + +
    + {/each} +
    +
    + + +
    +
    + Labels + +
    +
    + {#each editServiceLabels as label, index} +
    +
    + Key + +
    +
    + Value + +
    + +
    + {/each} +
    +
    + + + {#if selectedNode.dependsOn?.length > 0} +
    + Depends on +
    + {#each selectedNode.dependsOn as dep} +
    + {dep} + +
    + {/each} +
    +
    + {/if} +
    + {:else if selectedNode.type === 'network'} +
    + +
    + Driver + { networkEditDirty = true; }}> + + + {#if editNetworkDriver === 'bridge'} + + {:else if editNetworkDriver === 'host'} + + {:else if editNetworkDriver === 'overlay'} + + {:else if editNetworkDriver === 'macvlan'} + + {:else if editNetworkDriver === 'ipvlan'} + + {:else} + + {/if} + {editNetworkDriver} + + + + + {#snippet children()} +
    + + Bridge +
    + {/snippet} +
    + + {#snippet children()} +
    + + Host +
    + {/snippet} +
    + + {#snippet children()} +
    + + Overlay +
    + {/snippet} +
    + + {#snippet children()} +
    + + Macvlan +
    + {/snippet} +
    + + {#snippet children()} +
    + + IPvlan +
    + {/snippet} +
    + + {#snippet children()} +
    + + None +
    + {/snippet} +
    +
    +
    +
    + + +
    + IPAM configuration +
    +
    + Subnet + +
    +
    + Gateway + +
    +
    +
    + + +
    + + + +
    + + +
    +
    + Labels + +
    +
    + {#each editNetworkLabels as label, index} +
    +
    + Key + +
    +
    + Value + +
    + +
    + {/each} +
    +
    + + +
    +
    + Driver options + +
    +
    + {#each editNetworkOptions as opt, index} +
    +
    + Key + +
    +
    + Value + +
    + +
    + {/each} +
    +
    +
    + + {:else if selectedNode.type === 'volume'} +
    + +
    + Driver + +
    + + + + + +
    +
    + Labels + +
    +
    + {#each editVolumeLabels as label, index} +
    +
    + Key + +
    +
    + Value + +
    + +
    + {/each} +
    +
    + + +
    +
    + Driver options + +
    +
    + {#each editVolumeOptions as opt, index} +
    +
    + Key + +
    +
    + Value + +
    + +
    + {/each} +
    +
    +
    + + {:else if selectedNode.type === 'config'} +
    + + + + {#if editConfigExternal} + +
    + External name (optional) + +
    + {:else} + +

    Specify one of: file, content, or environment

    + + +
    + File path + +
    + + +
    + Content (inline) + +
    + + +
    + Environment variable + +
    + {/if} +
    + + {:else if selectedNode.type === 'secret'} +
    + + + + {#if editSecretExternal} + +
    + External name (optional) + +
    + {:else} + +

    Specify one of: file or environment

    + + +
    + File path + +
    + + +
    + Environment variable + +
    + {/if} +
    + {/if} + {:else if selectedEdge} + {#if selectedEdge.type === 'dependency'} +

    + This service depends on {selectedEdge.source.replace('service-', '')} and will start after it. +

    + {:else if selectedEdge.type === 'volume-mount'} +

    + Volume mounted to this service. +

    + {:else if selectedEdge.type === 'network-connection'} +

    + Service connected to this network. +

    + {:else if selectedEdge.type === 'config-mount'} +

    + Config mounted to this service. +

    + {:else if selectedEdge.type === 'secret-mount'} +

    + Secret mounted to this service. +

    + {/if} + {/if} +
    +
    + {/if} +
    +
    +
    + + + + + + + {@const DialogIcon = getNodeIcon(addElementType)} + + Add {getElementTypeLabel(addElementType)} + + + +
    +
    + + +
    + + {#if addElementType === 'service'} +
    + + +
    + +
    + + +
    + {/if} +
    + +
    + + +
    +
    +
    + + +{#if showAddMenu} + +{/if} diff --git a/routes/stacks/GitDeployProgressPopover.svelte b/routes/stacks/GitDeployProgressPopover.svelte new file mode 100644 index 0000000..83f4a0f --- /dev/null +++ b/routes/stacks/GitDeployProgressPopover.svelte @@ -0,0 +1,265 @@ + + + + + {@render children()} + + + +
    +
    + + {stackName} +
    + + +
    +
    + {#if overallStatus === 'idle'} + + Initializing... + {:else if overallStatus === 'deploying'} + + Deploying... + {:else if overallStatus === 'complete'} + + Complete! + {:else if overallStatus === 'error'} + + Failed + {/if} +
    + {#if currentStep?.step && currentStep?.totalSteps} + + {currentStep.step}/{currentStep.totalSteps} + + {/if} +
    + + {#if currentStep?.message && overallStatus === 'deploying'} +

    {currentStep.message}

    + {/if} + + {#if currentStep?.totalSteps} + + {/if} + + {#if errorMessage} +
    + + {errorMessage} +
    + {/if} +
    + + + {#if steps.length > 0} +
    +
    + {#each steps as step, index (index)} + {@const StepIcon = getStepIcon(step.status)} + {@const isCurrentStep = index === steps.length - 1 && overallStatus === 'deploying'} +
    + + + {step.message || step.status} + +
    + {/each} +
    +
    + {/if} + + + {#if overallStatus === 'complete' || overallStatus === 'error'} +
    + +
    + {/if} +
    +
    diff --git a/routes/stacks/GitStackModal.svelte b/routes/stacks/GitStackModal.svelte new file mode 100644 index 0000000..93baa84 --- /dev/null +++ b/routes/stacks/GitStackModal.svelte @@ -0,0 +1,805 @@ + + + { if (isOpen) focusFirstInput(); }}> + + + + + {gitStack ? 'Edit git stack' : 'Deploy from Git'} + + + {gitStack ? 'Update git stack settings' : 'Deploy a compose stack from a Git repository'} + + + +
    + +
    + + {#if !gitStack} +
    + +
    + + +
    + + {#if formRepoMode === 'existing'} + { formRepositoryId = v ? parseInt(v) : null; errors.repository = undefined; }} + > + + {#if selectedRepo} + {@const repoPath = selectedRepo.url.replace(/^https?:\/\/[^/]+\//, '').replace(/\.git$/, '')} +
    + {#if selectedRepo.url.includes('github.com')} + + {:else} + + {/if} + {selectedRepo.name} + +
    + {:else} + Select a repository... + {/if} +
    + + {#each repositories as repo} + {@const repoPath = repo.url.replace(/^https?:\/\/[^/]+\//, '').replace(/\.git$/, '')} + +
    + {#if repo.url.includes('github.com')} + + {:else} + + {/if} + {repo.name} + - {repoPath} + + + {repo.branch} + +
    +
    + {/each} +
    +
    + {#if errors.repository} +

    {errors.repository}

    + {:else if repositories.length === 0} +

    + No repositories configured. Click "Add new" to add one. +

    + {/if} + {:else} +
    +
    + + errors.repoName = undefined} + /> + {#if errors.repoName} +

    {errors.repoName}

    + {/if} +
    +
    + + errors.repoUrl = undefined} + /> + {#if errors.repoUrl} +

    {errors.repoUrl}

    + {/if} +
    +
    +
    + + +
    +
    + + formNewRepoCredentialId = v === 'none' ? null : parseInt(v)} + > + + {@const selectedCred = credentials.find(c => c.id === formNewRepoCredentialId)} + {#if selectedCred} + {#if selectedCred.authType === 'ssh'} + + {:else if selectedCred.authType === 'password'} + + {:else} + + {/if} + {selectedCred.name} ({getAuthLabel(selectedCred.authType)}) + {:else} + + None (public) + {/if} + + + + + + None (public) + + + {#each credentials as cred} + + + {#if cred.authType === 'ssh'} + + {:else if cred.authType === 'password'} + + {:else} + + {/if} + {cred.name} ({getAuthLabel(cred.authType)}) + + + {/each} + + +
    +
    +
    + {/if} +
    + {/if} + + +
    + + { errors.stackName = undefined; formStackNameUserModified = true; }} + /> + {#if errors.stackName} +

    {errors.stackName}

    + {:else} +

    This will be the name of the deployed stack

    + {/if} +
    + +
    + + +

    Path to the compose file within the repository

    +
    + + +
    + + {#if gitStack && envFiles.length > 0} + + { + formEnvFilePath = v === 'none' ? null : v; + if (formEnvFilePath) { + loadEnvFileContents(formEnvFilePath); + } else { + fileEnvVars = {}; + } + }} + > + + {#if loadingEnvFiles} + + Loading... + {:else if formEnvFilePath} + + {formEnvFilePath} + {:else} + + None + {/if} + + + + None + + {#each envFiles as file} + + + + {file} + + + {/each} + + + {:else} + + + {/if} +

    Path to the .env file within the repository (optional)

    +
    + + +
    +
    +
    + + +
    + +
    +

    + Automatically sync repository and redeploy stack if there are changes. +

    + {#if formAutoUpdate} + formAutoUpdateCron = cron} + /> + {/if} +
    + + +
    +
    +
    + + +
    + +
    +

    + Receive push events from your Git provider to trigger sync and redeploy. +

    + {#if formWebhookEnabled} + {#if gitStack} +
    + +
    + + +
    +
    + {/if} +
    + +
    + + {#if gitStack && formWebhookSecret} + + {/if} + +
    +
    + {#if !gitStack} +

    + The webhook URL will be available after creating the stack. +

    + {:else} +

    + Configure this URL in your Git provider. Secret is used for signature verification. +

    + {/if} + {/if} +
    + + + {#if !gitStack} +
    +
    + +
    + +

    Clone and deploy the stack immediately

    +
    +
    + +
    + {/if} + + {#if formError} +

    {formError}

    + {/if} +
    + + +
    + +
    +
    + + + + {#if gitStack} + + + {:else} + + {/if} + +
    +
    diff --git a/routes/stacks/StackModal.svelte b/routes/stacks/StackModal.svelte new file mode 100644 index 0000000..8ed7b7c --- /dev/null +++ b/routes/stacks/StackModal.svelte @@ -0,0 +1,740 @@ + + + { + if (isOpen) { + focusFirstInput(); + } else { + // Prevent closing if there are unsaved changes - show confirmation instead + if (hasChanges) { + // Re-open the dialog and show confirmation + open = true; + showConfirmClose = true; + } + // If no changes, let it close naturally + } + }} +> + + +
    +
    +
    +
    + +
    +
    + + {#if mode === 'create'} + Create compose stack + {:else} + {stackName} + {/if} + + + {#if mode === 'create'} + Create a new Docker Compose stack + {:else} + Edit compose file and view stack structure + {/if} + +
    +
    + + +
    + + +
    +
    + +
    + + {#if activeTab === 'editor'} + + {/if} + + + +
    +
    +
    + +
    + {#if error} + + + {error} + + {/if} + + {#if errors.compose} + + + {errors.compose} + + {/if} + + {#if mode === 'edit' && loading} +
    +
    + + Loading compose file... +
    +
    + {:else if mode === 'edit' && loadError} +
    +
    +
    + +
    +

    Could not load compose file

    +

    {loadError}

    +

    + This stack may have been created outside of Dockhand or the compose file may have been moved. +

    +
    +
    + {:else} + + {#if mode === 'create'} +
    +
    + + errors.stackName = undefined} + /> + {#if errors.stackName} +

    {errors.stackName}

    + {/if} +
    +
    + {/if} + + +
    + {#if activeTab === 'editor'} + +
    + {#if open} +
    + +
    + {/if} +
    + +
    +
    + + Environment variables +
    +
    + validateEnvVars()} + /> +
    +
    + {:else if activeTab === 'graph'} + + + {/if} +
    + {/if} +
    + + +
    +
    + {#if hasChanges} + Unsaved changes + {:else} + No changes + {/if} +
    + +
    + + + {#if mode === 'create'} + + + + {:else} + + + + {/if} +
    +
    +
    +
    + + + + + + Unsaved changes + + You have unsaved changes. Are you sure you want to close without saving? + + +
    + + +
    +
    +
    diff --git a/routes/terminal/+page.svelte b/routes/terminal/+page.svelte new file mode 100644 index 0000000..4dd3fc2 --- /dev/null +++ b/routes/terminal/+page.svelte @@ -0,0 +1,349 @@ + + +{#if $environments.length === 0 || !$currentEnvironment} +
    + + +
    +{:else} +
    + +
    + +
    + +
    + + + +
    + + + {#if dropdownOpen} +
    + {#if filteredContainers().length === 0} +
    + {containers.length === 0 ? 'No running containers' : 'No matches found'} +
    + {:else} + {#each filteredContainers() as container} + + {/each} + {/if} +
    + {/if} +
    + + {#if selectedContainer} + + {/if} + + {#if !selectedContainer} +
    + + + + + {shellOptions.find(o => o.value === selectedShell)?.label || 'Select'} + + + {#each shellOptions as option} + + + {option.label} + + {/each} + + +
    +
    + + + + + {userOptions.find(o => o.value === selectedUser)?.label || 'Select'} + + + {#each userOptions as option} + + + {option.label} + + {/each} + + +
    + {/if} +
    + + +
    + {#if !selectedContainer} +
    +
    + +

    Select a container to open shell

    +
    +
    + {:else} + +
    +
    + {#if connected} + + + Connected + + {:else} + Disconnected + {/if} +
    +
    + changeFontSize(Number(v))}> + + {terminalFontSize}px + + + {#each fontSizeOptions as size} + {size}px + {/each} + + + + + +
    +
    +
    + {#key selectedContainer.id} + + {/key} +
    + {/if} +
    +
    +{/if} + + diff --git a/routes/terminal/Terminal.svelte b/routes/terminal/Terminal.svelte new file mode 100644 index 0000000..dd550ba --- /dev/null +++ b/routes/terminal/Terminal.svelte @@ -0,0 +1,285 @@ + + +
    + + diff --git a/routes/terminal/TerminalEmulator.svelte b/routes/terminal/TerminalEmulator.svelte new file mode 100644 index 0000000..f75aabd --- /dev/null +++ b/routes/terminal/TerminalEmulator.svelte @@ -0,0 +1,343 @@ + + +
    + +
    +
    + Terminal: + {containerName} + {#if connected} + + + Connected + + {:else} + Disconnected + {/if} +
    +
    + + updateFontSize(Number(v))}> + + + {fontSize}px + + + {#each fontSizeOptions as size} + + + {size}px + + {/each} + + + + + + + + {#if !connected} + + {/if} +
    +
    + + +
    +
    +
    +
    + + diff --git a/routes/terminal/TerminalPanel.svelte b/routes/terminal/TerminalPanel.svelte new file mode 100644 index 0000000..c7b2a68 --- /dev/null +++ b/routes/terminal/TerminalPanel.svelte @@ -0,0 +1,242 @@ + + + +
    + + + + +
    +
    + Terminal: + {containerName} + {#if connected} + + + Connected + + {:else} + Disconnected + {/if} +
    +
    + + updateFontSize(Number(v))}> + + {fontSize}px + + + {#each fontSizeOptions as size} + {size}px + {/each} + + + + + + + + {#if !connected} + + {/if} + + +
    +
    + + +
    + +
    +
    diff --git a/routes/terminal/[id]/+page.svelte b/routes/terminal/[id]/+page.svelte new file mode 100644 index 0000000..c32dd08 --- /dev/null +++ b/routes/terminal/[id]/+page.svelte @@ -0,0 +1,251 @@ + + + + Terminal - {containerName || 'Loading...'} + + +
    + +
    +
    + + {containerName} + {#if connected} + + + Connected + + {:else if error} + {error} + {:else} + Connecting... + {/if} +
    +
    + Shell: {shell} + | + User: {user} +
    +
    + + +
    + {#if xtermLoaded} +
    + {:else} +
    + Loading terminal... +
    + {/if} +
    +
    + + diff --git a/routes/volumes/+page.svelte b/routes/volumes/+page.svelte new file mode 100644 index 0000000..3a941f6 --- /dev/null +++ b/routes/volumes/+page.svelte @@ -0,0 +1,663 @@ + + +
    +
    + +
    +
    + + e.key === 'Escape' && (searchInput = '')} + class="pl-8 h-8 w-48 text-sm" + /> +
    + + + {#if $canAccess('volumes', 'remove')} + confirmPrune = open} + > + {#snippet children({ open })} + + {#if pruneStatus === 'pruning'} + + {:else if pruneStatus === 'success'} + + {:else if pruneStatus === 'error'} + + {:else} + + {/if} + Prune + + {/snippet} + + {/if} + + {#if $canAccess('volumes', 'create')} + + {/if} +
    +
    + + + {#if selectedVolumes.size > 0} +
    + {selectedInFilter.length} selected + + {#if $canAccess('volumes', 'remove')} + confirmBulkRemove = open} + > + {#snippet children({ open })} + + + Delete + + {/snippet} + + {/if} +
    + {/if} + + {#if !loading && ($environments.length === 0 || !$currentEnvironment)} + + {:else if !loading && volumes.length === 0} + + {:else} + { sortField = state.field as SortField; sortDirection = state.direction; }} + highlightedKey={highlightedRowId} + onRowClick={(volume) => { highlightedRowId = highlightedRowId === volume.name ? null : volume.name; }} + > + {#snippet cell(column, volume, rowState)} + {@const stack = volume.labels['com.docker.compose.project']} + {#if column.id === 'name'} + {volume.name} + {:else if column.id === 'driver'} + {volume.driver} + {:else if column.id === 'scope'} + {volume.scope} + {:else if column.id === 'stack'} + {#if stack} + {stack} + {:else} + - + {/if} + {:else if column.id === 'usedBy'} + {#if volume.usedBy && volume.usedBy.length > 0} +
    + {#each volume.usedBy.slice(0, 3) as container} + + {/each} + {#if volume.usedBy.length > 3} + +{volume.usedBy.length - 3} + {/if} +
    + {:else} + - + {/if} + {:else if column.id === 'created'} + {formatDate(volume.created)} + {:else if column.id === 'actions'} +
    + {#if $canAccess('volumes', 'inspect')} + + + + {/if} + {#if $canAccess('volumes', 'create')} + + {/if} + {#if $canAccess('volumes', 'remove')} +
    + removeVolume(volume.name)} + onOpenChange={(open) => confirmDeleteName = open ? volume.name : null} + > + {#snippet children({ open })} + + {/snippet} + + {#if deleteError?.name === volume.name} +
    + + {deleteError.message} + +
    + {/if} +
    + {/if} +
    + {/if} + {/snippet} +
    + {/if} +
    + + showCreateModal = false} + onSuccess={fetchVolumes} +/> + + + + showBrowserModal = false} +/> + + showCloneModal = false} + onsuccess={fetchVolumes} +/> + + + + showBatchOpModal = false} + onComplete={handleBatchComplete} +/> diff --git a/routes/volumes/CloneVolumeModal.svelte b/routes/volumes/CloneVolumeModal.svelte new file mode 100644 index 0000000..13d4ca2 --- /dev/null +++ b/routes/volumes/CloneVolumeModal.svelte @@ -0,0 +1,119 @@ + + + { if (isOpen) focusFirstInput(); handleOpenChange(isOpen); }}> + + + + + Clone volume + + + Create a new volume with the same driver and options as "{volumeName}". + + + +
    +
    + + e.key === 'Enter' && handleClone()} + /> +
    + + {#if error} +

    {error}

    + {/if} + +

    + Note: This creates an empty volume with the same configuration. To copy data, use the Export feature on the source volume and import into the new volume. +

    +
    + + + + + +
    +
    diff --git a/routes/volumes/CreateVolumeModal.svelte b/routes/volumes/CreateVolumeModal.svelte new file mode 100644 index 0000000..3c5a9b2 --- /dev/null +++ b/routes/volumes/CreateVolumeModal.svelte @@ -0,0 +1,295 @@ + + + + + { if (isOpen) focusFirstInput(); handleOpenChange(isOpen); }}> + + + Create volume + + +
    + {#if error} +
    + {error} +
    + {/if} + + +
    + + errors.name = undefined} + /> + {#if errors.name} +

    {errors.name}

    + {/if} +
    + + +
    + + + + {@const selectedDriver = VOLUME_DRIVERS.find(d => d.value === driver)} + + {#if selectedDriver} + + {selectedDriver.label} + {:else} + Select driver + {/if} + + + + {#each VOLUME_DRIVERS as d} + + +
    + {d.label} + {d.description} +
    +
    + {/each} +
    +
    +

    + Volume driver to use (local is default) +

    +
    + + +
    +
    + + +
    + {#if driverOpts.length > 0} +
    + {#each driverOpts as opt, i} +
    + + + +
    + {/each} +
    + {:else} +

    No driver options configured

    + {/if} +
    + + +
    +
    + + +
    + {#if labels.length > 0} +
    + {#each labels as label, i} +
    + + + +
    + {/each} +
    + {:else} +

    No labels configured

    + {/if} +
    + + + + + +
    +
    +
    diff --git a/routes/volumes/VolumeBrowserModal.svelte b/routes/volumes/VolumeBrowserModal.svelte new file mode 100644 index 0000000..8ecfebb --- /dev/null +++ b/routes/volumes/VolumeBrowserModal.svelte @@ -0,0 +1,93 @@ + + + + + + + + Browse volume - {volumeName} + {#if isInUse} + + + Read-only + + {/if} + + + {#if isInUse} + + + Volume is in use by: + {#each volumeUsage as container, i} + + + {container.containerName} + ({container.state}){#if i < volumeUsage.length - 1},{/if} + + {/each} + - editing disabled + + {:else} + Browse, edit, and manage files in the volume. + {/if} + + +
    + +
    +
    +
    diff --git a/routes/volumes/VolumeInspectModal.svelte b/routes/volumes/VolumeInspectModal.svelte new file mode 100644 index 0000000..9875d52 --- /dev/null +++ b/routes/volumes/VolumeInspectModal.svelte @@ -0,0 +1,167 @@ + + + + + + + + Volume details: {volumeName} + + + +
    + {#if loading} +
    + +
    + {:else if error} +
    + {error} +
    + {:else if volumeData} + +
    +

    Basic information

    +
    +
    +

    Name

    + {volumeData.Name} +
    +
    +

    Driver

    + {volumeData.Driver} +
    +
    +

    Scope

    + {volumeData.Scope} +
    +
    +

    Created

    +

    {formatDate(volumeData.CreatedAt)}

    +
    +
    +
    + + +
    +

    Mountpoint

    +
    + {volumeData.Mountpoint} +
    +

    + The location on the host where the volume data is stored +

    +
    + + + {#if volumeData.Options && Object.keys(volumeData.Options).length > 0} +
    +

    Driver options

    +
    + {#each Object.entries(volumeData.Options) as [key, value]} +
    + {key} + {value} +
    + {/each} +
    +
    + {:else} +
    +

    Driver options

    +

    No driver options configured

    +
    + {/if} + + + {#if volumeData.Labels && Object.keys(volumeData.Labels).length > 0} +
    +

    Labels

    +
    + {#each Object.entries(volumeData.Labels) as [key, value]} +
    + {key} + {value} +
    + {/each} +
    +
    + {/if} + + + {#if volumeData.Status} +
    +

    Status

    +
    + {#each Object.entries(volumeData.Status) as [key, value]} +
    + {key} + {value} +
    + {/each} +
    +
    + {/if} + + +
    +

    + Note: Removing this volume will permanently delete all data stored in it. + Make sure no containers are using this volume before removal. +

    +
    + {/if} +
    + + + + +
    +