project-nomad/admin/inertia/pages/settings/apps.tsx
Chris Sherwood b1edef27e8 feat(UI): add Night Ops dark mode with theme toggle
Add a warm charcoal dark mode ("Night Ops") using CSS variable swapping
under [data-theme="dark"]. All 23 desert palette variables are overridden
with dark-mode counterparts, and ~313 generic Tailwind classes (bg-white,
text-gray-*, border-gray-*) are replaced with semantic tokens.

Infrastructure:
- CSS variable overrides in app.css for both themes
- ThemeProvider + useTheme hook (localStorage + KV store sync)
- ThemeToggle component (moon/sun icons, "Night Ops"/"Day Ops" labels)
- FOUC prevention script in inertia_layout.edge
- Toggle placed in StyledSidebar and Footer for access on every page

Color replacements across 50 files:
- bg-white → bg-surface-primary
- bg-gray-50/100 → bg-surface-secondary
- text-gray-900/800 → text-text-primary
- text-gray-600/500 → text-text-secondary/text-text-muted
- border-gray-200/300 → border-border-subtle/border-border-default
- text-desert-white → text-white (fixes invisible text on colored bg)
- Button hover/active states use dedicated btn-green-hover/active vars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 11:46:10 -07:00

431 lines
15 KiB
TypeScript

import { Head } from '@inertiajs/react'
import StyledTable from '~/components/StyledTable'
import SettingsLayout from '~/layouts/SettingsLayout'
import { ServiceSlim } from '../../../types/services'
import { getServiceLink } from '~/lib/navigation'
import StyledButton from '~/components/StyledButton'
import { useModals } from '~/context/ModalContext'
import StyledModal from '~/components/StyledModal'
import api from '~/lib/api'
import { useEffect, useState } from 'react'
import InstallActivityFeed from '~/components/InstallActivityFeed'
import LoadingSpinner from '~/components/LoadingSpinner'
import useErrorNotification from '~/hooks/useErrorNotification'
import useInternetStatus from '~/hooks/useInternetStatus'
import useServiceInstallationActivity from '~/hooks/useServiceInstallationActivity'
import { useTransmit } from 'react-adonis-transmit'
import { BROADCAST_CHANNELS } from '../../../constants/broadcast'
import { IconArrowUp, IconCheck, IconDownload } from '@tabler/icons-react'
import UpdateServiceModal from '~/components/UpdateServiceModal'
function extractTag(containerImage: string): string {
if (!containerImage) return ''
const parts = containerImage.split(':')
return parts.length > 1 ? parts[parts.length - 1] : 'latest'
}
export default function SettingsPage(props: { system: { services: ServiceSlim[] } }) {
const { openModal, closeAllModals } = useModals()
const { showError } = useErrorNotification()
const { isOnline } = useInternetStatus()
const { subscribe } = useTransmit()
const installActivity = useServiceInstallationActivity()
const [isInstalling, setIsInstalling] = useState(false)
const [loading, setLoading] = useState(false)
const [checkingUpdates, setCheckingUpdates] = useState(false)
useEffect(() => {
if (installActivity.length === 0) return
if (
installActivity.some(
(activity) => activity.type === 'completed' || activity.type === 'update-complete'
)
) {
setTimeout(() => {
window.location.reload()
}, 3000)
}
}, [installActivity])
// Listen for service update check completion
useEffect(() => {
const unsubscribe = subscribe(BROADCAST_CHANNELS.SERVICE_UPDATES, () => {
setCheckingUpdates(false)
window.location.reload()
})
return () => { unsubscribe() }
}, [])
async function handleCheckUpdates() {
try {
if (!isOnline) {
showError('You must have an internet connection to check for updates.')
return
}
setCheckingUpdates(true)
const response = await api.checkServiceUpdates()
if (!response?.success) {
throw new Error('Failed to dispatch update check')
}
} catch (error) {
console.error('Error checking for updates:', error)
showError(`Failed to check for updates: ${error.message || 'Unknown error'}`)
setCheckingUpdates(false)
}
}
const handleInstallService = (service: ServiceSlim) => {
openModal(
<StyledModal
title="Install Service?"
onConfirm={() => {
installService(service.service_name)
closeAllModals()
}}
onCancel={closeAllModals}
open={true}
confirmText="Install"
cancelText="Cancel"
confirmVariant="primary"
icon={<IconDownload className="h-12 w-12 text-desert-green" />}
>
<p className="text-text-primary">
Are you sure you want to install {service.friendly_name || service.service_name}? This
will start the service and make it available in your Project N.O.M.A.D. instance. It may
take some time to complete.
</p>
</StyledModal>,
'install-service-modal'
)
}
async function installService(serviceName: string) {
try {
if (!isOnline) {
showError('You must have an internet connection to install services.')
return
}
setIsInstalling(true)
const response = await api.installService(serviceName)
if (!response) {
throw new Error('An internal error occurred while trying to install the service.')
}
if (!response.success) {
throw new Error(response.message)
}
} catch (error) {
console.error('Error installing service:', error)
showError(`Failed to install service: ${error.message || 'Unknown error'}`)
} finally {
setIsInstalling(false)
}
}
async function handleAffectAction(record: ServiceSlim, action: 'start' | 'stop' | 'restart') {
try {
setLoading(true)
const response = await api.affectService(record.service_name, action)
if (!response) {
throw new Error('An internal error occurred while trying to affect the service.')
}
if (!response.success) {
throw new Error(response.message)
}
closeAllModals()
setTimeout(() => {
setLoading(false)
window.location.reload()
}, 3000)
} catch (error) {
console.error(`Error affecting service ${record.service_name}:`, error)
showError(`Failed to ${action} service: ${error.message || 'Unknown error'}`)
}
}
async function handleForceReinstall(record: ServiceSlim) {
try {
setLoading(true)
const response = await api.forceReinstallService(record.service_name)
if (!response) {
throw new Error('An internal error occurred while trying to force reinstall the service.')
}
if (!response.success) {
throw new Error(response.message)
}
closeAllModals()
setTimeout(() => {
setLoading(false)
window.location.reload()
}, 3000)
} catch (error) {
console.error(`Error force reinstalling service ${record.service_name}:`, error)
showError(`Failed to force reinstall service: ${error.message || 'Unknown error'}`)
}
}
function handleUpdateService(record: ServiceSlim) {
const currentTag = extractTag(record.container_image)
const latestVersion = record.available_update_version!
openModal(
<UpdateServiceModal
record={record}
currentTag={currentTag}
latestVersion={latestVersion}
onCancel={closeAllModals}
onUpdate={async (targetVersion: string) => {
closeAllModals()
try {
setLoading(true)
const response = await api.updateService(record.service_name, targetVersion)
if (!response?.success) {
throw new Error(response?.message || 'Update failed')
}
} catch (error) {
console.error(`Error updating service ${record.service_name}:`, error)
showError(`Failed to update service: ${error.message || 'Unknown error'}`)
setLoading(false)
}
}}
showError={showError}
/>,
`${record.service_name}-update-modal`
)
}
const AppActions = ({ record }: { record: ServiceSlim }) => {
const ForceReinstallButton = () => (
<StyledButton
icon="IconDownload"
variant="action"
onClick={() => {
openModal(
<StyledModal
title={'Force Reinstall?'}
onConfirm={() => handleForceReinstall(record)}
onCancel={closeAllModals}
open={true}
confirmText={'Force Reinstall'}
cancelText="Cancel"
>
<p className="text-text-primary">
Are you sure you want to force reinstall {record.service_name}? This will{' '}
<strong>WIPE ALL DATA</strong> for this service and cannot be undone. You should
only do this if the service is malfunctioning and other troubleshooting steps have
failed.
</p>
</StyledModal>,
`${record.service_name}-force-reinstall-modal`
)
}}
disabled={isInstalling}
>
Force Reinstall
</StyledButton>
)
if (!record) return null
if (!record.installed) {
return (
<div className="flex flex-wrap gap-2">
<StyledButton
icon={'IconDownload'}
variant="primary"
onClick={() => handleInstallService(record)}
disabled={isInstalling || !isOnline}
loading={isInstalling}
>
Install
</StyledButton>
<ForceReinstallButton />
</div>
)
}
return (
<div className="flex flex-wrap gap-2">
<StyledButton
icon={'IconExternalLink'}
onClick={() => {
window.open(getServiceLink(record.ui_location || 'unknown'), '_blank')
}}
>
Open
</StyledButton>
{record.available_update_version && (
<StyledButton
icon="IconArrowUp"
variant="primary"
onClick={() => handleUpdateService(record)}
disabled={isInstalling || !isOnline}
>
Update
</StyledButton>
)}
{record.status && record.status !== 'unknown' && (
<>
<StyledButton
icon={record.status === 'running' ? 'IconPlayerStop' : 'IconPlayerPlay'}
variant={record.status === 'running' ? 'action' : undefined}
onClick={() => {
openModal(
<StyledModal
title={`${record.status === 'running' ? 'Stop' : 'Start'} Service?`}
onConfirm={() =>
handleAffectAction(record, record.status === 'running' ? 'stop' : 'start')
}
onCancel={closeAllModals}
open={true}
confirmText={record.status === 'running' ? 'Stop' : 'Start'}
cancelText="Cancel"
>
<p className="text-text-primary">
Are you sure you want to {record.status === 'running' ? 'stop' : 'start'}{' '}
{record.service_name}?
</p>
</StyledModal>,
`${record.service_name}-affect-modal`
)
}}
disabled={isInstalling}
>
{record.status === 'running' ? 'Stop' : 'Start'}
</StyledButton>
{record.status === 'running' && (
<StyledButton
icon="IconRefresh"
variant="action"
onClick={() => {
openModal(
<StyledModal
title={'Restart Service?'}
onConfirm={() => handleAffectAction(record, 'restart')}
onCancel={closeAllModals}
open={true}
confirmText={'Restart'}
cancelText="Cancel"
>
<p className="text-text-primary">
Are you sure you want to restart {record.service_name}?
</p>
</StyledModal>,
`${record.service_name}-affect-modal`
)
}}
disabled={isInstalling}
>
Restart
</StyledButton>
)}
<ForceReinstallButton />
</>
)}
</div>
)
}
return (
<SettingsLayout>
<Head title="App Settings" />
<div className="xl:pl-72 w-full">
<main className="px-12 py-6">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-4xl font-semibold">Apps</h1>
<p className="text-text-muted mt-1">
Manage the applications that are available in your Project N.O.M.A.D. instance. Nightly update checks will automatically detect when new versions of these apps are available.
</p>
</div>
<StyledButton
icon="IconRefreshAlert"
onClick={handleCheckUpdates}
disabled={checkingUpdates || !isOnline}
loading={checkingUpdates}
>
Check for Updates
</StyledButton>
</div>
{loading && <LoadingSpinner fullscreen />}
{!loading && (
<StyledTable<ServiceSlim & { actions?: any }>
className="font-semibold !overflow-x-auto"
rowLines={true}
columns={[
{
accessor: 'friendly_name',
title: 'Name',
render(record) {
return (
<div className="flex flex-col">
<p>{record.friendly_name || record.service_name}</p>
<p className="text-sm text-text-muted">{record.description}</p>
</div>
)
},
},
{
accessor: 'ui_location',
title: 'Location',
render: (record) => (
<a
href={getServiceLink(record.ui_location || 'unknown')}
target="_blank"
rel="noopener noreferrer"
className="text-desert-green hover:underline font-semibold"
>
{record.ui_location}
</a>
),
},
{
accessor: 'installed',
title: 'Installed',
render: (record) =>
record.installed ? <IconCheck className="h-6 w-6 text-desert-green" /> : '',
},
{
accessor: 'container_image',
title: 'Version',
render: (record) => {
if (!record.installed) return null
const currentTag = extractTag(record.container_image)
if (record.available_update_version) {
return (
<div className="flex items-center gap-1.5">
<span className="text-text-muted">{currentTag}</span>
<IconArrowUp className="h-4 w-4 text-desert-green" />
<span className="text-desert-green font-semibold">
{record.available_update_version}
</span>
</div>
)
}
return <span className="text-text-secondary">{currentTag}</span>
},
},
{
accessor: 'actions',
title: 'Actions',
className: '!whitespace-normal',
render: (record) => <AppActions record={record} />,
},
]}
data={props.system.services}
/>
)}
{installActivity.length > 0 && (
<InstallActivityFeed activity={installActivity} className="mt-8" withHeader />
)}
</main>
</div>
</SettingsLayout>
)
}