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:
Tran G. (Revernomad) Khoa
2026-04-18 15:56:04 +07:00
parent b7d670a91c
commit bf09a6ed1c
12 changed files with 253 additions and 56 deletions

View File

@@ -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, " ")

View File

@@ -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">

View File

@@ -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;