Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions admin/backend/api/v1/settings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 9 additions & 2 deletions admin/backend/api/v1/settings/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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"])
Expand All @@ -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
Comment on lines +81 to +85

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 P1 New bench.onboarding validation ships without any test

This adds a new accepted/rejected settings field and a new str | None contract on _apply_bench, but no test covers it. Every sibling applier that can reject input has one (e.g. tests/admin/backend/api/test_settings_lite_mode.py:25, test_settings_waf.py:53), and the project rule requires tests for behavior changes.

Suggested change
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
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
Prompt for AI agents
In admin/backend/api/v1/settings/config.py at lines 81-85: New bench.onboarding validation ships without any test

This adds a new accepted/rejected settings field and a new `str | None` contract on `_apply_bench`, but no test covers it. Every sibling applier that can reject input has one (e.g. `tests/admin/backend/api/test_settings_lite_mode.py:25`, `test_settings_waf.py:53`), and the project rule requires tests for behavior changes.

Suggested replacement for those lines:
        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

Check the claim against the code first. Fix it only if it holds.

Fix with your agent

Comment on lines +81 to +85

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 P1 No tests for the new onboarding state validation

The new bench.onboarding state machine (validation in _apply_bench, the build_settings_response field, and the whole SetupChecklist flow) ships with no test, while every sibling settings group has one (tests/admin/backend/api/test_settings_lite_mode.py:25, test_settings_https.py:34). Project rule requires tests for behavior changes.

Prompt for AI agents
In admin/backend/api/v1/settings/config.py at lines 81-85: No tests for the new onboarding state validation

The new `bench.onboarding` state machine (validation in `_apply_bench`, the `build_settings_response` field, and the whole `SetupChecklist` flow) ships with no test, while every sibling settings group has one (`tests/admin/backend/api/test_settings_lite_mode.py:25`, `test_settings_https.py:34`). Project rule requires tests for behavior changes.

Check the claim against the code first. Fix it only if it holds.

Fix with your agent


def _apply_lite_mode(self) -> None:
lite_mode = self.data.get("lite_mode") or {}
Expand Down
1 change: 1 addition & 0 deletions admin/backend/api/v1/types/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ class GeneralSettings(TypedDict):
default_branch: str
db_type: str
allow_developer_mode: bool
onboarding: str


class MariaDBSettings(TypedDict):
Expand Down
5 changes: 5 additions & 0 deletions admin/frontend/dashboard/e2e/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
80 changes: 80 additions & 0 deletions admin/frontend/dashboard/e2e/onboarding.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, object> = {
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)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<script setup lang="ts">
defineProps<{ value: number }>()
</script>

<template>
<svg
viewBox="0 0 16 16"
fill="none"
stroke-width="2"
class="size-4 -rotate-90"
aria-hidden="true"
>
<circle cx="8" cy="8" r="6" class="stroke-current text-ink-gray-3" />

<circle
cx="8"
cy="8"
r="6"
pathLength="100"
stroke-linecap="round"
:stroke-dasharray="`${value} 100`"
class="stroke-current text-ink-gray-8 transition-[stroke-dasharray] duration-500"
/>
</svg>
</template>
26 changes: 5 additions & 21 deletions admin/frontend/dashboard/src/components/common/TerminalOutput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,63 +36,48 @@ defineExpose({ scrollToBottom })
<template>
<div
ref="el"
class="terminal select-text font-mono text-sm leading-[1.6]"
class="terminal select-text bg-surface-gray-1 text-ink-gray-8 font-mono text-sm leading-[1.6]"
:class="{ 'terminal--fill': fill }"
:style="fill ? '' : `max-height:${maxHeight}`"
>
<div v-if="!lines.length" class="terminal__empty">{{ emptyText }}</div>
<div v-for="(line, index) in lines" :key="index" class="terminal__row">
<span v-if="lineNumbers" class="terminal__lineno">{{ index + 1 }}</span>
<div v-if="!lines.length" class="terminal__empty text-ink-gray-5">{{ emptyText }}</div>
<div v-for="(line, index) in lines" :key="index" class="terminal__row hover:bg-surface-gray-2">
<span v-if="lineNumbers" class="terminal__lineno text-ink-gray-4">{{ index + 1 }}</span>
<span
class="terminal__text"
:class="{ 'terminal__text--pad': !lineNumbers }"
v-html="line || '&nbsp;'"
/>
</div>

<div v-if="streaming" class="terminal__cursor">█</div>
<div v-if="streaming" class="terminal__cursor text-ink-green-6">█</div>
</div>
</template>

<style scoped>
.terminal {
--terminal-bg: #1e1e2e;
--terminal-fg: #cdd6f4;
--terminal-muted: #585b70;
--terminal-lineno: #45475a;
--terminal-cursor: #a6e3a1;
overflow: auto;
border-radius: 0.5rem;
min-height: 120px;
padding: 0.75rem 0;
background: var(--terminal-bg);
color: var(--terminal-fg);
}
.terminal ::selection {
background: rgb(255 255 255 / 0.25);
}
.terminal--fill {
flex: 1;
height: 0;
}
.terminal__empty {
padding: 0.25rem 1rem;
color: var(--terminal-muted);
}
.terminal__row {
display: flex;
}
.terminal__row:hover {
background: rgb(255 255 255 / 0.04);
}
.terminal__lineno {
flex-shrink: 0;
min-width: 3.5rem;
padding-left: 0.75rem;
padding-right: 1rem;
text-align: right;
user-select: none;
color: var(--terminal-lineno);
}
.terminal__text {
flex: 1;
Expand All @@ -105,7 +90,6 @@ defineExpose({ scrollToBottom })
}
.terminal__cursor {
padding: 0.25rem 1rem;
color: var(--terminal-cursor);
animation: terminal-blink 1s step-start infinite;
}
@keyframes terminal-blink {
Expand Down
Loading
Loading