mirror of
https://github.com/mudler/LocalAI.git
synced 2026-04-29 19:44:13 -04:00
feat(react-ui): polish Manage page with kebab menus and gallery rows
Bring the System / Manage page up to the visual standard of the Install
gallery so installed models and backends stop reading like a debug dump.
- Unified ResourceRow anatomy (icon, name+description, badges, status,
expandable detail) shared across both tabs.
- Gallery enrichment cross-references installed names against the gallery
list endpoints to surface icons, descriptions, license, tags, and links
with a graceful "no description" fallback for custom imports.
- Header summary with four StatCards (Models / Backends / Running /
Updates) — clickable to switch tab + pre-set filter.
- Backends meta + development entries hidden by default; "Show meta &
development" paired toggle in the FilterBar with hidden-count hint.
- Kebab (three-dot) ActionMenu replaces the inline button cluster on
every row; restrained until hover, keyboard-navigable, danger items
separated by a divider.
- Backend "Version" cell now falls back to short digest, OCI tag, or
ocifile basename when no semver is set, instead of showing "—" for
every OCI install. Detail panel exposes full Source URI + Digest.
- Drop redundant column headers ("Actions", "On") — kebabs and toggles
carry their own affordance; screen readers still get a label.
- Inline System / User / Meta / Dev badges next to the backend name so
the dedicated Type column doesn't reserve space for "USER" repeated.
- Tightened the spacing between the System Resources card and the
StatCards so they no longer crowd the RAM bar.
Extracted StatCard and GalleryLoader from Nodes.jsx and Models.jsx into
shared components so the visual language is one source of truth.
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-4-7 [Read] [Edit] [Write] [Bash]
This commit is contained in:
@@ -6195,6 +6195,304 @@ select.input {
|
|||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ResourceRow — unified expandable row anatomy used by the Manage page so
|
||||||
|
installed models and backends share the same visual grammar as the Install
|
||||||
|
gallery (icon, name, description, badges, expandable detail panel). */
|
||||||
|
.resource-row { transition: background var(--duration-fast) var(--ease-default); }
|
||||||
|
.resource-row.is-dimmed { opacity: 0.55; transition: opacity 0.2s; }
|
||||||
|
.resource-row.is-expanded { background: var(--color-bg-tertiary); }
|
||||||
|
|
||||||
|
.resource-row__chevron-cell { width: 30px; }
|
||||||
|
.resource-row__icon-cell { width: 64px; }
|
||||||
|
|
||||||
|
.resource-row__icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.resource-row__icon > img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.resource-row__icon > i {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-row__detail-row > .resource-row__detail-cell {
|
||||||
|
padding: 0;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
border-top: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
.resource-row__detail {
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
}
|
||||||
|
.resource-row__detail h4 {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0 0 var(--spacing-sm) 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
.resource-row__detail h4 i { color: var(--color-accent); }
|
||||||
|
.resource-row__detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: 6px var(--spacing-md);
|
||||||
|
}
|
||||||
|
.resource-row__detail-grid dt {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
align-self: start;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
.resource-row__detail-grid dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
min-width: 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.resource-row__detail-md {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.resource-row__detail-md p:first-child { margin-top: 0; }
|
||||||
|
.resource-row__detail-md p:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* Description line directly under the row name — Install gallery already
|
||||||
|
does this; Manage rows used to show only the bare name. */
|
||||||
|
.resource-row__desc {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
max-width: 42ch;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ResourceActions — split lifecycle vs destructive with a thin divider so
|
||||||
|
the trash icon doesn't sit at the same eye-weight as the routine buttons.
|
||||||
|
Used now only for the rare row whose actions can't collapse into the
|
||||||
|
kebab menu (e.g. a "Protected" badge on system backends). */
|
||||||
|
.resource-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.resource-actions__group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
.resource-actions__divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--color-border-subtle);
|
||||||
|
margin: 0 var(--spacing-xs);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ActionMenu — kebab trigger + popover menu. Borrows claudemaster's
|
||||||
|
restrained pattern: button reads as a quiet ellipsis at rest, lights up
|
||||||
|
on row hover, and the menu items hold typography-first weight (icon +
|
||||||
|
label, no fills until hover). The trigger stays at low opacity until the
|
||||||
|
user reaches for the row, so dense tables don't read as control panels. */
|
||||||
|
.action-menu__trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
opacity: 0.45;
|
||||||
|
transition:
|
||||||
|
opacity var(--duration-fast) var(--ease-default),
|
||||||
|
color var(--duration-fast) var(--ease-default),
|
||||||
|
background var(--duration-fast) var(--ease-default),
|
||||||
|
border-color var(--duration-fast) var(--ease-default);
|
||||||
|
}
|
||||||
|
.resource-row:hover .action-menu__trigger,
|
||||||
|
.action-menu__trigger:focus-visible,
|
||||||
|
.action-menu__trigger.is-open {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.action-menu__trigger:hover,
|
||||||
|
.action-menu__trigger.is-open {
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border-color: var(--color-border-subtle);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.action-menu__trigger:focus-visible {
|
||||||
|
outline: 2px solid var(--color-border-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.action-menu__trigger--compact {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 4px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.action-menu__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
transition: background var(--duration-fast) var(--ease-default),
|
||||||
|
color var(--duration-fast) var(--ease-default);
|
||||||
|
}
|
||||||
|
.action-menu__item.is-active,
|
||||||
|
.action-menu__item:hover:not(:disabled) {
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
}
|
||||||
|
.action-menu__item:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
.action-menu__icon {
|
||||||
|
width: 14px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.action-menu__item.is-active .action-menu__icon,
|
||||||
|
.action-menu__item:hover:not(:disabled) .action-menu__icon {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.action-menu__label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.action-menu__shortcut {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.action-menu__item.is-danger { color: var(--color-error); }
|
||||||
|
.action-menu__item.is-danger .action-menu__icon { color: var(--color-error); }
|
||||||
|
.action-menu__item.is-danger:hover:not(:disabled),
|
||||||
|
.action-menu__item.is-danger.is-active {
|
||||||
|
background: var(--color-error-light);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
.action-menu__item.is-danger:hover:not(:disabled) .action-menu__icon,
|
||||||
|
.action-menu__item.is-danger.is-active .action-menu__icon {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
.action-menu__divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--color-border-subtle);
|
||||||
|
margin: 4px 2px;
|
||||||
|
}
|
||||||
|
.action-menu__badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.action-menu__badge > i { color: var(--color-text-muted); font-size: var(--text-xs); width: 14px; text-align: center; }
|
||||||
|
|
||||||
|
/* Pulse modifier — subtle ring on the Pin button when a model is pinned, so
|
||||||
|
pinning is visible at a glance without a second icon next to the name. */
|
||||||
|
.btn--pulse {
|
||||||
|
animation: btnPulseWarning 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes btnPulseWarning {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(235, 203, 139, 0); }
|
||||||
|
50% { box-shadow: 0 0 0 4px rgba(235, 203, 139, 0.18); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* StatCard clickable variant — the Manage summary cards double as shortcuts
|
||||||
|
to the relevant tab + filter, so the card needs a real button affordance. */
|
||||||
|
.stat-card[data-clickable="true"] {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.stat-card[data-clickable="true"]:hover {
|
||||||
|
border-color: var(--stat-accent, var(--color-border));
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.stat-card[data-clickable="true"]:focus-visible {
|
||||||
|
outline: 2px solid var(--color-border-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Manage summary marker — same .stat-grid layout. Top margin separates the
|
||||||
|
cards from the System Resources card above (otherwise they sit too close
|
||||||
|
to the RAM bar) and bottom margin tightens the gap to the tabs below. */
|
||||||
|
.manage-summary {
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screen-reader-only label, used for table headers whose visual cell needs
|
||||||
|
no label (kebab-only Actions column, toggle-only Enabled column). The
|
||||||
|
header still announces correctly to assistive tech without making the
|
||||||
|
table feel like it's labelling sparse columns twice. */
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Reduced motion accessibility */
|
/* Reduced motion accessibility */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
|
|||||||
141
core/http/react-ui/src/components/ActionMenu.jsx
Normal file
141
core/http/react-ui/src/components/ActionMenu.jsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { useRef, useState, useEffect, useCallback } from 'react'
|
||||||
|
import Popover from './Popover'
|
||||||
|
|
||||||
|
// ActionMenu renders a kebab (three-dot) button that opens a popover with a
|
||||||
|
// list of row actions. Replaces the inline cluster of icon buttons that made
|
||||||
|
// dense tables feel like a control panel — actions stay out of the way until
|
||||||
|
// the user reaches for them, the way Linear/Vercel/Notion handle row menus.
|
||||||
|
//
|
||||||
|
// Items shape:
|
||||||
|
// { key, icon?, label, onClick, danger?, disabled?, hidden?, shortcut? }
|
||||||
|
// { divider: true } // visual separator
|
||||||
|
// { type: 'badge', icon?, label } // non-interactive badge row
|
||||||
|
//
|
||||||
|
// Hidden items are filtered out so callers can write conditional menus
|
||||||
|
// inline (`{ key: 'stop', visible: isRunning, ... }` style) without ternaries.
|
||||||
|
//
|
||||||
|
// Keyboard:
|
||||||
|
// ArrowUp / ArrowDown — move highlight (skipping dividers + badges)
|
||||||
|
// Enter / Space — activate
|
||||||
|
// Escape — close, return focus to trigger
|
||||||
|
export default function ActionMenu({ items, ariaLabel = 'Actions', triggerLabel, compact = false }) {
|
||||||
|
const triggerRef = useRef(null)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [activeIdx, setActiveIdx] = useState(-1)
|
||||||
|
|
||||||
|
const interactive = (Array.isArray(items) ? items : []).filter(it => it && !it.divider && it.type !== 'badge' && !it.hidden)
|
||||||
|
const visible = (Array.isArray(items) ? items : []).filter(it => it && !it.hidden)
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setOpen(false)
|
||||||
|
setActiveIdx(-1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Move highlight to the first interactive item when opening, so keyboard
|
||||||
|
// users land somewhere meaningful instead of having to arrow into the menu.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && activeIdx === -1 && interactive.length > 0) {
|
||||||
|
setActiveIdx(0)
|
||||||
|
}
|
||||||
|
}, [open, activeIdx, interactive.length])
|
||||||
|
|
||||||
|
const handleTriggerKeyDown = (e) => {
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMenuKeyDown = (e) => {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
setActiveIdx(i => Math.min(interactive.length - 1, (i < 0 ? -1 : i) + 1))
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
setActiveIdx(i => Math.max(0, (i < 0 ? interactive.length : i) - 1))
|
||||||
|
} else if (e.key === 'Home') {
|
||||||
|
e.preventDefault()
|
||||||
|
setActiveIdx(0)
|
||||||
|
} else if (e.key === 'End') {
|
||||||
|
e.preventDefault()
|
||||||
|
setActiveIdx(interactive.length - 1)
|
||||||
|
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
const item = interactive[activeIdx]
|
||||||
|
if (item && !item.disabled) {
|
||||||
|
close()
|
||||||
|
item.onClick?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interactive.length === 0 && !visible.some(it => it.type === 'badge')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
className={`action-menu__trigger${compact ? ' action-menu__trigger--compact' : ''}${open ? ' is-open' : ''}`}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={triggerLabel || ariaLabel}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setOpen(v => !v) }}
|
||||||
|
onKeyDown={handleTriggerKeyDown}
|
||||||
|
>
|
||||||
|
<i className="fas fa-ellipsis-vertical" />
|
||||||
|
</button>
|
||||||
|
<Popover anchor={triggerRef} open={open} onClose={close} ariaLabel={ariaLabel}>
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="action-menu"
|
||||||
|
onKeyDown={handleMenuKeyDown}
|
||||||
|
// Capture focus when the menu opens so arrow keys work without the
|
||||||
|
// user clicking inside first.
|
||||||
|
tabIndex={-1}
|
||||||
|
ref={el => { if (el && open) el.focus() }}
|
||||||
|
>
|
||||||
|
{visible.map((item, i) => {
|
||||||
|
if (item.divider) {
|
||||||
|
return <div key={`d-${i}`} className="action-menu__divider" role="separator" />
|
||||||
|
}
|
||||||
|
if (item.type === 'badge') {
|
||||||
|
return (
|
||||||
|
<div key={item.key || `b-${i}`} className="action-menu__badge" role="presentation">
|
||||||
|
{item.icon && <i className={`fas ${item.icon}`} aria-hidden="true" />}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const idx = interactive.indexOf(item)
|
||||||
|
const active = idx === activeIdx
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
disabled={item.disabled}
|
||||||
|
className={`action-menu__item${item.danger ? ' is-danger' : ''}${active ? ' is-active' : ''}`}
|
||||||
|
onMouseEnter={() => setActiveIdx(idx)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (item.disabled) return
|
||||||
|
close()
|
||||||
|
item.onClick?.()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.icon && <i className={`fas ${item.icon} action-menu__icon`} aria-hidden="true" />}
|
||||||
|
<span className="action-menu__label">{item.label}</span>
|
||||||
|
{item.shortcut && <span className="action-menu__shortcut">{item.shortcut}</span>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
79
core/http/react-ui/src/components/GalleryLoader.jsx
Normal file
79
core/http/react-ui/src/components/GalleryLoader.jsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
const LOADING_PHRASES = [
|
||||||
|
{ text: 'Loading models...', icon: 'fa-brain' },
|
||||||
|
{ text: 'Fetching gallery...', icon: 'fa-download' },
|
||||||
|
{ text: 'Checking availability...', icon: 'fa-circle-check' },
|
||||||
|
{ text: 'Almost ready...', icon: 'fa-hourglass-half' },
|
||||||
|
{ text: 'Preparing gallery...', icon: 'fa-store' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// GalleryLoader is the animated skeleton used while the gallery list loads.
|
||||||
|
// Used by Models, Backends, and (now) the Manage page so an empty fetch state
|
||||||
|
// reads the same everywhere instead of one tab showing pulsing dots and the
|
||||||
|
// other showing "Loading...".
|
||||||
|
export default function GalleryLoader() {
|
||||||
|
const [idx, setIdx] = useState(() => Math.floor(Math.random() * LOADING_PHRASES.length))
|
||||||
|
const [fade, setFade] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setFade(false)
|
||||||
|
setTimeout(() => {
|
||||||
|
setIdx(prev => (prev + 1) % LOADING_PHRASES.length)
|
||||||
|
setFade(true)
|
||||||
|
}, 300)
|
||||||
|
}, 2800)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const phrase = LOADING_PHRASES[idx]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||||
|
justifyContent: 'center', padding: 'var(--spacing-xl) var(--spacing-md)',
|
||||||
|
minHeight: '280px', gap: 'var(--spacing-lg)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
|
||||||
|
{[0, 1, 2, 3, 4].map(i => (
|
||||||
|
<div key={i} style={{
|
||||||
|
width: 10, height: 10, borderRadius: '50%',
|
||||||
|
background: 'var(--color-primary)',
|
||||||
|
animation: `galleryDot 1.4s ease-in-out ${i * 0.15}s infinite`,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)',
|
||||||
|
opacity: fade ? 1 : 0,
|
||||||
|
transition: 'opacity 300ms ease',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
fontSize: '0.9375rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
<i className={`fas ${phrase.icon}`} style={{ color: 'var(--color-accent)', fontSize: '1.125rem' }} />
|
||||||
|
{phrase.text}
|
||||||
|
</div>
|
||||||
|
<div style={{ width: '100%', maxWidth: '700px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
{[0.9, 0.7, 0.5].map((opacity, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
height: '48px', borderRadius: 'var(--radius-md)',
|
||||||
|
background: 'var(--color-bg-tertiary)', opacity,
|
||||||
|
animation: `galleryShimmer 1.8s ease-in-out ${i * 0.2}s infinite`,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<style>{`
|
||||||
|
@keyframes galleryDot {
|
||||||
|
0%, 80%, 100% { transform: scale(0.4); opacity: 0.3; }
|
||||||
|
40% { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes galleryShimmer {
|
||||||
|
0%, 100% { opacity: var(--shimmer-base, 0.15); }
|
||||||
|
50% { opacity: var(--shimmer-peak, 0.3); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
core/http/react-ui/src/components/ManageSummary.jsx
Normal file
47
core/http/react-ui/src/components/ManageSummary.jsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import StatCard from './StatCard'
|
||||||
|
|
||||||
|
// ManageSummary anchors the Manage page with the same StatCard pattern the
|
||||||
|
// Nodes dashboard uses, so the page reads as a real overview rather than
|
||||||
|
// "two tabs in a hat". Counts are derived in-memory by the parent — this
|
||||||
|
// component is purely presentational. Cards are clickable and route the
|
||||||
|
// user to the relevant tab + filter.
|
||||||
|
export default function ManageSummary({
|
||||||
|
modelsCount,
|
||||||
|
backendsCount,
|
||||||
|
runningCount,
|
||||||
|
updatesCount,
|
||||||
|
onCardClick,
|
||||||
|
}) {
|
||||||
|
const click = (tab, filter) => onCardClick && onCardClick(tab, filter)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stat-grid manage-summary">
|
||||||
|
<StatCard
|
||||||
|
icon="fas fa-brain"
|
||||||
|
label="Models Installed"
|
||||||
|
value={modelsCount}
|
||||||
|
onClick={() => click('models', 'all')}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon="fas fa-server"
|
||||||
|
label="Backends Installed"
|
||||||
|
value={backendsCount}
|
||||||
|
onClick={() => click('backends', 'all')}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon="fas fa-circle-play"
|
||||||
|
label="Currently Running"
|
||||||
|
value={runningCount}
|
||||||
|
accentVar={runningCount > 0 ? '--color-success' : undefined}
|
||||||
|
onClick={() => click('models', 'running')}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon="fas fa-arrow-up"
|
||||||
|
label="Updates Available"
|
||||||
|
value={updatesCount}
|
||||||
|
accentVar={updatesCount > 0 ? '--color-warning' : undefined}
|
||||||
|
onClick={() => click('backends', updatesCount > 0 ? 'upgradable' : 'all')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
core/http/react-ui/src/components/MetaBadgeRow.jsx
Normal file
30
core/http/react-ui/src/components/MetaBadgeRow.jsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// MetaBadgeRow renders the System / User / Meta / Dev badge cluster the same
|
||||||
|
// way everywhere — Manage tabs and (in future) Install gallery. The badges
|
||||||
|
// already exist as classes; this component locks down the icons + labels so
|
||||||
|
// the same backend type doesn't read "User" in one tab and "downloaded" in
|
||||||
|
// another.
|
||||||
|
export default function MetaBadgeRow({ isSystem, isMeta, isDevelopment }) {
|
||||||
|
return (
|
||||||
|
<div className="badge-row">
|
||||||
|
{isSystem ? (
|
||||||
|
<span className="badge badge-info" title="Bundled with the LocalAI runtime">
|
||||||
|
<i className="fas fa-shield-alt" /> System
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-success" title="Installed from the gallery or external source">
|
||||||
|
<i className="fas fa-download" /> User
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isMeta && (
|
||||||
|
<span className="badge badge-accent" title="Meta backend — selects a concrete variant per node">
|
||||||
|
<i className="fas fa-layer-group" /> Meta
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isDevelopment && (
|
||||||
|
<span className="badge badge-warning" title="Marked as development / pre-release by the gallery">
|
||||||
|
<i className="fas fa-flask" /> Dev
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
core/http/react-ui/src/components/ResourceActions.jsx
Normal file
29
core/http/react-ui/src/components/ResourceActions.jsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// ResourceActions groups row-level buttons into a lifecycle cluster (start,
|
||||||
|
// stop, pin, reinstall, upgrade) and a destructive cluster (delete) with a
|
||||||
|
// thin divider between them, so a destructive intent visually separates from
|
||||||
|
// a routine one. Replaces the old 4px-gap row of buttons in the Manage page
|
||||||
|
// where Stop / Pin / Delete sat shoulder-to-shoulder with no visual cue
|
||||||
|
// telling apart "click to fiddle" from "click to throw away".
|
||||||
|
//
|
||||||
|
// `lifecycle` and `destructive` accept any ReactNode — typically one or more
|
||||||
|
// <button>s. The wrapping div stops click propagation so action clicks don't
|
||||||
|
// also expand the row.
|
||||||
|
export default function ResourceActions({ lifecycle, destructive }) {
|
||||||
|
const hasLifecycle = !!lifecycle
|
||||||
|
const hasDestructive = !!destructive
|
||||||
|
if (!hasLifecycle && !hasDestructive) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="resource-actions" onClick={e => e.stopPropagation()}>
|
||||||
|
{hasLifecycle && (
|
||||||
|
<div className="resource-actions__group">{lifecycle}</div>
|
||||||
|
)}
|
||||||
|
{hasLifecycle && hasDestructive && (
|
||||||
|
<span className="resource-actions__divider" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{hasDestructive && (
|
||||||
|
<div className="resource-actions__group">{destructive}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
core/http/react-ui/src/components/ResourceRow.jsx
Normal file
81
core/http/react-ui/src/components/ResourceRow.jsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Fragment } from 'react'
|
||||||
|
|
||||||
|
// ResourceRow renders the visible row + its conditional detail row as a pair
|
||||||
|
// of <tr>s, so the existing .table styling keeps applying and the Manage page
|
||||||
|
// can re-use the gallery's expand-to-detail interaction without inventing a
|
||||||
|
// new table system. The consumer owns the cells (which pass through as
|
||||||
|
// children) — this component only manages the click-to-expand handler, the
|
||||||
|
// dimmed state for disabled rows, and the colSpan'd detail row beneath.
|
||||||
|
//
|
||||||
|
// `onToggleExpand` fires on row click only. Buttons / toggles inside cells
|
||||||
|
// must call e.stopPropagation() (or be wrapped in an .actions-stop wrapper)
|
||||||
|
// to avoid double-triggering the expand.
|
||||||
|
export default function ResourceRow({
|
||||||
|
expanded,
|
||||||
|
onToggleExpand,
|
||||||
|
detail,
|
||||||
|
colSpan,
|
||||||
|
dimmed,
|
||||||
|
className = '',
|
||||||
|
children,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<tr
|
||||||
|
className={`resource-row${dimmed ? ' is-dimmed' : ''}${expanded ? ' is-expanded' : ''} ${className}`.trim()}
|
||||||
|
onClick={onToggleExpand}
|
||||||
|
style={{ cursor: onToggleExpand ? 'pointer' : 'default' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</tr>
|
||||||
|
{expanded && detail && (
|
||||||
|
<tr className="resource-row__detail-row">
|
||||||
|
<td colSpan={colSpan} className="resource-row__detail-cell">
|
||||||
|
{detail}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChevronCell is the small rotating chevron used as the leftmost cell of an
|
||||||
|
// expandable row. Mirrors the Nodes/Models/Backends gallery affordance so
|
||||||
|
// users see the same "click to expand" cue everywhere.
|
||||||
|
export function ChevronCell({ expanded }) {
|
||||||
|
return (
|
||||||
|
<td className="resource-row__chevron-cell">
|
||||||
|
<span className={`row-chevron${expanded ? ' is-expanded' : ''}`} aria-hidden="true">
|
||||||
|
<i className="fas fa-chevron-right" />
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IconCell renders the 48px brand icon shell — the same one the Install
|
||||||
|
// gallery uses. `icon` is the image URL (from gallery metadata); when absent
|
||||||
|
// or broken we fall back to a FontAwesome glyph so custom-imported items
|
||||||
|
// still get a placeholder instead of an empty square.
|
||||||
|
export function IconCell({ icon, fallback = 'fa-cube', alt = '' }) {
|
||||||
|
return (
|
||||||
|
<td className="resource-row__icon-cell">
|
||||||
|
<div className="resource-row__icon">
|
||||||
|
{icon ? (
|
||||||
|
<img src={icon} alt={alt} loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<i className={`fas ${fallback}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopPropagationCell wraps cell contents that contain interactive controls
|
||||||
|
// (Toggle, action buttons) so a click on them doesn't also expand the row.
|
||||||
|
export function StopPropagationCell({ children, ...props }) {
|
||||||
|
return (
|
||||||
|
<td {...props} onClick={e => e.stopPropagation()}>
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
core/http/react-ui/src/components/StatCard.jsx
Normal file
39
core/http/react-ui/src/components/StatCard.jsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// StatCard renders a single cluster/dashboard metric card. The left accent
|
||||||
|
// bar + icon chip color is driven by `accentVar` (a CSS custom property name,
|
||||||
|
// e.g. "--color-success") so the card reads as semantic without the caller
|
||||||
|
// having to reach into colors directly. `onClick` upgrades the card to a
|
||||||
|
// keyboard-focusable button — used by the Manage page so cards double as
|
||||||
|
// shortcuts to the relevant tab + filter.
|
||||||
|
export default function StatCard({ icon, label, value, color, accentVar, onClick }) {
|
||||||
|
const accent = color || (accentVar ? `var(${accentVar})` : 'var(--color-text-primary)')
|
||||||
|
const interactive = typeof onClick === 'function'
|
||||||
|
|
||||||
|
const handleKeyDown = interactive
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
onClick(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="stat-card"
|
||||||
|
data-clickable={interactive ? 'true' : undefined}
|
||||||
|
role={interactive ? 'button' : undefined}
|
||||||
|
tabIndex={interactive ? 0 : undefined}
|
||||||
|
onClick={interactive ? onClick : undefined}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
style={accentVar ? { ['--stat-accent']: `var(${accentVar})` } : undefined}
|
||||||
|
>
|
||||||
|
<div className="stat-card__body">
|
||||||
|
<div className="stat-card__label">{label}</div>
|
||||||
|
<div className="stat-card__value" style={{ color: accent }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card__icon" style={accentVar ? { color: accent } : undefined}>
|
||||||
|
<i className={icon} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
core/http/react-ui/src/hooks/useGalleryEnrichment.js
vendored
Normal file
53
core/http/react-ui/src/hooks/useGalleryEnrichment.js
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { modelsApi, backendsApi } from '../utils/api'
|
||||||
|
|
||||||
|
// useGalleryEnrichment fetches the full model + backend gallery once and
|
||||||
|
// returns lookup helpers used by the Manage page. The Manage list APIs only
|
||||||
|
// know name/version/alias — descriptions, icons, licenses, tags, and links
|
||||||
|
// live on the gallery side. Cross-referencing here lets us light up the
|
||||||
|
// installed lists with the same metadata the Install pages show, instead of
|
||||||
|
// rendering them as bare names.
|
||||||
|
//
|
||||||
|
// Items not present in the gallery (custom imports, external OCI installs)
|
||||||
|
// resolve to `null` — callers fall back to a neutral icon + "no description".
|
||||||
|
export function useGalleryEnrichment() {
|
||||||
|
const [modelMap, setModelMap] = useState(() => new Map())
|
||||||
|
const [backendMap, setBackendMap] = useState(() => new Map())
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
Promise.allSettled([
|
||||||
|
modelsApi.list({ items: 9999, page: 1 }),
|
||||||
|
backendsApi.list({ items: 9999, page: 1 }),
|
||||||
|
]).then(([m, b]) => {
|
||||||
|
if (cancelled) return
|
||||||
|
const mm = new Map()
|
||||||
|
if (m.status === 'fulfilled') {
|
||||||
|
const list = m.value?.models || []
|
||||||
|
for (const x of list) {
|
||||||
|
const key = x.name || x.id
|
||||||
|
if (key) mm.set(key, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const bm = new Map()
|
||||||
|
if (b.status === 'fulfilled') {
|
||||||
|
const raw = b.value
|
||||||
|
const list = Array.isArray(raw?.backends) ? raw.backends : Array.isArray(raw) ? raw : []
|
||||||
|
for (const x of list) {
|
||||||
|
const key = x.name || x.id
|
||||||
|
if (key) bm.set(key, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setModelMap(mm)
|
||||||
|
setBackendMap(bm)
|
||||||
|
setLoaded(true)
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const enrichModel = useCallback((name) => (name ? modelMap.get(name) || null : null), [modelMap])
|
||||||
|
const enrichBackend = useCallback((name) => (name ? backendMap.get(name) || null : null), [backendMap])
|
||||||
|
|
||||||
|
return { enrichModel, enrichBackend, loaded }
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -6,87 +6,10 @@ import { useOperations } from '../hooks/useOperations'
|
|||||||
import { useResources } from '../hooks/useResources'
|
import { useResources } from '../hooks/useResources'
|
||||||
import SearchableSelect from '../components/SearchableSelect'
|
import SearchableSelect from '../components/SearchableSelect'
|
||||||
import ConfirmDialog from '../components/ConfirmDialog'
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
|
import GalleryLoader from '../components/GalleryLoader'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
|
|
||||||
const LOADING_PHRASES = [
|
|
||||||
{ text: 'Loading models...', icon: 'fa-brain' },
|
|
||||||
{ text: 'Fetching gallery...', icon: 'fa-download' },
|
|
||||||
{ text: 'Checking availability...', icon: 'fa-circle-check' },
|
|
||||||
{ text: 'Almost ready...', icon: 'fa-hourglass-half' },
|
|
||||||
{ text: 'Preparing gallery...', icon: 'fa-store' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function GalleryLoader() {
|
|
||||||
const [idx, setIdx] = useState(() => Math.floor(Math.random() * LOADING_PHRASES.length))
|
|
||||||
const [fade, setFade] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setFade(false)
|
|
||||||
setTimeout(() => {
|
|
||||||
setIdx(prev => (prev + 1) % LOADING_PHRASES.length)
|
|
||||||
setFade(true)
|
|
||||||
}, 300)
|
|
||||||
}, 2800)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const phrase = LOADING_PHRASES[idx]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
|
||||||
justifyContent: 'center', padding: 'var(--spacing-xl) var(--spacing-md)',
|
|
||||||
minHeight: '280px', gap: 'var(--spacing-lg)',
|
|
||||||
}}>
|
|
||||||
{/* Animated dots */}
|
|
||||||
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
|
|
||||||
{[0, 1, 2, 3, 4].map(i => (
|
|
||||||
<div key={i} style={{
|
|
||||||
width: 10, height: 10, borderRadius: '50%',
|
|
||||||
background: 'var(--color-primary)',
|
|
||||||
animation: `galleryDot 1.4s ease-in-out ${i * 0.15}s infinite`,
|
|
||||||
}} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{/* Rotating phrase */}
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)',
|
|
||||||
opacity: fade ? 1 : 0,
|
|
||||||
transition: 'opacity 300ms ease',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
fontSize: '0.9375rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
}}>
|
|
||||||
<i className={`fas ${phrase.icon}`} style={{ color: 'var(--color-accent)', fontSize: '1.125rem' }} />
|
|
||||||
{phrase.text}
|
|
||||||
</div>
|
|
||||||
{/* Skeleton rows */}
|
|
||||||
<div style={{ width: '100%', maxWidth: '700px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
|
||||||
{[0.9, 0.7, 0.5].map((opacity, i) => (
|
|
||||||
<div key={i} style={{
|
|
||||||
height: '48px', borderRadius: 'var(--radius-md)',
|
|
||||||
background: 'var(--color-bg-tertiary)', opacity,
|
|
||||||
animation: `galleryShimmer 1.8s ease-in-out ${i * 0.2}s infinite`,
|
|
||||||
}} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<style>{`
|
|
||||||
@keyframes galleryDot {
|
|
||||||
0%, 80%, 100% { transform: scale(0.4); opacity: 0.3; }
|
|
||||||
40% { transform: scale(1); opacity: 1; }
|
|
||||||
}
|
|
||||||
@keyframes galleryShimmer {
|
|
||||||
0%, 100% { opacity: var(--shimmer-base, 0.15); }
|
|
||||||
50% { opacity: var(--shimmer-peak, 0.3); }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const FILTERS = [
|
const FILTERS = [
|
||||||
{ key: '', label: 'All', icon: 'fa-layer-group' },
|
{ key: '', label: 'All', icon: 'fa-layer-group' },
|
||||||
{ key: 'llm', label: 'LLM', icon: 'fa-brain' },
|
{ key: 'llm', label: 'LLM', icon: 'fa-brain' },
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useModels } from '../hooks/useModels'
|
|||||||
import LoadingSpinner from '../components/LoadingSpinner'
|
import LoadingSpinner from '../components/LoadingSpinner'
|
||||||
import ConfirmDialog from '../components/ConfirmDialog'
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
import ImageSelector, { useImageSelector, dockerImage, dockerFlags } from '../components/ImageSelector'
|
import ImageSelector, { useImageSelector, dockerImage, dockerFlags } from '../components/ImageSelector'
|
||||||
|
import StatCard from '../components/StatCard'
|
||||||
|
|
||||||
function timeAgo(dateString) {
|
function timeAgo(dateString) {
|
||||||
if (!dateString) return 'never'
|
if (!dateString) return 'never'
|
||||||
@@ -51,27 +52,6 @@ const modelStateConfig = {
|
|||||||
idle: { bg: 'var(--color-bg-tertiary)', color: 'var(--color-text-muted)', border: 'var(--color-border-subtle)' },
|
idle: { bg: 'var(--color-bg-tertiary)', color: 'var(--color-text-muted)', border: 'var(--color-border-subtle)' },
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ icon, label, value, color, accentVar }) {
|
|
||||||
// accentVar: optional CSS variable for the left edge + icon chip, e.g.
|
|
||||||
// "--color-success". When unset the card reads neutral — used for simple
|
|
||||||
// counts so they don't compete with the semantic cards for attention.
|
|
||||||
const accent = color || (accentVar ? `var(${accentVar})` : 'var(--color-text-primary)')
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="stat-card"
|
|
||||||
style={accentVar ? { ['--stat-accent']: `var(${accentVar})` } : undefined}
|
|
||||||
>
|
|
||||||
<div className="stat-card__body">
|
|
||||||
<div className="stat-card__label">{label}</div>
|
|
||||||
<div className="stat-card__value" style={{ color: accent }}>{value}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card__icon" style={accentVar ? { color: accent } : undefined}>
|
|
||||||
<i className={icon} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepNumber({ n, bg, color }) {
|
function StepNumber({ n, bg, color }) {
|
||||||
return (
|
return (
|
||||||
<span style={{
|
<span style={{
|
||||||
|
|||||||
Reference in New Issue
Block a user