summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-07-29 12:21:32 +0200
committersalmonik <salmonik@tuta.io>2026-07-29 12:21:32 +0200
commitbc0d3d2f324ee613424ee31103207222b4b19640 (patch)
tree483f699f303c44af6ec27b8f357e220b4d683e0c
parentEnable/disable mods, batch toggle & delete (diff)
downloadminecraft-ui-bc0d3d2f324ee613424ee31103207222b4b19640.tar.gz
minecraft-ui-bc0d3d2f324ee613424ee31103207222b4b19640.zip
Config editor, host networking, whitelist RCON
- Config file editor at /instances/{id}/config (browse & edit config/ files) - Host networking for Docker container (fixes WireGuard MTU DNS issues) - Whitelist changes applied live via RCON instead of stopping the server - Config edit handler detects which fields changed: whitelist → RCON, other settings → restart container - walk_dir simplified (removed unused root param) - import_world uses if-let instead of never-looping while-let - docker-compose.yml updated as reference template
-rw-r--r--src/docker.rs1
-rw-r--r--src/routes/instances.rs282
-rw-r--r--src/sanitize.rs6
-rw-r--r--templates/instances/config.html64
-rw-r--r--templates/instances/edit.html226
-rw-r--r--templates/mods/list.html4
6 files changed, 375 insertions, 208 deletions
diff --git a/src/docker.rs b/src/docker.rs
index d66767e..f171d43 100644
--- a/src/docker.rs
+++ b/src/docker.rs
@@ -164,6 +164,7 @@ impl DockerManager {
164 let host_config = HostConfig { 164 let host_config = HostConfig {
165 binds: Some(vec![format!("{instance_data}:/data")]), 165 binds: Some(vec![format!("{instance_data}:/data")]),
166 port_bindings: Some(port_bindings), 166 port_bindings: Some(port_bindings),
167 network_mode: Some("host".into()),
167 ..Default::default() 168 ..Default::default()
168 }; 169 };
169 170
diff --git a/src/routes/instances.rs b/src/routes/instances.rs
index d3b8294..a3c4baa 100644
--- a/src/routes/instances.rs
+++ b/src/routes/instances.rs
@@ -1,7 +1,7 @@
1use askama::Template; 1use askama::Template;
2use axum::{ 2use axum::{
3 Form, Router, 3 Form, Router,
4 extract::{Multipart, Path, State}, 4 extract::{Multipart, Path, Query, State},
5 response::{Html, Redirect}, 5 response::{Html, Redirect},
6 routing::{get, post}, 6 routing::{get, post},
7}; 7};
@@ -95,6 +95,7 @@ pub fn router() -> Router<AppState> {
95 .route("/instances/{id}/world/export", get(export_world)) 95 .route("/instances/{id}/world/export", get(export_world))
96 .route("/instances/{id}/world/import", post(import_world)) 96 .route("/instances/{id}/world/import", post(import_world))
97 .route("/instances/{id}/world/regenerate", post(regenerate_world)) 97 .route("/instances/{id}/world/regenerate", post(regenerate_world))
98 .route("/instances/{id}/config", get(config_editor).post(save_config))
98} 99}
99 100
100async fn list_instances( 101async fn list_instances(
@@ -345,7 +346,53 @@ async fn edit_instance(
345 let pvp = form.pvp.as_deref() == Some("on"); 346 let pvp = form.pvp.as_deref() == Some("on");
346 let whitelist_enabled = form.whitelist_enabled.as_deref() == Some("on"); 347 let whitelist_enabled = form.whitelist_enabled.as_deref() == Some("on");
347 348
348 let is_active = { 349 // Read current values to detect what changed
350 let (_instance_name, is_active, old_whitelist, rcon_password): (String, bool, bool, String) = {
351 let conn = state.db.conn();
352 conn.query_row(
353 "SELECT name, is_active, whitelist_enabled, rcon_password
354 FROM instances WHERE id = ?1",
355 params![id],
356 |row| Ok((
357 row.get(0)?,
358 row.get::<_, i32>(1)? != 0,
359 row.get::<_, i32>(2)? != 0,
360 row.get(3)?,
361 )),
362 )
363 .map_err(|_| Redirect::to("/instances"))?
364 };
365
366 // Check if any restart-requiring field changed
367 let (old_difficulty, old_gamemode, old_max_players, old_pvp, old_motd, old_memory): (i32, i32, i32, bool, String, i32) = {
368 let conn = state.db.conn();
369 conn.query_row(
370 "SELECT difficulty, gamemode, max_players, pvp, motd, memory_mb
371 FROM instances WHERE id = ?1",
372 params![id],
373 |row| Ok((
374 row.get(0)?,
375 row.get(1)?,
376 row.get(2)?,
377 row.get::<_, i32>(3)? != 0,
378 row.get(4)?,
379 row.get(5)?,
380 )),
381 )
382 .map_err(|_| Redirect::to("/instances"))?
383 };
384
385 let needs_restart = form.difficulty != old_difficulty
386 || form.gamemode != old_gamemode
387 || form.max_players != old_max_players
388 || pvp != old_pvp
389 || form.motd.as_deref().unwrap_or("") != old_motd
390 || form.memory_mb != old_memory;
391
392 let whitelist_changed = whitelist_enabled != old_whitelist;
393
394 // Persist changes
395 {
349 let conn = state.db.conn(); 396 let conn = state.db.conn();
350 conn.execute( 397 conn.execute(
351 "UPDATE instances SET difficulty = ?1, gamemode = ?2, max_players = ?3, 398 "UPDATE instances SET difficulty = ?1, gamemode = ?2, max_players = ?3,
@@ -363,17 +410,23 @@ async fn edit_instance(
363 ], 410 ],
364 ) 411 )
365 .map_err(|_| Redirect::to("/instances"))?; 412 .map_err(|_| Redirect::to("/instances"))?;
366 413 }
367 conn.query_row(
368 "SELECT is_active FROM instances WHERE id = ?1",
369 params![id],
370 |row| row.get::<_, i32>(0).map(|v| v != 0),
371 )
372 .unwrap_or(false)
373 };
374 414
375 if is_active { 415 if is_active {
376 let _ = state.docker.stop_container().await; 416 // Apply whitelist change live via RCON if it was the only change
417 if whitelist_changed {
418 let cmd = if whitelist_enabled { "whitelist on" } else { "whitelist off" };
419 let rcon = crate::rcon::RconClient::new(rcon_password);
420 match rcon.send_command_async(cmd.into()).await {
421 Ok(resp) => tracing::info!("RCON {cmd}: {resp}"),
422 Err(e) => tracing::error!("RCON {cmd} failed: {e}"),
423 }
424 }
425
426 // If other settings changed, restart the container
427 if needs_restart {
428 let _ = state.docker.stop_container().await;
429 }
377 } 430 }
378 431
379 Ok(Redirect::to("/instances")) 432 Ok(Redirect::to("/instances"))
@@ -714,8 +767,8 @@ async fn import_world(
714 .join(&safe_name) 767 .join(&safe_name)
715 .join("data"); 768 .join("data");
716 769
717 // Extract uploaded ZIP 770 // Extract uploaded ZIP (only the first file is processed)
718 while let Ok(Some(field)) = multipart.next_field().await { 771 if let Ok(Some(field)) = multipart.next_field().await {
719 let bytes = field 772 let bytes = field
720 .bytes() 773 .bytes()
721 .await 774 .await
@@ -754,12 +807,213 @@ async fn import_world(
754 std::io::copy(&mut file, &mut out).ok(); 807 std::io::copy(&mut file, &mut out).ok();
755 } 808 }
756 } 809 }
757 break; // only process first file
758 } 810 }
759 811
760 Ok(Redirect::to(&format!("/instances/{id}/edit"))) 812 Ok(Redirect::to(&format!("/instances/{id}/edit")))
761} 813}
762 814
815#[derive(Template)]
816#[template(path = "instances/config.html")]
817struct ConfigEditorTemplate {
818 username: String,
819 is_superuser: bool,
820 password_change_required: bool,
821 instance_id: i64,
822 instance_name: String,
823 files: Vec<String>,
824 selected_file: Option<String>,
825 content: Option<String>,
826 error: Option<String>,
827 saved: bool,
828}
829
830#[derive(Debug, Deserialize)]
831struct ConfigPathQuery {
832 path: Option<String>,
833}
834
835#[derive(Debug, Deserialize)]
836struct SaveConfigForm {
837 path: String,
838 content: String,
839}
840
841/// Browse and edit config files in the instance's config/ directory.
842async fn config_editor(
843 State(state): State<AppState>,
844 Path(instance_id): Path<i64>,
845 CurrentUser(account): CurrentUser,
846 Query(query): Query<ConfigPathQuery>,
847) -> Result<Html<String>, axum::response::Redirect> {
848 let conn = state.db.conn();
849 let instance_name: String = conn
850 .query_row(
851 "SELECT name FROM instances WHERE id = ?1",
852 params![instance_id],
853 |row| row.get(0),
854 )
855 .map_err(|_| Redirect::to("/instances"))?;
856 drop(conn);
857
858 let safe_name = match sanitize::sanitize_instance_name(&instance_name) {
859 Some(n) => n,
860 None => return Err(Redirect::to("/instances")),
861 };
862
863 let config_dir = match sanitize::instance_config_dir(&state.config.server_directory, &safe_name)
864 {
865 Some(d) => d,
866 None => return Err(Redirect::to("/instances")),
867 };
868 let _ = std::fs::create_dir_all(&config_dir);
869
870 // Walk the config directory for files
871 let mut files: Vec<String> = Vec::new();
872 if let Ok(entries) = walk_dir(&config_dir, "") {
873 files = entries;
874 files.sort();
875 }
876
877 // If a file is selected, validate and read it
878 let (selected_file, content, error) = if let Some(raw_path) = &query.path {
879 let cleaned = raw_path.trim_start_matches('/');
880 let target = config_dir.join(cleaned);
881 if !sanitize::path_is_within_base(&target, &config_dir) {
882 (Some(cleaned.to_string()), None, Some("Invalid path".into()))
883 } else if !target.exists() || !target.is_file() {
884 (Some(cleaned.to_string()), None, Some("File not found".into()))
885 } else {
886 match std::fs::read_to_string(&target) {
887 Ok(c) => (Some(cleaned.to_string()), Some(c), None),
888 Err(e) => (Some(cleaned.to_string()), None, Some(format!("Read error: {e}"))),
889 }
890 }
891 } else {
892 (None, None, None)
893 };
894
895 let template = ConfigEditorTemplate {
896 username: account.username.clone(),
897 is_superuser: account.is_superuser,
898 password_change_required: account.password_change_required,
899 instance_id,
900 instance_name,
901 files,
902 selected_file,
903 content,
904 error,
905 saved: false,
906 };
907
908 match template.render() {
909 Ok(html) => Ok(Html(html)),
910 Err(e) => {
911 tracing::error!("Failed to render config template: {e}");
912 Err(Redirect::to(&format!("/instances/{instance_id}/edit")))
913 }
914 }
915}
916
917/// Save a config file back to disk.
918async fn save_config(
919 State(state): State<AppState>,
920 Path(instance_id): Path<i64>,
921 CurrentUser(_user): CurrentUser,
922 Form(form): Form<SaveConfigForm>,
923) -> Result<Html<String>, axum::response::Redirect> {
924 let conn = state.db.conn();
925 let instance_name: String = conn
926 .query_row(
927 "SELECT name FROM instances WHERE id = ?1",
928 params![instance_id],
929 |row| row.get(0),
930 )
931 .map_err(|_| Redirect::to("/instances"))?;
932 drop(conn);
933
934 let safe_name = match sanitize::sanitize_instance_name(&instance_name) {
935 Some(n) => n,
936 None => return Err(Redirect::to("/instances")),
937 };
938
939 let config_dir = match sanitize::instance_config_dir(&state.config.server_directory, &safe_name)
940 {
941 Some(d) => d,
942 None => return Err(Redirect::to("/instances")),
943 };
944
945 let cleaned = form.path.trim_start_matches('/');
946 let target = config_dir.join(cleaned);
947
948 let (error, saved) = if !sanitize::path_is_within_base(&target, &config_dir) {
949 (Some("Invalid path".into()), false)
950 } else if let Err(e) = std::fs::write(&target, &form.content) {
951 (Some(format!("Write error: {e}")), false)
952 } else {
953 tracing::info!("Saved config file: {}", target.display());
954 (None, true)
955 };
956
957 // Re-read the file list and selected file content
958 let _ = std::fs::create_dir_all(&config_dir);
959 let mut files: Vec<String> = Vec::new();
960 if let Ok(entries) = walk_dir(&config_dir, "") {
961 files = entries;
962 files.sort();
963 }
964
965 let content = if error.is_none() {
966 Some(form.content)
967 } else {
968 None
969 };
970
971 let template = ConfigEditorTemplate {
972 username: String::new(),
973 is_superuser: false,
974 password_change_required: false,
975 instance_id,
976 instance_name,
977 files,
978 selected_file: Some(cleaned.to_string()),
979 content,
980 error,
981 saved,
982 };
983
984 match template.render() {
985 Ok(html) => Ok(Html(html)),
986 Err(e) => {
987 tracing::error!("Failed to render config template after save: {e}");
988 Err(Redirect::to(&format!("/instances/{instance_id}/config?path={cleaned}")))
989 }
990 }
991}
992
993/// Recursively walk a directory and return relative paths of all files.
994fn walk_dir(dir: &std::path::Path, prefix: &str) -> std::io::Result<Vec<String>> {
995 let mut files = Vec::new();
996 for entry in std::fs::read_dir(dir)? {
997 let entry = entry?;
998 let path = entry.path();
999 let name = entry
1000 .file_name()
1001 .to_string_lossy()
1002 .to_string();
1003 let relative = if prefix.is_empty() {
1004 name
1005 } else {
1006 format!("{prefix}/{name}")
1007 };
1008 if path.is_dir() {
1009 files.extend(walk_dir(&path, &relative)?);
1010 } else if path.is_file() {
1011 files.push(relative);
1012 }
1013 }
1014 Ok(files)
1015}
1016
763async fn regenerate_world( 1017async fn regenerate_world(
764 State(state): State<AppState>, 1018 State(state): State<AppState>,
765 Path(id): Path<i64>, 1019 Path(id): Path<i64>,
diff --git a/src/sanitize.rs b/src/sanitize.rs
index f251131..19c7045 100644
--- a/src/sanitize.rs
+++ b/src/sanitize.rs
@@ -136,6 +136,12 @@ pub fn instance_dir(server_dir: &Path, instance_name: &str) -> Option<PathBuf> {
136 Some(server_dir.join("instances").join(&safe)) 136 Some(server_dir.join("instances").join(&safe))
137} 137}
138 138
139/// Build a safe path to the instance's config/ directory.
140pub fn instance_config_dir(server_dir: &Path, instance_name: &str) -> Option<PathBuf> {
141 let data = instance_data_dir(server_dir, instance_name)?;
142 Some(data.join("config"))
143}
144
139/// Validate that a backup filename does not contain path traversal. 145/// Validate that a backup filename does not contain path traversal.
140pub fn sanitize_backup_filename(raw: &str) -> Option<String> { 146pub fn sanitize_backup_filename(raw: &str) -> Option<String> {
141 let trimmed = raw.trim(); 147 let trimmed = raw.trim();
diff --git a/templates/instances/config.html b/templates/instances/config.html
new file mode 100644
index 0000000..192a123
--- /dev/null
+++ b/templates/instances/config.html
@@ -0,0 +1,64 @@
1{% extends "base.html" %} {% block content %}
2<div class="page-header">
3 <h1>Config Files: {{ instance_name }}</h1>
4 <a href="/instances/{{ instance_id }}/edit" class="btn"
5 >&larr; Back to Instance</a
6 >
7</div>
8
9{% if saved %}
10<div class="flash flash-ok">File saved successfully.</div>
11{% endif %} {% if let Some(err) = error %}
12<div class="flash flash-err">{{ err }}</div>
13{% endif %}
14
15<div style="display: flex; gap: 1.5rem; align-items: flex-start">
16 {# File list sidebar #}
17 <div class="card" style="flex: 0 0 260px; max-height: 70vh; overflow-y: auto">
18 <h3 style="margin-bottom: 0.5rem">Files</h3>
19 {% if files.is_empty() %}
20 <p style="font-size: 0.85rem; color: var(--fg-muted)">
21 No config files yet.<br />Start the server to generate them.
22 </p>
23 {% else %}
24 <ul style="list-style: none; padding: 0; margin: 0; font-size: 0.85rem">
25 {% for f in files %}
26 <li style="padding: 0.25rem 0">
27 <a
28 href="/instances/{{ instance_id }}/config?path={{ f }}"
29 style="{% if selected_file.as_deref() == Some(f) %}font-weight:bold;color:var(--ok){% endif %}"
30 >
31 {{ f }}
32 </a>
33 </li>
34 {% endfor %}
35 </ul>
36 {% endif %}
37 </div>
38
39 {# Editor area #}
40 <div class="card" style="flex: 1">
41 {% if let Some(file) = selected_file %}
42 <h3 style="margin-bottom: 0.5rem">{{ file }}</h3>
43 {% if let Some(content) = content %}
44 <form method="post" action="/instances/{{ instance_id }}/config">
45 <input type="hidden" name="path" value="{{ file }}" />
46 <textarea
47 name="content"
48 rows="30"
49 style="width: 100%; font-family: monospace; font-size: 0.8rem"
50 spellcheck="false"
51 >
52{{ content }}</textarea>
53 <div style="margin-top: 0.5rem">
54 <button type="submit" class="btn-ok">Save</button>
55 </div>
56 </form>
57 {% else %}
58 <p style="color: var(--fg-muted)">Could not load file content.</p>
59 {% endif %} {% else %}
60 <p style="color: var(--fg-muted)">Select a file from the list to edit.</p>
61 {% endif %}
62 </div>
63</div>
64{% endblock %}
diff --git a/templates/instances/edit.html b/templates/instances/edit.html
index 77dfca6..ef7d53e 100644
--- a/templates/instances/edit.html
+++ b/templates/instances/edit.html
@@ -19,125 +19,41 @@
19 <legend>Server Properties</legend> 19 <legend>Server Properties</legend>
20 <label> 20 <label>
21 Memory (MB) 21 Memory (MB)
22 <input 22 <input type="number" name="memory_mb" value="{{ instance.memory_mb }}" min="512" max="{{ max_memory_mb }}" required />
23 type="number"
24 name="memory_mb"
25 value="{{ instance.memory_mb }}"
26 min="512"
27 max="{{ max_memory_mb }}"
28 required
29 />
30 </label> 23 </label>
31 <label> 24 <label>
32 Difficulty 25 Difficulty
33 <select name="difficulty"> 26 <select name="difficulty">
34 <option 27 <option value="0"{% if instance.difficulty == 0 %} selected{% endif %}>Peaceful</option>
35 value="0" 28 <option value="1"{% if instance.difficulty == 1 %} selected{% endif %}>Easy</option>
36 {% 29 <option value="2"{% if instance.difficulty == 2 %} selected{% endif %}>Normal</option>
37 if 30 <option value="3"{% if instance.difficulty == 3 %} selected{% endif %}>Hard</option>
38 instance.difficulty == 0
39 %}selected{%
40 endif
41 %}
42 >
43 Peaceful
44 </option>
45 <option
46 value="1"
47 {%
48 if
49 instance.difficulty == 1
50 %}selected{%
51 endif
52 %}
53 >
54 Easy
55 </option>
56 <option
57 value="2"
58 {%
59 if
60 instance.difficulty == 2
61 %}selected{%
62 endif
63 %}
64 >
65 Normal
66 </option>
67 <option
68 value="3"
69 {%
70 if
71 instance.difficulty == 3
72 %}selected{%
73 endif
74 %}
75 >
76 Hard
77 </option>
78 </select> 31 </select>
79 </label> 32 </label>
80 <label> 33 <label>
81 Gamemode 34 Gamemode
82 <select name="gamemode"> 35 <select name="gamemode">
83 <option value="0" {% if instance.gamemode == 0 %}selected{% endif %}> 36 <option value="0"{% if instance.gamemode == 0 %} selected{% endif %}>Survival</option>
84 Survival 37 <option value="1"{% if instance.gamemode == 1 %} selected{% endif %}>Creative</option>
85 </option> 38 <option value="2"{% if instance.gamemode == 2 %} selected{% endif %}>Adventure</option>
86 <option value="1" {% if instance.gamemode == 1 %}selected{% endif %}> 39 <option value="3"{% if instance.gamemode == 3 %} selected{% endif %}>Spectator</option>
87 Creative
88 </option>
89 <option value="2" {% if instance.gamemode == 2 %}selected{% endif %}>
90 Adventure
91 </option>
92 <option value="3" {% if instance.gamemode == 3 %}selected{% endif %}>
93 Spectator
94 </option>
95 </select> 40 </select>
96 </label> 41 </label>
97 <label> 42 <label>
98 Max Players 43 Max Players
99 <input 44 <input type="number" name="max_players" value="{{ instance.max_players }}" min="1" max="100" />
100 type="number"
101 name="max_players"
102 value="{{ instance.max_players }}"
103 min="1"
104 max="100"
105 />
106 </label> 45 </label>
107 <label> 46 <label>
108 <input 47 <input type="checkbox" name="pvp"{% if instance.pvp %} checked{% endif %} />
109 type="checkbox"
110 name="pvp"
111 {%
112 if
113 instance.pvp
114 %}checked{%
115 endif
116 %}
117 />
118 PvP enabled 48 PvP enabled
119 </label> 49 </label>
120 <label> 50 <label>
121 <input 51 <input type="checkbox" name="whitelist_enabled"{% if instance.whitelist_enabled %} checked{% endif %} />
122 type="checkbox"
123 name="whitelist_enabled"
124 {%
125 if
126 instance.whitelist_enabled
127 %}checked{%
128 endif
129 %}
130 />
131 Whitelist enabled 52 Whitelist enabled
132 </label> 53 </label>
133 <label> 54 <label>
134 MOTD 55 MOTD
135 <input 56 <input type="text" name="motd" value="{{ instance.motd }}" maxlength="59" />
136 type="text"
137 name="motd"
138 value="{{ instance.motd }}"
139 maxlength="59"
140 />
141 </label> 57 </label>
142 </fieldset> 58 </fieldset>
143 59
@@ -147,12 +63,8 @@
147 </div> 63 </div>
148</form> 64</form>
149 65
150<div 66<div class="card{% if instance.is_active %} card-muted{% endif %}" style="margin-top: 1.5rem">
151 class="card{% if instance.is_active %} card-muted{% endif %}"
152 style="margin-top: 1.5rem"
153>
154 <h3>World Management</h3> 67 <h3>World Management</h3>
155
156 {% if instance.is_active %} 68 {% if instance.is_active %}
157 <div class="flash flash-warn">Stop the server before managing the world.</div> 69 <div class="flash flash-warn">Stop the server before managing the world.</div>
158 {% endif %} 70 {% endif %}
@@ -166,109 +78,39 @@
166 </span> 78 </span>
167 </div> 79 </div>
168 80
169 <div 81 <div class="actions" style="flex-wrap:wrap; gap:1rem;{% if instance.is_active %} pointer-events:none; opacity:0.5{% endif %}">
170 class="actions" 82 <a href="/instances/{{ instance.id }}/world/export" class="btn" hx-boost="false"{% if instance.is_active %} tabindex="-1"{% endif %}>Export World</a>
171 style="flex-wrap:wrap; gap:1rem; {% if instance.is_active %}pointer-events:none; opacity:0.5{% endif %}"
172 >
173 <a
174 href="/instances/{{ instance.id }}/world/export"
175 class="btn"
176 hx-boost="false"
177 {%
178 if
179 instance.is_active
180 %}tabindex="-1"
181 {%
182 endif
183 %}
184 >Export World</a
185 >
186 83
187 <form 84 <form action="/instances/{{ instance.id }}/world/import" method="post" enctype="multipart/form-data" style="display: flex; align-items: center; gap: 0.5rem" hx-boost="false">
188 action="/instances/{{ instance.id }}/world/import" 85 <input type="file" name="world_zip" accept=".zip" required style="font-size: 0.85rem"{% if instance.is_active %} disabled{% endif %} />
189 method="post" 86 <button type="submit" class="btn" onclick="return confirm('This will replace the current world. Continue?');"{% if instance.is_active %} disabled{% endif %}>Import</button>
190 enctype="multipart/form-data"
191 style="display: flex; align-items: center; gap: 0.5rem"
192 hx-boost="false"
193 >
194 <input
195 type="file"
196 name="world_zip"
197 accept=".zip"
198 required
199 style="font-size: 0.85rem"
200 {%
201 if
202 instance.is_active
203 %}disabled{%
204 endif
205 %}
206 />
207 <button
208 type="submit"
209 class="btn"
210 onclick="
211 return confirm('This will replace the current world. Continue?');
212 "
213 {%
214 if
215 instance.is_active
216 %}disabled{%
217 endif
218 %}
219 >
220 Import
221 </button>
222 </form> 87 </form>
223 88
224 <form 89 <form action="/instances/{{ instance.id }}/world/regenerate" method="post" class="form-row" onsubmit="return confirm('This will DELETE the current world and generate a new one. Continue?');">
225 action="/instances/{{ instance.id }}/world/regenerate" 90 <input type="text" name="seed" placeholder="Seed (empty = random)" style="width: 180px"{% if instance.is_active %} disabled{% endif %} />
226 method="post" 91 <button type="submit" class="btn-danger"{% if instance.is_active %} disabled{% endif %}>Regenerate</button>
227 class="form-row"
228 onsubmit="
229 return confirm(
230 'This will DELETE the current world and generate a new one. Continue?',
231 );
232 "
233 >
234 <input
235 type="text"
236 name="seed"
237 placeholder="Seed (empty = random)"
238 style="width: 180px"
239 {%
240 if
241 instance.is_active
242 %}disabled{%
243 endif
244 %}
245 />
246 <button
247 type="submit"
248 class="btn-danger"
249 {%
250 if
251 instance.is_active
252 %}disabled{%
253 endif
254 %}
255 >
256 Regenerate
257 </button>
258 </form> 92 </form>
259 </div> 93 </div>
260</div> 94</div>
261 95
262<div class="card" style="margin-top: 1.5rem"> 96<div class="card" style="margin-top: 1.5rem">
97 <h3>Configuration Files</h3>
98 <div class="meta" style="margin-bottom: 0.5rem">
99 <span>Edit mod/plugin config files inside <code>config/</code></span>
100 </div>
101 <div class="actions">
102 <a href="/instances/{{ instance.id }}/config" class="btn">Open Config Editor</a>
103 </div>
104</div>
105
106<div class="card" style="margin-top: 1.5rem">
263 <h3>Mods</h3> 107 <h3>Mods</h3>
264 <div class="meta" style="margin-bottom: 0.5rem"> 108 <div class="meta" style="margin-bottom: 0.5rem">
265 <span> 109 <span>
266 {% if instance.has_modpack %} 110 {% if instance.has_modpack %}
267 <strong>{{ mod_count }}</strong> mod{% if mod_count != 1 %}s{% endif %} 111 <strong>{{ mod_count }}</strong> mod{% if mod_count != 1 %}s{% endif %}
268 installed {% if rejected_count > 0 %} (<strong 112 installed{% if rejected_count > 0 %} (<strong>{{ rejected_count }}</strong> rejected){% endif %}
269 >{{ rejected_count }}</strong 113 {% else %} No mods installed {% endif %}
270 >
271 rejected) {% endif %} {% else %} No mods installed {% endif %}
272 </span> 114 </span>
273 </div> 115 </div>
274 <div class="actions"> 116 <div class="actions">
diff --git a/templates/mods/list.html b/templates/mods/list.html
index b5420b8..b05a0e6 100644
--- a/templates/mods/list.html
+++ b/templates/mods/list.html
@@ -119,7 +119,7 @@ function batchSubmit(action) {
119 const checked = document.querySelectorAll('input[name="mod_id"]:checked'); 119 const checked = document.querySelectorAll('input[name="mod_id"]:checked');
120 if (checked.length === 0) return; 120 if (checked.length === 0) return;
121 121
122 const form = document.getElementById('batch-form'); 122 const form = document.querySelector('#batch-form');
123 // Clear any previous hidden inputs 123 // Clear any previous hidden inputs
124 form.textContent = ''; 124 form.textContent = '';
125 form.action = '/instances/{{ instance_id }}/mods/batch-' + action; 125 form.action = '/instances/{{ instance_id }}/mods/batch-' + action;
@@ -129,7 +129,7 @@ function batchSubmit(action) {
129 input.type = 'hidden'; 129 input.type = 'hidden';
130 input.name = 'mod_id'; 130 input.name = 'mod_id';
131 input.value = cb.value; 131 input.value = cb.value;
132 form.appendChild(input); 132 form.append(input);
133 } 133 }
134 134
135 form.submit(); 135 form.submit();