feat(ui): marketplace icons + shared search/filter, jobs tab-group, KPI + terminal fixes

Marketplace:
- Bundle 14 illustrated extension cover icons (ExtensionIcons.js resolver);
  render on a clean light cover, larger & centered (no gradient/white-chip clash)
- Move search into the page top bar and add a reusable schema-driven FilterDrawer
  (SearchField + FilterDrawer/FilterButton ds primitives); drop the always-on
  publisher segmented control and the "N results" count

Jobs:
- Convert to a tab-group page (Activity / Scheduled) so scheduled jobs are a tab,
  not a scroll; reuse the shared SearchField + FilterDrawer in the top bar

KPI fixes (lost in the recovery rebuild):
- Add missing grid rules: .mon-kpis, .fleet-kpis, .ftp-kpis (stacked full-width)
- .sk-kpi__val gets an explicit color: var(--text) so values aren't near-black
  on non-tabgroup shells

Terminal:
- List Log Files first so the default landing matches the highlighted tab
- Drop the top padding so the topbar pins flush like the other pages

gitignore: allow the bundled extension-icons PNGs (real Vite-imported assets).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Juan DenisandClaude Opus 4.8 committed 2026-07-09 01:28:20 -04:00
1 parent bc19039c5b
commit 8bd9c1a7dd
32 files changed
+721 -327

No files matched your search

+5
View File
@@ -123,6 +123,11 @@ SECURITY_AND_CODE_AUDIT.md
!/docs/screenshots/
!/docs/screenshots/*.png
# …and the bundled marketplace extension cover icons — real source assets
# imported/hashed by Vite, so they must be committed.
!/frontend/src/assets/extension-icons/
!/frontend/src/assets/extension-icons/*.png
# MSI / build artifacts that should not be committed.
/agent/packaging/msi/output/
/agent/packaging/msi/*.wixpdb
+5 -1
View File
@@ -44,6 +44,7 @@ import { MARKET_TABS } from './components/marketplace/marketTabs';
import { BACKUP_TABS } from './components/backups/backupTabs';
import { SECURITY_TABS } from './components/security/securityTabs';
import { ORG_TABS } from './components/organization/organizationTabs';
import { JOBS_TABS } from './components/jobs/jobsTabs';
import Downloads from './pages/Downloads';
import SSLCertificates from './pages/SSLCertificates';
import SSOCallback from './pages/SSOCallback';
@@ -432,7 +433,10 @@ function AppRoutes() {
<Route path="queue/:groupSlug/:queueSlug" element={<QueueDetail />} />
<Route path="notifications" element={<Notifications />} />
<Route path="admin/notifications" element={<DeliveryLog />} />
<Route path="jobs" element={<Jobs />} />
<Route element={<TabGroupLayout tabs={JOBS_TABS} />}>
<Route path="jobs" element={<Jobs />} />
<Route path="jobs/scheduled" element={<Jobs />} />
</Route>
<Route path="settings" element={<Settings />} />
<Route path="settings/:tab" element={<Settings />} />
{dashboardRoutes}
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+126
View File
@@ -0,0 +1,126 @@
import { SlidersHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Drawer } from './Drawer';
// Schema-driven advanced-filter slide-over, shared across tables/lists (the
// marketplace today; domains/services/etc. next). A page passes a `groups`
// schema plus a controlled `value`/`onChange`, and the drawer renders each
// group as a set of toggle chips. Changes apply live so results update behind
// the open drawer.
//
// groups = [
// { key: 'ownership', label: 'Publisher', type: 'single',
// options: [{ value: 'serverkit', label: 'By ServerKit' }, …] },
// { key: 'category', label: 'Categories', type: 'multi',
// options: [{ value: 'security', label: 'Security' }, …] },
// ]
// value = { ownership: '', category: ['security'] }
//
// `single` groups hold a string ('' = none); `multi` groups hold a string[].
// Count how many filter selections are active — a single-select group counts 1
// when set, a multi-select group counts its selected length. Drives the
// FilterButton badge.
export function countActiveFilters(value = {}) {
return Object.values(value).reduce((total, entry) => {
if (Array.isArray(entry)) return total + entry.length;
return total + (entry ? 1 : 0);
}, 0);
}
// Empty value object for a given schema — used to clear/initialise state.
export function emptyFilterValue(groups = []) {
const next = {};
groups.forEach((group) => { next[group.key] = group.type === 'multi' ? [] : ''; });
return next;
}
// The trigger button, with an active-count badge. Kept here so hosts get the
// button + drawer as a matched pair.
export function FilterButton({ count = 0, onClick, className, label = 'Filters' }) {
return (
<Button
variant="outline"
size="sm"
onClick={onClick}
className={cn('sk-filter-btn', count > 0 && 'sk-filter-btn--active', className)}
>
<SlidersHorizontal aria-hidden="true" />
{label}
{count > 0 && <span className="sk-filter-btn__badge">{count}</span>}
</Button>
);
}
export function FilterDrawer({
open,
onOpenChange,
groups = [],
value = {},
onChange,
title = 'Filters',
width = 380,
}) {
const isOn = (group, optValue) => (
group.type === 'multi'
? (value[group.key] || []).includes(optValue)
: (value[group.key] || '') === optValue
);
const toggle = (group, optValue) => {
if (group.type === 'multi') {
const current = Array.isArray(value[group.key]) ? value[group.key] : [];
const next = current.includes(optValue)
? current.filter((item) => item !== optValue)
: [...current, optValue];
onChange({ ...value, [group.key]: next });
} else {
const current = value[group.key] || '';
onChange({ ...value, [group.key]: current === optValue ? '' : optValue });
}
};
const active = countActiveFilters(value);
const clearAll = () => onChange(emptyFilterValue(groups));
return (
<Drawer
open={open}
onOpenChange={onOpenChange}
title={title}
subtitle={active ? `${active} active` : 'no filters'}
icon={<SlidersHorizontal size={16} />}
width={width}
>
<div className="sk-filter">
{groups.map((group) => (
<div key={group.key} className="sk-filter__group">
<div className="sk-filter__label">{group.label}</div>
<div className="sk-filter__chips">
{group.options.map((option) => (
<button
key={option.value}
type="button"
className={cn('sk-filter__chip', isOn(group, option.value) && 'is-on')}
onClick={() => toggle(group, option.value)}
aria-pressed={isOn(group, option.value)}
>
{option.label}
</button>
))}
</div>
</div>
))}
<div className="sk-filter__footer">
<Button variant="ghost" size="sm" onClick={clearAll} disabled={!active}>
Clear all
</Button>
<Button size="sm" onClick={() => onOpenChange(false)}>Done</Button>
</div>
</div>
</Drawer>
);
}
export default FilterDrawer;
@@ -0,0 +1,46 @@
import { useState, useEffect, useRef } from 'react';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
// Self-contained search box: an icon + debounced text input. Keeps its own
// internal text state and only lifts the (debounced) value up via onSearch, so
// it can be hosted anywhere — including a re-published PageTopbar action node —
// without losing focus between keystrokes. Reusable across any table/list.
export function SearchField({
value = '',
onSearch,
placeholder = 'Search…',
debounce = 200,
className,
}) {
const [text, setText] = useState(value);
const timer = useRef(null);
// Sync when the parent resets/changes the value programmatically (e.g. a
// "Reset filters" action). Typing keeps text ahead of the debounced value,
// and setting to an equal string is a no-op, so this won't fight the user.
useEffect(() => { setText(value); }, [value]);
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
const handleChange = (next) => {
setText(next);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => onSearch?.(next), debounce);
};
return (
<div className={cn('sk-searchfield', className)}>
<Search className="sk-searchfield__icon" aria-hidden="true" />
<input
type="search"
className="sk-searchfield__input"
placeholder={placeholder}
value={text}
onChange={(event) => handleChange(event.target.value)}
aria-label={placeholder}
/>
</div>
);
}
export default SearchField;
+4
View File
@@ -13,6 +13,10 @@ export { AreaChart } from './AreaChart';
export { ScoreGauge } from './ScoreGauge';
export { Feed, FeedItem } from './Feed';
export { Drawer } from './Drawer';
export { SearchField } from './SearchField';
export {
FilterDrawer, FilterButton, countActiveFilters, emptyFilterValue,
} from './FilterDrawer';
export { PageTopbar } from './PageTopbar';
export { DataTable } from './DataTable';
export { ResourceCard, ResourceList } from './ResourceCard';
@@ -0,0 +1,67 @@
// Illustrated raster cover icons for bundled extensions. These sit ahead of the
// flat Simple Icons brand marks (ExtensionBrands.jsx) in the cover fallback
// chain so recognizable ServerKit capabilities show a rich 3D icon, and every
// category has a sensible default. Slugs/categories with no match return null so
// the caller falls back to the brand mark, manifest SVG, or category glyph.
//
// PNGs are bundled + hashed by Vite via these URL imports.
import backup from '../../assets/extension-icons/backup.png';
import cloudflare from '../../assets/extension-icons/cloudflare.png';
import extensions from '../../assets/extension-icons/extensions.png';
import git from '../../assets/extension-icons/git.png';
import gpu from '../../assets/extension-icons/gpu.png';
import hosting from '../../assets/extension-icons/hosting.png';
import mail from '../../assets/extension-icons/mail.png';
import mailserver from '../../assets/extension-icons/mailserver.png';
import monitor from '../../assets/extension-icons/monitor.png';
import network from '../../assets/extension-icons/network.png';
import security from '../../assets/extension-icons/security.png';
import terminal from '../../assets/extension-icons/terminal.png';
import vpn from '../../assets/extension-icons/vpn.png';
import workflow from '../../assets/extension-icons/workflow.png';
// Strip the conventional `serverkit-` prefix and lowercase, matching
// ExtensionBrands.normalizeSlug so keyword rules hit both `serverkit-mail`
// and `mail`.
const normalizeSlug = (slug) => String(slug || '').toLowerCase().replace(/^serverkit-/, '');
// Ordered keyword rules — first match wins. Keep the most specific keys first.
const KEYWORD_RULES = [
{ keys: ['cloudflare'], icon: cloudflare },
{ keys: ['gitea', 'forgejo', 'git'], icon: git },
{ keys: ['gpu', 'nvidia', 'cuda'], icon: gpu },
{ keys: ['wireguard', 'vpn', 'tunnel', 'remote'], icon: vpn },
{ keys: ['mailserver', 'mail-relay', 'relay'], icon: mailserver },
{ keys: ['mail', 'email', 'smtp', 'imap'], icon: mail },
{ keys: ['backup', 'restore', 'snapshot', 'sync', 'import', 'migrate'], icon: backup },
{ keys: ['crowdsec', 'fail2ban', 'security', 'firewall', 'waf', 'cve'], icon: security },
{ keys: ['dns', 'domain', 'network'], icon: network },
{ keys: ['cron', 'job', 'workflow', 'automation', 'queue', 'pipeline', 'schedule'], icon: workflow },
{ keys: ['terminal', 'console', 'shell', 'cli'], icon: terminal },
{ keys: ['monitor', 'metric', 'status', 'uptime', 'analytic', 'observ'], icon: monitor },
{ keys: ['plugin', 'extension', 'marketplace', 'gui', 'theme'], icon: extensions },
{ keys: ['host', 'deploy', 'provision'], icon: hosting },
];
// Per-category fallbacks. AI intentionally omitted — none of these icons read as
// "AI", so it keeps the lucide Sparkles glyph.
const CATEGORY_ICONS = {
monitoring: monitor,
security,
deployment: hosting,
integration: network,
ui: extensions,
utility: terminal,
};
// Resolve a raster cover icon URL for an extension, or null when nothing fits.
export function resolveExtensionIcon(slug, category) {
const normalized = normalizeSlug(slug);
const rule = KEYWORD_RULES.find((item) => item.keys.some((key) => normalized.includes(key)));
if (rule) return rule.icon;
return CATEGORY_ICONS[category] || null;
}
export function hasExtensionIcon(slug, category) {
return Boolean(resolveExtensionIcon(slug, category));
}
+10
View File
@@ -0,0 +1,10 @@
import { ListChecks, Clock } from 'lucide-react';
// Shared sub-nav for the Jobs page group (Activity / Scheduled). Rendered in the
// shared PageTopbar via <TabGroupLayout tabs={JOBS_TABS}> so the two surfaces act
// like real tabs (see docs/REDESIGN_MAP.md §6) instead of one long scroll. Both
// routes render <Jobs/>; the page picks the view from the pathname.
export const JOBS_TABS = [
{ to: '/jobs', label: 'Activity', end: true, icon: <ListChecks size={15} /> },
{ to: '/jobs/scheduled', label: 'Scheduled', icon: <Clock size={15} /> },
];
+133 -142
View File
@@ -1,23 +1,28 @@
// Jobs — admin view over the unified job system (job orchestration, "Phase 9").
//
// Host-idiom rebuild (plan 39): the Servers/Domains table-with-search treatment
// — SegControl status filter + kind select + debounced search, DataTable rows,
// clickable compact KPIs, and server-side pagination against a job store that
// can hold six figures of scheduler-tick rows. Wired to the real ApiService job
// methods (see frontend/src/services/api/jobs.js).
// Tab-group page (the Marketplace/Domains pattern): a shared PageTopbar carries
// the Activity/Scheduled sub-nav (JOBS_TABS) plus the reusable SearchField +
// FilterDrawer (status/kind) + Refresh. Activity shows clickable compact KPIs, a
// DataTable of runs, and server-side pagination over a job store that can hold
// six figures of scheduler-tick rows; Scheduled is its own tab so it's not
// buried below the runs. Wired to the real ApiService job methods
// (see frontend/src/services/api/jobs.js).
import { useState, useEffect, useCallback, useRef } from 'react';
import { ListChecks, RefreshCw, RotateCcw, XCircle, Play, Clock, Search, ChevronLeft, ChevronRight } from 'lucide-react';
import { useLocation } from 'react-router-dom';
import { ListChecks, RefreshCw, RotateCcw, XCircle, Play, Clock, ChevronLeft, ChevronRight } from 'lucide-react';
import api from '../services/api';
import { PageTopbar, MetricCard, KpiBand, Pill, DataTable, SegControl } from '@/components/ds';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
MetricCard, KpiBand, Pill, DataTable,
SearchField, FilterDrawer, FilterButton, countActiveFilters,
} from '@/components/ds';
import { Button } from '@/components/ui/button';
import { useTopbarActions } from '@/hooks/useTopbarActions';
import { useAuth } from '../contexts/AuthContext';
import { useToast } from '../contexts/ToastContext';
import { timeAgo } from '../utils/timeAgo';
const titleCase = (value = '') => value.charAt(0).toUpperCase() + value.slice(1);
const STATUSES = ['all', 'queued', 'running', 'succeeded', 'failed', 'cancelled'];
const PAGE_SIZE = 50;
const POLL_MS = 5000;
@@ -60,34 +65,27 @@ const canRetry = (s) => ['failed', 'error', 'cancelled', 'canceled'].includes(St
export default function Jobs() {
const { isAdmin } = useAuth();
const toast = useToast();
const location = useLocation();
const scheduledView = location.pathname.endsWith('/scheduled');
const [jobs, setJobs] = useState([]);
const [total, setTotal] = useState(0);
const [stats, setStats] = useState(null);
const [scheduled, setScheduled] = useState([]);
const [status, setStatus] = useState('all');
const [kind, setKind] = useState('all');
// Advanced filters live in the shared FilterDrawer (status + kind, single-
// select; '' = all). Search is a separate debounced term.
const [filters, setFilters] = useState({ status: '', kind: '' });
const [filtersOpen, setFiltersOpen] = useState(false);
const [kinds, setKinds] = useState([]);
const [searchInput, setSearchInput] = useState('');
const [q, setQ] = useState('');
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(true);
const pollRef = useRef(null);
// Debounce the raw search box into the query term; reset to the first page
// whenever the term changes so results start from the top.
useEffect(() => {
const t = setTimeout(() => {
setQ(searchInput.trim());
setPage(0);
}, 350);
return () => clearTimeout(t);
}, [searchInput]);
const load = useCallback(async () => {
try {
const params = { limit: PAGE_SIZE, offset: page * PAGE_SIZE };
if (status !== 'all') params.status = status;
if (kind !== 'all') params.kind = kind;
if (filters.status) params.status = filters.status;
if (filters.kind) params.kind = filters.kind;
if (q) params.q = q;
const [jobsRes, statsRes, schedRes] = await Promise.all([
api.getJobs(params),
@@ -103,7 +101,7 @@ export default function Jobs() {
} finally {
setLoading(false);
}
}, [status, kind, q, page]);
}, [filters, q, page]);
useEffect(() => {
if (!isAdmin) return undefined;
@@ -120,8 +118,32 @@ export default function Jobs() {
return () => clearInterval(pollRef.current);
}, [isAdmin, load]);
const setStatusFilter = (value) => { setStatus(value); setPage(0); };
const setKindFilter = (value) => { setKind(value); setPage(0); };
// KPI tiles are quick status filters; the drawer owns the full set.
const setStatusQuick = (value) => { setFilters((f) => ({ ...f, status: value })); setPage(0); };
const onFiltersChange = (next) => { setFilters(next); setPage(0); };
const onSearch = (value) => { setQ(value.trim()); setPage(0); };
const resetFilters = () => { setFilters({ status: '', kind: '' }); setQ(''); setPage(0); };
const activeFilterCount = countActiveFilters(filters);
// Search + advanced-filter trigger + Refresh sit in the shared page top bar
// (the Marketplace/Domains pattern). Search/filters only apply to the
// Activity tab; the Scheduled tab just gets Refresh.
useTopbarActions(() => {
if (!isAdmin) return null;
return (
<>
{!scheduledView && (
<>
<SearchField value={q} onSearch={onSearch} placeholder="Search by kind or owner…" />
<FilterButton count={activeFilterCount} onClick={() => setFiltersOpen(true)} />
</>
)}
<Button variant="outline" size="sm" onClick={load}>
<RefreshCw size={14} /> Refresh
</Button>
</>
);
}, [isAdmin, scheduledView, q, activeFilterCount, load]);
const onRetry = async (id) => {
try { await api.retryJob(id); toast.success('Job re-queued'); load(); }
@@ -142,17 +164,14 @@ export default function Jobs() {
if (!isAdmin) {
return (
<>
<PageTopbar icon={<ListChecks size={18} />} title="Jobs" />
<div className="sk-tabgroup__inner jobs-page">
<div className="sk-jobs"><div className="sk-jobs__empty">Admins only.</div></div>
</>
</div>
);
}
const byStatus = stats?.by_status || {};
const hasFilters = status !== 'all' || kind !== 'all' || Boolean(q);
const rangeStart = total === 0 ? 0 : page * PAGE_SIZE + 1;
const rangeEnd = page * PAGE_SIZE + jobs.length;
const hasFilters = Boolean(filters.status || filters.kind || q);
const hasPrev = page > 0;
const hasNext = (page + 1) * PAGE_SIZE < total;
@@ -160,6 +179,21 @@ export default function Jobs() {
.map((k) => (typeof k === 'string' ? k : k.kind || k.name))
.filter(Boolean);
const filterGroups = [
{
key: 'status',
label: 'Status',
type: 'single',
options: STATUSES.filter((s) => s !== 'all').map((s) => ({ value: s, label: titleCase(s) })),
},
{
key: 'kind',
label: 'Kind',
type: 'single',
options: kindOptions.map((k) => ({ value: k, label: k })),
},
];
const jobColumns = [
{ key: 'status', header: 'Status', render: (j) => <Pill kind={statusKind(j.status)}>{j.status}</Pill> },
{ key: 'kind', header: 'Kind', cellClassName: 'sk-jobs__kind', render: (j) => j.kind || '—' },
@@ -224,123 +258,80 @@ export default function Jobs() {
];
return (
<>
<PageTopbar
icon={<ListChecks size={18} />}
title="Jobs"
meta="Unified job orchestration across the panel"
actions={(
<Button variant="outline" size="sm" onClick={load}>
<RefreshCw size={14} /> Refresh
</Button>
)}
/>
<div className="sk-tabgroup__inner jobs-page">
<div className="sk-jobs">
<KpiBand>
<MetricCard label="Total" value={stats?.total ?? total ?? 0} tone="accent" compact
onClick={() => setStatusFilter('all')} />
<MetricCard label="Running" value={byStatus.running ?? 0} tone="cyan" compact
onClick={() => setStatusFilter('running')} />
<MetricCard label="Queued" value={byStatus.pending ?? byStatus.queued ?? 0} tone="amber" compact
onClick={() => setStatusFilter('queued')} />
<MetricCard label="Failed" value={byStatus.failed ?? 0} tone="red" compact
onClick={() => setStatusFilter('failed')} />
</KpiBand>
<div className="sk-jobs__command-bar">
<div className="sk-jobs__filters">
<SegControl
options={STATUSES.map((s) => ({ value: s, label: s.charAt(0).toUpperCase() + s.slice(1) }))}
value={status}
onChange={setStatusFilter}
aria-label="Filter by status"
/>
<Select value={kind} onValueChange={setKindFilter}>
<SelectTrigger className="sk-jobs__kind-select" aria-label="Filter by kind">
<SelectValue placeholder="All kinds" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All kinds</SelectItem>
{kindOptions.map((k) => <SelectItem key={k} value={k}>{k}</SelectItem>)}
</SelectContent>
</Select>
<label className="search-box sk-jobs__search">
<Search size={16} />
<Input
type="text"
placeholder="Search by kind or owner..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
aria-label="Search jobs"
/>
</label>
</div>
<div className="sk-jobs__results-summary">
{total > 0 ? (
<span>Showing <strong>{rangeStart}</strong>–<strong>{rangeEnd}</strong> of <strong>{total}</strong></span>
) : (
<span>No jobs</span>
{scheduledView ? (
<DataTable
columns={scheduledColumns}
data={scheduled}
keyField="id"
sortable={false}
loading={loading && scheduled.length === 0}
emptyState={(
<div className="sk-jobs__empty">
<Clock size={24} aria-hidden="true" />
<p>No scheduled jobs yet.</p>
</div>
)}
/>
) : (
<>
<KpiBand>
<MetricCard label="Total" value={stats?.total ?? total ?? 0} tone="accent" compact
onClick={() => setStatusQuick('')} />
<MetricCard label="Running" value={byStatus.running ?? 0} tone="cyan" compact
onClick={() => setStatusQuick('running')} />
<MetricCard label="Queued" value={byStatus.pending ?? byStatus.queued ?? 0} tone="amber" compact
onClick={() => setStatusQuick('queued')} />
<MetricCard label="Failed" value={byStatus.failed ?? 0} tone="red" compact
onClick={() => setStatusQuick('failed')} />
</KpiBand>
{hasFilters && (
<Button
type="button"
variant="ghost"
size="sm"
className="sk-jobs__clear-filters"
onClick={() => {
setStatus('all');
setKind('all');
setSearchInput('');
setPage(0);
}}
>
Clear filters
</Button>
<div className="sk-jobs__resultbar">
<Button variant="ghost" size="sm" onClick={resetFilters}>
Reset filters
</Button>
</div>
)}
</div>
</div>
<DataTable
columns={jobColumns}
data={jobs}
keyField="id"
sortable={false}
loading={loading && jobs.length === 0}
emptyState={(
<div className="sk-jobs__empty">
<ListChecks size={24} aria-hidden="true" />
<p>{hasFilters ? 'No jobs match these filters.' : 'No jobs have run yet.'}</p>
</div>
)}
/>
{(hasPrev || hasNext) && (
<div className="sk-jobs__pager">
<Button variant="outline" size="sm" disabled={!hasPrev} onClick={() => setPage((p) => Math.max(0, p - 1))}>
<ChevronLeft size={14} /> Prev
</Button>
<span className="sk-jobs__pager-label">Page {page + 1}</span>
<Button variant="outline" size="sm" disabled={!hasNext} onClick={() => setPage((p) => p + 1)}>
Next <ChevronRight size={14} />
</Button>
</div>
)}
{scheduled.length > 0 && (
<section className="sk-jobs__scheduled">
<h2 className="sk-jobs__section-title">
<Clock size={16} aria-hidden="true" /> Scheduled jobs
</h2>
<DataTable
columns={scheduledColumns}
data={scheduled}
columns={jobColumns}
data={jobs}
keyField="id"
sortable={false}
loading={loading && jobs.length === 0}
emptyState={(
<div className="sk-jobs__empty">
<ListChecks size={24} aria-hidden="true" />
<p>{hasFilters ? 'No jobs match these filters.' : 'No jobs have run yet.'}</p>
</div>
)}
/>
</section>
{(hasPrev || hasNext) && (
<div className="sk-jobs__pager">
<Button variant="outline" size="sm" disabled={!hasPrev} onClick={() => setPage((p) => Math.max(0, p - 1))}>
<ChevronLeft size={14} /> Prev
</Button>
<span className="sk-jobs__pager-label">Page {page + 1}</span>
<Button variant="outline" size="sm" disabled={!hasNext} onClick={() => setPage((p) => p + 1)}>
Next <ChevronRight size={14} />
</Button>
</div>
)}
</>
)}
</div>
</>
<FilterDrawer
open={filtersOpen}
onOpenChange={setFiltersOpen}
groups={filterGroups}
value={filters}
onChange={onFiltersChange}
title="Filter jobs"
/>
</div>
);
}
+95 -60
View File
@@ -9,7 +9,6 @@ import {
PackageCheck,
Plug,
PlugZap,
Search,
ServerCog,
ShieldCheck,
Sparkles,
@@ -22,17 +21,16 @@ import Modal from '@/components/Modal';
import PageLoader from '../components/PageLoader';
import EmptyState from '../components/EmptyState';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { SegControl } from '@/components/ds';
SearchField, FilterDrawer, FilterButton, countActiveFilters,
} from '@/components/ds';
import { useTopbarActions } from '@/hooks/useTopbarActions';
import ManualInstallModal from '../components/marketplace/ManualInstallModal';
import {
ExtensionBrandMark, hasBrandMark, extensionCoverStyle,
} from '../components/icons/ExtensionBrands';
import { resolveExtensionIcon } from '../components/icons/ExtensionIcons';
const CATEGORIES = ['ai', 'monitoring', 'security', 'deployment', 'integration', 'ui', 'utility'];
@@ -151,9 +149,10 @@ const Marketplace = () => {
const [pluginUpdates, setPluginUpdates] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [category, setCategory] = useState('');
// Ownership filter: '' (all), 'serverkit' (firstParty), 'community'.
const [ownership, setOwnership] = useState('');
// Advanced filters live in a shared FilterDrawer. `ownership` is single-select
// ('' all, 'serverkit' firstParty, 'community'); `category` is multi-select.
const [filters, setFilters] = useState({ ownership: '', category: [] });
const [filtersOpen, setFiltersOpen] = useState(false);
const location = useLocation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
@@ -278,8 +277,7 @@ const Marketplace = () => {
const resetFilters = () => {
setSearch('');
setCategory('');
setOwnership('');
setFilters({ ownership: '', category: [] });
};
const pluginStatusVariant = (status) => {
@@ -288,14 +286,31 @@ const Marketplace = () => {
return 'outline';
};
const activeFilterCount = countActiveFilters(filters);
// Search + advanced-filter trigger sit in the shared page top bar (next to
// Install manually) so the browse body starts straight at the grid. Only the
// browse view gets search/filters; the installed view keeps Install manually
// alone. Search is debounced, so re-publishing here is cheap and React
// reconciles the input in place (focus is preserved between keystrokes).
useTopbarActions(() =>
<>
{!installedView && (
<>
<SearchField
value={search}
onSearch={setSearch}
placeholder="Search extensions…"
/>
<FilterButton count={activeFilterCount} onClick={() => setFiltersOpen(true)} />
</>
)}
<Button variant="outline" size="sm" onClick={() => setManualInstallSource('url')}>
<UploadCloud aria-hidden="true" />
Install manually
</Button>
</>,
[],
[installedView, search, activeFilterCount],
);
if (loading) return <PageLoader />;
@@ -311,61 +326,48 @@ const Marketplace = () => {
});
const mergedCatalogEntries = [...localCatalogEntries, ...registryCatalogEntries];
const catalogCategories = deriveCatalogCategories(mergedCatalogEntries);
const filterGroups = [
{
key: 'ownership',
label: 'Publisher',
type: 'single',
options: [
{ value: 'serverkit', label: 'By ServerKit' },
{ value: 'community', label: 'Community' },
],
},
{
key: 'category',
label: 'Categories',
type: 'multi',
options: catalogCategories.map((item) => ({ value: item, label: titleCase(item) })),
},
];
const catalogEntries = mergedCatalogEntries
.filter((entry) => catalogEntryMatches(entry, search, category))
// Category is handled below (multi-select), so pass '' to the shared matcher.
.filter((entry) => catalogEntryMatches(entry, search, ''))
.filter((entry) => {
if (ownership === 'serverkit') return entry.firstParty;
if (ownership === 'community') return !entry.firstParty;
if (filters.ownership === 'serverkit') return entry.firstParty;
if (filters.ownership === 'community') return !entry.firstParty;
return true;
})
.filter((entry) => {
const cats = filters.category || [];
return cats.length === 0 || cats.includes(entry.category);
});
const hasFilters = Boolean(search.trim() || category || ownership);
const hasFilters = Boolean(search.trim() || activeFilterCount > 0);
return (
<div className="sk-tabgroup__inner marketplace-page">
{!installedView ? (
<>
<div className="marketplace-toolbar">
<div className="marketplace-search">
<Search className="marketplace-search__icon" aria-hidden="true" />
<Input
placeholder="Search extensions..."
value={search}
onChange={(event) => setSearch(event.target.value)}
aria-label="Search extensions"
/>
</div>
<Select
value={category || 'all'}
onValueChange={(value) => setCategory(value === 'all' ? '' : value)}
>
<SelectTrigger className="marketplace-toolbar__select" aria-label="Filter by category">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All categories</SelectItem>
{catalogCategories.map((item) => (
<SelectItem key={item} value={item}>{titleCase(item)}</SelectItem>
))}
</SelectContent>
</Select>
<SegControl
className="marketplace-toolbar__seg"
aria-label="Filter by publisher"
options={[
{ value: '', label: 'All' },
{ value: 'serverkit', label: 'By ServerKit' },
{ value: 'community', label: 'Community' },
]}
value={ownership}
onChange={setOwnership}
/>
{hasFilters && (
{hasFilters && (
<div className="marketplace-resultbar">
<Button variant="ghost" size="sm" onClick={resetFilters}>
Reset
Reset filters
</Button>
)}
<span className="marketplace-toolbar__count">{catalogEntries.length} results</span>
</div>
</div>
)}
<section className="marketplace-section">
{catalogEntries.length > 0 ? (
@@ -427,6 +429,15 @@ const Marketplace = () => {
</section>
)}
<FilterDrawer
open={filtersOpen}
onOpenChange={setFiltersOpen}
groups={filterGroups}
value={filters}
onChange={setFilters}
title="Filter extensions"
/>
{manualInstallSource && (
<ManualInstallModal
defaultSource={manualInstallSource}
@@ -466,6 +477,18 @@ const Marketplace = () => {
);
};
// Cover chrome differs by artwork: illustrated raster icons get a clean light
// tile (their 3D art is drawn for a light backdrop and fills the space), while
// glyph / Simple-Icons brand-mark fallbacks keep the deterministic gradient so
// the white mark stays legible. `base` is the cover class ('extension-card__cover'
// or 'extension-detail__cover').
const coverProps = (base, entry, category) => {
if (resolveExtensionIcon(entry.installKey, category)) {
return { className: `${base} ${base}--icon` };
}
return { className: base, style: extensionCoverStyle(entry.installKey, category) };
};
const SectionHeader = ({ kicker, title, meta }) => (
<div className="marketplace-section__header">
<div>
@@ -477,13 +500,14 @@ const SectionHeader = ({ kicker, title, meta }) => (
);
// Cover artwork with a deterministic fallback chain:
// registry logo image -> bundled brand mark -> manifest icon SVG -> category
// lucide glyph. Kept as its own component so both the card and the detail modal
// share the exact same resolution order.
// registry logo image -> bundled illustrated icon -> Simple Icons brand mark ->
// manifest icon SVG -> category lucide glyph. Kept as its own component so both
// the card and the detail modal share the exact same resolution order.
const ExtensionCover = ({ entry, category, brandSize = 34 }) => {
const [logoFailed, setLogoFailed] = useState(false);
const Icon = getCategoryIcon(category);
const iconSvg = entry.icon ? sanitizeSvgInner(entry.icon) : '';
const rasterIcon = resolveExtensionIcon(entry.installKey, category);
if (entry.logo && !logoFailed) {
return (
@@ -496,6 +520,17 @@ const ExtensionCover = ({ entry, category, brandSize = 34 }) => {
/>
);
}
if (rasterIcon) {
return (
<img
src={rasterIcon}
loading="lazy"
alt=""
aria-hidden="true"
className="extension-card__icon"
/>
);
}
if (hasBrandMark(entry.installKey)) {
return (
<ExtensionBrandMark
@@ -544,7 +579,7 @@ const CatalogExtensionCard = ({ entry, installing, onInstall, onOpenDetail, stat
onClick={openDetail}
onKeyDown={handleKeyDown}
>
<div className="extension-card__cover" style={extensionCoverStyle(entry.installKey, category)}>
<div {...coverProps('extension-card__cover', entry, category)}>
<ExtensionCover entry={entry} category={category} brandSize={34} />
</div>
<div className="extension-card__badges">
@@ -604,7 +639,7 @@ const ExtensionDetailModal = ({ entry, installing, statusVariant, onClose, onIns
return (
<Modal open onClose={onClose} title={entry.displayName} size="lg">
<div className="extension-detail">
<div className="extension-detail__cover" style={extensionCoverStyle(entry.installKey, category)}>
<div {...coverProps('extension-detail__cover', entry, category)}>
<ExtensionCover entry={entry} category={category} brandSize={46} />
</div>
<div className="extension-detail__header">
+4 -2
View File
@@ -23,10 +23,12 @@ import {
const VALID_TABS = ['logs', 'journal', 'processes', 'services', 'shell'];
// Section tabs rendered inline in the PageTopbar (Servers-style), routed via
// /terminal/<tab>. Logs is the default landing (/terminal).
// /terminal/<tab>. Log Files is the default landing (/terminal) — it needs no
// paired agent — so it's listed first, keeping the highlighted tab and the
// landing view in sync. The interactive Terminal sits next.
const TERMINAL_TABS = [
{ to: '/terminal/shell', label: 'Terminal', icon: <TerminalIcon size={15} /> },
{ to: '/terminal', label: 'Log Files', end: true, icon: <FileText size={15} /> },
{ to: '/terminal/shell', label: 'Terminal', icon: <TerminalIcon size={15} /> },
{ to: '/terminal/journal', label: 'System Journal', icon: <ScrollText size={15} /> },
{ to: '/terminal/processes', label: 'Processes', icon: <Cpu size={15} /> },
{ to: '/terminal/services', label: 'Services', icon: <Settings size={15} /> },
+11 -14
View File
@@ -1,23 +1,20 @@
{
"plugins": [
{
"name": "serverkit-git",
"slug": "serverkit-git",
"display_name": "Git Server",
"name": "serverkit-wordpress",
"slug": "serverkit-wordpress",
"display_name": "WordPress",
"version": "1.0.0",
"frontend_entry": "",
"path": "serverkit-git"
"path": "serverkit-wordpress"
},
{
"name": "serverkit-gui",
"slug": "serverkit-gui",
"display_name": "ServerKit Agent GUI",
"version": "0.1.0",
"frontend_entry": "components/ServerGui.jsx",
"path": "serverkit-gui",
"styles": [
"plugins/serverkit-gui/styles/server-gui.css"
]
"name": "serverkit-cloudflare-ops",
"slug": "serverkit-cloudflare-ops",
"display_name": "Cloudflare Zone Ops",
"version": "1.0.0",
"frontend_entry": "",
"path": "serverkit-cloudflare-ops"
}
]
}
}
@@ -155,6 +155,9 @@ $sk-tones: ('accent', 'green', 'cyan', 'amber', 'red', 'violet');
&__val {
font-family: var(--mono); font-size: 27px; font-weight: 600;
letter-spacing: -0.02em; line-height: 1;
// Explicit theme colour — don't inherit, or a host with a stray text
// colour (e.g. a non-tabgroup page shell) renders the value near-black.
color: var(--text);
// Even columns of digits, and a truncation guard so an un-migrated
// 6-digit value degrades to an ellipsis instead of bursting the tile.
font-variant-numeric: tabular-nums;
@@ -470,6 +473,122 @@ $sk-tones: ('accent', 'green', 'cyan', 'amber', 'red', 'violet');
&__body { flex: 1; overflow: auto; min-height: 0; }
}
// ------------------------------------------------------------
// SearchField — icon + debounced input. Reusable across tables.
// ------------------------------------------------------------
.sk-searchfield {
position: relative;
display: inline-flex;
align-items: center;
flex: 1 1 240px;
min-width: 180px;
max-width: 420px;
&__icon {
position: absolute;
left: 11px;
width: 15px;
height: 15px;
color: var(--text-faint);
pointer-events: none;
}
&__input {
width: 100%;
height: 32px;
padding: 0 12px 0 34px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text);
font-size: 13px;
font-family: inherit;
transition: border-color 0.14s, box-shadow 0.14s;
&::placeholder { color: var(--text-faint); }
&:focus {
outline: none;
border-color: var(--violet);
box-shadow: 0 0 0 3px var(--violet-bg);
}
// Hide the native search clear affordance for a consistent look.
&::-webkit-search-cancel-button { -webkit-appearance: none; }
}
}
// ------------------------------------------------------------
// FilterButton + FilterDrawer — schema-driven advanced filters.
// ------------------------------------------------------------
.sk-filter-btn {
position: relative;
&__badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 17px;
height: 17px;
padding: 0 5px;
margin-left: 2px;
border-radius: 9px;
background: var(--violet);
color: #fff;
font-family: var(--mono);
font-size: 10.5px;
font-weight: 600;
line-height: 1;
}
}
.sk-filter {
display: flex;
flex-direction: column;
gap: 22px;
padding: 18px;
&__group { display: flex; flex-direction: column; gap: 10px; }
&__label {
font-family: var(--mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-faint);
}
&__chips { display: flex; flex-wrap: wrap; gap: 7px; }
&__chip {
padding: 6px 13px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text-dim);
font-size: 12.5px;
font-family: inherit;
cursor: pointer;
transition: 0.14s;
&:hover { color: var(--text); border-color: var(--border-strong, var(--border)); }
&.is-on {
background: var(--violet-bg);
border-color: var(--violet);
color: var(--violet);
font-weight: 550;
}
}
&__footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding-top: 6px;
margin-top: 4px;
border-top: 1px solid var(--border-soft);
}
}
// ------------------------------------------------------------
// ResourceCard / ResourceList — consistent list item for servers,
// apps, domains, containers, and other named resources.
@@ -2,6 +2,22 @@
// FLEET PAGES (Agent Fleet + Fleet Monitor)
// ============================================
// KPI band (Total agents / Online / Offline / Success rate). The rule was lost
// in the recovery rebuild, so the tiles stacked full-width.
.fleet-kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-bottom: $space-5;
@media (max-width: 1100px) {
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
}
@media (max-width: 560px) {
grid-template-columns: 1fr 1fr;
}
}
// Amber warning row (queued commands, forecast errors)
.fleet-warnrow {
display: flex;
@@ -6,6 +6,22 @@
.ftp-server {
// ─── KPI strip (status / active server / users / connections) ───
// Rule was lost in the recovery rebuild, so the tiles stacked full-width.
.ftp-kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-bottom: $space-5;
@media (max-width: 1100px) {
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
}
@media (max-width: 560px) {
grid-template-columns: 1fr 1fr;
}
}
// ─── Mono eyebrow section headers (Users / Connections / Logs) ───
.section-header {
display: flex;
+7 -53
View File
@@ -4,52 +4,21 @@
// file only lays out the page shell, the command bar, pagination, the
// scheduled-jobs section, and the row cell tints.
// The page shell (.page-container in Jobs.jsx) handles max-width + centering and
// .main-content supplies the outer padding — matching /cron and /domains — so
// this wrapper only owns the internal vertical rhythm.
.sk-jobs {
padding: $space-5;
.sk-kpiband-wrap { margin-bottom: $space-5; }
}
// ── Command bar: status seg + kind select + search, results summary ─────
.sk-jobs__command-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: $space-3;
margin: $space-5 0 $space-4;
flex-wrap: wrap;
}
.sk-jobs__filters {
// ── Slim reset bar (search + filters live in the page top bar; shown only
// while filtering) ────────────────────────────────────────────────────
.sk-jobs__resultbar {
display: flex;
align-items: center;
gap: $space-3;
flex: 1;
flex-wrap: wrap;
min-width: 0;
margin-bottom: $space-3;
}
.sk-jobs__search {
flex: 1;
min-width: 220px;
max-width: 360px;
}
.sk-jobs__kind-select { min-width: 150px; }
.sk-jobs__results-summary {
display: flex;
align-items: center;
gap: $space-2;
color: $text-tertiary;
font-size: $font-size-sm;
white-space: nowrap;
strong { color: $text-primary; font-family: $font-mono; }
}
.sk-jobs__clear-filters { margin-left: $space-2; }
// ── Row cell tints ──────────────────────────────────────────────────────
.sk-jobs__kind { color: $text-secondary; }
@@ -94,21 +63,6 @@
&-label { color: $text-tertiary; font-size: $font-size-sm; }
}
// ── Scheduled jobs section ──────────────────────────────────────────────
.sk-jobs__scheduled { margin-top: $space-6; }
.sk-jobs__section-title {
display: flex;
align-items: center;
gap: $space-2;
margin: 0 0 $space-3;
font-size: $font-size-md;
font-weight: $font-weight-semibold;
color: $text-primary;
svg { color: $text-tertiary; }
}
.sk-jobs__empty {
display: flex;
flex-direction: column;
+37 -54
View File
@@ -71,52 +71,13 @@
}
}
.marketplace-toolbar {
// Slim bar above the grid — only shown while filtering, to offer a reset.
// Search + filters live in the page top bar; there is no result count.
.marketplace-resultbar {
display: flex;
flex-wrap: wrap;
gap: $space-3;
align-items: center;
margin-bottom: $space-5;
}
.marketplace-search {
position: relative;
flex: 1 1 260px;
min-width: 200px;
.ui-input {
padding-left: 42px;
background: $bg-card;
}
}
.marketplace-toolbar__select.ui-select-trigger {
flex: 0 0 auto;
min-width: 160px;
background: $bg-card;
}
.marketplace-toolbar__seg {
flex: 0 0 auto;
}
.marketplace-toolbar__count {
margin-left: auto;
color: $text-tertiary;
font-size: $font-size-sm;
white-space: nowrap;
}
.marketplace-search__icon {
position: absolute;
top: 50%;
left: $space-4;
z-index: 1;
width: 16px;
height: 16px;
color: $text-tertiary;
transform: translateY(-50%);
pointer-events: none;
gap: $space-3;
margin-bottom: $space-3;
}
.marketplace-section {
@@ -182,14 +143,20 @@
}
}
// Data-derived gradient banner (per-slug hue set inline via
// extensionCoverStyle). Holds the brand mark / manifest icon / category glyph.
// Cover banner. Gradient variant (per-slug hue set inline via
// extensionCoverStyle) holds the brand mark / manifest icon / category glyph.
.extension-card__cover {
display: grid;
place-items: center;
height: 72px;
height: 140px;
border-radius: $radius-md;
overflow: hidden;
// Illustrated-icon variant: a clean light tile that lets the colourful
// 3D art sit centred with breathing room (drawn for a light backdrop).
&--icon {
background: #fff;
}
}
.extension-card__brand,
@@ -208,6 +175,15 @@
object-fit: contain;
}
// Illustrated raster icon sits centred at a fixed size (never stretched or
// zoomed) so every card reads consistently with room around the art.
.extension-card__icon {
width: 92px;
height: 92px;
max-width: 66%;
object-fit: contain;
}
.extension-card__footer,
.extension-card__info,
.extension-card__actions {
@@ -384,10 +360,6 @@
}
}
.marketplace-toolbar {
grid-template-columns: 1fr;
}
.extensions-grid {
grid-template-columns: 1fr;
}
@@ -452,15 +424,19 @@
gap: $space-4;
}
// Data-derived gradient banner in the detail modal (hue set inline via
// extensionCoverStyle). Wider than the card cover to read as a header.
// Cover banner in the detail modal. Gradient variant holds the brand mark /
// glyph; the --icon variant is a clean light tile for illustrated art.
&__cover {
display: grid;
place-items: center;
height: 108px;
height: 128px;
border-radius: $radius-md;
overflow: hidden;
&--icon {
background: #fff;
}
.extension-card__brand,
.extension-card__glyph {
color: rgba(255, 255, 255, 0.92);
@@ -476,6 +452,13 @@
height: 60px;
object-fit: contain;
}
.extension-card__icon {
width: 108px;
height: 108px;
max-width: 60%;
object-fit: contain;
}
}
&__heading {
@@ -128,6 +128,23 @@
color: var(--red);
}
// ── overview summary band — 4 KPI tiles in a responsive row ───
// (mirrors the shared KpiBand geometry; the class carried no rule after the
// recovery rebuild, so the tiles stacked full-width.)
.mon-kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-bottom: $space-5;
@media (max-width: 1100px) {
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
}
@media (max-width: 560px) {
grid-template-columns: 1fr 1fr;
}
}
// ── current-metrics host cards (prototype .host-card) ───
.mon-host-grid {
display: grid;
+3 -1
View File
@@ -836,7 +836,9 @@ $lk-default: #646b7a;
min-height: 0;
display: flex;
flex-direction: column;
padding: $space-3 $space-4;
// No top padding — the topbar pins flush to the top like the tab-group
// pages (Marketplace/Domains); only inset the sides and bottom.
padding: 0 $space-4 $space-4;
overflow: hidden;
> .sk-topbar { flex-shrink: 0; }