feat: api health check and version display

This commit is contained in:
2026-03-22 12:34:33 +01:00
parent 9d22027f23
commit 4cc005d309
12 changed files with 236 additions and 342 deletions

View File

@@ -5,9 +5,17 @@ import { ServerCreationPopupContainer } from '@/components/server/ServerCreation
import { requireAuth } from '@/lib/auth/server'; import { requireAuth } from '@/lib/auth/server';
import { AppShell } from '@/components/ui/AppShell'; import { AppShell } from '@/components/ui/AppShell';
import { hasPermission } from '@/lib/schemas'; import { hasPermission } from '@/lib/schemas';
import { checkHealth } from '@/lib/api/server/health';
import { redirect } from 'next/navigation';
export default async function DashboardLayout({ children }: Readonly<{ children: React.ReactNode }>) { export default async function DashboardLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const session = await requireAuth(); const session = await requireAuth();
const health = await checkHealth();
if (!health.healthy) {
redirect('/login');
}
const showUsers = hasPermission(session.user!, 'membership.view'); const showUsers = hasPermission(session.user!, 'membership.view');
return ( return (
<WebSocketProvider openToken={session.openToken!}> <WebSocketProvider openToken={session.openToken!}>

View File

@@ -1,15 +1,18 @@
import { Suspense } from 'react'; import { Suspense } from 'react';
import LoginForm from '@/components/login/LoginForm'; import LoginForm from '@/components/login/LoginForm';
import { checkHealth } from '@/lib/api/server/health';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export default function LoginPage({ export default async function LoginPage({
searchParams searchParams
}: { }: {
searchParams: Promise<{ expired: boolean | undefined }>; searchParams: Promise<{ expired: boolean | undefined }>;
}) { }) {
const health = await checkHealth();
return ( return (
<Suspense fallback={<div>Loading...</div>}> <Suspense fallback={<div>Loading...</div>}>
<LoginForm searchParams={searchParams} /> <LoginForm searchParams={searchParams} health={health} />
</Suspense> </Suspense>
); );
} }

View File

@@ -1,15 +1,11 @@
'use client'; 'use client';
import { useState } from 'react';
import type { AssistRules } from '@/lib/schemas/config'; import type { AssistRules } from '@/lib/schemas/config';
import { updateAssistRulesAction } from '@/lib/actions/configuration';
interface AssistRulesEditorProps { interface AssistRulesEditorProps {
serverId: string;
formData: AssistRules; formData: AssistRules;
restart: boolean; disabled?: boolean;
onFormDataChange: (data: AssistRules) => void; onFormDataChange: (data: AssistRules) => void;
onRestartChange: (restart: boolean) => void;
} }
const assistFields = [ const assistFields = [
@@ -60,31 +56,7 @@ const assistFields = [
} }
]; ];
export function AssistRulesEditor({ serverId, formData, restart, onFormDataChange, onRestartChange }: AssistRulesEditorProps) { export function AssistRulesEditor({ formData, disabled, onFormDataChange }: AssistRulesEditorProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formDataObj = new FormData();
Object.entries(formData).forEach(([key, value]) => {
formDataObj.append(key, value.toString());
});
if (restart) {
formDataObj.append('restart', 'on');
}
try {
const result = await updateAssistRulesAction(serverId, formDataObj);
if (!result.success) {
console.error('Failed to update assist rules:', result.message);
}
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (key: keyof AssistRules, value: string | number) => { const handleInputChange = (key: keyof AssistRules, value: string | number) => {
onFormDataChange({ onFormDataChange({
...formData, ...formData,
@@ -93,7 +65,7 @@ export function AssistRulesEditor({ serverId, formData, restart, onFormDataChang
}; };
return ( return (
<form onSubmit={handleSubmit} className="max-w-3xl space-y-6"> <div className="max-w-3xl space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{assistFields.map(({ key, label, type }) => ( {assistFields.map(({ key, label, type }) => (
<div key={key}> <div key={key}>
@@ -101,7 +73,7 @@ export function AssistRulesEditor({ serverId, formData, restart, onFormDataChang
{type === 'number' ? ( {type === 'number' ? (
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData[key]} value={formData[key]}
onChange={(e) => handleInputChange(key, e.target.value)} onChange={(e) => handleInputChange(key, e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -110,7 +82,7 @@ export function AssistRulesEditor({ serverId, formData, restart, onFormDataChang
/> />
) : ( ) : (
<select <select
disabled={isSubmitting} disabled={disabled}
value={formData[key]} value={formData[key]}
onChange={(e) => handleInputChange(key, e.target.value)} onChange={(e) => handleInputChange(key, e.target.value)}
className="form-select w-full" className="form-select w-full"
@@ -122,28 +94,6 @@ export function AssistRulesEditor({ serverId, formData, restart, onFormDataChang
</div> </div>
))} ))}
</div> </div>
</div>
<div className="border-t border-border pt-6">
<label className="flex items-center gap-2 text-sm text-muted">
<input
type="checkbox"
checked={restart}
onChange={(e) => onRestartChange(e.target.checked)}
className="h-4 w-4 rounded border-border bg-overlay accent-green focus:ring-1 focus:ring-blue"
/>
Restart server after saving
</label>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-btn-green border border-btn-green px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-btn-green-hover disabled:cursor-not-allowed disabled:opacity-40"
>
{isSubmitting ? 'Saving…' : 'Save Changes'}
</button>
</div>
</form>
); );
} }

View File

@@ -1,42 +1,14 @@
'use client'; 'use client';
import { useState } from 'react';
import type { Configuration } from '@/lib/schemas/config'; import type { Configuration } from '@/lib/schemas/config';
import { updateConfigurationAction } from '@/lib/actions/configuration';
interface ConfigurationEditorProps { interface ConfigurationEditorProps {
serverId: string;
formData: Configuration; formData: Configuration;
restart: boolean; disabled?: boolean;
onFormDataChange: (data: Configuration) => void; onFormDataChange: (data: Configuration) => void;
onRestartChange: (restart: boolean) => void;
} }
export function ConfigurationEditor({ serverId, formData, restart, onFormDataChange, onRestartChange }: ConfigurationEditorProps) { export function ConfigurationEditor({ formData, disabled, onFormDataChange }: ConfigurationEditorProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formDataObj = new FormData();
Object.entries(formData).forEach(([key, value]) => {
formDataObj.append(key, value.toString());
});
if (restart) {
formDataObj.append('restart', 'on');
}
try {
const result = await updateConfigurationAction(serverId, formDataObj);
if (!result.success) {
console.error('Failed to update configuration:', result.message);
}
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (key: keyof Configuration, value: string | number) => { const handleInputChange = (key: keyof Configuration, value: string | number) => {
onFormDataChange({ onFormDataChange({
...formData, ...formData,
@@ -45,13 +17,13 @@ export function ConfigurationEditor({ serverId, formData, restart, onFormDataCha
}; };
return ( return (
<form onSubmit={handleSubmit} className="max-w-3xl space-y-6"> <div className="max-w-3xl space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary">UDP Port</label> <label className="mb-1.5 block text-sm font-medium text-secondary">UDP Port</label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData.udpPort} value={formData.udpPort}
onChange={(e) => handleInputChange('udpPort', e.target.value)} onChange={(e) => handleInputChange('udpPort', e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -62,7 +34,7 @@ export function ConfigurationEditor({ serverId, formData, restart, onFormDataCha
<label className="mb-1.5 block text-sm font-medium text-secondary">TCP Port</label> <label className="mb-1.5 block text-sm font-medium text-secondary">TCP Port</label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData.tcpPort} value={formData.tcpPort}
onChange={(e) => handleInputChange('tcpPort', e.target.value)} onChange={(e) => handleInputChange('tcpPort', e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -73,7 +45,7 @@ export function ConfigurationEditor({ serverId, formData, restart, onFormDataCha
<label className="mb-1.5 block text-sm font-medium text-secondary">Max Connections</label> <label className="mb-1.5 block text-sm font-medium text-secondary">Max Connections</label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData.maxConnections} value={formData.maxConnections}
onChange={(e) => handleInputChange('maxConnections', e.target.value)} onChange={(e) => handleInputChange('maxConnections', e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -83,7 +55,7 @@ export function ConfigurationEditor({ serverId, formData, restart, onFormDataCha
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary">LAN Discovery</label> <label className="mb-1.5 block text-sm font-medium text-secondary">LAN Discovery</label>
<select <select
disabled={isSubmitting} disabled={disabled}
value={formData.lanDiscovery} value={formData.lanDiscovery}
onChange={(e) => handleInputChange('lanDiscovery', e.target.value)} onChange={(e) => handleInputChange('lanDiscovery', e.target.value)}
className="form-select w-full" className="form-select w-full"
@@ -96,7 +68,7 @@ export function ConfigurationEditor({ serverId, formData, restart, onFormDataCha
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary">Register To Lobby</label> <label className="mb-1.5 block text-sm font-medium text-secondary">Register To Lobby</label>
<select <select
disabled={isSubmitting} disabled={disabled}
value={formData.registerToLobby} value={formData.registerToLobby}
onChange={(e) => handleInputChange('registerToLobby', e.target.value)} onChange={(e) => handleInputChange('registerToLobby', e.target.value)}
className="form-select w-full" className="form-select w-full"
@@ -106,28 +78,6 @@ export function ConfigurationEditor({ serverId, formData, restart, onFormDataCha
</select> </select>
</div> </div>
</div> </div>
</div>
<div className="border-t border-border pt-6">
<label className="flex items-center gap-2 text-sm text-muted">
<input
type="checkbox"
checked={restart}
onChange={(e) => onRestartChange(e.target.checked)}
className="h-4 w-4 rounded border-border bg-overlay accent-green focus:ring-1 focus:ring-blue"
/>
Restart server after saving
</label>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-btn-green border border-btn-green px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-btn-green-hover disabled:cursor-not-allowed disabled:opacity-40"
>
{isSubmitting ? 'Saving…' : 'Save Changes'}
</button>
</div>
</form>
); );
} }

View File

@@ -1,15 +1,11 @@
'use client'; 'use client';
import { useState } from 'react';
import type { EventConfig, Session } from '@/lib/schemas/config'; import type { EventConfig, Session } from '@/lib/schemas/config';
import { updateEventConfigAction } from '@/lib/actions/configuration';
interface EventConfigEditorProps { interface EventConfigEditorProps {
serverId: string;
formData: EventConfig; formData: EventConfig;
restart: boolean; disabled?: boolean;
onFormDataChange: (data: EventConfig) => void; onFormDataChange: (data: EventConfig) => void;
onRestartChange: (restart: boolean) => void;
} }
const sessionTypes = [ const sessionTypes = [
@@ -18,35 +14,7 @@ const sessionTypes = [
{ value: 'R', label: 'Race' } { value: 'R', label: 'Race' }
]; ];
export function EventConfigEditor({ serverId, formData, restart, onFormDataChange, onRestartChange }: EventConfigEditorProps) { export function EventConfigEditor({ formData, disabled, onFormDataChange }: EventConfigEditorProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formDataObj = new FormData();
Object.entries(formData).forEach(([key, value]) => {
if (key === 'sessions') {
formDataObj.append(key, JSON.stringify(value));
} else {
formDataObj.append(key, value.toString());
}
});
if (restart) {
formDataObj.append('restart', 'on');
}
try {
const result = await updateEventConfigAction(serverId, formDataObj);
if (!result.success) {
console.error('Failed to update event config:', result.message);
}
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (key: keyof EventConfig, value: string | number) => { const handleInputChange = (key: keyof EventConfig, value: string | number) => {
if (key === 'sessions') return; if (key === 'sessions') return;
@@ -87,7 +55,7 @@ export function EventConfigEditor({ serverId, formData, restart, onFormDataChang
}; };
return ( return (
<form onSubmit={handleSubmit} className="max-w-4xl space-y-8"> <div className="max-w-4xl space-y-8">
<div className="space-y-6"> <div className="space-y-6">
<h3 className="border-b border-border-muted pb-2 text-sm font-semibold text-primary"> <h3 className="border-b border-border-muted pb-2 text-sm font-semibold text-primary">
Basic Event Settings Basic Event Settings
@@ -96,7 +64,7 @@ export function EventConfigEditor({ serverId, formData, restart, onFormDataChang
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary">Track</label> <label className="mb-1.5 block text-sm font-medium text-secondary">Track</label>
<select <select
disabled={isSubmitting} disabled={disabled}
value={formData.track} value={formData.track}
onChange={(e) => handleInputChange('track', e.target.value)} onChange={(e) => handleInputChange('track', e.target.value)}
className="form-select w-full" className="form-select w-full"
@@ -135,7 +103,7 @@ export function EventConfigEditor({ serverId, formData, restart, onFormDataChang
</label> </label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData.preRaceWaitingTimeSeconds} value={formData.preRaceWaitingTimeSeconds}
onChange={(e) => handleInputChange('preRaceWaitingTimeSeconds', e.target.value)} onChange={(e) => handleInputChange('preRaceWaitingTimeSeconds', e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -149,7 +117,7 @@ export function EventConfigEditor({ serverId, formData, restart, onFormDataChang
</label> </label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData.sessionOverTimeSeconds} value={formData.sessionOverTimeSeconds}
onChange={(e) => handleInputChange('sessionOverTimeSeconds', e.target.value)} onChange={(e) => handleInputChange('sessionOverTimeSeconds', e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -163,7 +131,7 @@ export function EventConfigEditor({ serverId, formData, restart, onFormDataChang
</label> </label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData.postQualySeconds} value={formData.postQualySeconds}
onChange={(e) => handleInputChange('postQualySeconds', e.target.value)} onChange={(e) => handleInputChange('postQualySeconds', e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -177,7 +145,7 @@ export function EventConfigEditor({ serverId, formData, restart, onFormDataChang
</label> </label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData.postRaceSeconds} value={formData.postRaceSeconds}
onChange={(e) => handleInputChange('postRaceSeconds', e.target.value)} onChange={(e) => handleInputChange('postRaceSeconds', e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -198,7 +166,7 @@ export function EventConfigEditor({ serverId, formData, restart, onFormDataChang
</label> </label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData.ambientTemp} value={formData.ambientTemp}
onChange={(e) => handleInputChange('ambientTemp', e.target.value)} onChange={(e) => handleInputChange('ambientTemp', e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -213,7 +181,7 @@ export function EventConfigEditor({ serverId, formData, restart, onFormDataChang
</label> </label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData.cloudLevel} value={formData.cloudLevel}
onChange={(e) => handleInputChange('cloudLevel', e.target.value)} onChange={(e) => handleInputChange('cloudLevel', e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -227,7 +195,7 @@ export function EventConfigEditor({ serverId, formData, restart, onFormDataChang
<label className="mb-1.5 block text-sm font-medium text-secondary">Rain (0.0-1.0)</label> <label className="mb-1.5 block text-sm font-medium text-secondary">Rain (0.0-1.0)</label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData.rain} value={formData.rain}
onChange={(e) => handleInputChange('rain', e.target.value)} onChange={(e) => handleInputChange('rain', e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -243,7 +211,7 @@ export function EventConfigEditor({ serverId, formData, restart, onFormDataChang
</label> </label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData.weatherRandomness} value={formData.weatherRandomness}
onChange={(e) => handleInputChange('weatherRandomness', e.target.value)} onChange={(e) => handleInputChange('weatherRandomness', e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -257,7 +225,7 @@ export function EventConfigEditor({ serverId, formData, restart, onFormDataChang
Simracer Weather Conditions Simracer Weather Conditions
</label> </label>
<select <select
disabled={isSubmitting} disabled={disabled}
value={formData.simracerWeatherConditions} value={formData.simracerWeatherConditions}
onChange={(e) => handleInputChange('simracerWeatherConditions', e.target.value)} onChange={(e) => handleInputChange('simracerWeatherConditions', e.target.value)}
className="form-select w-full" className="form-select w-full"
@@ -272,7 +240,7 @@ export function EventConfigEditor({ serverId, formData, restart, onFormDataChang
Fixed Condition Qualification Fixed Condition Qualification
</label> </label>
<select <select
disabled={isSubmitting} disabled={disabled}
value={formData.isFixedConditionQualification} value={formData.isFixedConditionQualification}
onChange={(e) => handleInputChange('isFixedConditionQualification', e.target.value)} onChange={(e) => handleInputChange('isFixedConditionQualification', e.target.value)}
className="form-select w-full" className="form-select w-full"
@@ -290,7 +258,7 @@ export function EventConfigEditor({ serverId, formData, restart, onFormDataChang
<button <button
type="button" type="button"
onClick={addSession} onClick={addSession}
disabled={isSubmitting} disabled={disabled}
className="rounded-md border border-border bg-overlay px-3 py-1.5 text-xs font-medium text-secondary hover:bg-border disabled:opacity-40" className="rounded-md border border-border bg-overlay px-3 py-1.5 text-xs font-medium text-secondary hover:bg-border disabled:opacity-40"
> >
+ Add Session + Add Session
@@ -305,7 +273,7 @@ export function EventConfigEditor({ serverId, formData, restart, onFormDataChang
<button <button
type="button" type="button"
onClick={() => removeSession(index)} onClick={() => removeSession(index)}
disabled={isSubmitting} disabled={disabled}
className="rounded px-2 py-1 text-xs text-red hover:bg-red-bg disabled:opacity-40" className="rounded px-2 py-1 text-xs text-red hover:bg-red-bg disabled:opacity-40"
> >
Remove Remove
@@ -318,7 +286,7 @@ export function EventConfigEditor({ serverId, formData, restart, onFormDataChang
Session Type Session Type
</label> </label>
<select <select
disabled={isSubmitting} disabled={disabled}
value={session.sessionType} value={session.sessionType}
onChange={(e) => handleSessionChange(index, 'sessionType', e.target.value)} onChange={(e) => handleSessionChange(index, 'sessionType', e.target.value)}
className="form-select w-full text-sm" className="form-select w-full text-sm"
@@ -337,7 +305,7 @@ export function EventConfigEditor({ serverId, formData, restart, onFormDataChang
</label> </label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={session.hourOfDay} value={session.hourOfDay}
onChange={(e) => handleSessionChange(index, 'hourOfDay', e.target.value)} onChange={(e) => handleSessionChange(index, 'hourOfDay', e.target.value)}
className="form-input w-full text-sm" className="form-input w-full text-sm"
@@ -352,7 +320,7 @@ export function EventConfigEditor({ serverId, formData, restart, onFormDataChang
</label> </label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={session.dayOfWeekend} value={session.dayOfWeekend}
onChange={(e) => handleSessionChange(index, 'dayOfWeekend', e.target.value)} onChange={(e) => handleSessionChange(index, 'dayOfWeekend', e.target.value)}
className="form-input w-full text-sm" className="form-input w-full text-sm"
@@ -367,7 +335,7 @@ export function EventConfigEditor({ serverId, formData, restart, onFormDataChang
</label> </label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={session.timeMultiplier} value={session.timeMultiplier}
onChange={(e) => handleSessionChange(index, 'timeMultiplier', e.target.value)} onChange={(e) => handleSessionChange(index, 'timeMultiplier', e.target.value)}
className="form-input w-full text-sm" className="form-input w-full text-sm"
@@ -382,7 +350,7 @@ export function EventConfigEditor({ serverId, formData, restart, onFormDataChang
</label> </label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={session.sessionDurationMinutes} value={session.sessionDurationMinutes}
onChange={(e) => onChange={(e) =>
handleSessionChange(index, 'sessionDurationMinutes', e.target.value) handleSessionChange(index, 'sessionDurationMinutes', e.target.value)
@@ -396,28 +364,6 @@ export function EventConfigEditor({ serverId, formData, restart, onFormDataChang
))} ))}
</div> </div>
</div> </div>
</div>
<div className="border-t border-border pt-6">
<label className="flex items-center gap-2 text-sm text-muted">
<input
type="checkbox"
checked={restart}
onChange={(e) => onRestartChange(e.target.checked)}
className="h-4 w-4 rounded border-border bg-overlay accent-green focus:ring-1 focus:ring-blue"
/>
Restart server after saving
</label>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-btn-green border border-btn-green px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-btn-green-hover disabled:cursor-not-allowed disabled:opacity-40"
>
{isSubmitting ? 'Saving…' : 'Save Changes'}
</button>
</div>
</form>
); );
} }

View File

@@ -1,15 +1,11 @@
'use client'; 'use client';
import { useState } from 'react';
import type { EventRules } from '@/lib/schemas/config'; import type { EventRules } from '@/lib/schemas/config';
import { updateEventRulesAction } from '@/lib/actions/configuration';
interface EventRulesEditorProps { interface EventRulesEditorProps {
serverId: string;
formData: EventRules; formData: EventRules;
restart: boolean; disabled?: boolean;
onFormDataChange: (data: EventRules) => void; onFormDataChange: (data: EventRules) => void;
onRestartChange: (restart: boolean) => void;
} }
const numberFields = [ const numberFields = [
@@ -71,37 +67,13 @@ const booleanFields = [
} }
]; ];
export function EventRulesEditor({ serverId, formData, restart, onFormDataChange, onRestartChange }: EventRulesEditorProps) { export function EventRulesEditor({ formData, disabled, onFormDataChange }: EventRulesEditorProps) {
const [isSubmitting, setIsSubmitting] = useState(false); const handleInputChange = (key: keyof EventRules, value: number) => {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formDataObj = new FormData();
Object.entries(formData).forEach(([key, value]) => {
formDataObj.append(key, value.toString());
});
if (restart) {
formDataObj.append('restart', 'on');
}
try {
const result = await updateEventRulesAction(serverId, formDataObj);
if (!result.success) {
console.error('Failed to update event rules:', result.message);
}
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (key: keyof EventRules, value: string | number | boolean) => {
onFormDataChange({ ...formData, [key]: value }); onFormDataChange({ ...formData, [key]: value });
}; };
return ( return (
<form onSubmit={handleSubmit} className="max-w-4xl space-y-8"> <div className="max-w-4xl space-y-8">
<div className="space-y-6"> <div className="space-y-6">
<h3 className="border-b border-border-muted pb-2 text-sm font-semibold text-primary">Race Rules</h3> <h3 className="border-b border-border-muted pb-2 text-sm font-semibold text-primary">Race Rules</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
@@ -110,7 +82,7 @@ export function EventRulesEditor({ serverId, formData, restart, onFormDataChange
<label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData[key] as number} value={formData[key] as number}
onChange={(e) => handleInputChange(key, parseInt(e.target.value) || 0)} onChange={(e) => handleInputChange(key, parseInt(e.target.value) || 0)}
className="form-input w-full" className="form-input w-full"
@@ -131,9 +103,9 @@ export function EventRulesEditor({ serverId, formData, restart, onFormDataChange
<div key={key}> <div key={key}>
<label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label>
<select <select
disabled={isSubmitting} disabled={disabled}
value={formData[key] ? 'true' : 'false'} value={formData[key] ? 'true' : 'false'}
onChange={(e) => handleInputChange(key, e.target.value === 'true')} onChange={(e) => handleInputChange(key, e.target.value === 'true' ? 1 : 0)}
className="form-select w-full" className="form-select w-full"
> >
<option value="false">No</option> <option value="false">No</option>
@@ -143,28 +115,6 @@ export function EventRulesEditor({ serverId, formData, restart, onFormDataChange
))} ))}
</div> </div>
</div> </div>
</div>
<div className="border-t border-border pt-6">
<label className="flex items-center gap-2 text-sm text-muted">
<input
type="checkbox"
checked={restart}
onChange={(e) => onRestartChange(e.target.checked)}
className="h-4 w-4 rounded border-border bg-overlay accent-green focus:ring-1 focus:ring-blue"
/>
Restart server after saving
</label>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-btn-green border border-btn-green px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-btn-green-hover disabled:cursor-not-allowed disabled:opacity-40"
>
{isSubmitting ? 'Saving…' : 'Save Changes'}
</button>
</div>
</form>
); );
} }

View File

@@ -1,15 +1,11 @@
'use client'; 'use client';
import { useState } from 'react';
import type { ServerSettings } from '@/lib/schemas/config'; import type { ServerSettings } from '@/lib/schemas/config';
import { updateServerSettingsAction } from '@/lib/actions/configuration';
interface ServerSettingsEditorProps { interface ServerSettingsEditorProps {
serverId: string;
formData: ServerSettings; formData: ServerSettings;
restart: boolean; disabled?: boolean;
onFormDataChange: (data: ServerSettings) => void; onFormDataChange: (data: ServerSettings) => void;
onRestartChange: (restart: boolean) => void;
} }
const textFields = [ const textFields = [
@@ -90,37 +86,13 @@ const selectFields = [
} }
]; ];
export function ServerSettingsEditor({ serverId, formData, restart, onFormDataChange, onRestartChange }: ServerSettingsEditorProps) { export function ServerSettingsEditor({ formData, disabled, onFormDataChange }: ServerSettingsEditorProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formDataObj = new FormData();
Object.entries(formData).forEach(([key, value]) => {
formDataObj.append(key, value?.toString() ?? '');
});
if (restart) {
formDataObj.append('restart', 'on');
}
try {
const result = await updateServerSettingsAction(serverId, formDataObj);
if (!result.success) {
console.error('Failed to update server settings:', result.message);
}
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (key: keyof ServerSettings, value: string | number) => { const handleInputChange = (key: keyof ServerSettings, value: string | number) => {
onFormDataChange({ ...formData, [key]: value }); onFormDataChange({ ...formData, [key]: value });
}; };
return ( return (
<form onSubmit={handleSubmit} className="max-w-4xl space-y-8"> <div className="max-w-4xl space-y-8">
<div className="space-y-6"> <div className="space-y-6">
<h3 className="border-b border-border-muted pb-2 text-sm font-semibold text-primary"> <h3 className="border-b border-border-muted pb-2 text-sm font-semibold text-primary">
Basic Settings Basic Settings
@@ -131,7 +103,7 @@ export function ServerSettingsEditor({ serverId, formData, restart, onFormDataCh
<label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label>
<input <input
type={type} type={type}
disabled={isSubmitting} disabled={disabled}
value={formData[key] as string} value={formData[key] as string}
onChange={(e) => handleInputChange(key, e.target.value)} onChange={(e) => handleInputChange(key, e.target.value)}
className="form-input w-full" className="form-input w-full"
@@ -142,7 +114,7 @@ export function ServerSettingsEditor({ serverId, formData, restart, onFormDataCh
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-secondary">Car Group</label> <label className="mb-1.5 block text-sm font-medium text-secondary">Car Group</label>
<select <select
disabled={isSubmitting} disabled={disabled}
value={formData.carGroup} value={formData.carGroup}
onChange={(e) => handleInputChange('carGroup', e.target.value)} onChange={(e) => handleInputChange('carGroup', e.target.value)}
className="form-select w-full" className="form-select w-full"
@@ -167,7 +139,7 @@ export function ServerSettingsEditor({ serverId, formData, restart, onFormDataCh
<label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label>
<input <input
type="number" type="number"
disabled={isSubmitting} disabled={disabled}
value={formData[key] as number} value={formData[key] as number}
onChange={(e) => handleInputChange(key, parseInt(e.target.value) || 0)} onChange={(e) => handleInputChange(key, parseInt(e.target.value) || 0)}
className="form-input w-full" className="form-input w-full"
@@ -188,7 +160,7 @@ export function ServerSettingsEditor({ serverId, formData, restart, onFormDataCh
<div key={key}> <div key={key}>
<label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label> <label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label>
<select <select
disabled={isSubmitting} disabled={disabled}
value={formData[key] as number} value={formData[key] as number}
onChange={(e) => handleInputChange(key, parseInt(e.target.value))} onChange={(e) => handleInputChange(key, parseInt(e.target.value))}
className="form-select w-full" className="form-select w-full"
@@ -204,7 +176,7 @@ export function ServerSettingsEditor({ serverId, formData, restart, onFormDataCh
Formation Lap Type Formation Lap Type
</label> </label>
<select <select
disabled={isSubmitting} disabled={disabled}
value={formData.formationLapType} value={formData.formationLapType}
onChange={(e) => handleInputChange('formationLapType', parseInt(e.target.value))} onChange={(e) => handleInputChange('formationLapType', parseInt(e.target.value))}
className="form-select w-full" className="form-select w-full"
@@ -218,28 +190,6 @@ export function ServerSettingsEditor({ serverId, formData, restart, onFormDataCh
</div> </div>
</div> </div>
</div> </div>
</div>
<div className="border-t border-border pt-6">
<label className="flex items-center gap-2 text-sm text-muted">
<input
type="checkbox"
checked={restart}
onChange={(e) => onRestartChange(e.target.checked)}
className="h-4 w-4 rounded border-border bg-overlay accent-green focus:ring-1 focus:ring-blue"
/>
Restart server after saving
</label>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-btn-green border border-btn-green px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-btn-green-hover disabled:cursor-not-allowed disabled:opacity-40"
>
{isSubmitting ? 'Saving…' : 'Save Changes'}
</button>
</div>
</form>
); );
} }

View File

@@ -2,6 +2,8 @@
import { clearExpiredSessionAction, loginAction, LoginResult } from '@/lib/actions/auth'; import { clearExpiredSessionAction, loginAction, LoginResult } from '@/lib/actions/auth';
import { use, useActionState, useEffect } from 'react'; import { use, useActionState, useEffect } from 'react';
import type { HealthStatus } from '@/lib/api/server/health';
import { Badge } from '@/components/ui/Badge';
const initialState: LoginResult = { const initialState: LoginResult = {
message: '', message: '',
@@ -9,9 +11,11 @@ const initialState: LoginResult = {
}; };
export default function LoginForm({ export default function LoginForm({
searchParams searchParams,
health
}: { }: {
searchParams: Promise<{ expired: boolean | undefined }>; searchParams: Promise<{ expired: boolean | undefined }>;
health: HealthStatus;
}) { }) {
const params = use(searchParams); const params = use(searchParams);
const expired = params.expired; const expired = params.expired;
@@ -28,6 +32,13 @@ export default function LoginForm({
<div className="text-center"> <div className="text-center">
<h1 className="text-xl font-bold text-primary">ACC Server Manager</h1> <h1 className="text-xl font-bold text-primary">ACC Server Manager</h1>
<p className="mt-1 text-sm text-muted">Sign in to manage your servers</p> <p className="mt-1 text-sm text-muted">Sign in to manage your servers</p>
<div className="mt-2 flex justify-center">
{health.healthy ? (
<Badge variant="green">API v{health.version}</Badge>
) : (
<Badge variant="red">API unreachable</Badge>
)}
</div>
</div> </div>
{expired && ( {expired && (
<div className="rounded-md border border-yellow/30 bg-yellow-bg px-3 py-2 text-sm text-yellow"> <div className="rounded-md border border-yellow/30 bg-yellow-bg px-3 py-2 text-sm text-yellow">

View File

@@ -11,6 +11,7 @@ import { LeaderboardManager } from '@/components/leaderboard/LeaderboardManager'
import { useState } from 'react'; import { useState } from 'react';
import { StateHistoryStats } from '@/lib/schemas'; import { StateHistoryStats } from '@/lib/schemas';
import { Leaderboard } from '@/lib/schemas/leaderboard'; import { Leaderboard } from '@/lib/schemas/leaderboard';
import { bulkUpdateConfigurationsAction } from '@/lib/actions/configuration';
interface ServerConfigurationTabsProps { interface ServerConfigurationTabsProps {
serverId: string; serverId: string;
@@ -28,6 +29,14 @@ const tabs = [
{ id: ServerTab.leaderboard, name: 'Leaderboard', icon: '🏆' } { id: ServerTab.leaderboard, name: 'Leaderboard', icon: '🏆' }
]; ];
const configTabs = new Set([
ServerTab.configuration,
ServerTab.assistRules,
ServerTab.event,
ServerTab.eventRules,
ServerTab.settings
]);
export function ServerConfigurationTabs({ export function ServerConfigurationTabs({
serverId, serverId,
configurations, configurations,
@@ -37,19 +46,44 @@ export function ServerConfigurationTabs({
const [currentTab, setCurrentTab] = useState(ServerTab.statistics); const [currentTab, setCurrentTab] = useState(ServerTab.statistics);
const [configData, setConfigData] = useState(configurations.configuration); const [configData, setConfigData] = useState(configurations.configuration);
const [configRestart, setConfigRestart] = useState(true);
const [assistData, setAssistData] = useState(configurations.assistRules); const [assistData, setAssistData] = useState(configurations.assistRules);
const [assistRestart, setAssistRestart] = useState(true);
const [eventData, setEventData] = useState(configurations.event); const [eventData, setEventData] = useState(configurations.event);
const [eventRestart, setEventRestart] = useState(true);
const [eventRulesData, setEventRulesData] = useState(configurations.eventRules); const [eventRulesData, setEventRulesData] = useState(configurations.eventRules);
const [eventRulesRestart, setEventRulesRestart] = useState(true);
const [settingsData, setSettingsData] = useState(configurations.settings); const [settingsData, setSettingsData] = useState(configurations.settings);
const [settingsRestart, setSettingsRestart] = useState(true);
const [restart, setRestart] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
const handleSaveAll = async () => {
setIsSubmitting(true);
setSaveError(null);
setSaveSuccess(false);
const result = await bulkUpdateConfigurationsAction(
serverId,
{
configuration: configData,
assistRules: assistData,
event: eventData,
eventRules: eventRulesData,
settings: settingsData
},
restart
);
if (result.success) {
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 3000);
} else {
setSaveError(result.message);
}
setIsSubmitting(false);
};
const isConfigTab = configTabs.has(currentTab);
const renderTabContent = () => { const renderTabContent = () => {
switch (currentTab) { switch (currentTab) {
@@ -59,55 +93,45 @@ export function ServerConfigurationTabs({
case ServerTab.configuration: case ServerTab.configuration:
return ( return (
<ConfigurationEditor <ConfigurationEditor
serverId={serverId}
formData={configData} formData={configData}
restart={configRestart} disabled={isSubmitting}
onFormDataChange={setConfigData} onFormDataChange={setConfigData}
onRestartChange={setConfigRestart}
/> />
); );
case ServerTab.assistRules: case ServerTab.assistRules:
return ( return (
<AssistRulesEditor <AssistRulesEditor
serverId={serverId}
formData={assistData} formData={assistData}
restart={assistRestart} disabled={isSubmitting}
onFormDataChange={setAssistData} onFormDataChange={setAssistData}
onRestartChange={setAssistRestart}
/> />
); );
case ServerTab.event: case ServerTab.event:
return ( return (
<EventConfigEditor <EventConfigEditor
serverId={serverId}
formData={eventData} formData={eventData}
restart={eventRestart} disabled={isSubmitting}
onFormDataChange={setEventData} onFormDataChange={setEventData}
onRestartChange={setEventRestart}
/> />
); );
case ServerTab.eventRules: case ServerTab.eventRules:
return ( return (
<EventRulesEditor <EventRulesEditor
serverId={serverId}
formData={eventRulesData} formData={eventRulesData}
restart={eventRulesRestart} disabled={isSubmitting}
onFormDataChange={setEventRulesData} onFormDataChange={setEventRulesData}
onRestartChange={setEventRulesRestart}
/> />
); );
case ServerTab.settings: case ServerTab.settings:
return ( return (
<ServerSettingsEditor <ServerSettingsEditor
serverId={serverId}
formData={settingsData} formData={settingsData}
restart={settingsRestart} disabled={isSubmitting}
onFormDataChange={setSettingsData} onFormDataChange={setSettingsData}
onRestartChange={setSettingsRestart}
/> />
); );
@@ -147,6 +171,37 @@ export function ServerConfigurationTabs({
})} })}
</nav> </nav>
{isConfigTab && (
<div className="flex items-center justify-between border-b border-border-muted bg-overlay px-5 py-3">
<div className="flex items-center gap-3">
{saveError && (
<p className="text-sm text-red">{saveError}</p>
)}
{saveSuccess && (
<p className="text-sm text-green">All configurations saved.</p>
)}
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-muted">
<input
type="checkbox"
checked={restart}
onChange={(e) => setRestart(e.target.checked)}
className="h-4 w-4 rounded border-border bg-overlay accent-green focus:ring-1 focus:ring-blue"
/>
Restart after saving
</label>
<button
onClick={handleSaveAll}
disabled={isSubmitting}
className="rounded-md bg-btn-green border border-btn-green px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-btn-green-hover disabled:cursor-not-allowed disabled:opacity-40"
>
{isSubmitting ? 'Saving…' : 'Save All'}
</button>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto p-5">{renderTabContent()}</div> <div className="flex-1 overflow-y-auto p-5">{renderTabContent()}</div>
</div> </div>
); );

View File

@@ -2,10 +2,11 @@
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { requireAuth } from '@/lib/auth/server'; import { requireAuth } from '@/lib/auth/server';
import { updateServerConfiguration } from '@/lib/api/server/configuration'; import { updateServerConfiguration, bulkUpdateServerConfigurations } from '@/lib/api/server/configuration';
import { import {
assistRulesSchema, assistRulesSchema,
ConfigFile, ConfigFile,
configurationsSchema,
eventConfigSchema, eventConfigSchema,
eventRulesSchema, eventRulesSchema,
serverSettingsSchema serverSettingsSchema
@@ -13,6 +14,7 @@ import {
import type { import type {
Configuration, Configuration,
AssistRules, AssistRules,
Configurations,
EventConfig, EventConfig,
EventRules, EventRules,
ServerSettings ServerSettings
@@ -232,3 +234,28 @@ export async function updateEventRulesAction(serverId: string, formData: FormDat
}; };
} }
} }
export async function bulkUpdateConfigurationsAction(
serverId: string,
configs: Configurations,
restart: boolean
) {
try {
const session = await requireAuth();
const validated = configurationsSchema.safeParse(configs);
if (!validated.success) {
return { success: false, message: validated.error.message };
}
await bulkUpdateServerConfigurations(session.token!, serverId, validated.data, restart);
revalidatePath(`/dashboard/server/${serverId}`);
return { success: true, message: 'All configurations updated successfully' };
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to update configurations'
};
}
}

View File

@@ -8,14 +8,23 @@ import {
} from '@/lib/schemas/config'; } from '@/lib/schemas/config';
import * as z from 'zod'; import * as z from 'zod';
export type ConfigAuditRecord = {
id: string;
serverId: string;
configFile: string;
oldConfig: string;
newConfig: string;
changedAt: string;
};
const serverRoute = '/server'; const serverRoute = '/server';
export async function getServerConfigurations( export async function getServerConfigurations(
token: string, token: string,
serverId: string serverId: string
): Promise<Configurations> { ): Promise<Configurations> {
const response = await fetchServerAPI<Configurations>(`${serverRoute}/${serverId}/config`, token); const response = await fetchServerAPI<Configurations>(`${serverRoute}/${serverId}/config`, token);
return configurationsSchema.parse(response.data); return configurationsSchema.parse(response.data);
} }
export function validateConfig( export function validateConfig(
@@ -45,8 +54,26 @@ export async function updateServerConfiguration(
config: Config, config: Config,
restart = false restart = false
): Promise<void> { ): Promise<void> {
await fetchServerAPI(`${serverRoute}/${serverId}/config/${configType}?override=true`, token, 'PUT', { await fetchServerAPI(
...validateConfig(configType, config), `${serverRoute}/${serverId}/config/${configType}?override=true&restart=${restart}`,
restart token,
}); 'PUT',
{
...validateConfig(configType, config)
}
);
}
export async function bulkUpdateServerConfigurations(
token: string,
serverId: string,
configs: Configurations,
restart = false
): Promise<void> {
await fetchServerAPI<ConfigAuditRecord[]>(
`${serverRoute}/${serverId}/config?override=true&restart=${restart}`,
token,
'PATCH',
configs
);
} }

View File

@@ -0,0 +1,17 @@
const BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
export type HealthStatus = { healthy: true; version: string } | { healthy: false; version: null };
export async function checkHealth(): Promise<HealthStatus> {
try {
const response = await fetch(`${BASE_URL}/system/health`, {
method: 'GET',
next: { revalidate: 0 }
});
if (!response.ok) return { healthy: false, version: null };
const version = await response.text();
return { healthy: true, version: version.trim() || 'unknown' };
} catch {
return { healthy: false, version: null };
}
}