refactor(ui): adopt PageHeader on ops/admin/media pages (batch B)

Replace hand-rolled .page-header title blocks with the shared editorial
PageHeader component across 14 pages (Manage, Middleware, Models,
NodeBackendLogs, Nodes, P2P, SkillEdit, Skills, Sound, Traces, TTS, Usage,
Users, VideoGen). Title/subtitle move into PageHeader; header-own action
clusters (Models stats+buttons, Skills search+buttons) move into the actions
slot. Tabs, filters, stat cards, ResourceMonitor and page body stay as
siblings. Eyebrow is left to auto-derive from the route.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-06-18 15:02:30 +00:00
parent f1132f9143
commit 30245d7bec
14 changed files with 127 additions and 128 deletions

View File

@@ -3,6 +3,7 @@ import { useNavigate, useOutletContext, useSearchParams, useLocation } from 'rea
import { useTranslation } from 'react-i18next'
import { fromState } from '../utils/editorNav'
import ResourceMonitor from '../components/ResourceMonitor'
import PageHeader from '../components/PageHeader'
import ConfirmDialog from '../components/ConfirmDialog'
import NodeDistributionChip from '../components/NodeDistributionChip'
import FilterBar from '../components/FilterBar'
@@ -448,10 +449,7 @@ export default function Manage() {
return (
<div className="page page--wide">
<div className="page-header">
<h1 className="page-title">{t('manage.title')}</h1>
<p className="page-subtitle">{t('manage.subtitle')}</p>
</div>
<PageHeader title={t('manage.title')} supporting={t('manage.subtitle')} />
{/* Resource Monitor */}
<ResourceMonitor />

View File

@@ -5,6 +5,7 @@ import { fromState } from '../utils/editorNav'
import { settingsApi, modelsApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
import Toggle from '../components/Toggle'
import PageHeader from '../components/PageHeader'
// Middleware admin page. Three tabs:
// - Filtering: per-model resolved PII state + per-model detector list
@@ -129,12 +130,10 @@ export default function Middleware() {
return (
<div className="page page--wide">
<div className="page-header" style={{ marginBottom: 'var(--spacing-sm)' }}>
<h1 className="page-title">Middleware</h1>
<p className="page-subtitle">
Inspect and configure routing-module middleware: PII filtering and intelligent routing.
</p>
</div>
<PageHeader
title="Middleware"
supporting="Inspect and configure routing-module middleware: PII filtering and intelligent routing."
/>
{/* Tab bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)', marginBottom: 'var(--spacing-md)', flexWrap: 'wrap' }}>

View File

@@ -8,6 +8,7 @@ import { useDebouncedCallback } from '../hooks/useDebounce'
import { useOperations } from '../hooks/useOperations'
import { useResources } from '../hooks/useResources'
import SearchableSelect from '../components/SearchableSelect'
import PageHeader from '../components/PageHeader'
import ConfirmDialog from '../components/ConfirmDialog'
import GalleryLoader from '../components/GalleryLoader'
import Toggle from '../components/Toggle'
@@ -271,32 +272,32 @@ export default function Models() {
return (
<div className="page page--wide">
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h1 className="page-title">{t('title')}</h1>
<p className="page-subtitle">{t('subtitle')}</p>
</div>
<div style={{ display: 'flex', gap: 'var(--spacing-md)', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 'var(--spacing-md)', fontSize: '0.8125rem' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-primary)' }}>{stats.total}</div>
<div style={{ color: 'var(--color-text-muted)' }}>{t('stats.available')}</div>
</div>
<div style={{ textAlign: 'center' }}>
<a onClick={() => navigate('/app/manage')} style={{ cursor: 'pointer' }}>
<div style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-success)' }}>{stats.installed}</div>
<div style={{ color: 'var(--color-text-muted)' }}>{t('stats.installed')}</div>
</a>
<PageHeader
title={t('title')}
supporting={t('subtitle')}
actions={
<div style={{ display: 'flex', gap: 'var(--spacing-md)', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 'var(--spacing-md)', fontSize: '0.8125rem' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-primary)' }}>{stats.total}</div>
<div style={{ color: 'var(--color-text-muted)' }}>{t('stats.available')}</div>
</div>
<div style={{ textAlign: 'center' }}>
<a onClick={() => navigate('/app/manage')} style={{ cursor: 'pointer' }}>
<div style={{ fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-success)' }}>{stats.installed}</div>
<div style={{ color: 'var(--color-text-muted)' }}>{t('stats.installed')}</div>
</a>
</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/app/model-editor', { state: fromState(location, t('models')) })}>
<i className="fas fa-plus" /> {t('actions.addModel')}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/import-model')}>
<i className="fas fa-upload" /> {t('actions.importModel')}
</button>
</div>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/app/model-editor', { state: fromState(location, t('models')) })}>
<i className="fas fa-plus" /> {t('actions.addModel')}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/import-model')}>
<i className="fas fa-upload" /> {t('actions.importModel')}
</button>
</div>
</div>
}
/>
{/* Search */}
<div className="search-bar" style={{ marginBottom: 'var(--spacing-md)' }}>

View File

@@ -4,6 +4,7 @@ import { nodesApi } from '../utils/api'
import { formatTimestamp } from '../utils/format'
import { apiUrl } from '../utils/basePath'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
function wsUrl(path) {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -176,9 +177,9 @@ export default function NodeBackendLogs() {
return (
<div className="page page--wide">
<div className="page-header">
<div>
<h1 className="page-title" style={{ marginBottom: 0 }}>
<PageHeader
title={
<>
<i className="fas fa-terminal" style={{ fontSize: '0.8em', marginRight: 'var(--spacing-sm)' }} />
{baseModelName}
{!isMerged && (
@@ -217,13 +218,15 @@ export default function NodeBackendLogs() {
merged · {replicas.length} replicas
</span>
)}
</h1>
<p className="page-subtitle" style={{ marginTop: 'var(--spacing-xs)' }}>
</>
}
supporting={
<>
Backend logs from node <strong>{nodeName || nodeId}</strong>
{' '}<Link to="/app/nodes" style={{ color: 'var(--color-primary)', fontSize: '0.8125rem' }}>(back to nodes)</Link>
</p>
</div>
</div>
</>
}
/>
{showReplicaToggle && (
<div role="radiogroup" aria-label="Replica scope" className="segmented" style={{ marginBottom: 'var(--spacing-sm)' }}>

View File

@@ -3,6 +3,7 @@ import { useOutletContext, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { nodesApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
import ConfirmDialog from '../components/ConfirmDialog'
import ActionMenu from '../components/ActionMenu'
import SearchableModelSelect from '../components/SearchableModelSelect'
@@ -995,15 +996,15 @@ export default function Nodes() {
return (
<div className="page page--wide">
<div className="page-header">
<h1 className="page-title">
<i className="fas fa-network-wired" style={{ marginRight: 'var(--spacing-sm)' }} />
{t('nodes.title')}
</h1>
<p className="page-subtitle">
{t('nodes.subtitle')}
</p>
</div>
<PageHeader
title={
<>
<i className="fas fa-network-wired" style={{ marginRight: 'var(--spacing-sm)' }} />
{t('nodes.title')}
</>
}
supporting={t('nodes.subtitle')}
/>
{/* Tabs */}
<div className="tabs" style={{ marginBottom: 'var(--spacing-lg)' }}>

View File

@@ -3,6 +3,7 @@ import { useOutletContext } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { p2pApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
import ImageSelector, { useImageSelector, dockerImage, dockerFlags } from '../components/ImageSelector'
function NodeCard({ node, label, iconColor, iconBg }) {
@@ -295,20 +296,24 @@ export default function P2P() {
return (
<div className="page page--narrow">
<div className="page-header">
<h1 className="page-title">
<i className="fas fa-circle-nodes" style={{ marginRight: 'var(--spacing-sm)' }} />
{t('p2p.title')}
</h1>
<p className="page-subtitle">
{t('p2p.subtitle')}
{' '}
<a href="https://localai.io/features/distribute/" target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--color-primary)' }}>
<i className="fas fa-circle-info" />
</a>
</p>
</div>
<PageHeader
title={
<>
<i className="fas fa-circle-nodes" style={{ marginRight: 'var(--spacing-sm)' }} />
{t('p2p.title')}
</>
}
supporting={
<>
{t('p2p.subtitle')}
{' '}
<a href="https://localai.io/features/distribute/" target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--color-primary)' }}>
<i className="fas fa-circle-info" />
</a>
</>
}
/>
{/* Network Token */}
<div style={{

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate, useLocation, useOutletContext, useSearchParams } from 'react-router-dom'
import { skillsApi } from '../utils/api'
import PageHeader from '../components/PageHeader'
const RESOURCE_PREFIXES = ['scripts/', 'references/', 'assets/']
function isValidResourcePath(path) {
@@ -498,11 +499,13 @@ export default function SkillEdit() {
<a className="skilledit-back-link" onClick={() => navigate('/app/skills')}>
<i className="fas fa-arrow-left" /> Back to skills
</a>
<div className="page-header">
<h1 className="page-title">
<i className="fas fa-book" style={{ marginRight: 'var(--spacing-xs)' }} /> {isNew ? 'New skill' : `Edit: ${name}`}
</h1>
</div>
<PageHeader
title={
<>
<i className="fas fa-book" style={{ marginRight: 'var(--spacing-xs)' }} /> {isNew ? 'New skill' : `Edit: ${name}`}
</>
}
/>
<div className="card" style={{ marginTop: 'var(--spacing-md)' }}>
<div className="skilledit-layout">

View File

@@ -5,6 +5,7 @@ import { skillsApi } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { useUserMap } from '../hooks/useUserMap'
import UserGroupSection from '../components/UserGroupSection'
import PageHeader from '../components/PageHeader'
import ConfirmDialog from '../components/ConfirmDialog'
export default function Skills() {
@@ -207,10 +208,7 @@ export default function Skills() {
if (unavailable) {
return (
<div className="page page--wide">
<div className="page-header">
<h1 className="page-title">{t('title')}</h1>
<p className="page-subtitle">{t('unavailable.subtitle')}</p>
</div>
<PageHeader title={t('title')} supporting={t('unavailable.subtitle')} />
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<button className="btn btn-primary" onClick={() => { setUnavailable(false); fetchSkills() }}>
<i className="fas fa-redo" /> {t('unavailable.retry')}
@@ -312,41 +310,41 @@ export default function Skills() {
}
`}</style>
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h1 className="page-title">{t('title')}</h1>
<p className="page-subtitle">{t('subtitle')}</p>
</div>
<div className="skills-header-actions">
<input
type="text"
className="input"
placeholder={t('search.placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{ width: '200px' }}
/>
<button className="btn btn-primary" onClick={() => navigate('/app/skills/new')}>
<i className="fas fa-plus" /> {t('actions.newSkill')}
</button>
<label className="btn btn-secondary" style={{ cursor: 'pointer' }}>
<i className="fas fa-file-import" /> {importing ? t('actions.importing') : t('actions.import')}
<PageHeader
title={t('title')}
supporting={t('subtitle')}
actions={
<div className="skills-header-actions">
<input
type="file"
accept=".tar.gz"
className="skills-import-input"
onChange={handleImport}
disabled={importing}
type="text"
className="input"
placeholder={t('search.placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{ width: '200px' }}
/>
</label>
<button
className={`btn ${showGitRepos ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setShowGitRepos((v) => !v)}
>
<i className="fas fa-code-branch" /> {t('actions.gitRepos')}
</button>
</div>
</div>
<button className="btn btn-primary" onClick={() => navigate('/app/skills/new')}>
<i className="fas fa-plus" /> {t('actions.newSkill')}
</button>
<label className="btn btn-secondary" style={{ cursor: 'pointer' }}>
<i className="fas fa-file-import" /> {importing ? t('actions.importing') : t('actions.import')}
<input
type="file"
accept=".tar.gz"
className="skills-import-input"
onChange={handleImport}
disabled={importing}
/>
</label>
<button
className={`btn ${showGitRepos ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setShowGitRepos((v) => !v)}
>
<i className="fas fa-code-branch" /> {t('actions.gitRepos')}
</button>
</div>
}
/>
{showGitRepos && (
<div className="skills-git-section">

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { useParams, useOutletContext } from 'react-router-dom'
import ModelSelector from '../components/ModelSelector'
import PageHeader from '../components/PageHeader'
import { CAP_SOUND_GENERATION } from '../utils/capabilities'
import LoadingSpinner from '../components/LoadingSpinner'
import ErrorWithTraceLink from '../components/ErrorWithTraceLink'
@@ -77,9 +78,7 @@ export default function Sound() {
return (
<div className="media-layout">
<div className="media-controls">
<div className="page-header">
<h1 className="page-title"><i className="fas fa-music" /> Sound Generation</h1>
</div>
<PageHeader title={<><i className="fas fa-music" /> Sound Generation</>} />
<form onSubmit={handleGenerate}>
<div className="form-group">

View File

@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useParams, useOutletContext } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import ModelSelector from '../components/ModelSelector'
import PageHeader from '../components/PageHeader'
import { CAP_TTS } from '../utils/capabilities'
import LoadingSpinner from '../components/LoadingSpinner'
import ErrorWithTraceLink from '../components/ErrorWithTraceLink'
@@ -49,9 +50,7 @@ export default function TTS() {
return (
<div className="media-layout">
<div className="media-controls">
<div className="page-header">
<h1 className="page-title"><i className="fas fa-headphones" /> {t('tts.title')}</h1>
</div>
<PageHeader title={<><i className="fas fa-headphones" /> {t('tts.title')}</>} />
<form onSubmit={handleGenerate}>
<div className="form-group">

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { tracesApi, settingsApi } from '../utils/api'
import { formatTimestamp } from '../utils/format'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
import Toggle from '../components/Toggle'
import SettingRow from '../components/SettingRow'
import WaveformPlayer from '../components/audio/WaveformPlayer'
@@ -407,10 +408,7 @@ export default function Traces() {
return (
<div className="page page--wide">
<div className="page-header">
<h1 className="page-title">{t('traces.title')}</h1>
<p className="page-subtitle">{t('traces.subtitle')}</p>
</div>
<PageHeader title={t('traces.title')} supporting={t('traces.subtitle')} />
<div className="tabs">
<button className={`tab ${activeTab === 'api' ? 'tab-active' : ''}`} onClick={() => setActiveTab('api')}>

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { useAuth } from '../context/AuthContext'
import { apiUrl } from '../utils/basePath'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
import SourcesTab from './Usage/SourcesTab'
const PERIODS = [
@@ -707,10 +708,7 @@ export default function Usage() {
return (
<div className="page page--wide">
<div className="page-header" style={{ marginBottom: 'var(--spacing-sm)' }}>
<h1 className="page-title">{t('usage.title')}</h1>
<p className="page-subtitle">{t('usage.subtitle')}</p>
</div>
<PageHeader title={t('usage.title')} supporting={t('usage.subtitle')} />
{/* Period selector + tabs */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)', marginBottom: 'var(--spacing-md)', flexWrap: 'wrap' }}>

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { useAuth } from '../context/AuthContext'
import { adminUsersApi, adminInvitesApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
import Modal from '../components/Modal'
import ConfirmDialog from '../components/ConfirmDialog'
import Toggle from '../components/Toggle'
@@ -804,10 +805,7 @@ export default function Users() {
return (
<div className="page page--wide">
<div className="page-header">
<h1 className="page-title">{t('users.title')}</h1>
<p className="page-subtitle">{t('users.subtitle')}</p>
</div>
<PageHeader title={t('users.title')} supporting={t('users.subtitle')} />
{/* Tab bar */}
<div className="auth-tab-bar">

View File

@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useParams, useOutletContext } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import ModelSelector from '../components/ModelSelector'
import PageHeader from '../components/PageHeader'
import { CAP_VIDEO } from '../utils/capabilities'
import LoadingSpinner from '../components/LoadingSpinner'
import ErrorWithTraceLink from '../components/ErrorWithTraceLink'
@@ -81,9 +82,7 @@ export default function VideoGen() {
return (
<div className="media-layout">
<div className="media-controls">
<div className="page-header">
<h1 className="page-title"><i className="fas fa-video" /> {t('video.title')}</h1>
</div>
<PageHeader title={<><i className="fas fa-video" /> {t('video.title')}</>} />
<form onSubmit={handleGenerate}>
<div className="form-group">