Merge plan-47 lean-core (wizard installs + 4 backend extractions + security tab-group)

This commit is contained in:
Juan Denis committed 2026-07-15 15:24:07 -04:00
commit b7911026be
51 files changed
+1410 -309

No files matched your search

+6 -1
View File
@@ -429,9 +429,14 @@ function AppRoutes() {
<Route path="backups/:tab" element={<Backups />} />
</Route>
<Route path="cron" element={<CronJobs />} />
<Route element={<TabGroupLayout tabs={SECURITY_TABS} />}>
{/* Security tab group. groupId="security" (plan 47 Phase 3a) lets
extracted security features (fail2ban, clamav, …) contribute
tabs + group-nested routes via their manifests, so they return
as tabs in the place users know. */}
<Route element={<TabGroupLayout tabs={SECURITY_TABS} groupId="security" />}>
<Route path="security" element={<Security />} />
<Route path="security/:tab" element={<Security />} />
{groupRoutes.security}
</Route>
{/* Email routes are gated by the Email module toggle
(Settings → Modules); disabled ⇒ redirect to the dashboard. */}
@@ -1,6 +1,8 @@
import { useState, useEffect } from 'react';
import { useResourceTier } from '../../contexts/ResourceTierContext';
import { Sparkles } from 'lucide-react';
import { Sparkles, Check, Loader, AlertTriangle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import api from '../../services/api';
const USE_CASE_LABELS = {
wordpress: 'WordPress Sites',
@@ -9,35 +11,79 @@ const USE_CASE_LABELS = {
devops: 'DevOps & Monitoring',
};
const RECOMMENDATIONS = {
wordpress: ['WordPress', 'Nginx Proxy Manager', 'phpMyAdmin'],
'web-apps': ['Node.js App', 'Python App', 'Portainer'],
'self-hosted': ['Nextcloud', 'Vaultwarden', 'Wiki.js'],
devops: ['Grafana', 'Prometheus', 'Portainer'],
};
const DEFAULT_RECOMMENDATIONS = ['Portainer', 'Uptime Kuma', 'Nginx Proxy Manager'];
function getRecommendations(useCases) {
if (!useCases || useCases.length === 0) return DEFAULT_RECOMMENDATIONS;
const seen = new Set();
const result = [];
for (const uc of useCases) {
const items = RECOMMENDATIONS[uc] || [];
for (const item of items) {
if (!seen.has(item)) {
seen.add(item);
result.push(item);
}
}
}
return result.length > 0 ? result.slice(0, 4) : DEFAULT_RECOMMENDATIONS;
}
const SetupStepSummary = ({ accountInfo, useCases, onFinish }) => {
const { tier, specs, loading } = useResourceTier();
const recommendations = getRecommendations(useCases);
// Recommended extensions (real slugs from the backend), the checked set, and
// per-slug install status shown while finishing.
const [recommendations, setRecommendations] = useState([]);
const [recsLoading, setRecsLoading] = useState(true);
const [checked, setChecked] = useState(() => new Set());
const [installing, setInstalling] = useState(false);
const [installState, setInstallState] = useState({}); // slug -> 'installing'|'done'|'error'
useEffect(() => {
let active = true;
setRecsLoading(true);
api.getRecommendedExtensions(useCases)
.then((res) => {
if (!active) return;
const recs = res?.recommendations || [];
setRecommendations(recs);
// Default: everything checked ("lean" = uncheck what you don't want)
setChecked(new Set(recs.filter((r) => !r.installed).map((r) => r.slug)));
})
.catch(() => {
if (active) setRecommendations([]);
})
.finally(() => {
if (active) setRecsLoading(false);
});
return () => {
active = false;
};
}, [useCases]);
function toggle(slug) {
setChecked((prev) => {
const next = new Set(prev);
if (next.has(slug)) next.delete(slug);
else next.add(slug);
return next;
});
}
async function handleFinish() {
// Install the checked (not-already-installed) extensions, source-aware,
// one at a time with per-item progress. Fail-soft: an install failure is
// surfaced but never blocks completing onboarding.
const toInstall = recommendations.filter(
(r) => checked.has(r.slug) && !r.installed
);
const installedSlugs = recommendations
.filter((r) => r.installed && checked.has(r.slug))
.map((r) => r.slug);
if (toInstall.length > 0) {
setInstalling(true);
for (const rec of toInstall) {
setInstallState((s) => ({ ...s, [rec.slug]: 'installing' }));
try {
if (rec.source === 'registry') {
await api.installRegistryExtension(rec.slug);
} else {
await api.installBuiltinExtension(rec.slug);
}
installedSlugs.push(rec.slug);
setInstallState((s) => ({ ...s, [rec.slug]: 'done' }));
} catch {
setInstallState((s) => ({ ...s, [rec.slug]: 'error' }));
}
}
}
await onFinish(installedSlugs);
}
function formatSpecs() {
if (!specs) return 'Detecting...';
@@ -53,6 +99,25 @@ const SetupStepSummary = ({ accountInfo, useCases, onFinish }) => {
return tier.charAt(0).toUpperCase() + tier.slice(1);
}
const anyError = Object.values(installState).some((v) => v === 'error');
function renderRecStatus(rec) {
if (rec.installed) {
return <span className="recommendation-item__status recommendation-item__status--installed">Installed</span>;
}
const state = installState[rec.slug];
if (state === 'installing') {
return <Loader size={15} className="recommendation-item__spinner" />;
}
if (state === 'done') {
return <Check size={15} className="recommendation-item__status--done" />;
}
if (state === 'error') {
return <AlertTriangle size={15} className="recommendation-item__status--error" />;
}
return null;
}
return (
<div className="wizard-step">
<h2 className="wizard-step-title">You&apos;re all set</h2>
@@ -102,24 +167,66 @@ const SetupStepSummary = ({ accountInfo, useCases, onFinish }) => {
</div>
</div>
<div className="summary-section">
<div className="summary-section-title">
<Sparkles size={14} style={{ display: 'inline', verticalAlign: 'middle', marginRight: 6 }} />
Recommended for you
{(recsLoading || recommendations.length > 0) && (
<div className="summary-section">
<div className="summary-section-title">
<Sparkles size={14} style={{ display: 'inline', verticalAlign: 'middle', marginRight: 6 }} />
Recommended for you
</div>
{recsLoading ? (
<div className="summary-row">
<span className="summary-label">Loading recommendations...</span>
</div>
) : (
<>
<p className="recommendation-hint">
We&apos;ll install what you check. Uncheck anything you don&apos;t
need — you can add it later from Extensions.
</p>
<div className="recommendation-list">
{recommendations.map((rec) => (
<label key={rec.slug} className="recommendation-item">
<input
type="checkbox"
className="recommendation-item__check"
checked={rec.installed || checked.has(rec.slug)}
disabled={rec.installed || installing}
onChange={() => toggle(rec.slug)}
/>
<span className="recommendation-item__body">
<span className="recommendation-item__name">
{rec.display_name}
</span>
{rec.description && (
<span className="recommendation-item__desc">
{rec.description}
</span>
)}
</span>
{renderRecStatus(rec)}
</label>
))}
</div>
{anyError && (
<p className="recommendation-error">
Some extensions couldn&apos;t be installed. You can retry
from the <a href="/extensions">Extensions</a> page.
</p>
)}
</>
)}
</div>
<div className="recommendation-chips">
{recommendations.map((name) => (
<span key={name} className="recommendation-chip">
{name}
</span>
))}
</div>
</div>
)}
</div>
<div className="wizard-nav" style={{ borderTop: 'none', marginTop: 0, paddingTop: 0 }}>
<button type="button" className="btn-wizard-next" onClick={onFinish}>
Go to Dashboard
<button
type="button"
className="btn-wizard-next"
onClick={handleFinish}
disabled={installing}
>
{installing ? 'Setting up...' : 'Go to Dashboard'}
</button>
</div>
</div>
+2 -2
View File
@@ -116,8 +116,8 @@ export function AuthProvider({ children }) {
return data;
}
async function completeOnboarding(useCases) {
await api.completeOnboarding(useCases);
async function completeOnboarding(useCases, installedExtensions = []) {
await api.completeOnboarding(useCases, installedExtensions);
// Tailor the initial sidebar to the use cases picked during setup, so a
// fresh install opens focused instead of showing every item. Best-effort:
// if it fails, the sidebar just falls back to the "Recommended" default.
+2 -2
View File
@@ -47,8 +47,8 @@ const Setup = () => {
setCurrentStep(4);
}
async function handleFinish() {
await completeOnboarding(useCases);
async function handleFinish(installedExtensions = []) {
await completeOnboarding(useCases, installedExtensions);
navigate('/');
}
@@ -10,6 +10,8 @@
"permissions": [
"network"
],
"entry_point": "cloud_provisioning:cloud_provisioning_bp",
"url_prefix": "/api/v1/cloud",
"icon": "<path d=\"M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z\"/>",
"contributions": {
"tabs": [
@@ -11,6 +11,8 @@
"shell",
"filesystem"
],
"entry_point": "ftp:ftp_bp",
"url_prefix": "/api/v1/ftp",
"icon": "<rect x=\"16\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\"/><rect x=\"2\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\"/><rect x=\"9\" y=\"2\" width=\"6\" height=\"6\" rx=\"1\"/><path d=\"M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3\"/><path d=\"M12 12V8\"/>",
"contributions": {
"tabs": [
@@ -10,6 +10,8 @@
"permissions": [
"network"
],
"entry_point": "tunnels:tunnels_bp",
"url_prefix": "/api/v1/tunnels",
"icon": "<rect x=\"16\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\"/><rect x=\"2\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\"/><rect x=\"9\" y=\"2\" width=\"6\" height=\"6\" rx=\"1\"/><path d=\"M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3\"/><path d=\"M12 12V8\"/>",
"contributions": {
"tabs": [
@@ -2,12 +2,14 @@
"name": "serverkit-status",
"display_name": "Status Pages",
"version": "1.0.0",
"description": "Public status pages backed by uptime monitors, with the management UI as a tab in the Observability group. The public /status/<slug> page itself stays core.",
"description": "Public status pages backed by uptime monitors, with the management UI as a tab in the Observability group. Installing the extension serves the public /status/<slug> and /status/badge/<slug> pages too.",
"author": "ServerKit",
"license": "MIT",
"category": "monitoring",
"homepage": "https://github.com/jhd3197/ServerKit",
"permissions": [],
"entry_point": "status_pages:status_pages_bp",
"url_prefix": "/api/v1/status",
"icon": "<circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20\"/><path d=\"M2 12h20\"/>",
"contributions": {
"tabs": [
+2 -2
View File
@@ -25,10 +25,10 @@ export async function register(email, username, password, inviteToken) {
return data;
}
export async function completeOnboarding(useCases) {
export async function completeOnboarding(useCases, installedExtensions = []) {
return this.request('/auth/complete-onboarding', {
method: 'POST',
body: { use_cases: useCases },
body: { use_cases: useCases, installed_extensions: installedExtensions },
});
}
+9
View File
@@ -103,6 +103,15 @@ export async function getBuiltinExtensions() {
return this.request('/plugins/builtin');
}
// Setup wizard: extensions recommended for the selected onboarding use cases.
// Returns { recommendations: [{ slug, display_name, description, category,
// source: 'builtin'|'registry', installed, status }] }.
export async function getRecommendedExtensions(useCases = []) {
const list = (useCases || []).filter(Boolean);
const q = list.length ? `?use_cases=${encodeURIComponent(list.join(','))}` : '';
return this.request(`/plugins/recommendations${q}`);
}
// One-click install for a bundled extension by slug.
export async function installBuiltinExtension(slug) {
return this.request(`/plugins/builtin/${encodeURIComponent(slug)}/install`, {
@@ -385,6 +385,97 @@
color: $text-primary;
}
// --------------------------------------------
// Recommended extensions — install checkboxes (plan 47)
// --------------------------------------------
.recommendation-hint {
margin: 0 0 $space-3;
font-size: $font-size-sm;
color: $text-secondary;
}
.recommendation-list {
display: flex;
flex-direction: column;
gap: $space-2;
}
.recommendation-item {
display: flex;
align-items: flex-start;
gap: $space-3;
padding: $space-3 $space-4;
background: $bg-secondary;
border: 1px solid $border-default;
border-radius: $radius-lg;
cursor: pointer;
&:hover {
border-color: $primary-color;
}
&__check {
margin-top: 3px;
flex-shrink: 0;
cursor: pointer;
}
&__body {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
&__name {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $text-primary;
}
&__desc {
font-size: $font-size-xs;
color: $text-secondary;
}
&__spinner {
color: $text-secondary;
animation: spin 1s linear infinite;
flex-shrink: 0;
}
&__status {
font-size: $font-size-xs;
flex-shrink: 0;
&--installed {
color: $text-secondary;
}
&--done {
color: $success-color;
flex-shrink: 0;
}
&--error {
color: $danger-color;
flex-shrink: 0;
}
}
}
.recommendation-error {
margin: $space-3 0 0;
font-size: $font-size-sm;
color: $danger-color;
a {
color: $primary-color;
text-decoration: underline;
}
}
// --------------------------------------------
// Navigation buttons
// --------------------------------------------