mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-13 15:32:53 +09:00
Merge plan-47 lean-core (wizard installs + 4 backend extractions + security tab-group)
This commit is contained in:
commit
b7911026be
51 files changed
+1410
-309
No files matched your search
@@ -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'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'll install what you check. Uncheck anything you don'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'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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
// --------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user