mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 11:17:07 +00:00
feat(app-store): editorial hero, category rail, security scan signal per tile (#679)
* feat(app-store): editorial hero, category rail, security scan signal per tile Rework the App Store view into an editorial layout with a 180px category rail, a featured-template hero, and compact tiles that surface star counts and vulnerability scan status at a glance. The deploy sheet splits into Essentials (one-click deploy) and Advanced (full port/volume/env control) tabs. Backend enriches /api/templates with scan_status, scan_cve_count, and a featured flag computed from the highest-star template with recorded stars. Scan lookups use a single batched SQL query to avoid N+1 round-trips. * fix(app-store): split firstSentence helper into its own module The firstSentence helper lived alongside the TemplateLogo component, which violates react-refresh/only-export-components — a file that exports a component must not also export non-component values, or Fast Refresh cannot establish an HMR boundary. Move the helper into appstore/util.ts and update the two import sites.
This commit is contained in:
+30
-1
@@ -7258,7 +7258,36 @@ app.post('/api/system/networks', async (req: Request, res: Response) => {
|
|||||||
app.get('/api/templates', authMiddleware, async (req: Request, res: Response) => {
|
app.get('/api/templates', authMiddleware, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const templates = await templateService.getTemplates();
|
const templates = await templateService.getTemplates();
|
||||||
res.json(templates);
|
|
||||||
|
const imageRefs = templates.map(t => t.image).filter((i): i is string => !!i);
|
||||||
|
const scanSummary = DatabaseService.getInstance().getLatestScanSummaryByImageRefs(req.nodeId, imageRefs);
|
||||||
|
|
||||||
|
let featuredIndex = -1;
|
||||||
|
let featuredStars = 0;
|
||||||
|
templates.forEach((t, i) => {
|
||||||
|
const s = t.stars ?? 0;
|
||||||
|
if (s > featuredStars) {
|
||||||
|
featuredStars = s;
|
||||||
|
featuredIndex = i;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const enriched = templates.map((t, i) => {
|
||||||
|
const summary = t.image ? scanSummary.get(t.image) : undefined;
|
||||||
|
const scan_status: 'clean' | 'vulnerable' | 'unscanned' = summary
|
||||||
|
? (summary.total === 0 ? 'clean' : 'vulnerable')
|
||||||
|
: 'unscanned';
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
scan_status,
|
||||||
|
scan_cve_count: summary?.total ?? 0,
|
||||||
|
scan_critical_count: summary?.critical ?? 0,
|
||||||
|
scan_high_count: summary?.high ?? 0,
|
||||||
|
featured: i === featuredIndex,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(enriched);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Templates] Failed to fetch:', error);
|
console.error('[Templates] Failed to fetch:', error);
|
||||||
res.status(500).json({ error: 'Failed to fetch templates' });
|
res.status(500).json({ error: 'Failed to fetch templates' });
|
||||||
|
|||||||
@@ -2508,6 +2508,44 @@ export class DatabaseService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getLatestScanSummaryByImageRefs(
|
||||||
|
nodeId: number,
|
||||||
|
imageRefs: string[],
|
||||||
|
): Map<string, { total: number; critical: number; high: number; scannedAt: number }> {
|
||||||
|
const summary = new Map<string, { total: number; critical: number; high: number; scannedAt: number }>();
|
||||||
|
if (imageRefs.length === 0) return summary;
|
||||||
|
|
||||||
|
const placeholders = imageRefs.map(() => '?').join(',');
|
||||||
|
const rows = this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT image_ref, total_vulnerabilities, critical_count, high_count, scanned_at
|
||||||
|
FROM vulnerability_scans v1
|
||||||
|
WHERE node_id = ?
|
||||||
|
AND image_ref IN (${placeholders})
|
||||||
|
AND scanned_at = (
|
||||||
|
SELECT MAX(scanned_at) FROM vulnerability_scans v2
|
||||||
|
WHERE v2.node_id = v1.node_id AND v2.image_ref = v1.image_ref
|
||||||
|
)`,
|
||||||
|
)
|
||||||
|
.all(nodeId, ...imageRefs) as Array<{
|
||||||
|
image_ref: string;
|
||||||
|
total_vulnerabilities: number;
|
||||||
|
critical_count: number;
|
||||||
|
high_count: number;
|
||||||
|
scanned_at: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
summary.set(row.image_ref, {
|
||||||
|
total: row.total_vulnerabilities,
|
||||||
|
critical: row.critical_count,
|
||||||
|
high: row.high_count,
|
||||||
|
scannedAt: row.scanned_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
public getLatestScanByDigest(digest: string, scannersUsed?: string): VulnerabilityScan | null {
|
public getLatestScanByDigest(digest: string, scannersUsed?: string): VulnerabilityScan | null {
|
||||||
if (!digest) return null;
|
if (!digest) return null;
|
||||||
if (scannersUsed) {
|
if (scannersUsed) {
|
||||||
|
|||||||
+20
-10
@@ -6,29 +6,31 @@ description: Browse and deploy pre-configured application templates in one click
|
|||||||
The **App Store** tab lets you browse a curated catalogue of Docker Compose templates and deploy any of them as a new stack with environment-specific configuration, no YAML required.
|
The **App Store** tab lets you browse a curated catalogue of Docker Compose templates and deploy any of them as a new stack with environment-specific configuration, no YAML required.
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
<img src="/images/app-store/app-store-overview.png" alt="App Store showing a grid of application templates with category filters" />
|
<img src="/images/app-store/app-store-hero.png" alt="App Store with a category sidebar, a featured template hero banner, and editorial tiles with security scan badges" />
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
## Browsing templates
|
## Browsing templates
|
||||||
|
|
||||||
Templates are loaded from a remote registry (configurable in **Settings > App Store**). The default registry is LinuxServer.io. A live count of available apps is shown next to the category pills.
|
Templates are loaded from a remote registry (configurable in **Settings > App Store**). The default registry is LinuxServer.io. A live count of available apps is shown in the top right of the view.
|
||||||
|
|
||||||
**Search:** Type in the search bar to filter templates by name, description, or category in real-time.
|
**Search:** Type in the search bar to filter templates by name, description, or category in real-time.
|
||||||
|
|
||||||
**Categories:** Click any category pill to narrow the list:
|
**Category sidebar:** The left rail lists every category with its count. Click a category to narrow the grid. The active category is highlighted with a cyan rail and tinted background.
|
||||||
`All` · `Automation` · `Books` · `Development` · `Documentation` · `Downloaders` · `Media` · `Monitoring` · `Networking` · `Other` · `Productivity` · `Security` · `Utilities`
|
|
||||||
|
|
||||||
Each template card shows:
|
**Featured hero:** A featured template is pinned at the top of the grid with its logo, title, description, and a primary **Deploy** button. The hero disappears while you have an active search query.
|
||||||
- Application logo (or a placeholder icon if unavailable)
|
|
||||||
- Name and short description
|
Each tile shows:
|
||||||
- Up to three category badges (click a badge to filter by that category)
|
- A brand-tinted logo square (falls back to the first letter of the template name)
|
||||||
|
- Name and first-sentence pitch
|
||||||
|
- GitHub star count and primary category
|
||||||
|
- A **security scan badge** on the right: `CLEAN` (no CVEs detected), `{n} CVE(s)` (amber, open vulnerabilities), or `UNSCANNED` (no scan recorded yet)
|
||||||
|
|
||||||
## Deploying a template
|
## Deploying a template
|
||||||
|
|
||||||
Click any template card to open the **deployment sheet** on the right side of the screen.
|
Click any tile to open the **deployment sheet** on the right side of the screen. The sheet splits configuration into two tabs: **Essentials** for a fast one-click deploy and **Advanced** for full control over ports, volumes, and environment.
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
<img src="/images/app-store/app-store-deploy.png" alt="Deployment sheet for heimdall showing stack name, ports, volumes, and environment variables" />
|
<img src="/images/app-store/deploy-sheet-essentials.png" alt="Deployment sheet opened on the Essentials tab, showing the stack name field and a deploy-with-defaults hint" />
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
The sheet header displays the app logo, title, and a truncated description with a **Read more** / **Read less** toggle. Below the description you may see:
|
The sheet header displays the app logo, title, and a truncated description with a **Read more** / **Read less** toggle. Below the description you may see:
|
||||||
@@ -40,10 +42,18 @@ The sheet header displays the app logo, title, and a truncated description with
|
|||||||
|
|
||||||
When deploying to a remote node, a badge at the top of the sheet shows the target node name.
|
When deploying to a remote node, a badge at the top of the sheet shows the target node name.
|
||||||
|
|
||||||
|
### Essentials tab
|
||||||
|
|
||||||
|
The **Essentials** tab has a single field (the stack name) plus a hint that the template's recommended ports, volumes, and environment variables will be used as-is. Click **Deploy {template}** to ship it in one click. For anything more nuanced, switch to **Advanced**.
|
||||||
|
|
||||||
### Stack name
|
### Stack name
|
||||||
|
|
||||||
Pre-filled with the template name in lowercase. You can change it; the same [naming rules](/features/stack-management#creating-a-stack) apply (lowercase, hyphens, no spaces).
|
Pre-filled with the template name in lowercase. You can change it; the same [naming rules](/features/stack-management#creating-a-stack) apply (lowercase, hyphens, no spaces).
|
||||||
|
|
||||||
|
### Advanced tab
|
||||||
|
|
||||||
|
The **Advanced** tab exposes every knob the template declares: port mappings, volumes, environment variables, custom key/value pairs, and the post-deploy vulnerability scan toggle. Fields on this tab are pre-populated with the template's defaults, so customizing is additive rather than required.
|
||||||
|
|
||||||
### Ports
|
### Ports
|
||||||
|
|
||||||
For each exposed port, the sheet shows an editable **host port** field (left side) and the fixed **container port** (right side). Edit the host port to avoid conflicts with other services on the same machine. Ports must be in the valid range (1 to 65535); invalid values are highlighted and block deployment.
|
For each exposed port, the sheet shows an editable **host port** field (left side) and the fixed **container port** (right side). Edit the host port to avoid conflicts with other services on the same machine. Ports must be in the valid range (1 to 65535); invalid values are highlighted and block deployment.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 216 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 243 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 189 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
@@ -1,19 +1,23 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Input } from '@/components/ui/input';
|
||||||
import { Input } from "@/components/ui/input";
|
import { Label } from '@/components/ui/label';
|
||||||
import { Label } from "@/components/ui/label";
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from '@/components/ui/sheet';
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from "@/components/ui/sheet";
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { Button } from '@/components/ui/button';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import { Search, Rocket, Loader2, Info, ExternalLink, Star, ShieldCheck } from "lucide-react";
|
import { Search, Rocket, Loader2, Info, ExternalLink, Star, ShieldCheck } from 'lucide-react';
|
||||||
import { toast } from "@/components/ui/toast-store";
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { useNodes } from '@/context/NodeContext';
|
import { useNodes } from '@/context/NodeContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { CursorProvider, CursorContainer, Cursor, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor';
|
import { CursorProvider, CursorContainer, Cursor, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor';
|
||||||
|
import { CategorySidebar } from '@/components/appstore/CategorySidebar';
|
||||||
|
import { FeaturedHero } from '@/components/appstore/FeaturedHero';
|
||||||
|
import { TemplateTile } from '@/components/appstore/TemplateTile';
|
||||||
|
import type { Template } from '@/components/appstore/types';
|
||||||
|
|
||||||
function isValidPort(value: string): boolean {
|
function isValidPort(value: string): boolean {
|
||||||
if (!value) return true;
|
if (!value) return true;
|
||||||
@@ -21,34 +25,6 @@ function isValidPort(value: string): boolean {
|
|||||||
return Number.isInteger(num) && num >= 1 && num <= 65535;
|
return Number.isInteger(num) && num >= 1 && num <= 65535;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TemplateEnv {
|
|
||||||
name: string;
|
|
||||||
label?: string;
|
|
||||||
default?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TemplateVolume {
|
|
||||||
container?: string;
|
|
||||||
bind?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Template {
|
|
||||||
type?: number;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
logo?: string;
|
|
||||||
image?: string;
|
|
||||||
ports?: string[];
|
|
||||||
volumes?: TemplateVolume[];
|
|
||||||
env?: TemplateEnv[];
|
|
||||||
categories?: string[];
|
|
||||||
github_url?: string;
|
|
||||||
docs_url?: string;
|
|
||||||
architectures?: string[];
|
|
||||||
stars?: number;
|
|
||||||
source?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PortInUseInfo {
|
interface PortInUseInfo {
|
||||||
stack: string | null;
|
stack: string | null;
|
||||||
container: string;
|
container: string;
|
||||||
@@ -75,12 +51,13 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
|||||||
const [portVars, setPortVars] = useState<Record<string, string>>({});
|
const [portVars, setPortVars] = useState<Record<string, string>>({});
|
||||||
const [isDescExpanded, setIsDescExpanded] = useState(false);
|
const [isDescExpanded, setIsDescExpanded] = useState(false);
|
||||||
const [volVars, setVolVars] = useState<Record<string, string>>({});
|
const [volVars, setVolVars] = useState<Record<string, string>>({});
|
||||||
const [customEnvs, setCustomEnvs] = useState<Array<{ key: string, value: string }>>([]);
|
const [customEnvs, setCustomEnvs] = useState<Array<{ key: string; value: string }>>([]);
|
||||||
const [newEnvKey, setNewEnvKey] = useState('');
|
const [newEnvKey, setNewEnvKey] = useState('');
|
||||||
const [portsInUse, setPortsInUse] = useState<Record<string, PortInUseInfo>>({});
|
const [portsInUse, setPortsInUse] = useState<Record<string, PortInUseInfo>>({});
|
||||||
const [newEnvVal, setNewEnvVal] = useState('');
|
const [newEnvVal, setNewEnvVal] = useState('');
|
||||||
const [autoScan, setAutoScan] = useState(true);
|
const [autoScan, setAutoScan] = useState(true);
|
||||||
const [trivyAvailable, setTrivyAvailable] = useState(false);
|
const [trivyAvailable, setTrivyAvailable] = useState(false);
|
||||||
|
const [sheetTab, setSheetTab] = useState<'essentials' | 'advanced'>('essentials');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTemplates();
|
fetchTemplates();
|
||||||
@@ -99,16 +76,14 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setTemplates(data || []);
|
setTemplates(data || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error((err as Error).message || "Failed to load App Shop");
|
toast.error((err as Error).message || 'Failed to load App Store');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectTemplate = (t: Template) => {
|
const handleSelectTemplate = (t: Template) => {
|
||||||
// Work on a copy to avoid mutating the template in state
|
|
||||||
const envsCopy = [...(t.env || [])];
|
const envsCopy = [...(t.env || [])];
|
||||||
// Inject LSIO standards if missing from the API
|
|
||||||
if (!envsCopy.find(e => e.name === 'PUID')) envsCopy.push({ name: 'PUID', label: 'User ID (PUID)', default: '1000' });
|
if (!envsCopy.find(e => e.name === 'PUID')) envsCopy.push({ name: 'PUID', label: 'User ID (PUID)', default: '1000' });
|
||||||
if (!envsCopy.find(e => e.name === 'PGID')) envsCopy.push({ name: 'PGID', label: 'Group ID (PGID)', default: '1000' });
|
if (!envsCopy.find(e => e.name === 'PGID')) envsCopy.push({ name: 'PGID', label: 'Group ID (PGID)', default: '1000' });
|
||||||
if (!envsCopy.find(e => e.name === 'TZ')) {
|
if (!envsCopy.find(e => e.name === 'TZ')) {
|
||||||
@@ -125,7 +100,6 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
|||||||
});
|
});
|
||||||
setEnvVars(initEnvs);
|
setEnvVars(initEnvs);
|
||||||
|
|
||||||
// Initialize Volumes
|
|
||||||
const initVols: Record<string, string> = {};
|
const initVols: Record<string, string> = {};
|
||||||
t.volumes?.forEach((v) => {
|
t.volumes?.forEach((v) => {
|
||||||
if (v.container) {
|
if (v.container) {
|
||||||
@@ -133,7 +107,7 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
setVolVars(initVols);
|
setVolVars(initVols);
|
||||||
setCustomEnvs([]); // Reset custom envs
|
setCustomEnvs([]);
|
||||||
setNewEnvKey('');
|
setNewEnvKey('');
|
||||||
setNewEnvVal('');
|
setNewEnvVal('');
|
||||||
|
|
||||||
@@ -141,13 +115,13 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
|||||||
t.ports?.forEach(p => {
|
t.ports?.forEach(p => {
|
||||||
const parts = p.split(':');
|
const parts = p.split(':');
|
||||||
if (parts.length > 1) {
|
if (parts.length > 1) {
|
||||||
initPorts[p] = parts[0]; // Store just the host port for editing
|
initPorts[p] = parts[0];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setPortVars(initPorts);
|
setPortVars(initPorts);
|
||||||
setIsDescExpanded(false); // Reset description toggle
|
setIsDescExpanded(false);
|
||||||
|
setSheetTab('essentials');
|
||||||
|
|
||||||
// Auto-generate stack name from title
|
|
||||||
const defaultName = t.title
|
const defaultName = t.title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9-]/g, '-')
|
.replace(/[^a-z0-9-]/g, '-')
|
||||||
@@ -155,7 +129,6 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
|||||||
.replace(/^-|-$/g, '');
|
.replace(/^-|-$/g, '');
|
||||||
setStackName(defaultName);
|
setStackName(defaultName);
|
||||||
|
|
||||||
// Fetch currently bound host ports for conflict detection
|
|
||||||
setPortsInUse({});
|
setPortsInUse({});
|
||||||
apiFetch('/ports/in-use').then(r => r.ok ? r.json() : {}).then(setPortsInUse).catch(err => console.error('[AppStore] Failed to fetch ports in use:', err));
|
apiFetch('/ports/in-use').then(r => r.ok ? r.json() : {}).then(setPortsInUse).catch(err => console.error('[AppStore] Failed to fetch ports in use:', err));
|
||||||
|
|
||||||
@@ -163,19 +136,18 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeploy = async () => {
|
const handleDeploy = async () => {
|
||||||
|
if (!selectedTemplate) return;
|
||||||
if (!stackName.trim()) {
|
if (!stackName.trim()) {
|
||||||
toast.error("Stack name is required");
|
toast.error('Stack name is required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate port numbers
|
|
||||||
const invalidPort = Object.entries(portVars).find(([, val]) => val && !isValidPort(val));
|
const invalidPort = Object.entries(portVars).find(([, val]) => val && !isValidPort(val));
|
||||||
if (invalidPort) {
|
if (invalidPort) {
|
||||||
toast.error(`Invalid port: ${invalidPort[1]}. Ports must be between 1 and 65535.`);
|
toast.error(`Invalid port: ${invalidPort[1]}. Ports must be between 1 and 65535.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-check for duplicate stack name
|
|
||||||
try {
|
try {
|
||||||
const checkRes = await apiFetch('/stacks');
|
const checkRes = await apiFetch('/stacks');
|
||||||
if (checkRes.ok) {
|
if (checkRes.ok) {
|
||||||
@@ -191,18 +163,17 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
|||||||
|
|
||||||
setIsDeploying(true);
|
setIsDeploying(true);
|
||||||
|
|
||||||
const modifiedTemplate = { ...selectedTemplate };
|
const modifiedTemplate: Template = { ...selectedTemplate };
|
||||||
if (modifiedTemplate.ports) {
|
if (modifiedTemplate.ports) {
|
||||||
modifiedTemplate.ports = modifiedTemplate.ports.map(p => {
|
modifiedTemplate.ports = modifiedTemplate.ports.map(p => {
|
||||||
const parts = p.split(':');
|
const parts = p.split(':');
|
||||||
if (parts.length > 1 && portVars[p]) {
|
if (parts.length > 1 && portVars[p]) {
|
||||||
return `${portVars[p]}:${parts[1]}`; // Stitch the edited host port back
|
return `${portVars[p]}:${parts[1]}`;
|
||||||
}
|
}
|
||||||
return p;
|
return p;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process Volumes
|
|
||||||
if (modifiedTemplate.volumes) {
|
if (modifiedTemplate.volumes) {
|
||||||
modifiedTemplate.volumes = modifiedTemplate.volumes.map((v) => {
|
modifiedTemplate.volumes = modifiedTemplate.volumes.map((v) => {
|
||||||
if (v.container && volVars[v.container] !== undefined) {
|
if (v.container && volVars[v.container] !== undefined) {
|
||||||
@@ -212,7 +183,6 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge envs
|
|
||||||
const finalEnvVars = { ...envVars };
|
const finalEnvVars = { ...envVars };
|
||||||
customEnvs.forEach(ce => {
|
customEnvs.forEach(ce => {
|
||||||
if (ce.key.trim()) finalEnvVars[ce.key.trim()] = ce.value;
|
if (ce.key.trim()) finalEnvVars[ce.key.trim()] = ce.value;
|
||||||
@@ -225,8 +195,8 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
|||||||
stackName: stackName.trim(),
|
stackName: stackName.trim(),
|
||||||
template: modifiedTemplate,
|
template: modifiedTemplate,
|
||||||
envVars: finalEnvVars,
|
envVars: finalEnvVars,
|
||||||
skip_scan: !autoScan
|
skip_scan: !autoScan,
|
||||||
})
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error || 'Failed to deploy template');
|
if (!res.ok) throw new Error(data.error || 'Failed to deploy template');
|
||||||
@@ -241,10 +211,17 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const categories = useMemo(() => {
|
const categoryEntries = useMemo(() => {
|
||||||
const cats = new Set<string>();
|
const counts = new Map<string, number>();
|
||||||
templates.forEach(t => t.categories?.forEach(c => cats.add(c)));
|
templates.forEach(t => {
|
||||||
return ['All', ...Array.from(cats).sort()];
|
t.categories?.forEach(c => {
|
||||||
|
counts.set(c, (counts.get(c) || 0) + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const sorted = Array.from(counts.entries())
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
.map(([name, count]) => ({ name, count }));
|
||||||
|
return [{ name: 'All', count: templates.length }, ...sorted];
|
||||||
}, [templates]);
|
}, [templates]);
|
||||||
|
|
||||||
const filtered = useMemo(() => templates.filter(t => {
|
const filtered = useMemo(() => templates.filter(t => {
|
||||||
@@ -257,9 +234,19 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
|||||||
return matchesCategory && matchesSearch;
|
return matchesCategory && matchesSearch;
|
||||||
}), [templates, selectedCategory, searchQuery]);
|
}), [templates, selectedCategory, searchQuery]);
|
||||||
|
|
||||||
|
const featuredTemplate = useMemo(() => {
|
||||||
|
if (searchQuery) return null;
|
||||||
|
return filtered.find(t => t.featured) || null;
|
||||||
|
}, [filtered, searchQuery]);
|
||||||
|
|
||||||
|
const gridTemplates = useMemo(() => {
|
||||||
|
if (!featuredTemplate) return filtered;
|
||||||
|
return filtered.filter(t => t.title !== featuredTemplate.title);
|
||||||
|
}, [filtered, featuredTemplate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full space-y-6">
|
<div className="flex flex-col h-full gap-5">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center gap-3">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -270,94 +257,69 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
|||||||
onChange={(e) => { setSearchQuery(e.target.value); }}
|
onChange={(e) => { setSearchQuery(e.target.value); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-xs text-stat-subtitle font-mono tabular-nums shrink-0">
|
||||||
|
{filtered.length} app{filtered.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!loading && categories.length > 1 && (
|
<div className="flex flex-1 min-h-0 gap-5">
|
||||||
<div className="flex items-center gap-2">
|
{!loading && categoryEntries.length > 1 && (
|
||||||
<div className="flex gap-1.5 overflow-x-auto pb-1 flex-1 scrollbar-none">
|
<CategorySidebar
|
||||||
{categories.map(cat => (
|
categories={categoryEntries}
|
||||||
<Button
|
selected={selectedCategory}
|
||||||
key={cat}
|
onSelect={setSelectedCategory}
|
||||||
variant={selectedCategory === cat ? 'default' : 'outline'}
|
/>
|
||||||
size="sm"
|
|
||||||
className="shrink-0 h-7 text-xs px-3 rounded-full"
|
|
||||||
onClick={() => setSelectedCategory(cat)}
|
|
||||||
>
|
|
||||||
{cat}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
|
|
||||||
{filtered.length} app{filtered.length !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center h-48">
|
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-8">
|
|
||||||
{filtered.map((t, idx) => (
|
|
||||||
<Card
|
|
||||||
key={idx}
|
|
||||||
className="cursor-pointer flex flex-col overflow-hidden h-full"
|
|
||||||
onClick={() => handleSelectTemplate(t)}
|
|
||||||
>
|
|
||||||
<CardHeader className="flex flex-row items-start gap-4 space-y-0">
|
|
||||||
<div className="w-12 h-12 rounded bg-muted/50 p-1 flex-shrink-0 flex items-center justify-center overflow-hidden">
|
|
||||||
{t.logo && !imgErrors[t.logo] ? (
|
|
||||||
<img src={t.logo} alt={t.title} className="w-full h-full object-contain" onError={() => setImgErrors(prev => ({ ...prev, [t.logo!]: true }))} />
|
|
||||||
) : (
|
|
||||||
<Rocket className="w-6 h-6 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<CardTitle className="text-base truncate">{t.title}</CardTitle>
|
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2 mt-1 min-h-[40px]">
|
|
||||||
{t.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
{t.categories && t.categories.length > 0 && (
|
|
||||||
<CardContent className="pt-0 mt-auto">
|
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
|
||||||
{t.categories.slice(0, 3).map(c => (
|
|
||||||
<Badge
|
|
||||||
variant={selectedCategory === c ? 'default' : 'secondary'}
|
|
||||||
key={c}
|
|
||||||
className="text-[10px] px-1.5 py-0 pb-0.5 cursor-pointer"
|
|
||||||
onClick={(e) => { e.stopPropagation(); setSelectedCategory(c); }}
|
|
||||||
>
|
|
||||||
{c}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<div className="col-span-full py-12 text-center text-muted-foreground">
|
|
||||||
<Info className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
|
||||||
{templates.length === 0 ? (
|
|
||||||
<p>Registry returned no templates. Check your registry URL in Settings.</p>
|
|
||||||
) : (
|
|
||||||
<p>No apps found matching "{searchQuery}"</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-48">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-5 pb-8 pr-4">
|
||||||
|
{featuredTemplate && (
|
||||||
|
<FeaturedHero
|
||||||
|
template={featuredTemplate}
|
||||||
|
category={selectedCategory !== 'All' ? selectedCategory : undefined}
|
||||||
|
onOpen={handleSelectTemplate}
|
||||||
|
imgError={!!featuredTemplate.logo && !!imgErrors[featuredTemplate.logo]}
|
||||||
|
onImgError={() => featuredTemplate.logo && setImgErrors(prev => ({ ...prev, [featuredTemplate.logo!]: true }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gridTemplates.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||||
|
{gridTemplates.map((t, idx) => (
|
||||||
|
<TemplateTile
|
||||||
|
key={`${t.title}-${idx}`}
|
||||||
|
template={t}
|
||||||
|
onSelect={handleSelectTemplate}
|
||||||
|
imgError={!!t.logo && !!imgErrors[t.logo]}
|
||||||
|
onImgError={() => t.logo && setImgErrors(prev => ({ ...prev, [t.logo!]: true }))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : !featuredTemplate ? (
|
||||||
|
<div className="py-12 text-center text-muted-foreground">
|
||||||
|
<Info className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||||
|
{templates.length === 0 ? (
|
||||||
|
<p>Registry returned no templates. Check your registry URL in Settings.</p>
|
||||||
|
) : (
|
||||||
|
<p>No apps found matching "{searchQuery}"</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||||
<SheetContent className="w-full sm:max-w-md flex flex-col h-full" side="right">
|
<SheetContent className="w-full sm:max-w-md flex flex-col h-full" side="right">
|
||||||
{selectedTemplate && (
|
{selectedTemplate && (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<SheetHeader className="mb-6 text-left">
|
<SheetHeader className="mb-4 text-left">
|
||||||
{activeNode?.type === 'remote' && (
|
{activeNode?.type === 'remote' && (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<Badge variant="secondary" className="text-xs font-normal">
|
<Badge variant="secondary" className="text-xs font-normal">
|
||||||
@@ -373,10 +335,10 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
|||||||
<Rocket className="w-8 h-8 text-muted-foreground" />
|
<Rocket className="w-8 h-8 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<SheetTitle className="text-xl">{selectedTemplate.title}</SheetTitle>
|
<SheetTitle className="text-xl truncate">{selectedTemplate.title}</SheetTitle>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<SheetDescription className={isDescExpanded ? "" : "line-clamp-3 text-sm text-muted-foreground"}>
|
<SheetDescription className={isDescExpanded ? '' : 'line-clamp-3 text-sm text-muted-foreground'}>
|
||||||
{selectedTemplate.description}
|
{selectedTemplate.description}
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
<span className="text-xs text-primary cursor-pointer hover:underline mt-1 inline-block" onClick={() => setIsDescExpanded(!isDescExpanded)}>
|
<span className="text-xs text-primary cursor-pointer hover:underline mt-1 inline-block" onClick={() => setIsDescExpanded(!isDescExpanded)}>
|
||||||
@@ -422,157 +384,178 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<ScrollArea className="flex-1 pr-4 -mx-4 px-4">
|
<Tabs value={sheetTab} onValueChange={(v) => setSheetTab(v as 'essentials' | 'advanced')} className="flex flex-col flex-1 min-h-0">
|
||||||
<div className="space-y-6 pb-8">
|
<TabsList className="self-start mb-3">
|
||||||
<div className="space-y-2">
|
<TabsTrigger value="essentials">Essentials</TabsTrigger>
|
||||||
<Label htmlFor="stackName" className="font-semibold">
|
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
||||||
Stack Name <span className="text-destructive">*</span>
|
</TabsList>
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="stackName"
|
|
||||||
value={stackName}
|
|
||||||
onChange={(e) => setStackName(e.target.value)}
|
|
||||||
placeholder="e.g. my-app"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
This determines the directory name and docker project name.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedTemplate.ports && selectedTemplate.ports.length > 0 && (
|
<TabsContent value="essentials" className="flex-1 min-h-0 mt-0">
|
||||||
<div className="space-y-4 pt-4 border-t">
|
<ScrollArea className="h-full pr-4 -mx-4 px-4">
|
||||||
<h4 className="font-semibold">Ports (Host : Container)</h4>
|
<div className="space-y-4 pb-4">
|
||||||
{selectedTemplate.ports.map((p, idx) => {
|
<div className="space-y-2">
|
||||||
const parts = p.split(':');
|
<Label htmlFor="stackName" className="font-semibold">
|
||||||
if (parts.length < 2) return null;
|
Stack Name <span className="text-destructive">*</span>
|
||||||
const hostPort = portVars[p] || '';
|
</Label>
|
||||||
const conflict = hostPort ? portsInUse[hostPort] : undefined;
|
<Input
|
||||||
return (
|
id="stackName"
|
||||||
<div key={idx} className="flex items-center space-x-2">
|
value={stackName}
|
||||||
<Input
|
onChange={(e) => setStackName(e.target.value)}
|
||||||
value={hostPort}
|
placeholder="e.g. my-app"
|
||||||
onChange={(e) => {
|
/>
|
||||||
const val = e.target.value.replace(/[^0-9]/g, '');
|
<p className="text-xs text-muted-foreground">
|
||||||
setPortVars(prev => ({ ...prev, [p]: val }));
|
This determines the directory name and docker project name.
|
||||||
}}
|
</p>
|
||||||
className={cn("w-24 text-center font-mono", hostPort && !isValidPort(hostPort) && "border-destructive")}
|
</div>
|
||||||
/>
|
|
||||||
{conflict ? (
|
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-2.5 text-xs text-stat-subtitle">
|
||||||
<CursorProvider>
|
Deploy with defaults: the template's recommended ports, volumes, and environment variables.
|
||||||
<CursorContainer className="inline-flex items-center gap-2">
|
Use the Advanced tab to customize.
|
||||||
<span className="text-muted-foreground font-mono">: {parts[1]}</span>
|
|
||||||
<span className="w-2 h-2 rounded-full bg-warning animate-pulse" />
|
|
||||||
</CursorContainer>
|
|
||||||
<Cursor>
|
|
||||||
<div className="h-2 w-2 rounded-full bg-brand" />
|
|
||||||
</Cursor>
|
|
||||||
<CursorFollow side="bottom" sideOffset={4} align="center" transition={{ stiffness: 400, damping: 40, bounce: 0 }}>
|
|
||||||
<div className="rounded-md border border-card-border bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] px-2.5 py-1.5 shadow-md">
|
|
||||||
<span className="font-mono tabular-nums text-xs text-stat-value">
|
|
||||||
{conflict.stack !== null
|
|
||||||
? <>Port {hostPort} used by <span className="text-brand">{conflict.stack}</span></>
|
|
||||||
: <>Port {hostPort} used by an external app</>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CursorFollow>
|
|
||||||
</CursorProvider>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground font-mono">: {parts[1]}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedTemplate.volumes && selectedTemplate.volumes.length > 0 && (
|
|
||||||
<div className="space-y-4 pt-4 border-t">
|
|
||||||
<h4 className="font-semibold">Volumes (Host : Container)</h4>
|
|
||||||
{selectedTemplate.volumes.map((v, idx: number) => {
|
|
||||||
const containerPath = v.container;
|
|
||||||
if (!containerPath) return null;
|
|
||||||
return (
|
|
||||||
<div key={idx} className="space-y-1.5">
|
|
||||||
<Label className="text-xs text-muted-foreground font-mono">Container: {containerPath}</Label>
|
|
||||||
<Input
|
|
||||||
value={volVars[containerPath] !== undefined ? volVars[containerPath] : ''}
|
|
||||||
onChange={(e) => setVolVars(prev => ({ ...prev, [containerPath]: e.target.value }))}
|
|
||||||
placeholder={`/path/to/host/dir`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedTemplate.env && selectedTemplate.env.length > 0 && (
|
|
||||||
<div className="space-y-4 pt-4 border-t">
|
|
||||||
<h4 className="font-semibold">Environment Variables</h4>
|
|
||||||
{selectedTemplate.env.map((e, idx) => (
|
|
||||||
<div key={idx} className="space-y-1.5">
|
|
||||||
<Label htmlFor={`env-${e.name}`} className="text-sm">
|
|
||||||
{e.label || e.name}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={`env-${e.name}`}
|
|
||||||
value={envVars[e.name] !== undefined ? envVars[e.name] : ''}
|
|
||||||
onChange={(ev) => setEnvVars(prev => ({ ...prev, [e.name]: ev.target.value }))}
|
|
||||||
placeholder={e.default || `Enter value for ${e.name}`}
|
|
||||||
/>
|
|
||||||
<p className="text-[10px] text-muted-foreground font-mono">
|
|
||||||
{e.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-4 pt-4 border-t">
|
|
||||||
<h4 className="font-semibold text-sm">Add Custom Variable</h4>
|
|
||||||
{customEnvs.map((ce, idx) => (
|
|
||||||
<div key={idx} className="flex gap-2">
|
|
||||||
<Input value={ce.key} readOnly className="w-1/3 bg-muted font-mono text-xs" />
|
|
||||||
<Input value={ce.value} readOnly className="flex-1 bg-muted font-mono text-xs" />
|
|
||||||
<Button variant="ghost" size="icon" className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground" onClick={() => setCustomEnvs(prev => prev.filter((_, i) => i !== idx))}>-</Button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input placeholder="KEY" value={newEnvKey} onChange={e => setNewEnvKey(e.target.value)} className="w-1/3 font-mono text-xs" />
|
|
||||||
<Input placeholder="VALUE" value={newEnvVal} onChange={e => setNewEnvVal(e.target.value)} className="flex-1 font-mono text-xs" />
|
|
||||||
<Button variant="secondary" onClick={() => {
|
|
||||||
if (newEnvKey.trim()) {
|
|
||||||
if (selectedTemplate?.env?.find(e => e.name === newEnvKey.trim())) {
|
|
||||||
toast.warning(`"${newEnvKey.trim()}" already exists in template defaults. The custom value will override it.`);
|
|
||||||
}
|
|
||||||
setCustomEnvs(prev => [...prev, { key: newEnvKey, value: newEnvVal }]);
|
|
||||||
setNewEnvKey('');
|
|
||||||
setNewEnvVal('');
|
|
||||||
}
|
|
||||||
}}>+</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ScrollArea>
|
||||||
</div>
|
</TabsContent>
|
||||||
</ScrollArea>
|
|
||||||
|
<TabsContent value="advanced" className="flex-1 min-h-0 mt-0">
|
||||||
|
<ScrollArea className="h-full pr-4 -mx-4 px-4">
|
||||||
|
<div className="space-y-6 pb-4">
|
||||||
|
{selectedTemplate.ports && selectedTemplate.ports.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="font-semibold">Ports (Host : Container)</h4>
|
||||||
|
{selectedTemplate.ports.map((p, idx) => {
|
||||||
|
const parts = p.split(':');
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
const hostPort = portVars[p] || '';
|
||||||
|
const conflict = hostPort ? portsInUse[hostPort] : undefined;
|
||||||
|
return (
|
||||||
|
<div key={idx} className="flex items-center space-x-2">
|
||||||
|
<Input
|
||||||
|
value={hostPort}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value.replace(/[^0-9]/g, '');
|
||||||
|
setPortVars(prev => ({ ...prev, [p]: val }));
|
||||||
|
}}
|
||||||
|
className={cn('w-24 text-center font-mono', hostPort && !isValidPort(hostPort) && 'border-destructive')}
|
||||||
|
/>
|
||||||
|
{conflict ? (
|
||||||
|
<CursorProvider>
|
||||||
|
<CursorContainer className="inline-flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground font-mono">: {parts[1]}</span>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-warning animate-pulse" />
|
||||||
|
</CursorContainer>
|
||||||
|
<Cursor>
|
||||||
|
<div className="h-2 w-2 rounded-full bg-brand" />
|
||||||
|
</Cursor>
|
||||||
|
<CursorFollow side="bottom" sideOffset={4} align="center" transition={{ stiffness: 400, damping: 40, bounce: 0 }}>
|
||||||
|
<div className="rounded-md border border-card-border bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] px-2.5 py-1.5 shadow-md">
|
||||||
|
<span className="font-mono tabular-nums text-xs text-stat-value">
|
||||||
|
{conflict.stack !== null
|
||||||
|
? <>Port {hostPort} used by <span className="text-brand">{conflict.stack}</span></>
|
||||||
|
: <>Port {hostPort} used by an external app</>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CursorFollow>
|
||||||
|
</CursorProvider>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground font-mono">: {parts[1]}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedTemplate.volumes && selectedTemplate.volumes.length > 0 && (
|
||||||
|
<div className="space-y-4 pt-4 border-t">
|
||||||
|
<h4 className="font-semibold">Volumes (Host : Container)</h4>
|
||||||
|
{selectedTemplate.volumes.map((v, idx) => {
|
||||||
|
const containerPath = v.container;
|
||||||
|
if (!containerPath) return null;
|
||||||
|
return (
|
||||||
|
<div key={idx} className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground font-mono">Container: {containerPath}</Label>
|
||||||
|
<Input
|
||||||
|
value={volVars[containerPath] !== undefined ? volVars[containerPath] : ''}
|
||||||
|
onChange={(e) => setVolVars(prev => ({ ...prev, [containerPath]: e.target.value }))}
|
||||||
|
placeholder="/path/to/host/dir"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedTemplate.env && selectedTemplate.env.length > 0 && (
|
||||||
|
<div className="space-y-4 pt-4 border-t">
|
||||||
|
<h4 className="font-semibold">Environment Variables</h4>
|
||||||
|
{selectedTemplate.env.map((e, idx) => (
|
||||||
|
<div key={idx} className="space-y-1.5">
|
||||||
|
<Label htmlFor={`env-${e.name}`} className="text-sm">
|
||||||
|
{e.label || e.name}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`env-${e.name}`}
|
||||||
|
value={envVars[e.name] !== undefined ? envVars[e.name] : ''}
|
||||||
|
onChange={(ev) => setEnvVars(prev => ({ ...prev, [e.name]: ev.target.value }))}
|
||||||
|
placeholder={e.default || `Enter value for ${e.name}`}
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground font-mono">
|
||||||
|
{e.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4 pt-4 border-t">
|
||||||
|
<h4 className="font-semibold text-sm">Add Custom Variable</h4>
|
||||||
|
{customEnvs.map((ce, idx) => (
|
||||||
|
<div key={idx} className="flex gap-2">
|
||||||
|
<Input value={ce.key} readOnly className="w-1/3 bg-muted font-mono text-xs" />
|
||||||
|
<Input value={ce.value} readOnly className="flex-1 bg-muted font-mono text-xs" />
|
||||||
|
<Button variant="ghost" size="icon" className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground" onClick={() => setCustomEnvs(prev => prev.filter((_, i) => i !== idx))}>-</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input placeholder="KEY" value={newEnvKey} onChange={e => setNewEnvKey(e.target.value)} className="w-1/3 font-mono text-xs" />
|
||||||
|
<Input placeholder="VALUE" value={newEnvVal} onChange={e => setNewEnvVal(e.target.value)} className="flex-1 font-mono text-xs" />
|
||||||
|
<Button variant="secondary" onClick={() => {
|
||||||
|
if (newEnvKey.trim()) {
|
||||||
|
if (selectedTemplate?.env?.find(e => e.name === newEnvKey.trim())) {
|
||||||
|
toast.warning(`"${newEnvKey.trim()}" already exists in template defaults. The custom value will override it.`);
|
||||||
|
}
|
||||||
|
setCustomEnvs(prev => [...prev, { key: newEnvKey, value: newEnvVal }]);
|
||||||
|
setNewEnvKey('');
|
||||||
|
setNewEnvVal('');
|
||||||
|
}
|
||||||
|
}}>+</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{trivyAvailable && (
|
||||||
|
<div className="flex items-center gap-2 pt-4 border-t">
|
||||||
|
<Checkbox
|
||||||
|
id="auto-scan"
|
||||||
|
checked={autoScan}
|
||||||
|
onCheckedChange={(checked) => setAutoScan(!!checked)}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="auto-scan"
|
||||||
|
className="text-sm text-muted-foreground cursor-pointer flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||||
|
Scan images for vulnerabilities after deploy
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<SheetFooter className="pt-4 mt-auto border-t sm:justify-start">
|
<SheetFooter className="pt-4 mt-auto border-t sm:justify-start">
|
||||||
<div className="flex flex-col w-full gap-3">
|
<div className="flex flex-col w-full gap-3">
|
||||||
{trivyAvailable && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id="auto-scan"
|
|
||||||
checked={autoScan}
|
|
||||||
onCheckedChange={(checked) => setAutoScan(!!checked)}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
htmlFor="auto-scan"
|
|
||||||
className="text-sm text-muted-foreground cursor-pointer flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />
|
|
||||||
Scan images for vulnerabilities after deploy
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleDeploy}
|
onClick={handleDeploy}
|
||||||
disabled={isDeploying || !stackName.trim() || !can('stack:create')}
|
disabled={isDeploying || !stackName.trim() || !can('stack:create')}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface CategoryEntry {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategorySidebarProps {
|
||||||
|
categories: CategoryEntry[];
|
||||||
|
selected: string;
|
||||||
|
onSelect: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategorySidebar({ categories, selected, onSelect }: CategorySidebarProps) {
|
||||||
|
return (
|
||||||
|
<aside className="w-[180px] shrink-0 border-r border-border/60 pr-4">
|
||||||
|
<div className="sticky top-0 flex flex-col gap-1">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle px-2 py-1.5">
|
||||||
|
Categories
|
||||||
|
</span>
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{categories.map(cat => {
|
||||||
|
const isActive = selected === cat.name;
|
||||||
|
return (
|
||||||
|
<li key={cat.name}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(cat.name)}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'text-brand bg-gradient-to-r from-brand/[0.12] to-transparent'
|
||||||
|
: 'text-foreground/80 hover:bg-muted/40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isActive && <span className="absolute inset-y-0 left-0 w-[2px] bg-brand rounded-sm" />}
|
||||||
|
<span className="truncate text-sm">{cat.name}</span>
|
||||||
|
<span className={cn(
|
||||||
|
'shrink-0 font-mono text-[10px] tabular-nums',
|
||||||
|
isActive ? 'text-brand' : 'text-stat-subtitle',
|
||||||
|
)}>
|
||||||
|
{cat.count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Rocket } from 'lucide-react';
|
||||||
|
import { TemplateLogo } from './TemplateLogo';
|
||||||
|
import { firstSentence } from './util';
|
||||||
|
import type { Template } from './types';
|
||||||
|
|
||||||
|
interface FeaturedHeroProps {
|
||||||
|
template: Template;
|
||||||
|
category?: string;
|
||||||
|
onOpen: (t: Template) => void;
|
||||||
|
imgError: boolean;
|
||||||
|
onImgError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeaturedHero({ template, category, onOpen, imgError, onImgError }: FeaturedHeroProps) {
|
||||||
|
const pitch = firstSentence(template.description);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-hidden rounded-lg border border-brand/25 border-t-brand/35 bg-card shadow-card-bevel">
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-brand/[0.10] via-brand/[0.02] to-transparent" />
|
||||||
|
<div className="absolute inset-y-0 left-0 w-[3px] bg-brand" />
|
||||||
|
<div className="relative grid grid-cols-[80px_1fr_auto] items-center gap-5 py-5 pl-7 pr-6">
|
||||||
|
<TemplateLogo
|
||||||
|
logo={template.logo}
|
||||||
|
title={template.title}
|
||||||
|
size="lg"
|
||||||
|
imgError={imgError}
|
||||||
|
onImgError={onImgError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-brand">
|
||||||
|
Featured{category ? ` · ${category}` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="font-display italic text-3xl leading-tight tracking-tight text-stat-value truncate">
|
||||||
|
{template.title}
|
||||||
|
</span>
|
||||||
|
{pitch ? (
|
||||||
|
<span className="text-sm text-stat-subtitle/90 line-clamp-1">
|
||||||
|
{pitch}.
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Button
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() => onOpen(template)}
|
||||||
|
>
|
||||||
|
<Rocket className="h-4 w-4" strokeWidth={1.5} />
|
||||||
|
Deploy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface TemplateLogoProps {
|
||||||
|
logo?: string;
|
||||||
|
title: string;
|
||||||
|
size: 'sm' | 'lg';
|
||||||
|
imgError: boolean;
|
||||||
|
onImgError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE_CLASS = {
|
||||||
|
sm: 'h-9 w-9 rounded-sm',
|
||||||
|
lg: 'h-20 w-20 rounded-md',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const LETTER_CLASS = {
|
||||||
|
sm: 'text-lg',
|
||||||
|
lg: 'text-5xl',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const PAD_CLASS = {
|
||||||
|
sm: 'p-1',
|
||||||
|
lg: 'p-3',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function TemplateLogo({ logo, title, size, imgError, onImgError }: TemplateLogoProps) {
|
||||||
|
const firstLetter = title.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
SIZE_CLASS[size],
|
||||||
|
'bg-gradient-to-br border border-brand/25 flex items-center justify-center overflow-hidden',
|
||||||
|
size === 'lg'
|
||||||
|
? 'from-brand/40 via-brand/20 to-teal-500/20 border-brand/30'
|
||||||
|
: 'from-brand/25 via-brand/10 to-teal-500/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{logo && !imgError ? (
|
||||||
|
<img
|
||||||
|
src={logo}
|
||||||
|
alt={title}
|
||||||
|
className={cn('w-full h-full object-contain', PAD_CLASS[size])}
|
||||||
|
onError={onImgError}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className={cn('font-display italic text-brand leading-none', LETTER_CLASS[size])}>
|
||||||
|
{firstLetter}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { Star } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { TemplateLogo } from './TemplateLogo';
|
||||||
|
import { firstSentence } from './util';
|
||||||
|
import type { Template, ScanStatus } from './types';
|
||||||
|
|
||||||
|
interface TemplateTileProps {
|
||||||
|
template: Template;
|
||||||
|
onSelect: (t: Template) => void;
|
||||||
|
imgError: boolean;
|
||||||
|
onImgError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScanBadge({ status, cveCount }: { status: ScanStatus; cveCount: number }) {
|
||||||
|
if (status === 'clean') {
|
||||||
|
return (
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-success">
|
||||||
|
Clean
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === 'vulnerable') {
|
||||||
|
return (
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-warning tabular-nums">
|
||||||
|
{cveCount} CVE{cveCount === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||||
|
Unscanned
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplateTile({ template, onSelect, imgError, onImgError }: TemplateTileProps) {
|
||||||
|
const status: ScanStatus = template.scan_status ?? 'unscanned';
|
||||||
|
const cveCount = template.scan_cve_count ?? 0;
|
||||||
|
const pitch = firstSentence(template.description);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(template)}
|
||||||
|
className={cn(
|
||||||
|
'group relative grid grid-cols-[36px_1fr_auto] items-start gap-3 rounded-md border border-card-border border-t-card-border-top bg-card px-3 py-3 text-left shadow-card-bevel transition-colors',
|
||||||
|
'hover:border-t-card-border-hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TemplateLogo
|
||||||
|
logo={template.logo}
|
||||||
|
title={template.title}
|
||||||
|
size="sm"
|
||||||
|
imgError={imgError}
|
||||||
|
onImgError={onImgError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-0.5 min-w-0">
|
||||||
|
<span className="text-sm font-medium text-stat-value truncate">{template.title}</span>
|
||||||
|
{pitch ? (
|
||||||
|
<span className="text-xs text-stat-subtitle line-clamp-1">{pitch}.</span>
|
||||||
|
) : null}
|
||||||
|
<div className="flex items-center gap-3 pt-1">
|
||||||
|
{typeof template.stars === 'number' && template.stars > 0 ? (
|
||||||
|
<span className="flex items-center gap-1 text-[10px] text-stat-subtitle tabular-nums font-mono">
|
||||||
|
<Star className="h-3 w-3 fill-warning text-warning" strokeWidth={1.5} />
|
||||||
|
{template.stars.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{template.categories?.[0] ? (
|
||||||
|
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-stat-subtitle/80 truncate">
|
||||||
|
{template.categories[0]}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start pt-0.5">
|
||||||
|
<ScanBadge status={status} cveCount={cveCount} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export interface TemplateEnv {
|
||||||
|
name: string;
|
||||||
|
label?: string;
|
||||||
|
default?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateVolume {
|
||||||
|
container?: string;
|
||||||
|
bind?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScanStatus = 'clean' | 'vulnerable' | 'unscanned';
|
||||||
|
|
||||||
|
export interface Template {
|
||||||
|
type?: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
logo?: string;
|
||||||
|
image?: string;
|
||||||
|
ports?: string[];
|
||||||
|
volumes?: TemplateVolume[];
|
||||||
|
env?: TemplateEnv[];
|
||||||
|
categories?: string[];
|
||||||
|
github_url?: string;
|
||||||
|
docs_url?: string;
|
||||||
|
architectures?: string[];
|
||||||
|
stars?: number;
|
||||||
|
source?: string;
|
||||||
|
scan_status?: ScanStatus;
|
||||||
|
scan_cve_count?: number;
|
||||||
|
scan_critical_count?: number;
|
||||||
|
scan_high_count?: number;
|
||||||
|
featured?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export function firstSentence(text?: string): string | undefined {
|
||||||
|
if (!text) return undefined;
|
||||||
|
return text.split(/[.!?]/)[0]?.trim() || undefined;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user