fix: fix Arma 3 log discovery and improve config editor UX
- Fix logfiles_router and thread_registry to resolve .rpt log files from Path(server["exe_path"]).parent/server/ instead of the languard data dir, which never contained log files — log list and live tail both now work correctly - Rewrite get_ui_schema() in config_generator to cover all ~80 fields across all 5 sections (server/basic/profile/launch/rcon) with proper toggle/select/number/password/tag-list/hidden widgets and labels; missions field is hidden (managed by Missions tab) - Add formatSelectDisplay() to ConfigEditor so select fields show descriptive text (e.g. "0 - Never") instead of raw numbers in view mode - Add ToggleDisplay for boolean fields (Enabled/Disabled with indicator dot) - Add section tab labels and descriptions to ConfigEditor - Add MissionList UX hints and dynamic Add/In Rotation button labels - Add "hidden" to FieldSchema widget union type - Update API.md, ARCHITECTURE.md, CLAUDE.md, FRONTEND.md, MODULES.md, THREADING.md to document log path fix and schema coverage
This commit is contained in:
@@ -14,6 +14,22 @@ interface ConfigEditorProps {
|
||||
|
||||
const SENSITIVE_KEYS = new Set(["password", "password_admin", "server_command_password", "rcon_password"]);
|
||||
|
||||
const SECTION_LABELS: Record<string, string> = {
|
||||
server: "Server",
|
||||
basic: "Network",
|
||||
profile: "Difficulty",
|
||||
launch: "Startup",
|
||||
rcon: "RCon",
|
||||
};
|
||||
|
||||
const SECTION_DESCRIPTIONS: Record<string, string> = {
|
||||
server: "General server settings — identity, players, security, voting, and timeouts.",
|
||||
basic: "Low-level network bandwidth and packet tuning.",
|
||||
profile: "Custom difficulty settings applied when Forced Difficulty is set to 'Custom'.",
|
||||
launch: "Startup parameters passed to the server executable.",
|
||||
rcon: "Remote console access for live server administration.",
|
||||
};
|
||||
|
||||
export function ConfigEditor({ serverId }: ConfigEditorProps) {
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||
const addNotification = useUIStore((s) => s.addNotification);
|
||||
@@ -34,23 +50,27 @@ export function ConfigEditor({ serverId }: ConfigEditorProps) {
|
||||
|
||||
return (
|
||||
<div data-testid="config-editor">
|
||||
<div className="flex gap-1 mb-4 overflow-x-auto">
|
||||
<div className="flex gap-1 mb-3 overflow-x-auto">
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
key={section}
|
||||
onClick={() => setActiveSection(section)}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-lg text-sm font-medium transition-colors capitalize",
|
||||
"px-3 py-1.5 rounded-lg text-sm font-medium transition-colors",
|
||||
currentSection === section
|
||||
? "bg-accent text-text-inverse"
|
||||
: "text-text-secondary hover:text-text-primary hover:bg-surface-overlay",
|
||||
)}
|
||||
>
|
||||
{section}
|
||||
{SECTION_LABELS[section] ?? section}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{SECTION_DESCRIPTIONS[currentSection] && (
|
||||
<p className="text-text-muted text-xs mb-4">{SECTION_DESCRIPTIONS[currentSection]}</p>
|
||||
)}
|
||||
|
||||
{currentSection && (
|
||||
<ConfigSectionForm
|
||||
key={currentSection}
|
||||
@@ -89,11 +109,15 @@ function ConfigSectionForm({
|
||||
return <div className="text-text-muted text-sm">No data for this section.</div>;
|
||||
}
|
||||
|
||||
const fields = Object.entries(sectionData).filter(([key]) => key !== "_meta");
|
||||
const sectionSchema = schema?.[section] ?? {};
|
||||
const fields = Object.entries(sectionData).filter(([key]) => {
|
||||
if (key === "_meta") return false;
|
||||
if (sectionSchema[key]?.widget === "hidden") return false;
|
||||
return true;
|
||||
});
|
||||
const meta = sectionData._meta;
|
||||
const displayValues = editValues ?? Object.fromEntries(fields);
|
||||
const isEditing = editValues !== null;
|
||||
const sectionSchema = schema?.[section] ?? {};
|
||||
|
||||
const handleEdit = () => {
|
||||
setEditValues(Object.fromEntries(fields));
|
||||
@@ -171,6 +195,14 @@ function ConfigSectionForm({
|
||||
onTogglePassword={() => toggleShowPassword(key)}
|
||||
onChange={(v) => handleChange(key, v)}
|
||||
/>
|
||||
) : widget === "toggle" ? (
|
||||
<div className="flex-1 px-3 py-2">
|
||||
<ToggleDisplay value={rawValue} />
|
||||
</div>
|
||||
) : widget === "select" ? (
|
||||
<span className="text-text-primary text-sm flex-1 bg-surface-recessed rounded-lg px-3 py-2">
|
||||
{formatSelectDisplay(rawValue, fieldSchema)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-text-primary font-mono text-sm flex-1 bg-surface-recessed rounded-lg px-3 py-2">
|
||||
{widget === "password" ? "••••••••" : formatDisplayValue(rawValue)}
|
||||
@@ -222,18 +254,33 @@ function FieldWidget({
|
||||
/>
|
||||
);
|
||||
|
||||
case "select":
|
||||
case "select": {
|
||||
const options = fieldSchema?.options ?? [];
|
||||
// Options may use "N - Description" format for numeric fields
|
||||
const isNumericOptions = options.length > 0 && /^\d+ /.test(options[0]);
|
||||
const selectedOpt = isNumericOptions
|
||||
? (options.find((opt) => parseInt(opt, 10) === Number(value)) ?? String(value ?? ""))
|
||||
: String(value ?? "");
|
||||
return (
|
||||
<select
|
||||
className="neu-input flex-1 text-sm"
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
value={selectedOpt}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (isNumericOptions) {
|
||||
const num = parseInt(raw, 10);
|
||||
onChange(isNaN(num) ? raw : num);
|
||||
} else {
|
||||
onChange(raw);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(fieldSchema?.options ?? []).map((opt) => (
|
||||
{options.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
case "toggle":
|
||||
return (
|
||||
@@ -241,7 +288,7 @@ function FieldWidget({
|
||||
type="checkbox"
|
||||
className="w-5 h-5 accent-accent"
|
||||
checked={value === true || value === 1 || value === "true"}
|
||||
onChange={(e) => onChange(e.target.checked ? "true" : "false")}
|
||||
onChange={(e) => onChange(e.target.checked ? 1 : 0)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -298,11 +345,32 @@ function FieldWidget({
|
||||
}
|
||||
}
|
||||
|
||||
function ToggleDisplay({ value }: { value: unknown }) {
|
||||
const on = value === true || value === 1 || value === "true" || value === "1";
|
||||
return (
|
||||
<span className={clsx("inline-flex items-center gap-1.5 text-sm font-sans", on ? "text-green-400" : "text-text-muted")}>
|
||||
<span className={clsx("w-3 h-3 rounded-full", on ? "bg-green-400" : "bg-surface-overlay border border-text-muted")} />
|
||||
{on ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDisplayValue(value: unknown): string {
|
||||
if (Array.isArray(value)) return value.join(", ") || "--";
|
||||
return String(value ?? "--");
|
||||
}
|
||||
|
||||
function formatSelectDisplay(value: unknown, fieldSchema: FieldSchema | undefined): string {
|
||||
const options = fieldSchema?.options;
|
||||
if (!options?.length) return formatDisplayValue(value);
|
||||
const isNumeric = /^\d+ /.test(options[0]);
|
||||
if (isNumeric) {
|
||||
const match = options.find((opt) => parseInt(opt, 10) === Number(value));
|
||||
return match ?? String(value ?? "--");
|
||||
}
|
||||
return String(value ?? "--");
|
||||
}
|
||||
|
||||
function formatLabel(key: string): string {
|
||||
return key
|
||||
.replace(/_/g, " ")
|
||||
|
||||
@@ -114,7 +114,7 @@ export function MissionList({ serverId }: MissionListProps) {
|
||||
<div data-testid="mission-list" className="space-y-8">
|
||||
{/* Section A: Available Missions */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h3 className="text-text-primary font-semibold">
|
||||
Available Missions ({missions.length})
|
||||
</h3>
|
||||
@@ -134,6 +134,9 @@ export function MissionList({ serverId }: MissionListProps) {
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-text-muted text-xs mb-3">
|
||||
Upload .pbo mission files, then click <strong className="text-text-secondary">+ Add to Rotation</strong> to schedule them for the server.
|
||||
</p>
|
||||
|
||||
{uploadProgress.length > 0 && (
|
||||
<div className="mb-3 space-y-1">
|
||||
@@ -195,10 +198,10 @@ export function MissionList({ serverId }: MissionListProps) {
|
||||
onClick={() => addToRotation(mission.name)}
|
||||
disabled={rotation.some((r) => r.name === mission.name)}
|
||||
className="btn-ghost text-xs flex items-center gap-1"
|
||||
title="Add to rotation"
|
||||
title={rotation.some((r) => r.name === mission.name) ? "Already in rotation" : "Add to mission rotation"}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Rotation
|
||||
{rotation.some((r) => r.name === mission.name) ? "In Rotation" : "Add to Rotation"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(mission.filename)}
|
||||
@@ -221,7 +224,7 @@ export function MissionList({ serverId }: MissionListProps) {
|
||||
|
||||
{/* Section B: Mission Rotation */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h3 className="text-text-primary font-semibold">
|
||||
Mission Rotation ({rotation.length})
|
||||
</h3>
|
||||
@@ -245,6 +248,9 @@ export function MissionList({ serverId }: MissionListProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-text-muted text-xs mb-3">
|
||||
The server cycles through these missions in order. Set per-mission difficulty, then click <strong className="text-text-secondary">Save Rotation</strong> to apply.
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
|
||||
@@ -126,7 +126,7 @@ export interface Mod {
|
||||
}
|
||||
|
||||
export interface FieldSchema {
|
||||
widget: "text" | "number" | "password" | "textarea" | "select" | "toggle" | "tag-list";
|
||||
widget: "text" | "number" | "password" | "textarea" | "select" | "toggle" | "tag-list" | "hidden";
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
min?: number;
|
||||
|
||||
Reference in New Issue
Block a user