diff --git a/frontend/src/pages/Templates.jsx b/frontend/src/pages/Templates.jsx
index cb62d74a..317fe0e1 100644
--- a/frontend/src/pages/Templates.jsx
+++ b/frontend/src/pages/Templates.jsx
@@ -4,7 +4,7 @@ import {
Search, X, Star, ExternalLink, BookOpen, Container, Globe, BarChart3,
Database, Shield, Cloud, MessageSquare, Video, Music, Image, Home,
Code, Server, GitBranch, Workflow, HardDrive, Lock, Users, FileText,
- Settings, Layers, LayoutTemplate, ChevronDown, Copy, Check, Tag, Cpu,
+ Settings, Layers, LayoutTemplate, Copy, Check, Tag, Cpu,
Newspaper, TrendingUp, Rocket
} from 'lucide-react';
import api from '../services/api';
@@ -12,9 +12,12 @@ import { useToast } from '../contexts/ToastContext';
import Modal from '@/components/Modal';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
+import { Badge } from '@/components/ui/badge';
+import {
+ SearchField, FilterDrawer, FilterButton, countActiveFilters,
+} from '@/components/ds';
import { useTopbarActions } from '@/hooks/useTopbarActions';
import EmptyState from '../components/EmptyState';
-import { DEPLOY_TEMPLATES } from '@/data/deployTemplates';
// Featured templates (curated list)
const FEATURED_TEMPLATES = [
@@ -37,6 +40,7 @@ const TEMPLATE_ICONS = {
'langflow': Workflow,
'anythingllm': Cpu,
'librechat': MessageSquare,
+ 'agentsite': Cpu,
// Monitoring
'uptime-kuma': BarChart3,
'grafana': BarChart3,
@@ -153,6 +157,17 @@ const TEMPLATE_ICONS = {
'node-app': Code
};
+const SORT_OPTIONS = [
+ { value: 'featured', label: 'Featured first' },
+ { value: 'name-asc', label: 'Name (A–Z)' },
+ { value: 'name-desc', label: 'Name (Z–A)' },
+];
+
+const KIND_OPTIONS = [
+ { value: 'compose', label: 'One-click' },
+ { value: 'repo', label: 'Git repo' },
+];
+
const Templates = () => {
const navigate = useNavigate();
const toast = useToast();
@@ -165,10 +180,14 @@ const Templates = () => {
const [selectedTemplate, setSelectedTemplate] = useState(null);
const [showInstallModal, setShowInstallModal] = useState(false);
const [copiedCompose, setCopiedCompose] = useState(false);
- const [showAllCategories, setShowAllCategories] = useState(false);
+ const [filtersOpen, setFiltersOpen] = useState(false);
+ // Repo-template manifest inspection (populated when a repo detail opens).
+ const [repoManifest, setRepoManifest] = useState(null);
+ const [repoManifestLoading, setRepoManifestLoading] = useState(false);
// Initialize from URL params
- const selectedCategory = searchParams.get('category') || null;
+ const selectedCategory = searchParams.get('category') || '';
+ const selectedKind = searchParams.get('kind') || '';
const searchQuery = searchParams.get('search') || '';
const sortBy = searchParams.get('sort') || 'featured';
const installTemplateId = searchParams.get('install');
@@ -177,20 +196,30 @@ const Templates = () => {
loadData();
}, []);
- // Auto-open install modal if template ID is in URL
+ // Compat: the old `#deploy-templates` anchor (linked from the wizard + docs)
+ // now redirects to the repo-kind filtered view of the unified grid.
+ useEffect(() => {
+ if (window.location.hash === '#deploy-templates' && !selectedKind) {
+ updateFilters({ kind: 'repo' });
+ // Drop the hash so a refresh doesn't re-trigger.
+ window.history.replaceState(null, '', window.location.pathname + window.location.search);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Auto-open install modal if template ID is in URL (compose templates only;
+ // repo templates deploy through the wizard, never the install modal).
useEffect(() => {
if (installTemplateId && templates.length > 0 && !loading) {
- // WordPress has its own dedicated page
if (installTemplateId === 'wordpress') {
navigate('/wordpress', { replace: true });
return;
}
const template = templates.find(t => t.id === installTemplateId);
- if (template) {
+ if (template && template.kind !== 'repo') {
handleViewTemplate(template).then(() => {
setShowInstallModal(true);
});
- // Clear the install param from URL
const newParams = new URLSearchParams(searchParams);
newParams.delete('install');
setSearchParams(newParams, { replace: true });
@@ -222,10 +251,6 @@ const Templates = () => {
updateFilters({ search: search || null });
}
- function setSortByFilter(sort) {
- updateFilters({ sort });
- }
-
function clearAllFilters() {
setSearchParams(new URLSearchParams());
}
@@ -247,7 +272,7 @@ const Templates = () => {
async function loadTemplates() {
try {
- const result = await api.listTemplates(selectedCategory, searchQuery || null);
+ const result = await api.listTemplates(selectedCategory || null, searchQuery || null);
setTemplates(result.templates || []);
} catch (err) {
console.error('Failed to load templates:', err);
@@ -309,6 +334,8 @@ const Templates = () => {
chat: MessageSquare,
'low-code': Workflow,
workflow: Workflow,
+ website: Globe,
+ deployment: Rocket,
// Search
search: Search,
vector: Database,
@@ -360,14 +387,37 @@ const Templates = () => {
const result = await api.getTemplate(template.id);
if (result.template) {
setSelectedTemplate(result.template);
+ if (result.template.kind === 'repo') {
+ loadRepoManifest(result.template);
+ }
}
} catch (err) {
toast.error('Failed to load template details');
}
}
- // Hover-reveal Deploy: skip the detail modal, go straight to install
+ // Fetch the repo template's detected manifests / env from the Phase 1
+ // endpoint so the detail body shows real data (or labeled hints).
+ function loadRepoManifest(template) {
+ setRepoManifest(null);
+ setRepoManifestLoading(true);
+ api.inspectTemplateManifest(template.id, template.repo?.branch)
+ .then(data => setRepoManifest(data))
+ .catch(() => setRepoManifest(null))
+ .finally(() => setRepoManifestLoading(false));
+ }
+
+ function deployRepoTemplate(templateId) {
+ navigate(`/services/new?template=${encodeURIComponent(templateId)}`);
+ }
+
+ // Hover-reveal primary action. Repo templates route to the wizard; compose
+ // templates skip the detail modal and go straight to install.
async function handleDeploy(template) {
+ if (template.kind === 'repo') {
+ deployRepoTemplate(template.id);
+ return;
+ }
if (template.id === 'wordpress') {
navigate('/wordpress');
return;
@@ -388,8 +438,8 @@ const Templates = () => {
}
// Sort templates
- function sortTemplates(templates) {
- const sorted = [...templates];
+ function sortTemplates(list) {
+ const sorted = [...list];
switch (sortBy) {
case 'name-asc':
return sorted.sort((a, b) => a.name.localeCompare(b.name));
@@ -408,27 +458,62 @@ const Templates = () => {
}
}
- const sortedTemplates = sortTemplates(templates);
- const hasActiveFilters = selectedCategory || searchQuery;
- const visibleCategories = showAllCategories ? categories : categories.slice(0, 10);
- const hiddenCategoryCount = Math.max(categories.length - visibleCategories.length, 0);
+ // Kind is filtered client-side (the list endpoint filters category + search).
+ const kindFiltered = selectedKind
+ ? templates.filter(t => (t.kind || 'compose') === selectedKind)
+ : templates;
+ const sortedTemplates = sortTemplates(kindFiltered);
+
+ // One row of the largest categories as quick chips (no spillover button).
+ const quickCategories = categories.slice(0, 8);
+
+ // The active-filter badge counts real filters only (category + kind); sort is
+ // an ordering, not a filter, so it never inflates the count.
+ const activeFilterCount = countActiveFilters({ category: selectedCategory, kind: selectedKind });
+ const hasActiveFilters = Boolean(selectedCategory || selectedKind || searchQuery
+ || (sortBy && sortBy !== 'featured'));
+
+ const filterGroups = [
+ {
+ key: 'kind',
+ label: 'Type',
+ type: 'single',
+ options: KIND_OPTIONS,
+ },
+ {
+ key: 'category',
+ label: 'Category',
+ type: 'single',
+ options: categories.map(cat => ({ value: cat, label: cat })),
+ },
+ {
+ key: 'sort',
+ label: 'Sort',
+ type: 'single',
+ options: SORT_OPTIONS,
+ },
+ ];
+
+ const filterValue = { category: selectedCategory, kind: selectedKind, sort: sortBy };
+
+ function handleFilterChange(next) {
+ updateFilters({
+ category: next.category || null,
+ kind: next.kind || null,
+ sort: next.sort && next.sort !== 'featured' ? next.sort : null,
+ });
+ }
useTopbarActions(() =>
-
-
-
+ setSearchQueryFilter(e.target.value)}
+ onSearch={setSearchQueryFilter}
+ placeholder="Search templates…"
/>
- {searchQuery && (
- setSearchQueryFilter('')}>
-
-
- )}
-
,
- [searchQuery]
+ setFiltersOpen(true)} />
+ >,
+ [searchQuery, activeFilterCount]
);
if (loading) {
@@ -441,45 +526,8 @@ const Templates = () => {
return (
- {/* Deploy templates — manifest-ready repos, shared with the New
- Service wizard's "Deploy Template" source. */}
-
-
-
-
Deploy templates
-
Manifest-ready repositories that prefill the New Service wizard.
-
-
-
- {DEPLOY_TEMPLATES.map(template => (
-
-
-
- {template.name}
- {template.example && (
- Example
- )}
-
-
{template.description}
-
{template.repoUrl}
-
- {(template.badges || []).map(badge => (
- {badge}
- ))}
-
-
-
navigate(`/services/new?template=${encodeURIComponent(template.id)}`)}
- >
- Use template
-
-
- ))}
-
-
-
- {/* Results and Filters */}
+ {/* Quick category chips + result count. The full category list, kind,
+ and sort live in the shared FilterDrawer (opened from the topbar). */}
{sortedTemplates.length} template{sortedTemplates.length !== 1 ? 's' : ''}
@@ -491,38 +539,32 @@ const Templates = () => {
>
All
- {visibleCategories.map(category => (
+ {quickCategories.map(category => (
setSelectedCategoryFilter(category)}
+ onClick={() => setSelectedCategoryFilter(
+ selectedCategory === category ? null : category
+ )}
>
{getCategoryIcon(category)} {category}
))}
- {categories.length > 10 && (
- setShowAllCategories(prev => !prev)}
- >
- {showAllCategories ? 'Less' : `More +${hiddenCategoryCount}`}
-
- )}
-
-
- Sort by:
- setSortByFilter(e.target.value)}>
- Name (A-Z)
- Name (Z-A)
- Featured First
-
-
{/* Active Filters */}
{hasActiveFilters && (
+ {selectedKind && (
+
+
+ {KIND_OPTIONS.find(k => k.value === selectedKind)?.label || selectedKind}
+ updateFilters({ kind: null })}>
+
+
+
+ )}
{selectedCategory && (
@@ -561,58 +603,64 @@ const Templates = () => {
)}
/>
) : (
- sortedTemplates.map(template => (
- handleViewTemplate(template)}>
- {isFeatured(template.id) && (
-
-
-
- )}
-
-
- {renderIcon(template, 22)}
-
-
-
{template.name}
-
v{template.version}
+ sortedTemplates.map(template => {
+ const isRepo = (template.kind || 'compose') === 'repo';
+ return (
+
handleViewTemplate(template)}>
+ {isFeatured(template.id) && (
+
+
+
+ )}
+
+
+ {renderIcon(template, 22)}
+
+
+
{template.name}
+
v{template.version}
+
+
+
{template.description}
+
+
+ {isRepo ? 'Git repo' : 'One-click'}
+
+ {(template.categories || []).slice(0, 2).map(cat => (
+
+ {cat}
+
+ ))}
+ {template.website && (
+
+
+
+ )}
+ {template.documentation && (
+
+
+
+ )}
+ {
+ e.stopPropagation();
+ handleDeploy(template);
+ }}
+ >
+ Deploy
+
-
{template.description}
-
- {(template.categories || []).slice(0, 3).map(cat => (
-
- {cat}
-
- ))}
- {template.website && (
-
-
-
- )}
- {template.documentation && (
-
-
-
- )}
- {
- e.stopPropagation();
- handleDeploy(template);
- }}
- >
- Deploy
-
-
-
- ))
+ );
+ })
)}
{/* Template Detail Modal */}
{selectedTemplate && !showInstallModal && (
-
setSelectedTemplate(null)}>
+
{ setSelectedTemplate(null); setRepoManifest(null); }}>
e.stopPropagation()}>
@@ -624,7 +672,7 @@ const Templates = () => {
Version {selectedTemplate.version}
-
setSelectedTemplate(null)}>×
+
{ setSelectedTemplate(null); setRepoManifest(null); }}>×
{selectedTemplate.description}
@@ -646,122 +694,133 @@ const Templates = () => {
)}
-
-
-
Categories
-
- {(selectedTemplate.categories || []).map(cat => (
-
- {getCategoryIcon(cat)} {cat}
-
- ))}
-
-
-
- {selectedTemplate.requirements && (
+ {selectedTemplate.kind === 'repo' ? (
+
+ ) : (
+
-
Requirements
-
- {selectedTemplate.requirements.memory && (
-
- Memory:
- {selectedTemplate.requirements.memory}
-
- )}
- {selectedTemplate.requirements.storage && (
-
- Storage:
- {selectedTemplate.requirements.storage}
-
- )}
+
Categories
+
+ {(selectedTemplate.categories || []).map(cat => (
+
+ {getCategoryIcon(cat)} {cat}
+
+ ))}
- )}
- {selectedTemplate.variables && selectedTemplate.variables.length > 0 && (
-
-
Configuration Variables
-
- {selectedTemplate.variables
- .filter(v => !v.hidden)
- .sort((a, b) => (b.required ? 1 : 0) - (a.required ? 1 : 0))
- .map(variable => (
-
-
-
{variable.name}
- {variable.required &&
Required }
- {variable.auto_generated &&
Auto }
+ {selectedTemplate.requirements && (
+
+
Requirements
+
+ {selectedTemplate.requirements.memory && (
+
+ Memory:
+ {selectedTemplate.requirements.memory}
- {variable.description && (
-
{variable.description}
- )}
- {variable.default && !variable.auto_generated && (
-
Default: {variable.default}
- )}
-
- ))}
+ )}
+ {selectedTemplate.requirements.storage && (
+
+ Storage:
+ {selectedTemplate.requirements.storage}
+
+ )}
+
-
- )}
+ )}
- {selectedTemplate.ports && selectedTemplate.ports.length > 0 && (
-
-
Exposed Ports
-
- {selectedTemplate.ports.map((port, index) => (
-
- {port.port}
- {port.protocol}
- {port.description && (
- {port.description}
- )}
-
- ))}
+ {selectedTemplate.variables && selectedTemplate.variables.length > 0 && (
+
+
Configuration Variables
+
+ {selectedTemplate.variables
+ .filter(v => !v.hidden)
+ .sort((a, b) => (b.required ? 1 : 0) - (a.required ? 1 : 0))
+ .map(variable => (
+
+
+ {variable.name}
+ {variable.required && Required }
+ {variable.auto_generated && Auto }
+
+ {variable.description && (
+
{variable.description}
+ )}
+ {variable.default && !variable.auto_generated && (
+
Default: {variable.default}
+ )}
+
+ ))}
+
-
- )}
+ )}
- {selectedTemplate.has_compose && (
-
-
- Docker Compose
- {
- navigator.clipboard.writeText('docker-compose.yml available after install');
- setCopiedCompose(true);
- setTimeout(() => setCopiedCompose(false), 2000);
- }}
- >
- {copiedCompose ? : }
-
-
-
-
Docker Compose configuration will be generated during installation
+ {selectedTemplate.ports && selectedTemplate.ports.length > 0 && (
+
+
Exposed Ports
+
+ {selectedTemplate.ports.map((port, index) => (
+
+ {port.port}
+ {port.protocol}
+ {port.description && (
+ {port.description}
+ )}
+
+ ))}
+
-
- )}
-
+ )}
+
+ {selectedTemplate.has_compose && (
+
+
+ Docker Compose
+ {
+ navigator.clipboard.writeText('docker-compose.yml available after install');
+ setCopiedCompose(true);
+ setTimeout(() => setCopiedCompose(false), 2000);
+ }}
+ >
+ {copiedCompose ? : }
+
+
+
+ Docker Compose configuration will be generated during installation
+
+
+ )}
+
+ )}
- setSelectedTemplate(null)}>
+ { setSelectedTemplate(null); setRepoManifest(null); }}>
Close
- {
- setShowInstallModal(true);
- }}
- >
- Install Template
-
+ {selectedTemplate.kind === 'repo' ? (
+ deployRepoTemplate(selectedTemplate.id)}>
+ Deploy
+
+ ) : (
+ setShowInstallModal(true)}>
+ Install Template
+
+ )}
)}
- {/* Install Modal */}
+ {/* Install Modal (compose templates only) */}
{showInstallModal && selectedTemplate && (
{
}}
/>
)}
+
+
+
+ );
+};
+
+// Repo-template detail body: repo URL, detected manifests, and env preview from
+// the /templates/
/manifest endpoint (real inspection or labeled hints).
+const RepoTemplateDetailBody = ({ template, manifest, loading, getCategoryIcon }) => {
+ const repo = template.repo || {};
+ const data = manifest?.manifest || null;
+ const sourceLabel = manifest?.source === 'repo'
+ ? 'Detected from repository'
+ : manifest?.source === 'template-hints'
+ ? 'From template hints'
+ : null;
+
+ return (
+
+
+
Repository
+
+
+ {repo.url}
+
+
+ {repo.branch && branch: {repo.branch} }
+ {repo.app_type && type: {repo.app_type} }
+ {repo.build_method && build: {repo.build_method} }
+ {repo.port && port: {repo.port} }
+
+
+
+
+ {(template.categories || []).length > 0 && (
+
+
Categories
+
+ {(template.categories || []).map(cat => (
+
+ {getCategoryIcon(cat)} {cat}
+
+ ))}
+
+
+ )}
+
+
+
+ Detected manifests
+ {sourceLabel && {sourceLabel} }
+
+ {loading ? (
+
Inspecting repository…
+ ) : (data?.manifests || []).length > 0 ? (
+
+ {data.manifests.map(m => (
+
+ {m.file}
+
+ ))}
+
+ ) : (
+
No deploy manifests detected.
+ )}
+
+
+ {!loading && (data?.env || []).length > 0 && (
+
+
Environment
+
+ {data.env.map(env => (
+
+ {env.key}{env.required ? ' *' : ''}
+
+ ))}
+
+
+ Secret values stay empty until you add them to the service environment.
+
+
+ )}
);
};
diff --git a/frontend/src/styles/pages/_templates.scss b/frontend/src/styles/pages/_templates.scss
index 3bd7affb..aa6a4bb2 100644
--- a/frontend/src/styles/pages/_templates.scss
+++ b/frontend/src/styles/pages/_templates.scss
@@ -363,106 +363,11 @@
}
}
-// Results Header
-.deploy-templates-section {
- margin-bottom: $space-5;
- padding-bottom: $space-4;
- border-bottom: 1px solid $border-color;
-
- &__head {
- margin-bottom: $space-3;
-
- h3 {
- margin: 0 0 2px;
- font-size: $font-size-base;
- font-weight: $font-weight-semibold;
- color: $text-primary;
- }
-
- p {
- margin: 0;
- font-size: $font-size-sm;
- color: $text-tertiary;
- }
- }
-}
-
-.deploy-templates-list {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
- gap: $space-3;
-}
-
-.deploy-template-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: $space-3;
- padding: $space-3 $space-4;
- border: 1px solid $border-color;
- border-radius: $radius-md;
- background: $bg-card;
-
- &__main {
- min-width: 0;
-
- p {
- margin: 2px 0 $space-1;
- font-size: $font-size-sm;
- color: $text-secondary;
- }
-
- code {
- display: block;
- font-size: $font-size-xs;
- color: $text-tertiary;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- }
-
- &__title {
- display: flex;
- align-items: center;
- gap: $space-2;
-
- strong {
- color: $text-primary;
- font-weight: $font-weight-semibold;
- }
- }
-
- &__example {
- padding: 1px $space-2;
- border-radius: $radius-sm;
- background: rgba($accent-primary-raw, 0.15);
- color: $accent-primary;
- font-size: $font-size-xs;
- font-weight: $font-weight-medium;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- }
-
- &__badges {
- display: flex;
- flex-wrap: wrap;
- gap: $space-1;
- margin-top: $space-2;
-
- span {
- padding: 1px $space-2;
- border: 1px solid $border-color;
- border-radius: $radius-sm;
- font-size: $font-size-xs;
- color: $text-tertiary;
- }
- }
-}
-
+// Quick-chips row + result count. Full category list, kind, and sort live in
+// the shared FilterDrawer opened from the topbar.
.templates-results-header {
display: grid;
- grid-template-columns: auto minmax(0, 1fr) auto;
+ grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: $space-3;
margin-bottom: $space-4;
@@ -477,44 +382,93 @@
white-space: nowrap;
}
-.sort-dropdown {
- display: flex;
- align-items: center;
- gap: $space-2;
- position: relative;
+// Kind badge on catalog cards ("Git repo" / "One-click").
+.tpl-kind {
+ text-transform: none;
+}
- label {
+// Repo-template detail body
+.repo-detail {
+ display: flex;
+ flex-direction: column;
+ gap: $space-2;
+
+ &__url {
font-family: var(--mono);
- font-size: 11px;
- letter-spacing: 0.04em;
- text-transform: uppercase;
- color: var(--text-faint);
+ font-size: $font-size-sm;
+ color: var(--accent-bright);
+ word-break: break-all;
}
- select {
- appearance: none;
- padding: $space-2 $space-8 $space-2 $space-3;
- background: var(--surface);
- border: 1px solid var(--border);
- border-radius: $radius-md;
- font-size: $font-size-sm + 1;
- color: $text-primary;
- cursor: pointer;
+ &__meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: $space-3;
+ font-size: $font-size-sm;
+ color: $text-tertiary;
- &:focus {
- outline: none;
- border-color: var(--accent-primary);
+ code {
+ font-family: var(--mono);
+ color: $text-secondary;
}
}
+}
- .dropdown-icon {
- position: absolute;
- right: $space-2;
- pointer-events: none;
- color: var(--text-faint);
+.detail-source-tag {
+ margin-left: auto;
+ font-family: var(--mono);
+ font-size: 10px;
+ letter-spacing: 0.04em;
+ text-transform: none;
+ color: var(--text-faint);
+ background: var(--surface-2);
+ padding: 2px $space-2;
+ border-radius: $radius-sm;
+}
+
+.manifest-file-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: $space-2;
+}
+
+.manifest-file {
+ display: inline-flex;
+ align-items: center;
+ gap: $space-1;
+ padding: 2px 8px;
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--green);
+ background: var(--green-bg);
+ border-radius: $radius-sm;
+}
+
+.env-preview-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: $space-2;
+}
+
+.env-preview-chip {
+ padding: 2px 8px;
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--text-dim);
+ background: var(--surface-2);
+ border-radius: $radius-sm;
+
+ &--secret {
+ color: var(--amber);
+ background: rgba($accent-primary-raw, 0.1);
}
}
+.env-preview-note {
+ margin: $space-2 0 0;
+ font-size: $font-size-xs;
+}
+
// Template icon (detail modal header)
.template-icon-large {
width: 80px;
@@ -908,12 +862,4 @@
.category-filters {
width: 100%;
}
-
- .sort-dropdown {
- width: 100%;
-
- select {
- flex: 1;
- }
- }
}