diff --git a/admin/backend/api/v1/settings/__init__.py b/admin/backend/api/v1/settings/__init__.py index d35d302b8..5a23df47d 100644 --- a/admin/backend/api/v1/settings/__init__.py +++ b/admin/backend/api/v1/settings/__init__.py @@ -83,6 +83,7 @@ def build_settings_response(config: BenchConfig, bench_root: Path | None = None) "default_branch": config.default_branch, "db_type": config.db_type, "allow_developer_mode": config.allow_developer_mode, + "onboarding": config.onboarding, }, "mariadb": { "host": config.mariadb.host, diff --git a/admin/backend/api/v1/settings/config.py b/admin/backend/api/v1/settings/config.py index b51f69478..cb78f73a6 100644 --- a/admin/backend/api/v1/settings/config.py +++ b/admin/backend/api/v1/settings/config.py @@ -4,6 +4,7 @@ from pilot.config import BenchConfig, FirewallRule, S3Config, WafCondition, WafRule, WorkerGroup from pilot.config.alert_limit import RESOURCE_LIMIT_FIELDS +from pilot.config.bench import ONBOARDING_STATES from pilot.config.llm import LLMConfig from pilot.config.mail import MailConfig from pilot.core.alerts import check_mail_credentials @@ -39,7 +40,8 @@ def __init__(self, config: BenchConfig, data: dict, mail: MailConfig | None = No def apply(self) -> str | None: if error := self.get_group_type_error(): return error - self._apply_bench() + if error := self._apply_bench(): + return error self._apply_lite_mode() self._apply_letsencrypt() self._apply_workers() @@ -66,7 +68,7 @@ def get_group_type_error(self) -> str | None: return f"{name} must be {'a list' if expected is list else 'an object'}." return None - def _apply_bench(self) -> None: + def _apply_bench(self) -> str | None: bench = self.data.get("bench") or {} if "http_port" in bench: self.config.http_port = int(bench["http_port"]) @@ -76,6 +78,11 @@ def _apply_bench(self) -> None: self.config.default_branch = str(bench["default_branch"]).strip() if "allow_developer_mode" in bench: self.config.allow_developer_mode = bool(bench["allow_developer_mode"]) + if "onboarding" in bench: + if bench["onboarding"] not in ONBOARDING_STATES: + return f"bench.onboarding must be one of: {', '.join(repr(state) for state in ONBOARDING_STATES)}" + self.config.onboarding = bench["onboarding"] + return None def _apply_lite_mode(self) -> None: lite_mode = self.data.get("lite_mode") or {} diff --git a/admin/backend/api/v1/types/settings.py b/admin/backend/api/v1/types/settings.py index 3e7e23a97..ed984f2a5 100644 --- a/admin/backend/api/v1/types/settings.py +++ b/admin/backend/api/v1/types/settings.py @@ -111,6 +111,7 @@ class GeneralSettings(TypedDict): default_branch: str db_type: str allow_developer_mode: bool + onboarding: str class MariaDBSettings(TypedDict): diff --git a/admin/frontend/dashboard/e2e/global-setup.ts b/admin/frontend/dashboard/e2e/global-setup.ts index c0db8f0f9..85407052e 100644 --- a/admin/frontend/dashboard/e2e/global-setup.ts +++ b/admin/frontend/dashboard/e2e/global-setup.ts @@ -9,6 +9,11 @@ const globalSetup = async (config) => { }) await expect(res).toBeOK() + const onboarding = await context.patch('/api/v1/settings', { + data: { bench: { onboarding: 'dismissed' } }, + }) + await expect(onboarding).toBeOK() + await context.storageState({ path: storageState }) await context.dispose() } diff --git a/admin/frontend/dashboard/e2e/onboarding.spec.ts b/admin/frontend/dashboard/e2e/onboarding.spec.ts new file mode 100644 index 000000000..ff45221ad --- /dev/null +++ b/admin/frontend/dashboard/e2e/onboarding.spec.ts @@ -0,0 +1,80 @@ +import { expect, type Page, test } from '@playwright/test' + +const mockBench = async (page: Page, onboarding: string) => { + const updates: object[] = [] + const created: object[] = [] + + await page.route('**/api/v1/**', async (route) => { + const request = route.request() + const path = new URL(request.url()).pathname.replace('/api/v1/', '') + const method = request.method() + + if (path === 'settings' && method === 'PATCH') updates.push(request.postDataJSON()) + if (path === 'sites' && method === 'POST') created.push(request.postDataJSON()) + + const responses: Record = { + bootstrap: { mode: 'ready', enabled: true }, + 'auth/session': { authenticated: true }, + settings: { + bench: { onboarding }, + s3: {}, + s3_providers: [{ value: 'aws', label: 'AWS', regions: ['us-east-1'] }], + }, + 'git/connection': { connected: false, central: { available: false } }, + sites: method === 'POST' ? { task_id: 'onboarding-task' } : [], + 'sites/wildcard-domains': { domains: [] }, + apps: [], + 'marketplace/apps': [], + notifications: { data: [], meta: { unread: 0, next_cursor: null } }, + } + + await route.fulfill({ json: responses[path] ?? {} }) + }) + + return { updates, created } +} + +test('Opens on a fresh bench', async ({ page }) => { + await mockBench(page, '') + await page.goto('/sites') + + await expect(page.getByRole('dialog').getByRole('heading', { name: 'Connect your GitHub' })).toBeVisible() +}) + +test('Skips to the site step and opens its task', async ({ page }) => { + const { updates, created } = await mockBench(page, '') + await page.goto('/sites') + + const dialog = page.getByRole('dialog') + await dialog.getByRole('button', { name: 'Skip' }).click() + await expect(dialog.getByRole('heading', { name: 'Where should backups go?' })).toBeVisible() + + await dialog.getByRole('button', { name: 'Skip' }).click() + await dialog.getByLabel('Site name').fill('first.localhost') + await dialog.getByRole('button', { name: 'Create site' }).click() + + await expect(page).toHaveURL(/\/insights\/tasks\/onboarding-task$/) + await expect(dialog).toBeHidden() + expect(created).toEqual([{ name: 'first.localhost', apps: [] }]) + expect(updates).toContainEqual({ bench: { onboarding: 'minimized' } }) +}) + +test('Stays in the sidebar once minimized', async ({ page }) => { + await mockBench(page, 'minimized') + await page.goto('/sites') + + await expect(page.getByRole('button', { name: 'Get started' })).toBeVisible() + await expect(page.getByRole('dialog')).toHaveCount(0) +}) + +test('Stays hidden once dismissed', async ({ page }) => { + await mockBench(page, 'dismissed') + + const settingsLoaded = page.waitForResponse('**/api/v1/settings') + await page.goto('/sites') + await settingsLoaded + + await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Get started' })).toHaveCount(0) + await expect(page.getByRole('dialog')).toHaveCount(0) +}) diff --git a/admin/frontend/dashboard/src/components/common/CircularProgress.vue b/admin/frontend/dashboard/src/components/common/CircularProgress.vue new file mode 100644 index 000000000..5e1f14f1d --- /dev/null +++ b/admin/frontend/dashboard/src/components/common/CircularProgress.vue @@ -0,0 +1,25 @@ + + + diff --git a/admin/frontend/dashboard/src/components/common/TerminalOutput.vue b/admin/frontend/dashboard/src/components/common/TerminalOutput.vue index 4ed1a8419..e22181e1a 100644 --- a/admin/frontend/dashboard/src/components/common/TerminalOutput.vue +++ b/admin/frontend/dashboard/src/components/common/TerminalOutput.vue @@ -36,13 +36,13 @@ defineExpose({ scrollToBottom }) diff --git a/admin/frontend/dashboard/src/components/navigation/SetupIllustration.vue b/admin/frontend/dashboard/src/components/navigation/SetupIllustration.vue new file mode 100644 index 000000000..ae0a74bb3 --- /dev/null +++ b/admin/frontend/dashboard/src/components/navigation/SetupIllustration.vue @@ -0,0 +1,282 @@ + + + + + diff --git a/admin/frontend/dashboard/src/components/navigation/Sidebar.vue b/admin/frontend/dashboard/src/components/navigation/Sidebar.vue index da3e07d95..ac2f94f08 100644 --- a/admin/frontend/dashboard/src/components/navigation/Sidebar.vue +++ b/admin/frontend/dashboard/src/components/navigation/Sidebar.vue @@ -5,6 +5,7 @@ import { Sidebar, SidebarCollapseToggle, SidebarHeader, SidebarItem, SidebarLabe import PilotLogo from '@/components/icons/Pilot.vue' import NotificationsPanel from '@/components/notifications/NotificationsPanel.vue' +import SetupChecklist from '@/components/navigation/SetupChecklist.vue' import { sidebarSections } from '@/components/navigation/list' import { useAppMenu } from '@/components/navigation/useAppMenu' @@ -36,7 +37,7 @@ const isActive = (to: string) => route.path === to || route.path.startsWith(`${t + + diff --git a/admin/frontend/dashboard/src/components/settings/S3Bucket.vue b/admin/frontend/dashboard/src/components/settings/S3Bucket.vue index 8a23e6770..ae09cc901 100644 --- a/admin/frontend/dashboard/src/components/settings/S3Bucket.vue +++ b/admin/frontend/dashboard/src/components/settings/S3Bucket.vue @@ -1,138 +1,31 @@ @@ -166,22 +59,12 @@ onMounted(load)

{{ providerLabel }} · Access key {{ accessKey }}

-
- -
+ + +
- + -import { Badge, Button, Dialog, Dropdown, type DropdownItem, ErrorMessage, Select } from 'frappe-ui' +import { + Alert, + Badge, + Button, + Dialog, + Dropdown, + type DropdownItem, + ErrorMessage, + Select, +} from 'frappe-ui' import { computed, onMounted, ref } from 'vue' import { useRouter } from 'vue-router' import { apiErrorMessage } from '@/api/client' @@ -10,6 +19,7 @@ import ListSkeleton from '@/components/common/ListSkeleton.vue' import Table from '@/components/common/Table.vue' import BackupConfigDialog from '@/components/sites/BackupConfigDialog.vue' import RestoreBackupDialog from '@/components/sites/RestoreBackupDialog.vue' +import { useObjectStorage } from '@/composables/settings/useObjectStorage' import { useSite } from '@/composables/sites/useSite' import type { Backup, BackupFile, BackupSchedule } from '@/types/siteBackups' import { cronToLabel } from '@/utils/backup' @@ -34,6 +44,19 @@ const { setBackupsPageLength, } = useSite(props.siteName) +const { + loading: storageLoading, + connected: storageConnected, + load: loadStorage, +} = useObjectStorage() + +const storageAction = { + label: 'Connect storage', + onClick: () => { + router.push({ name: 'Settings', params: { section: 'general', subSection: 's3-bucket' } }) + }, +} + const pageLengths = [20, 50, 100].map((n) => ({ label: `${n} per page`, value: n })) const backingUp = ref(false) @@ -197,10 +220,21 @@ const confirmDelete = async () => { onMounted(() => { loadBackups() loadConfig() + loadStorage() })