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>
No files matched your search
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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} /> },
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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} /> },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||